Skip to content

Commit 00fb323

Browse files
feat: support configurable closest preview field ids [] (#10856)
* feat: support configurable closest preview field ids [] * chore: format preview config files [] * fix: exclude page types from sidebar assignment []
1 parent b272203 commit 00fb323

13 files changed

Lines changed: 793 additions & 129 deletions

apps/closest-preview/src/components/ContentTypeMultiSelect.tsx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import React, { useEffect, useState } from 'react';
1+
import React, { useEffect, useMemo, useState } from 'react';
22
import { Box, Paragraph, Pill, Skeleton, Stack } from '@contentful/f36-components';
33
import { Multiselect } from '@contentful/f36-multiselect';
44
import { ContentType } from '../types';
55
import { CMAClient, ConfigAppSDK } from '@contentful/app-sdk';
6-
import { getContentTypesWithoutLivePreview } from '../utils/livePreviewUtils';
6+
import { getContentTypes } from '../utils/livePreviewUtils';
77

88
type ContentTypeMultiSelectProps = {
99
selectedContentTypes: ContentType[];
@@ -18,11 +18,15 @@ const ContentTypeMultiSelect: React.FC<ContentTypeMultiSelectProps> = ({
1818
setSelectedContentTypes,
1919
sdk,
2020
cma,
21-
excludedContentTypesIds = [],
21+
excludedContentTypesIds,
2222
}) => {
2323
const [availableContentTypes, setAvailableContentTypes] = useState<ContentType[]>([]);
2424
const [isLoading, setIsLoading] = useState<boolean>(true);
2525
const [error, setError] = useState<string | null>(null);
26+
const resolvedExcludedContentTypeIds = useMemo(
27+
() => excludedContentTypesIds ?? [],
28+
[excludedContentTypesIds]
29+
);
2630

2731
const getPlaceholderText = () => {
2832
if (selectedContentTypes.length === 0) return 'Select one or more';
@@ -48,12 +52,9 @@ const ContentTypeMultiSelect: React.FC<ContentTypeMultiSelectProps> = ({
4852
const currentState = await sdk.app.getCurrentState();
4953
const currentContentTypesIds = Object.keys(currentState?.EditorInterface || {});
5054

51-
const contentTypesWithoutLivePreview = await getContentTypesWithoutLivePreview(
52-
cma,
53-
excludedContentTypesIds
54-
);
55-
56-
const newAvailableContentTypes = contentTypesWithoutLivePreview
55+
const contentTypes = await getContentTypes(cma);
56+
const newAvailableContentTypes = contentTypes
57+
.filter((ct) => !resolvedExcludedContentTypeIds.includes(ct.sys.id))
5758
.map((ct) => ({
5859
id: ct.sys.id,
5960
name: ct.name,
@@ -64,8 +65,12 @@ const ContentTypeMultiSelect: React.FC<ContentTypeMultiSelectProps> = ({
6465
setFilteredItems(newAvailableContentTypes);
6566

6667
if (currentContentTypesIds.length > 0) {
67-
const currentContentTypes = contentTypesWithoutLivePreview
68-
.filter((ct) => currentContentTypesIds.includes(ct.sys.id))
68+
const currentContentTypes = contentTypes
69+
.filter(
70+
(ct) =>
71+
currentContentTypesIds.includes(ct.sys.id) &&
72+
!resolvedExcludedContentTypeIds.includes(ct.sys.id)
73+
)
6974
.map((ct) => ({ id: ct.sys.id, name: ct.name }));
7075
setSelectedContentTypes(currentContentTypes);
7176
}
@@ -76,7 +81,7 @@ const ContentTypeMultiSelect: React.FC<ContentTypeMultiSelectProps> = ({
7681
setIsLoading(false);
7782
}
7883
})();
79-
}, []);
84+
}, [cma, resolvedExcludedContentTypeIds, sdk, setSelectedContentTypes]);
8085

8186
if (isLoading) {
8287
return (
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import React, { useEffect, useMemo, useState } from 'react';
2+
import { Box, Paragraph, Pill, Skeleton, Stack } from '@contentful/f36-components';
3+
import { Multiselect } from '@contentful/f36-multiselect';
4+
import { CMAClient } from '@contentful/app-sdk';
5+
import { ContentType, FieldOption } from '../types';
6+
7+
type PreviewFieldMultiSelectProps = {
8+
selectedContentTypes: ContentType[];
9+
selectedPreviewFieldIds: string[];
10+
setSelectedPreviewFieldIds: (fieldIds: string[]) => void;
11+
cma: CMAClient;
12+
};
13+
14+
export const getDeduplicatedFields = (
15+
contentTypes: Array<{ fields: Array<{ id: string; type: string }> }>
16+
): FieldOption[] => {
17+
const uniqueFields = new Map<string, FieldOption>();
18+
19+
contentTypes.forEach((contentType) => {
20+
contentType.fields.forEach((field) => {
21+
if (field.type === 'Symbol' && !uniqueFields.has(field.id)) {
22+
uniqueFields.set(field.id, { id: field.id, name: field.id });
23+
}
24+
});
25+
});
26+
27+
return [...uniqueFields.values()].sort((a, b) => a.name.localeCompare(b.name));
28+
};
29+
30+
const PreviewFieldMultiSelect: React.FC<PreviewFieldMultiSelectProps> = ({
31+
selectedContentTypes,
32+
selectedPreviewFieldIds,
33+
setSelectedPreviewFieldIds,
34+
cma,
35+
}) => {
36+
const [availableFields, setAvailableFields] = useState<FieldOption[]>([]);
37+
const [filteredItems, setFilteredItems] = useState<FieldOption[]>([]);
38+
const [isLoading, setIsLoading] = useState(false);
39+
const [error, setError] = useState<string | null>(null);
40+
41+
const selectedContentTypeIds = useMemo(
42+
() => selectedContentTypes.map((contentType) => contentType.id).sort(),
43+
[selectedContentTypes]
44+
);
45+
46+
const getPlaceholderText = () => {
47+
if (selectedContentTypes.length === 0) return 'Select content types first';
48+
if (selectedPreviewFieldIds.length === 0) return 'Select one or more';
49+
if (selectedPreviewFieldIds.length === 1) return selectedPreviewFieldIds[0];
50+
return `${selectedPreviewFieldIds[0]} and ${selectedPreviewFieldIds.length - 1} more`;
51+
};
52+
53+
const handleSearchValueChange = (event: { target: { value: string } }) => {
54+
const value = event.target.value.toLowerCase();
55+
setFilteredItems(availableFields.filter((field) => field.name.toLowerCase().includes(value)));
56+
};
57+
58+
useEffect(() => {
59+
(async () => {
60+
if (selectedContentTypeIds.length === 0) {
61+
setAvailableFields([]);
62+
setFilteredItems([]);
63+
if (selectedPreviewFieldIds.length > 0) {
64+
setSelectedPreviewFieldIds([]);
65+
}
66+
return;
67+
}
68+
69+
try {
70+
setIsLoading(true);
71+
setError(null);
72+
73+
const contentTypes = await Promise.all(
74+
selectedContentTypeIds.map((contentTypeId) => cma.contentType.get({ contentTypeId }))
75+
);
76+
77+
const nextAvailableFields = getDeduplicatedFields(contentTypes);
78+
79+
setAvailableFields(nextAvailableFields);
80+
setFilteredItems(nextAvailableFields);
81+
} catch (err) {
82+
console.error('Error loading preview fields:', err);
83+
setError('Failed to load fields for the selected content types. Please try again.');
84+
} finally {
85+
setIsLoading(false);
86+
}
87+
})();
88+
}, [cma, selectedContentTypeIds]);
89+
90+
useEffect(() => {
91+
if (availableFields.length === 0) {
92+
return;
93+
}
94+
95+
const nextAvailableFieldIds = new Set(availableFields.map((field) => field.id));
96+
const nextSelectedPreviewFieldIds = selectedPreviewFieldIds.filter((fieldId) =>
97+
nextAvailableFieldIds.has(fieldId)
98+
);
99+
100+
if (nextSelectedPreviewFieldIds.length !== selectedPreviewFieldIds.length) {
101+
setSelectedPreviewFieldIds(nextSelectedPreviewFieldIds);
102+
}
103+
}, [availableFields, selectedPreviewFieldIds, setSelectedPreviewFieldIds]);
104+
105+
if (isLoading) {
106+
return (
107+
<Skeleton.Container>
108+
<Skeleton.BodyText numberOfLines={2} />
109+
</Skeleton.Container>
110+
);
111+
}
112+
113+
if (error) {
114+
return (
115+
<Box>
116+
<Paragraph color="negative">{error}</Paragraph>
117+
</Box>
118+
);
119+
}
120+
121+
return (
122+
<Stack marginTop="spacingXs" flexDirection="column" alignItems="start">
123+
<Multiselect
124+
searchProps={{
125+
searchPlaceholder: 'Search field IDs',
126+
onSearchValueChange: handleSearchValueChange,
127+
}}
128+
placeholder={getPlaceholderText()}>
129+
{filteredItems.map((item) => (
130+
<Multiselect.Option
131+
key={item.id}
132+
value={item.id}
133+
itemId={item.id}
134+
isChecked={selectedPreviewFieldIds.includes(item.id)}
135+
onSelectItem={(e) => {
136+
const checked = e.target.checked;
137+
if (checked) {
138+
setSelectedPreviewFieldIds([...selectedPreviewFieldIds, item.id]);
139+
} else {
140+
setSelectedPreviewFieldIds(
141+
selectedPreviewFieldIds.filter((fieldId) => fieldId !== item.id)
142+
);
143+
}
144+
}}>
145+
{item.name}
146+
</Multiselect.Option>
147+
))}
148+
</Multiselect>
149+
150+
{selectedPreviewFieldIds.length > 0 && (
151+
<Box width="full" overflow="auto">
152+
<Stack flexDirection="row" spacing="spacing2Xs" flexWrap="wrap">
153+
{selectedPreviewFieldIds.map((fieldId) => (
154+
<Pill
155+
key={fieldId}
156+
testId={`pill-${fieldId}`}
157+
label={fieldId}
158+
isDraggable={false}
159+
onClose={() =>
160+
setSelectedPreviewFieldIds(
161+
selectedPreviewFieldIds.filter((selectedFieldId) => selectedFieldId !== fieldId)
162+
)
163+
}
164+
/>
165+
))}
166+
</Stack>
167+
</Box>
168+
)}
169+
</Stack>
170+
);
171+
};
172+
173+
export default PreviewFieldMultiSelect;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Box, Heading, Paragraph, SectionHeading } from '@contentful/f36-components';
2+
3+
const getSearchParam = (name: string) => {
4+
const value = new URLSearchParams(window.location.search).get(name);
5+
return value?.trim() || '';
6+
};
7+
8+
const PreviewPage = () => {
9+
const title = getSearchParam('title') || 'Untitled preview';
10+
const url = getSearchParam('url') || '/missing-url';
11+
const entryId = getSearchParam('entryId');
12+
13+
return (
14+
<Box
15+
padding="spacing2Xl"
16+
style={{
17+
minHeight: '100vh',
18+
background: 'linear-gradient(180deg, rgba(246,247,249,1) 0%, rgba(255,255,255,1) 100%)',
19+
}}>
20+
<Box
21+
style={{
22+
maxWidth: 860,
23+
margin: '0 auto',
24+
background: '#fff',
25+
border: '1px solid #d3dce0',
26+
borderRadius: 12,
27+
padding: 32,
28+
boxShadow: '0 12px 32px rgba(20, 38, 46, 0.08)',
29+
}}>
30+
<Paragraph marginBottom="spacingS">Closest Preview local preview target</Paragraph>
31+
<Heading marginBottom="spacingL">{title}</Heading>
32+
33+
<SectionHeading>Resolved URL</SectionHeading>
34+
<Paragraph marginBottom="spacingL">
35+
<code>{url}</code>
36+
</Paragraph>
37+
38+
{entryId && (
39+
<>
40+
<SectionHeading>Entry ID</SectionHeading>
41+
<Paragraph marginBottom="spacingL">
42+
<code>{entryId}</code>
43+
</Paragraph>
44+
</>
45+
)}
46+
47+
<SectionHeading>What This Page Is For</SectionHeading>
48+
<Paragraph marginBottom="spacingM">
49+
This is a lightweight preview surface for configuring Contentful content preview against
50+
the Closest Preview development app.
51+
</Paragraph>
52+
<Paragraph>
53+
Once native content preview is configured for the content type, Contentful can open this
54+
page as the preview destination while we continue iterating on the `url`-based page
55+
detection flow.
56+
</Paragraph>
57+
</Box>
58+
</Box>
59+
);
60+
};
61+
62+
export default PreviewPage;

apps/closest-preview/src/index.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,21 @@ import { SDKProvider } from '@contentful/react-apps-toolkit';
44
import { createRoot } from 'react-dom/client';
55
import App from './App';
66
import LocalhostWarning from './components/LocalhostWarning';
7+
import PreviewPage from './components/PreviewPage';
78

89
const container = document.getElementById('root')!;
910
const root = createRoot(container);
1011

11-
if (process.env.NODE_ENV === 'development' && window.self === window.top) {
12+
const isPreviewPage = window.location.pathname === '/preview';
13+
14+
if (isPreviewPage) {
15+
root.render(
16+
<>
17+
<GlobalStyles />
18+
<PreviewPage />
19+
</>
20+
);
21+
} else if (process.env.NODE_ENV === 'development' && window.self === window.top) {
1222
// You can remove this if block before deploying your app
1323
root.render(<LocalhostWarning />);
1424
} else {

0 commit comments

Comments
 (0)