Skip to content

Commit d79f5ca

Browse files
authored
Field Populator: Config screen [MAPS-102] (#10264)
* adding field populator config screen + tests * Adding quotation marks
1 parent ca7e868 commit d79f5ca

File tree

6 files changed

+391
-24
lines changed

6 files changed

+391
-24
lines changed
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { Box, Stack, Pill } from '@contentful/f36-components';
3+
import { Multiselect } from '@contentful/f36-multiselect';
4+
import { ContentTypeProps } from 'contentful-management';
5+
6+
interface ContentTypeMultiSelectProps {
7+
availableContentTypes: ContentTypeProps[];
8+
selectedContentTypes: ContentTypeProps[];
9+
onSelectionChange: (contentTypes: ContentTypeProps[]) => void;
10+
isDisabled?: boolean;
11+
}
12+
13+
const ContentTypeMultiSelect: React.FC<ContentTypeMultiSelectProps> = ({
14+
availableContentTypes,
15+
selectedContentTypes,
16+
onSelectionChange,
17+
isDisabled = false,
18+
}) => {
19+
const [filteredContentTypes, setFilteredContentTypes] =
20+
useState<ContentTypeProps[]>(availableContentTypes);
21+
22+
const getPlaceholderText = () => {
23+
if (selectedContentTypes.length === 0) return 'Select one or more';
24+
if (selectedContentTypes.length === 1) return selectedContentTypes[0].name;
25+
return `${selectedContentTypes[0].name} and ${selectedContentTypes.length - 1} more`;
26+
};
27+
28+
const handleSearchValueChange = (event: { target: { value: string } }) => {
29+
const value = event.target.value;
30+
const newFilteredContentTypes = availableContentTypes.filter((contentType) =>
31+
contentType.name.toLowerCase().includes(value.toLowerCase())
32+
);
33+
setFilteredContentTypes(newFilteredContentTypes);
34+
};
35+
36+
const handleContentTypeToggle = (contentType: ContentTypeProps, checked: boolean) => {
37+
if (checked) {
38+
onSelectionChange([...selectedContentTypes, contentType]);
39+
} else {
40+
onSelectionChange(selectedContentTypes.filter((ct) => ct.sys.id !== contentType.sys.id));
41+
}
42+
};
43+
44+
const handleContentTypeRemove = (contentTypeId: string) => {
45+
onSelectionChange(selectedContentTypes.filter((ct) => ct.sys.id !== contentTypeId));
46+
};
47+
48+
// Update filtered list when available content types change
49+
useEffect(() => {
50+
setFilteredContentTypes(availableContentTypes);
51+
}, [availableContentTypes]);
52+
53+
return (
54+
<Stack marginTop="spacingXs" flexDirection="column" alignItems="start">
55+
<Multiselect
56+
searchProps={{
57+
searchPlaceholder: 'Search content types',
58+
onSearchValueChange: handleSearchValueChange,
59+
}}
60+
placeholder={getPlaceholderText()}
61+
popoverProps={{ isFullWidth: true }}
62+
triggerButtonProps={{ isDisabled }}>
63+
{filteredContentTypes.map((contentType) => (
64+
<Multiselect.Option
65+
key={contentType.sys.id}
66+
value={contentType.sys.id}
67+
itemId={contentType.sys.id}
68+
isChecked={selectedContentTypes.some((ct) => ct.sys.id === contentType.sys.id)}
69+
onSelectItem={(e) => handleContentTypeToggle(contentType, e.target.checked)}>
70+
{contentType.name}
71+
</Multiselect.Option>
72+
))}
73+
</Multiselect>
74+
75+
{selectedContentTypes.length > 0 && (
76+
<Box width="full" overflow="auto">
77+
<Stack flexDirection="row" spacing="spacing2Xs" flexWrap="wrap">
78+
{selectedContentTypes.map((contentType) => (
79+
<Pill
80+
key={contentType.sys.id}
81+
testId={`pill-${contentType.name.replace(/\s+/g, '-')}`}
82+
label={contentType.name}
83+
isDraggable={false}
84+
onClose={() => handleContentTypeRemove(contentType.sys.id)}
85+
/>
86+
))}
87+
</Stack>
88+
</Box>
89+
)}
90+
</Stack>
91+
);
92+
};
93+
94+
export default ContentTypeMultiSelect;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { css } from 'emotion';
2+
3+
export const styles = {
4+
container: css({
5+
width: '50%',
6+
marginTop: 'spacing2Xl',
7+
marginLeft: 'auto',
8+
marginRight: 'auto',
9+
}),
10+
};

apps/field-populator/src/locations/ConfigScreen.tsx

Lines changed: 111 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,91 @@
1-
import { ConfigAppSDK } from '@contentful/app-sdk';
2-
import { Flex, Form, Heading, Paragraph } from '@contentful/f36-components';
3-
import { /* useCMA, */ useSDK } from '@contentful/react-apps-toolkit';
4-
import { css } from 'emotion';
1+
import { ConfigAppSDK, AppState } from '@contentful/app-sdk';
2+
import { Flex, Heading, Paragraph, FormControl } from '@contentful/f36-components';
3+
import { useSDK } from '@contentful/react-apps-toolkit';
54
import { useCallback, useEffect, useState } from 'react';
5+
import { ContentTypeProps } from 'contentful-management';
6+
import ContentTypeMultiSelect from '../components/ContentTypeMultiSelect';
7+
import { styles } from './ConfigScreen.styles';
68

79
export type AppInstallationParameters = Record<string, unknown>;
810

911
const ConfigScreen = () => {
1012
const [parameters, setParameters] = useState<AppInstallationParameters>({});
13+
const [allContentTypes, setAllContentTypes] = useState<ContentTypeProps[]>([]);
14+
const [selectedContentTypes, setSelectedContentTypes] = useState<ContentTypeProps[]>([]);
15+
const [isLoading, setIsLoading] = useState(true);
1116
const sdk = useSDK<ConfigAppSDK>();
1217

18+
const fetchAllContentTypes = async (): Promise<ContentTypeProps[]> => {
19+
const contentTypes: ContentTypeProps[] = [];
20+
let skip = 0;
21+
const limit = 1000;
22+
let fetched: number;
23+
24+
do {
25+
const response = await sdk.cma.contentType.getMany({
26+
spaceId: sdk.ids.space,
27+
environmentId: sdk.ids.environment,
28+
query: { skip, limit },
29+
});
30+
const items = response.items as ContentTypeProps[];
31+
contentTypes.push(...items);
32+
fetched = items.length;
33+
skip += limit;
34+
} while (fetched === limit);
35+
36+
return contentTypes.sort((a, b) => a.name.localeCompare(b.name));
37+
};
38+
39+
const loadContentTypesAndRestoreState = async () => {
40+
try {
41+
setIsLoading(true);
42+
const contentTypes = await fetchAllContentTypes();
43+
setAllContentTypes(contentTypes);
44+
45+
// Restore selected content types from saved state
46+
const currentState: AppState | null = await sdk.app.getCurrentState();
47+
if (currentState?.EditorInterface) {
48+
const selectedIds = Object.keys(currentState.EditorInterface);
49+
const restored = contentTypes.filter((ct) => selectedIds.includes(ct.sys.id));
50+
setSelectedContentTypes(restored);
51+
}
52+
} catch (error) {
53+
console.error('Error loading content types:', error);
54+
} finally {
55+
setIsLoading(false);
56+
}
57+
};
58+
1359
const onConfigure = useCallback(async () => {
1460
const currentState = await sdk.app.getCurrentState();
61+
const currentEditorInterface = currentState?.EditorInterface || {};
62+
63+
// Build new EditorInterface with selected content types assigned to sidebar
64+
const newEditorInterface: AppState['EditorInterface'] = {};
65+
66+
// Remove content types that are no longer selected
67+
Object.keys(currentEditorInterface).forEach((contentTypeId) => {
68+
if (selectedContentTypes.some((ct) => ct.sys.id === contentTypeId)) {
69+
newEditorInterface[contentTypeId] = currentEditorInterface[contentTypeId];
70+
}
71+
});
72+
73+
// Add newly selected content types to sidebar
74+
selectedContentTypes.forEach((contentType) => {
75+
if (!newEditorInterface[contentType.sys.id]) {
76+
newEditorInterface[contentType.sys.id] = {
77+
sidebar: { position: 1 },
78+
};
79+
}
80+
});
1581

1682
return {
1783
parameters,
18-
targetState: currentState,
84+
targetState: {
85+
EditorInterface: newEditorInterface,
86+
},
1987
};
20-
}, [parameters, sdk]);
88+
}, [parameters, selectedContentTypes, sdk]);
2189

