Skip to content

Commit aa18c42

Browse files
authored
Edit page link for preview bar (#306)
# Changes Adds an “edit page” link to the preview bar that links to the current page record editor (when we have id + __typename), so editors can jump from a preview page straight into the CMS record. ## Documentation link See: https://github.com/voorhoede/head-start/blob/feat/edit-page/docs/preview-mode.md#edit-in-datocms-link # How to test <img width="1434" height="434" alt="SCR-20260120-nsbz" src="https://github.com/user-attachments/assets/8dc06968-6f97-4f93-8fea-c7804d2d8c3a" /> # Checklist - [ ] I have performed a self-review of my own code - [ ] I have made sure that my PR is easy to review (not too big, includes comments) - [ ] I have made updated relevant documentation files (in project README, docs/, etc) - [ ] I have added a decision log entry if the change affects the architecture or changes a significant technology - [ ] I have notified a reviewer <!-- Please strike through and check off all items that do not apply (rather than removing them) -->
1 parent f9e56ea commit aa18c42

File tree

14 files changed

+244
-36
lines changed

14 files changed

+244
-36
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ node_modules/
1818
reports/
1919
src/assets/icon-sprite.svg
2020
src/assets/icon-sprite.ts
21+
src/lib/datocms/itemTypes.json
2122
src/lib/datocms/types.ts
2223
src/lib/i18n/messages.json
2324
src/lib/i18n/types.ts

config/preview.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { execSync } from 'node:child_process';
33
export const previewBranches = [
44
'preview',
55
'feat/pages-content-collection',
6+
'feat/edit-page'
67
];
78

89
function getGitBranch() {

docs/preview-mode.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
To enable preview mode for a git branch, you must add it to [`config/preview.ts`](../config/preview.ts). Preview branches will deploy as `output: 'server'` rather than `output: 'hybrid'`, ignoring all `getStaticPaths()` and always rendering the page during run-time. The `preview` branch is configured as one of the preview branches and is automatically kept in sync with the `main` branch, so it can be used as preview equivalent, for example from the CMS.
88

9+
> [!TIP]
10+
> To enable preview mode during local development:
11+
> - Make sure your current git branch is included in [`config/preview.ts`](../config/preview.ts).
12+
> - Set `HEAD_START_PREVIEW_SECRET` and `DATOCMS_READONLY_API_TOKEN` in your local `.env`.
13+
> - Enter preview mode via `/api/preview/enter/?secret=...` (or the preview login form).
14+
915
To protect a part of the page that must only be available in preview mode, you can wrap it in the `PreviewModeProvider`, as is done in the [`Default.astro` layout](../src/layouts/Default.astro):
1016

1117
```astro
@@ -58,10 +64,63 @@ const variables = { locale, slug };
5864
const { page } = await datocmsRequest<PageQuery>({ query, variables });
5965
---
6066
61-
<PreviewModeSubscription query={ query } variables={ variables } />
67+
<PreviewModeSubscription
68+
query={query}
69+
variables={variables}
70+
record={{ type: page.__typename, id: page.id }}
71+
/>
6272
<h1>{page.title}</h1>
6373
```
6474

75+
The `record` prop is used to generate the "edit in CMS" link in the preview bar.
76+
6577
## Preview mode bar
6678

6779
When in preview mode a bar in the user interface displays the status of the connection with the CMS, along with a link to exit preview mode. Depending on the layout of your project, you may want to move the preview mode bar to another position, for example if your project has a sticky header.
80+
81+
## Edit in CMS link
82+
83+
In preview mode, the preview bar shows an **"edit in CMS"** link that opens the record in DatoCMS.
84+
85+
### How it works
86+
87+
The link is automatically generated from the `record` prop passed to `PreviewModeSubscription`. The URL is built from:
88+
- **Project name**: extracted from `internalDomain` in `@lib/site.json`
89+
- **Environment**: from `datocms-environment.ts`
90+
- **Record info**: `id` + `type` (from `record` prop) → resolved to `itemTypeId` via auto-generated mappings
91+
92+
If any part can't be resolved, the link doesn't render.
93+
94+
### Usage
95+
96+
Pass the `record` prop to `PreviewModeSubscription`:
97+
98+
```astro
99+
<PreviewModeSubscription
100+
query={query}
101+
variables={variables}
102+
record={{ type: page.__typename, id: page.id }}
103+
/>
104+
```
105+
106+
Your GraphQL query needs:
107+
108+
```graphql
109+
query MyPage {
110+
page {
111+
id
112+
__typename
113+
# ... other fields
114+
}
115+
}
116+
```
117+
118+
### Auto-generated files
119+
120+
- `src/lib/datocms/itemTypes.json``__typename` → item type id (generated, in `.gitignore`)
121+
122+
When DatoCMS models change, regenerate:
123+
124+
```bash
125+
npm run prep:download-item-types
126+
```

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"prep:download-redirects": "jiti scripts/download-redirects.ts",
3333
"prep:download-site-data": "jiti scripts/download-site-data.ts",
3434
"prep:download-translations": "jiti scripts/download-translations.ts",
35+
"prep:download-item-types": "jiti scripts/download-item-types.ts",
3536
"prep:icons": "svg-sprite --svg-doctype=false --symbol --symbol-dest='src/assets' --symbol-sprite=icon-sprite.svg src/assets/icons/*.svg && jiti scripts/icon-types.ts",
3637
"preview": "wrangler pages dev ./dist",
3738
"lint": "run-s lint:* --print-label",

scripts/download-item-types.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { buildClient } from '@datocms/cma-client-node';
2+
import dotenv from 'dotenv-safe';
3+
import { mkdir, writeFile } from 'node:fs/promises';
4+
import { dirname } from 'node:path';
5+
import { datocmsEnvironment } from '../datocms-environment';
6+
7+
dotenv.config({
8+
allowEmptyValues: Boolean(process.env.CI),
9+
});
10+
11+
function toTypename(apiKey: string): string {
12+
const pascalCase = apiKey
13+
.split(/[_-]/)
14+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
15+
.join('');
16+
return `${pascalCase}Record`;
17+
}
18+
19+
async function downloadItemTypes() {
20+
const token = process.env.DATOCMS_API_TOKEN?.trim();
21+
const filePath = './src/lib/datocms/itemTypes.json';
22+
23+
if (!token) {
24+
if (process.env.CI) {
25+
console.log(
26+
'DATOCMS_API_TOKEN is missing; creating empty itemTypes.json for CI.',
27+
);
28+
await mkdir(dirname(filePath), { recursive: true });
29+
await writeFile(filePath, JSON.stringify({}, null, 2));
30+
return;
31+
}
32+
throw new Error(
33+
'DATOCMS_API_TOKEN is required. Set it and rerun `npm run prep:download-item-types`.',
34+
);
35+
}
36+
37+
const client = buildClient({
38+
apiToken: token,
39+
environment: datocmsEnvironment,
40+
});
41+
42+
const itemTypes = await client.itemTypes.list();
43+
const typenameMap: Record<string, string> = {};
44+
45+
for (const itemType of itemTypes) {
46+
const apiKey = itemType.api_key;
47+
if (!apiKey || !itemType.id) continue;
48+
49+
const typename = toTypename(apiKey);
50+
typenameMap[typename] = itemType.id;
51+
}
52+
53+
const sortedEntries = Object.entries(typenameMap)
54+
.sort(([a], [b]) => a.localeCompare(b));
55+
56+
const jsonContent = Object.fromEntries(sortedEntries);
57+
58+
await mkdir(dirname(filePath), { recursive: true });
59+
await writeFile(filePath, JSON.stringify(jsonContent, null, 2));
60+
61+
console.log('Item types downloaded');
62+
}
63+
64+
downloadItemTypes()
65+
.catch((error) => {
66+
console.error('Failed to download item types:', error);
67+
process.exit(1);
68+
});
69+

src/components/PreviewMode/PreviewMode.astro

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
---
2+
import siteDataUntyped from '@lib/site.json';
3+
4+
type SiteData = {
5+
internalDomain?: string;
6+
};
7+
8+
const siteData = siteDataUntyped as SiteData;
29
const { datocmsEnvironment, datocmsToken, isPreview } = Astro.locals;
10+
const projectName = siteData.internalDomain?.split('.')[0] || null;
311
---
412
<preview-mode
513
data-datocms-environment={ datocmsEnvironment }
614
data-datocms-token={ isPreview && datocmsToken }
15+
data-datocms-project={ projectName }
716
>
817
<preview-mode-bar>
918
Preview mode
1019
<span role="presentation" data-status="closed" />
11-
<a href={ `/api/preview/exit/?location=${Astro.url}` }>exit preview</a>
20+
<div class="preview-mode-actions">
21+
{/* eslint-disable-next-line astro/jsx-a11y/anchor-is-valid */}
22+
<a data-edit-record target="_blank" rel="noreferrer noopener">edit in CMS</a>
23+
<a href={ `/api/preview/exit/?location=${Astro.url}` }>exit preview</a>
24+
</div>
1225
</preview-mode-bar>
1326
<slot />
1427
</preview-mode>
@@ -29,6 +42,16 @@ a {
2942
color: white;
3043
}
3144

45+
a[data-edit-record]:not([href]) {
46+
display: none;
47+
}
48+
49+
.preview-mode-actions {
50+
display: flex;
51+
gap: 1em;
52+
align-items: center;
53+
}
54+
3255
[data-status]:before {
3356
display: inline-block;
3457
margin-left: .5em;

src/components/PreviewMode/PreviewMode.client.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import itemTypesJson from '@lib/datocms/itemTypes.json';
12
import type { ConnectionStatus as DatocmsConnectionStatus } from 'datocms-listen';
23
import { subscribeToQuery } from 'datocms-listen';
34
import { atom, map } from 'nanostores';
@@ -17,12 +18,15 @@ type QueryVariables = { query: Query; variables?: Variables };
1718
class PreviewMode extends HTMLElement {
1819
barElement: PreviewModeBar;
1920
subscriptionElements: PreviewModeSubscription[];
21+
editableRecord: { id: string, type: string } | null;
22+
editLinkElement: HTMLAnchorElement;
2023
$connections = map<Connection>({});
2124
$connectionError = atom<boolean>(false);
2225
$connectionStatus = atom<ConnectionStatus>('closed');
2326
$updateCounts = map<{ [key: string]: number }>({});
2427
#datocmsToken: string = '';
2528
#datocmsEnvironment: string = '';
29+
#datocmsProject: string = '';
2630

2731
/**
2832
* Generates a hashed key for the map tracking subscriptions.
@@ -38,13 +42,22 @@ class PreviewMode extends HTMLElement {
3842
return hash.toString(36);
3943
}
4044

45+
static getItemTypeId (typename?: string): string {
46+
const key = typename as keyof typeof itemTypesJson;
47+
return itemTypesJson[key] ?? null;
48+
}
49+
4150
constructor() {
4251
super();
4352

4453
this.barElement = this.querySelector('preview-mode-bar') as PreviewModeBar;
4554
this.subscriptionElements = [...this.querySelectorAll('preview-mode-subscription')] as PreviewModeSubscription[];
55+
this.editableRecord = JSON.parse(
56+
this.subscriptionElements.find((element) => element.dataset.record)?.dataset.record ?? 'null'
57+
);
58+
this.editLinkElement = this.querySelector('[data-edit-record]') as HTMLAnchorElement;
4659

47-
const { datocmsEnvironment, datocmsToken } = this.dataset;
60+
const { datocmsEnvironment, datocmsToken, datocmsProject } = this.dataset;
4861
if (!datocmsEnvironment) {
4962
console.warn('PreviewMode: missing required data-datocms-environment attribute');
5063
return;
@@ -55,6 +68,7 @@ class PreviewMode extends HTMLElement {
5568
}
5669
this.#datocmsEnvironment = datocmsEnvironment;
5770
this.#datocmsToken = datocmsToken;
71+
this.#datocmsProject = datocmsProject || '';
5872

5973
this.$connections.listen((connections) => {
6074
// set overall connection status to lowest of all connections:
@@ -97,6 +111,21 @@ class PreviewMode extends HTMLElement {
97111
getSubscriptionConfigs () {
98112
return this.subscriptionElements.map((element) => element.getConfig() as QueryVariables);
99113
}
114+
115+
connectedCallback() {
116+
const record = this.editableRecord;
117+
const isLinkableRecord = record?.id && record?.type && this.#datocmsProject;
118+
if (!isLinkableRecord || !record) {
119+
return;
120+
}
121+
122+
const itemTypeId = PreviewMode.getItemTypeId(record.type);
123+
if (!itemTypeId) {
124+
return;
125+
}
126+
127+
this.editLinkElement.href = `https://${this.#datocmsProject}.admin.datocms.com/environments/${this.#datocmsEnvironment}/editor/item_types/${itemTypeId}/items/${record.id}`;
128+
}
100129

101130
getInstanceCounts () {
102131
return this.getSubscriptionConfigs()

src/components/PreviewMode/PreviewModeSubscription.astro

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,28 @@ import { print } from 'graphql/language/printer';
55
interface Props {
66
query: DocumentNode;
77
variables?: { [key: string]: string };
8+
record?: { type: string; id: string };
89
}
9-
const { query, variables } = Astro.props;
10+
const { query, variables, record } = Astro.props;
1011
const { isPreview } = Astro.locals;
1112
---
12-
{ isPreview && (
13-
<preview-mode-subscription>
14-
<script
15-
is:inline
16-
type="application/graphql+json"
17-
set:html={ JSON.stringify({
18-
query: print(query),
19-
variables
20-
}, null, 2) }
21-
/>
22-
</preview-mode-subscription>
23-
)}
13+
14+
{
15+
isPreview && (
16+
<preview-mode-subscription data-record={JSON.stringify(record)}>
17+
<script
18+
is:inline
19+
type="application/graphql+json"
20+
set:html={JSON.stringify(
21+
{
22+
query: print(query),
23+
variables,
24+
record,
25+
},
26+
null,
27+
2,
28+
)}
29+
/>
30+
</preview-mode-subscription>
31+
)
32+
}

src/content/Pages/index.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
1-
import { defineCollection, z } from 'astro:content';
1+
import { combine } from '@lib/content';
2+
import { datocmsCollection, datocmsRequest } from '@lib/datocms';
23
import {
4+
PageRoute as fragment,
35
PageCollectionEntry as query,
46
type PageCollectionEntryQuery,
5-
PageRoute as fragment,
67
type PageRouteFragment,
78
type SiteLocale
89
} from '@lib/datocms/types';
9-
import { datocmsCollection, datocmsRequest } from '@lib/datocms';
10-
import { combine } from '@lib/content';
11-
import { getPagePath, getParentPages } from '@lib/routing/page';
10+
import { isLocale } from '@lib/i18n';
1211
import {
1312
formatBreadcrumb,
1413
getPageHref,
1514
getSlugFromPath,
1615
type Breadcrumb,
1716
type PageUrl,
1817
} from '@lib/routing';
19-
import { isLocale } from '@lib/i18n';
18+
import { getPagePath, getParentPages } from '@lib/routing/page';
19+
import { defineCollection, z } from 'astro:content';
2020

21-
type Meta = {
21+
type Meta<T extends PageCollectionEntryQuery['record']> = {
2222
recordId: string; // The record ID of the entry in DatoCMS
23+
recordType: NonNullable<T>['__typename']; // The type of the record in DatoCMS
2324
path: string; // The path of the page, excluding the locale
2425
locale: SiteLocale;
2526
breadcrumbs: Breadcrumb[]; // Breadcrumbs for the page, used for navigation
@@ -31,7 +32,7 @@ type QueryVariables = {
3132
};
3233
export type PageCollectionEntry = PageCollectionEntryQuery['record'] & {
3334
id: string, // A unique ID for the entry in the content collection, combining the path and locale
34-
meta: Meta,
35+
meta: Meta<PageCollectionEntryQuery['record']>,
3536
subscription: {
3637
variables: QueryVariables // Variables for the subscription
3738
}
@@ -78,6 +79,7 @@ const loadEntry = async (path: string, locale?: SiteLocale | null) => {
7879
id: combine({ id: path, locale }), // Combine the path and locale to create a unique ID for the entry
7980
meta: {
8081
recordId: record.id,
82+
recordType: record.__typename,
8183
path,
8284
locale,
8385
breadcrumbs,

0 commit comments

Comments
 (0)