Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/closest-preview/src/components/ContentTypeMultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@ import { getContentTypesWithoutLivePreview } from '../utils/livePreviewUtils';
type ContentTypeMultiSelectProps = {
selectedContentTypes: ContentType[];
setSelectedContentTypes: (contentTypes: ContentType[]) => void;
slugFieldId: string;
sdk: ConfigAppSDK;
cma: CMAClient;
excludedContentTypesIds?: string[];
};

const DEFAULT_EXCLUDED_CONTENT_TYPES_IDS: string[] = [];

const ContentTypeMultiSelect: React.FC<ContentTypeMultiSelectProps> = ({
selectedContentTypes,
setSelectedContentTypes,
slugFieldId,
sdk,
cma,
excludedContentTypesIds = [],
excludedContentTypesIds = DEFAULT_EXCLUDED_CONTENT_TYPES_IDS,
}) => {
const [availableContentTypes, setAvailableContentTypes] = useState<ContentType[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
Expand Down Expand Up @@ -50,7 +54,8 @@ const ContentTypeMultiSelect: React.FC<ContentTypeMultiSelectProps> = ({

const contentTypesWithoutLivePreview = await getContentTypesWithoutLivePreview(
cma,
excludedContentTypesIds
excludedContentTypesIds,
slugFieldId
);

const newAvailableContentTypes = contentTypesWithoutLivePreview
Expand All @@ -76,7 +81,7 @@ const ContentTypeMultiSelect: React.FC<ContentTypeMultiSelectProps> = ({
setIsLoading(false);
}
})();
}, []);
}, [cma, excludedContentTypesIds, sdk, setSelectedContentTypes, slugFieldId]);