2290
useEffect(() => {
2391
sdk.app.onConfigure(() => onConfigure());
@@ -31,16 +99,48 @@ const ConfigScreen = () => {
3199
setParameters(currentParameters);
32100
}
33101

102+
await loadContentTypesAndRestoreState();
34103
sdk.app.setReady();
35104
})();
36105
}, [sdk]);
37106

107+
if (isLoading) {
108+
return (
109+
<Flex justifyContent="center" alignItems="center">
110+
<Paragraph>Loading content types...</Paragraph>
111+
</Flex>
112+
);
113+
}
114+
38115
return (
39-
<Flex flexDirection="column" className={css({ margin: '80px', maxWidth: '800px' })}>
40-
<Form>
41-
<Heading>App Config</Heading>
42-
<Paragraph>Welcome to your contentful app. This is your config page.</Paragraph>
43-
</Form>
116+
<Flex justifyContent="center" marginTop="spacingL" marginLeft="spacingL" marginRight="spacingL">
117+
<Flex className={styles.container} flexDirection="column" alignItems="flex-start">
118+
<Flex flexDirection="column" alignItems="flex-start">
119+
<Heading marginBottom="spacingS">Set up Field Populator</Heading>
120+
<Paragraph marginBottom="spacing2Xl">
121+
Save time localizing content by instantly copying field values across locales with the
122+
Field Populator app.
123+
</Paragraph>
124+
</Flex>
125+
<Flex flexDirection="column" alignItems="flex-start">
126+
<Heading as="h3" marginBottom="spacingXs">
127+
Assign content types
128+
</Heading>
129+
<Paragraph marginBottom="spacingL">
130+
{`Select the content type(s) you want to use with Field Populator. You can change this
131+
anytime by navigating to the 'Sidebar' tab in your content model.`}
132+
</Paragraph>
133+
<FormControl id="contentTypes" style={{ width: '100%' }}>
134+
<FormControl.Label>Content types</FormControl.Label>
135+
<ContentTypeMultiSelect
136+
availableContentTypes={allContentTypes}
137+
selectedContentTypes={selectedContentTypes}
138+
onSelectionChange={setSelectedContentTypes}
139+
isDisabled={allContentTypes.length === 0}
140+
/>
141+
</FormControl>
142+
</Flex>
143+
</Flex>
44144
</Flex>
45145
);
46146
};

0 commit comments

Comments
 (0)