Skip to content
Draft
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';
Comment on lines +6 to +10

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description notes updates only in apps/deep-clone, but this PR also changes apps/closest-preview (new installation parameter + filtering logic + tests). Please either update the PR description/title/scope to reflect Closest Preview changes, or split these into a separate PR to keep review and release impact clear.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is valid and I am not treating it as ignorable. The PR currently mixes Deep Clone and Closest Preview changes, so before merge it should either be split into focused PRs or have its title/description updated to reflect the full scope.

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