diff --git a/apps/closest-preview/src/components/ContentTypeMultiSelect.tsx b/apps/closest-preview/src/components/ContentTypeMultiSelect.tsx index 33a5cba2de..0bcf5bb929 100644 --- a/apps/closest-preview/src/components/ContentTypeMultiSelect.tsx +++ b/apps/closest-preview/src/components/ContentTypeMultiSelect.tsx @@ -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 = ({ selectedContentTypes, setSelectedContentTypes, + slugFieldId, sdk, cma, - excludedContentTypesIds = [], + excludedContentTypesIds = DEFAULT_EXCLUDED_CONTENT_TYPES_IDS, }) => { const [availableContentTypes, setAvailableContentTypes] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -50,7 +54,8 @@ const ContentTypeMultiSelect: React.FC = ({ const contentTypesWithoutLivePreview = await getContentTypesWithoutLivePreview( cma, - excludedContentTypesIds + excludedContentTypesIds, + slugFieldId ); const newAvailableContentTypes = contentTypesWithoutLivePreview @@ -76,7 +81,7 @@ const ContentTypeMultiSelect: React.FC = ({ setIsLoading(false); } })(); - }, []); + }, [cma, excludedContentTypesIds, sdk, setSelectedContentTypes, slugFieldId]); if (isLoading) { return ( diff --git a/apps/closest-preview/src/locations/ConfigScreen.tsx b/apps/closest-preview/src/locations/ConfigScreen.tsx index 87b2f8e4ee..fa987c121b 100644 --- a/apps/closest-preview/src/locations/ConfigScreen.tsx +++ b/apps/closest-preview/src/locations/ConfigScreen.tsx @@ -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(); const [selectedContentTypes, setSelectedContentTypes] = useState([]); + const [parameters, setParameters] = useState({ + slugFieldId: DEFAULT_SLUG_FIELD_ID, + }); + const normalizedSlugFieldId = parameters.slugFieldId?.trim() || DEFAULT_SLUG_FIELD_ID; const onConfigure = useCallback(async () => { const editorInterface = selectedContentTypes.reduce((acc, contentType) => { @@ -23,12 +36,15 @@ 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()); @@ -36,9 +52,22 @@ const ConfigScreen = () => { useEffect(() => { (async () => { - sdk.app.setReady(); + try { + const currentParameters = await sdk.app.getParameters(); + + 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 ( @@ -52,6 +81,31 @@ const ConfigScreen = () => { given entry in order to preview the item. + + + Preview field + + + Choose the field id used to identify page-level entries with Live Preview enabled. The + default is {DEFAULT_SLUG_FIELD_ID}. + + + Preview field id + + setParameters({ + slugFieldId: event.target.value, + }) + } + /> + + Closest Preview will treat entries with this field populated as previewable pages. + + + + Assign content types @@ -65,15 +119,16 @@ const ConfigScreen = () => { - 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 {normalizedSlugFieldId}{' '} + field as previewable pages. Leave the default value if your page entries already use{' '} + {DEFAULT_SLUG_FIELD_ID}. diff --git a/apps/closest-preview/src/types.ts b/apps/closest-preview/src/types.ts index a69a441950..af351a96a0 100644 --- a/apps/closest-preview/src/types.ts +++ b/apps/closest-preview/src/types.ts @@ -2,3 +2,9 @@ export interface ContentType { id: string; name: string; } + +export interface AppInstallationParameters { + slugFieldId?: string; +} + +export const DEFAULT_SLUG_FIELD_ID = 'slug'; diff --git a/apps/closest-preview/src/utils/livePreviewUtils.ts b/apps/closest-preview/src/utils/livePreviewUtils.ts index 8ac6d352a0..cbe9e8a001 100644 --- a/apps/closest-preview/src/utils/livePreviewUtils.ts +++ b/apps/closest-preview/src/utils/livePreviewUtils.ts @@ -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 => { try { let allContentTypes: any[] = []; @@ -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; }); @@ -54,8 +56,12 @@ export const getRelatedEntries = async (sdk: SidebarAppSDK, id: string): Promise } }; -export const hasLivePreview = (entry: EntryProps, defaultLocale: string): boolean => { - return !!entry.fields.slug?.[defaultLocale]; +export const hasLivePreview = ( + entry: EntryProps, + defaultLocale: string, + slugFieldId: string = DEFAULT_SLUG_FIELD_ID +): boolean => { + return !!entry.fields[slugFieldId]?.[defaultLocale]; }; export const isNotChecked = ( @@ -76,6 +82,7 @@ export const getRootEntries = async (sdk: SidebarAppSDK): Promise const rootEntryData: EntryProps[] = []; let childEntries: EntryProps[] = []; const checkedEntries: Set = new Set([sdk.ids.entry]); + const slugFieldId = sdk.parameters.installation.slugFieldId || DEFAULT_SLUG_FIELD_ID; const initialEntry = await getEntry(sdk); @@ -99,7 +106,7 @@ export const getRootEntries = async (sdk: SidebarAppSDK): Promise 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); diff --git a/apps/closest-preview/test/locations/ConfigScreen.spec.tsx b/apps/closest-preview/test/locations/ConfigScreen.spec.tsx index 8aee01ca34..1e38cdebc4 100644 --- a/apps/closest-preview/test/locations/ConfigScreen.spec.tsx +++ b/apps/closest-preview/test/locations/ConfigScreen.spec.tsx @@ -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) => { // 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: [] }), @@ -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.' @@ -106,6 +110,9 @@ describe('ConfigScreen', () => { }); expect(result).toEqual({ + parameters: { + slugFieldId: 'slug', + }, targetState: { EditorInterface: { blogPost: { @@ -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(); + + 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(); + + 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(); + + 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(); diff --git a/apps/closest-preview/test/locations/Sidebar.spec.tsx b/apps/closest-preview/test/locations/Sidebar.spec.tsx index da7852983d..22bba07ea3 100644 --- a/apps/closest-preview/test/locations/Sidebar.spec.tsx +++ b/apps/closest-preview/test/locations/Sidebar.spec.tsx @@ -1,7 +1,7 @@ import Sidebar from '../../src/locations/Sidebar'; import { render, waitFor, screen } from '@testing-library/react'; -import { mockSdk } from '../mocks'; -import { vi } from 'vitest'; +import { mockCma, mockSdk } from '../mocks'; +import { beforeEach, vi } from 'vitest'; vi.mock('@contentful/react-apps-toolkit', () => ({ useSDK: () => mockSdk, @@ -9,6 +9,82 @@ vi.mock('@contentful/react-apps-toolkit', () => ({ })); describe('Sidebar component', () => { + beforeEach(() => { + mockSdk.parameters.installation.slugFieldId = 'slug'; + mockCma.entry.getMany.mockResolvedValue({ + items: [ + { + sys: { + id: 'Entry id 1', + updatedAt: '2021-01-01', + contentType: { sys: { id: 'blogPost' } }, + }, + fields: { + title: { 'en-US': 'Entry Title 1' }, + slug: { 'en-US': 'entry-1' }, + url: { 'en-US': 'entry-1' }, + }, + }, + { + sys: { + id: 'Entry id 2', + updatedAt: '2021-01-01', + contentType: { sys: { id: 'blogPost' } }, + }, + fields: { + title: { 'en-US': '' }, + slug: { 'en-US': 'entry-2' }, + url: { 'en-US': 'entry-2' }, + }, + }, + { + sys: { + id: 'Entry id 3', + updatedAt: '2021-01-01', + contentType: { sys: { id: 'blogPost' } }, + }, + fields: { + title: { 'en-US': undefined }, + slug: { 'en-US': 'entry-3' }, + url: { 'en-US': 'entry-3' }, + }, + }, + { + sys: { + id: 'Entry id 4', + updatedAt: '2021-01-01', + contentType: { sys: { id: 'blogPost' } }, + }, + fields: { + title: { 'en-US': 'Entry Title 4' }, + slug: { 'en-US': 'entry-4' }, + url: { 'en-US': 'entry-4' }, + }, + }, + { + sys: { + id: 'Entry id 5', + updatedAt: '2021-01-01', + contentType: { sys: { id: 'blogPost' } }, + }, + fields: { + title: { 'en-US': 'Entry Title 5' }, + slug: { 'en-US': 'entry-5' }, + url: { 'en-US': 'entry-5' }, + }, + }, + { + sys: { + id: 'Entry id 6', + updatedAt: '2021-01-01', + contentType: { sys: { id: 'blogPost' } }, + }, + fields: { title: { 'en-US': 'Non-root (no slug)' }, slug: { 'en-US': undefined } }, + }, + ], + }); + }); + it('Renders 5 entries with links and relative dates', async () => { const { getAllByText } = render(); @@ -55,4 +131,28 @@ describe('Sidebar component', () => { `https://${mockSdk.hostnames.webapp}/spaces/${mockSdk.ids.space}/environments/${mockSdk.ids.environmentAlias}/entries/Entry id 1` ); }); + + it('uses the configured preview field id when finding root entries', async () => { + mockSdk.parameters.installation.slugFieldId = 'url'; + mockCma.entry.getMany.mockResolvedValue({ + items: [ + { + sys: { + id: 'Entry id 1', + updatedAt: '2021-01-01', + contentType: { sys: { id: 'blogPost' } }, + }, + fields: { + title: { 'en-US': 'Entry Title 1' }, + slug: { 'en-US': undefined }, + url: { 'en-US': 'entry-1' }, + }, + }, + ], + }); + + render(); + + expect(await screen.findByRole('link', { name: 'Entry Title 1' })).toBeInTheDocument(); + }); }); diff --git a/apps/closest-preview/test/mocks/mockCma.ts b/apps/closest-preview/test/mocks/mockCma.ts index 3c86b4287e..a6b2ad4f45 100644 --- a/apps/closest-preview/test/mocks/mockCma.ts +++ b/apps/closest-preview/test/mocks/mockCma.ts @@ -10,6 +10,7 @@ const mockCma: any = { fields: [ { id: 'title', type: 'Symbol' }, { id: 'slug', type: 'Symbol' }, + { id: 'url', type: 'Symbol' }, ], }), }, @@ -33,7 +34,11 @@ const mockCma: any = { updatedAt: '2021-01-01', contentType: { sys: { id: 'blogPost' } }, }, - fields: { title: { 'en-US': 'Entry Title 1' }, slug: { 'en-US': 'entry-1' } }, + fields: { + title: { 'en-US': 'Entry Title 1' }, + slug: { 'en-US': 'entry-1' }, + url: { 'en-US': 'entry-1' }, + }, }, { sys: { @@ -41,7 +46,11 @@ const mockCma: any = { updatedAt: '2021-01-01', contentType: { sys: { id: 'blogPost' } }, }, - fields: { title: { 'en-US': '' }, slug: { 'en-US': 'entry-2' } }, + fields: { + title: { 'en-US': '' }, + slug: { 'en-US': 'entry-2' }, + url: { 'en-US': 'entry-2' }, + }, }, { sys: { @@ -49,7 +58,11 @@ const mockCma: any = { updatedAt: '2021-01-01', contentType: { sys: { id: 'blogPost' } }, }, - fields: { title: { 'en-US': undefined }, slug: { 'en-US': 'entry-3' } }, + fields: { + title: { 'en-US': undefined }, + slug: { 'en-US': 'entry-3' }, + url: { 'en-US': 'entry-3' }, + }, }, { sys: { @@ -57,7 +70,11 @@ const mockCma: any = { updatedAt: '2021-01-01', contentType: { sys: { id: 'blogPost' } }, }, - fields: { title: { 'en-US': 'Entry Title 4' }, slug: { 'en-US': 'entry-4' } }, + fields: { + title: { 'en-US': 'Entry Title 4' }, + slug: { 'en-US': 'entry-4' }, + url: { 'en-US': 'entry-4' }, + }, }, { sys: { @@ -65,7 +82,11 @@ const mockCma: any = { updatedAt: '2021-01-01', contentType: { sys: { id: 'blogPost' } }, }, - fields: { title: { 'en-US': 'Entry Title 5' }, slug: { 'en-US': 'entry-5' } }, + fields: { + title: { 'en-US': 'Entry Title 5' }, + slug: { 'en-US': 'entry-5' }, + url: { 'en-US': 'entry-5' }, + }, }, { sys: { diff --git a/apps/closest-preview/test/mocks/mockSdk.ts b/apps/closest-preview/test/mocks/mockSdk.ts index c1b4851a7a..0801cf46a2 100644 --- a/apps/closest-preview/test/mocks/mockSdk.ts +++ b/apps/closest-preview/test/mocks/mockSdk.ts @@ -16,6 +16,11 @@ const mockSdk: any = { entry: 'root-entry', }, locales: { default: 'en-US' }, + parameters: { + installation: { + slugFieldId: 'slug', + }, + }, cma: mockCma, hostnames: { webapp: 'app.contentful.com',