if (isLoading) {
return (
Expand Down
71 changes: 63 additions & 8 deletions apps/closest-preview/src/locations/ConfigScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import { useCallback, useState, useEffect } from 'react';
import { ConfigAppSDK } from '@contentful/app-sdk';
import { Heading, Form, Paragraph, Flex, Box, FormControl, Note } from '@contentful/f36-components';
import {
Heading,
Form,
Paragraph,
Flex,
Box,
FormControl,
Note,
TextInput,
} from '@contentful/f36-components';
import { useSDK } from '@contentful/react-apps-toolkit';
import ContentTypeMultiSelect from '../components/ContentTypeMultiSelect';
import { ContentType } from '../types';
import { AppInstallationParameters, ContentType, DEFAULT_SLUG_FIELD_ID } from '../types';
import { styles } from './ConfigScreen.styles';

const ConfigScreen = () => {
const sdk = useSDK<ConfigAppSDK>();
const [selectedContentTypes, setSelectedContentTypes] = useState<ContentType[]>([]);
const [parameters, setParameters] = useState<AppInstallationParameters>({
slugFieldId: DEFAULT_SLUG_FIELD_ID,
});
const normalizedSlugFieldId = parameters.slugFieldId?.trim() || DEFAULT_SLUG_FIELD_ID;

const onConfigure = useCallback(async () => {
const editorInterface = selectedContentTypes.reduce((acc, contentType) => {
Expand All @@ -23,22 +36,38 @@ const ConfigScreen = () => {
const currentState = await sdk.app.getCurrentState();

return {
parameters: {
slugFieldId: normalizedSlugFieldId,
},
targetState: {
...currentState,
EditorInterface: editorInterface,
},
};
}, [sdk, selectedContentTypes]);
}, [normalizedSlugFieldId, sdk, selectedContentTypes]);

useEffect(() => {
sdk.app.onConfigure(() => onConfigure());
}, [sdk, onConfigure]);

useEffect(() => {
(async () => {
sdk.app.setReady();
try {
const currentParameters = await sdk.app.getParameters<AppInstallationParameters>();

if (currentParameters) {
setParameters({
slugFieldId: currentParameters.slugFieldId || DEFAULT_SLUG_FIELD_ID,
});
}
} catch (error) {
console.error('Failed to load app installation parameters:', error);
sdk.notifier.error('Failed to load Closest Preview configuration. Please try again.');
} finally {
sdk.app.setReady();
}
})();
}, []);
}, [sdk]);

return (
<Flex justifyContent="center" alignItems="center">
Expand All @@ -52,6 +81,31 @@ const ConfigScreen = () => {
given entry in order to preview the item.
</Paragraph>

<Box marginBottom="spacing2Xl">
<Heading as="h3" marginBottom="spacingXs">
Preview field
</Heading>
<Paragraph marginBottom="spacingL">
Choose the field id used to identify page-level entries with Live Preview enabled. The
default is <code>{DEFAULT_SLUG_FIELD_ID}</code>.
</Paragraph>
<FormControl id="slugFieldId" marginBottom="spacingL">
<FormControl.Label>Preview field id</FormControl.Label>
<TextInput
name="slugFieldId"
value={parameters.slugFieldId}
onChange={(event) =>
setParameters({
slugFieldId: event.target.value,
})
}
/>
<FormControl.HelpText>
Closest Preview will treat entries with this field populated as previewable pages.
</FormControl.HelpText>
</FormControl>
</Box>

<Box marginBottom="spacing2Xl">
<Heading as="h3" marginBottom="spacingXs">
Assign content types
Expand All @@ -65,15 +119,16 @@ const ConfigScreen = () => {
<ContentTypeMultiSelect
selectedContentTypes={selectedContentTypes}
setSelectedContentTypes={setSelectedContentTypes}
slugFieldId={normalizedSlugFieldId}
sdk={sdk}
cma={sdk.cma}
/>
</FormControl>
</Box>
<Note variant="neutral">
This app assumes that all content types with Live Preview enabled include a field whose
id is 'slug'. If that field is missing or uses a different id, the app will not function
correctly.
This app treats content types with a populated <code>{normalizedSlugFieldId}</code>{' '}
field as previewable pages. Leave the default value if your page entries already use{' '}
<code>{DEFAULT_SLUG_FIELD_ID}</code>.
</Note>
</Form>
</Box>
Expand Down
6 changes: 6 additions & 0 deletions apps/closest-preview/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ export interface ContentType {
id: string;
name: string;
}

export interface AppInstallationParameters {
slugFieldId?: string;
}

export const DEFAULT_SLUG_FIELD_ID = 'slug';
17 changes: 12 additions & 5 deletions apps/closest-preview/src/utils/livePreviewUtils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { EntryProps, KeyValueMap } from 'contentful-management';
import { CMAClient, SidebarAppSDK } from '@contentful/app-sdk';
import { getEntry } from './entryUtils';
import { DEFAULT_SLUG_FIELD_ID } from '../types';

export const getContentTypesWithoutLivePreview = async (
cma: CMAClient,
excludedContentTypesIds: string[] = []
excludedContentTypesIds: string[] = [],
slugFieldId: string = DEFAULT_SLUG_FIELD_ID
): Promise<any[]> => {
try {
let allContentTypes: any[] = [];
Expand All @@ -27,7 +29,7 @@ export const getContentTypesWithoutLivePreview = async (

const contentTypesWithoutLivePreview = allContentTypes.filter((contentType) => {
const isExcluded = excludedContentTypesIds.includes(contentType.sys.id);
const hasSlugField = contentType.fields?.some((field: any) => field.id === 'slug');
const hasSlugField = contentType.fields?.some((field: any) => field.id === slugFieldId);

return !isExcluded && !hasSlugField;
});
Expand All @@ -54,8 +56,12 @@ export const getRelatedEntries = async (sdk: SidebarAppSDK, id: string): Promise
}
};

export const hasLivePreview = (entry: EntryProps<KeyValueMap>, defaultLocale: string): boolean => {
return !!entry.fields.slug?.[defaultLocale];
export const hasLivePreview = (
entry: EntryProps<KeyValueMap>,
defaultLocale: string,
slugFieldId: string = DEFAULT_SLUG_FIELD_ID
): boolean => {
return !!entry.fields[slugFieldId]?.[defaultLocale];
};

export const isNotChecked = (
Expand All @@ -76,6 +82,7 @@ export const getRootEntries = async (sdk: SidebarAppSDK): Promise<EntryProps[]>
const rootEntryData: EntryProps[] = [];
let childEntries: EntryProps[] = [];
const checkedEntries: Set<string> = new Set([sdk.ids.entry]);
const slugFieldId = sdk.parameters.installation.slugFieldId || DEFAULT_SLUG_FIELD_ID;

const initialEntry = await getEntry(sdk);

Expand All @@ -99,7 +106,7 @@ export const getRootEntries = async (sdk: SidebarAppSDK): Promise<EntryProps[]>
if (isNotChecked(entry, checkedEntries)) {
checkedEntries.add(entry.sys.id);

if (hasLivePreview(entry, sdk.locales.default)) {
if (hasLivePreview(entry, sdk.locales.default, slugFieldId)) {
entriesWithLivePreview.push(entry);
} else {
entriesWithoutLivePreview.push(entry);
Expand Down
68 changes: 68 additions & 0 deletions apps/closest-preview/test/locations/ConfigScreen.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ describe('ConfigScreen', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSdk.app.getCurrentState.mockResolvedValue({});
mockSdk.app.getParameters.mockResolvedValue({});
mockSdk.app.setReady.mockResolvedValue();
mockSdk.app.onConfigure.mockImplementation((cb: () => Promise<any>) => {
// Simulate Contentful's onConfigure callback registration
mockSdk._onConfigure = cb;
});
mockSdk.ids.space = 'test-space';
mockSdk.parameters.installation.slugFieldId = 'slug';
mockSdk.cma = mockCma;
mockCma.contentType = {
getMany: vi.fn().mockResolvedValue({ items: [] }),
Expand All @@ -40,6 +42,8 @@ describe('ConfigScreen', () => {
expect(screen.getByText('Set up Closest Preview')).toBeInTheDocument();
expect(screen.getByText('Assign content types')).toBeInTheDocument();
expect(screen.getByText('Content types')).toBeInTheDocument();
expect(screen.getByText('Preview field')).toBeInTheDocument();
expect(screen.getByDisplayValue('slug')).toBeInTheDocument();
expect(
screen.getByText(
'Closest Preview allows users to quickly navigate to the closest page level element for a given entry in order to preview the item.'
Expand Down Expand Up @@ -106,6 +110,9 @@ describe('ConfigScreen', () => {
});

expect(result).toEqual({
parameters: {
slugFieldId: 'slug',
},
targetState: {
EditorInterface: {
blogPost: {
Expand All @@ -127,12 +134,73 @@ describe('ConfigScreen', () => {
});

expect(result).toEqual({
parameters: {
slugFieldId: 'slug',
},
targetState: {
EditorInterface: {},
},
});
});

it('loads and saves a custom preview field id', async () => {
mockSdk.app.getParameters.mockResolvedValue({
slugFieldId: 'url',
});

render(<ConfigScreen />);

expect(await screen.findByDisplayValue('url')).toBeInTheDocument();

const result = await act(async () => {
return await saveAppInstallation();
});

expect(result).toEqual({
parameters: {
slugFieldId: 'url',
},
targetState: {
EditorInterface: {},
},
});
});

it('passes the custom preview field id into content type filtering', async () => {
mockSdk.app.getParameters.mockResolvedValue({
slugFieldId: 'url',
});
mockCma.contentType.getMany.mockResolvedValue({
items: [
{ sys: { id: 'page' }, name: 'Page', fields: [{ id: 'url', type: 'Symbol' }] },
{ sys: { id: 'component' }, name: 'Component', fields: [{ id: 'title', type: 'Symbol' }] },
],
});

render(<ConfigScreen />);

const autocomplete = await screen.findByPlaceholderText('Search content types');
await userEvent.click(autocomplete);

expect(await screen.findByText('Component')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByText('Page')).not.toBeInTheDocument();
});
});

it('still becomes ready when loading parameters fails', async () => {
mockSdk.app.getParameters.mockRejectedValue(new Error('boom'));

render(<ConfigScreen />);

await waitFor(() => {
expect(mockSdk.notifier.error).toHaveBeenCalledWith(
'Failed to load Closest Preview configuration. Please try again.'
);
expect(mockSdk.app.setReady).toHaveBeenCalled();
});
});

it('registers onConfigure callback on mount', async () => {
render(<ConfigScreen />);

Expand Down
Loading
Loading