From 75f24fa3c38ab1de100a3a7e8a07b789140d6064 Mon Sep 17 00:00:00 2001 From: Mitch Goudy Date: Fri, 20 Mar 2026 13:03:19 -0600 Subject: [PATCH 1/3] feat(closest-preview): make preview field id configurable --- .../src/components/ContentTypeMultiSelect.tsx | 11 +- .../src/locations/ConfigScreen.tsx | 63 +++++++++-- apps/closest-preview/src/types.ts | 6 + .../src/utils/livePreviewUtils.ts | 17 ++- .../test/locations/ConfigScreen.spec.tsx | 53 +++++++++ .../test/locations/Sidebar.spec.tsx | 104 +++++++++++++++++- apps/closest-preview/test/mocks/mockCma.ts | 31 +++++- apps/closest-preview/test/mocks/mockSdk.ts | 5 + 8 files changed, 268 insertions(+), 22 deletions(-) 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..82ba196a26 100644 --- a/apps/closest-preview/src/locations/ConfigScreen.tsx +++ b/apps/closest-preview/src/locations/ConfigScreen.tsx @@ -1,14 +1,26 @@ 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 onConfigure = useCallback(async () => { const editorInterface = selectedContentTypes.reduce((acc, contentType) => { @@ -23,12 +35,15 @@ const ConfigScreen = () => { const currentState = await sdk.app.getCurrentState(); return { + parameters: { + slugFieldId: parameters.slugFieldId.trim() || DEFAULT_SLUG_FIELD_ID, + }, targetState: { ...currentState, EditorInterface: editorInterface, }, }; - }, [sdk, selectedContentTypes]); + }, [parameters.slugFieldId, sdk, selectedContentTypes]); useEffect(() => { sdk.app.onConfigure(() => onConfigure()); @@ -36,9 +51,17 @@ const ConfigScreen = () => { useEffect(() => { (async () => { + const currentParameters = await sdk.app.getParameters(); + + if (currentParameters) { + setParameters({ + slugFieldId: currentParameters.slugFieldId || DEFAULT_SLUG_FIELD_ID, + }); + } + sdk.app.setReady(); })(); - }, []); + }, [sdk]); return ( @@ -52,6 +75,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 +113,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 {parameters.slugFieldId}{' '} + 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..8b2a96597b 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..579d5ef4e9 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,58 @@ 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(screen.queryByText('Page')).not.toBeInTheDocument(); + expect(await screen.findByText('Component')).toBeInTheDocument(); + }); + 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', From 9da7c7040f2efea89bccab525753a3211a1f4e4f Mon Sep 17 00:00:00 2001 From: Mitch Goudy Date: Fri, 20 Mar 2026 13:25:00 -0600 Subject: [PATCH 2/3] fix(closest-preview): harden configurable preview field setup --- .../src/locations/ConfigScreen.tsx | 28 +++++++++++-------- apps/closest-preview/src/types.ts | 2 +- .../test/locations/ConfigScreen.spec.tsx | 17 ++++++++++- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/apps/closest-preview/src/locations/ConfigScreen.tsx b/apps/closest-preview/src/locations/ConfigScreen.tsx index 82ba196a26..fa987c121b 100644 --- a/apps/closest-preview/src/locations/ConfigScreen.tsx +++ b/apps/closest-preview/src/locations/ConfigScreen.tsx @@ -21,6 +21,7 @@ const ConfigScreen = () => { 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) => { @@ -36,14 +37,14 @@ const ConfigScreen = () => { return { parameters: { - slugFieldId: parameters.slugFieldId.trim() || DEFAULT_SLUG_FIELD_ID, + slugFieldId: normalizedSlugFieldId, }, targetState: { ...currentState, EditorInterface: editorInterface, }, }; - }, [parameters.slugFieldId, sdk, selectedContentTypes]); + }, [normalizedSlugFieldId, sdk, selectedContentTypes]); useEffect(() => { sdk.app.onConfigure(() => onConfigure()); @@ -51,15 +52,20 @@ const ConfigScreen = () => { useEffect(() => { (async () => { - const currentParameters = await sdk.app.getParameters(); + try { + const currentParameters = await sdk.app.getParameters(); - if (currentParameters) { - setParameters({ - slugFieldId: currentParameters.slugFieldId || DEFAULT_SLUG_FIELD_ID, - }); + 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.app.setReady(); })(); }, [sdk]); @@ -113,14 +119,14 @@ const ConfigScreen = () => { - This app treats content types with a populated {parameters.slugFieldId}{' '} + 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 8b2a96597b..af351a96a0 100644 --- a/apps/closest-preview/src/types.ts +++ b/apps/closest-preview/src/types.ts @@ -4,7 +4,7 @@ export interface ContentType { } export interface AppInstallationParameters { - slugFieldId: string; + slugFieldId?: string; } export const DEFAULT_SLUG_FIELD_ID = 'slug'; diff --git a/apps/closest-preview/test/locations/ConfigScreen.spec.tsx b/apps/closest-preview/test/locations/ConfigScreen.spec.tsx index 579d5ef4e9..1e38cdebc4 100644 --- a/apps/closest-preview/test/locations/ConfigScreen.spec.tsx +++ b/apps/closest-preview/test/locations/ConfigScreen.spec.tsx @@ -182,8 +182,23 @@ describe('ConfigScreen', () => { const autocomplete = await screen.findByPlaceholderText('Search content types'); await userEvent.click(autocomplete); - expect(screen.queryByText('Page')).not.toBeInTheDocument(); 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 () => { From adaf5b35264adca626e56da35cacb28768ceb082 Mon Sep 17 00:00:00 2001 From: Mitch Goudy Date: Thu, 26 Mar 2026 10:06:52 -0600 Subject: [PATCH 3/3] fix: clone rich text embedded entries Support Deep Clone traversing embedded entry references inside Rich Text so cloned documents point at cloned entries instead of the originals. --- apps/deep-clone/src/utils/EntryCloner.ts | 91 +++++- .../deep-clone/test/utils/EntryCloner.spec.ts | 276 ++++++++++++++++++ 2 files changed, 357 insertions(+), 10 deletions(-) diff --git a/apps/deep-clone/src/utils/EntryCloner.ts b/apps/deep-clone/src/utils/EntryCloner.ts index 4fb46ba9f5..65fe6796ad 100644 --- a/apps/deep-clone/src/utils/EntryCloner.ts +++ b/apps/deep-clone/src/utils/EntryCloner.ts @@ -3,6 +3,26 @@ import { CMAClient } from '@contentful/app-sdk'; import { AppParameters } from '@/vite-env'; type ReferenceMap = Record; +type EntryLink = { + sys: { + type: 'Link'; + linkType: 'Entry'; + id: string; + }; +}; +type RichTextNode = { + nodeType?: string; + data?: { + target?: { + sys?: { + type?: string; + linkType?: string; + id?: string; + }; + }; + }; + content?: unknown[]; +}; class EntryCloner { private references: ReferenceMap = {}; @@ -141,10 +161,10 @@ class EntryCloner { private async inspectField(fieldValue: any): Promise { if (!fieldValue) return; - if (this.isReferenceArray(fieldValue)) { + if (Array.isArray(fieldValue)) { await Promise.all( - fieldValue.map((f: any) => { - return this.inspectField(f); + fieldValue.map((nestedValue) => { + return this.inspectField(nestedValue); }) ); return; @@ -152,16 +172,32 @@ class EntryCloner { if (this.isReference(fieldValue)) { await this.findReferences(fieldValue.sys.id); + return; + } + + if (this.isRichTextNode(fieldValue)) { + const embeddedEntryTarget = this.getEmbeddedEntryTarget(fieldValue); + if (embeddedEntryTarget) { + await this.findReferences(embeddedEntryTarget.sys.id); + } + + if (Array.isArray(fieldValue.content)) { + await Promise.all( + fieldValue.content.map((nestedValue) => { + return this.inspectField(nestedValue); + }) + ); + } } } private async updateReferencesOnField(fieldValue: any): Promise { if (!fieldValue) return false; - if (this.isReferenceArray(fieldValue)) { + if (Array.isArray(fieldValue)) { const didUpdateArray = await Promise.all( - fieldValue.map((f: any) => { - return this.updateReferencesOnField(f); + fieldValue.map((nestedValue) => { + return this.updateReferencesOnField(nestedValue); }) ); return didUpdateArray.some((didUpdate) => didUpdate); @@ -173,6 +209,30 @@ class EntryCloner { fieldValue.sys.id = clone.sys.id; return true; } + return false; + } + + if (this.isRichTextNode(fieldValue)) { + let didUpdate = false; + const embeddedEntryTarget = this.getEmbeddedEntryTarget(fieldValue); + if (embeddedEntryTarget) { + const clone = this.clones[embeddedEntryTarget.sys.id]; + if (clone !== undefined) { + embeddedEntryTarget.sys.id = clone.sys.id; + didUpdate = true; + } + } + + if (Array.isArray(fieldValue.content)) { + const didUpdateChildren = await Promise.all( + fieldValue.content.map((nestedValue) => { + return this.updateReferencesOnField(nestedValue); + }) + ); + didUpdate ||= didUpdateChildren.some((childDidUpdate) => childDidUpdate); + } + + return didUpdate; } return false; @@ -188,7 +248,9 @@ class EntryCloner { (await this.cma.contentType.get({ contentTypeId: contentTypeId })); this.contentTypes[contentTypeId] = contentType; - const titleField = contentType.fields.find((field) => field.id === contentType.displayField); + const titleField = contentType.fields.find( + (field: ContentTypeProps['fields'][number]) => field.id === contentType.displayField + ); // Update title field for the clone if (titleField && entryFields[titleField.id]) { @@ -204,11 +266,20 @@ class EntryCloner { return entryFields; } - private isReferenceArray(fieldValue: any): boolean { - return Array.isArray(fieldValue) && fieldValue.some((f: any) => this.isReference(f)); + private isRichTextNode(fieldValue: unknown): fieldValue is RichTextNode { + return typeof fieldValue === 'object' && fieldValue !== null && 'nodeType' in fieldValue; + } + + private getEmbeddedEntryTarget(node: RichTextNode): EntryLink | undefined { + const target = node.data?.target; + if (target && this.isReference(target)) { + return target; + } + + return undefined; } - private isReference(fieldValue: any): boolean { + private isReference(fieldValue: any): fieldValue is EntryLink { return fieldValue.sys && fieldValue.sys.type === 'Link' && fieldValue.sys.linkType === 'Entry'; } } diff --git a/apps/deep-clone/test/utils/EntryCloner.spec.ts b/apps/deep-clone/test/utils/EntryCloner.spec.ts index 359a95140f..57fb99ab14 100644 --- a/apps/deep-clone/test/utils/EntryCloner.spec.ts +++ b/apps/deep-clone/test/utils/EntryCloner.spec.ts @@ -385,6 +385,282 @@ describe('EntryCloner', () => { }); }); + describe('Clone entry with rich text embedded references', () => { + beforeEach(() => { + contentType = getMockContentType([ + { id: 'title', type: 'Text' }, + { id: 'body', type: 'RichText' }, + ]); + + referencedEntry = getMockEntry('referenced-entry-id', { + title: { 'en-US': 'Referenced Entry Title' }, + }); + + const inlineReferencedEntry = getMockEntry('inline-referenced-entry-id', { + title: { 'en-US': 'Inline Referenced Entry Title' }, + }); + + mainEntry = getMockEntry('main-entry-id', { + title: { 'en-US': 'Main Entry Title' }, + body: { + 'en-US': { + nodeType: 'document', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'Before inline reference', + marks: [], + data: {}, + }, + { + nodeType: 'embedded-entry-inline', + data: { + target: { + sys: { type: 'Link', linkType: 'Entry', id: 'inline-referenced-entry-id' }, + }, + }, + content: [], + }, + ], + }, + { + nodeType: 'embedded-entry-block', + data: { + target: { + sys: { type: 'Link', linkType: 'Entry', id: 'referenced-entry-id' }, + }, + }, + content: [], + }, + { + nodeType: 'embedded-asset-block', + data: { + target: { + sys: { type: 'Link', linkType: 'Asset', id: 'asset-id' }, + }, + }, + content: [], + }, + ], + }, + }, + }); + + clonedReferencedEntry = getMockEntry('cloned-referenced-entry-id', { + title: { 'en-US': '[CLONE] Referenced Entry Title' }, + }); + + const clonedInlineReferencedEntry = getMockEntry('cloned-inline-referenced-entry-id', { + title: { 'en-US': '[CLONE] Inline Referenced Entry Title' }, + }); + + clonedMainEntry = getMockEntry('cloned-main-entry-id', { + title: { 'en-US': '[CLONE] Main Entry Title' }, + body: { + 'en-US': { + nodeType: 'document', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'Before inline reference', + marks: [], + data: {}, + }, + { + nodeType: 'embedded-entry-inline', + data: { + target: { + sys: { type: 'Link', linkType: 'Entry', id: 'inline-referenced-entry-id' }, + }, + }, + content: [], + }, + ], + }, + { + nodeType: 'embedded-entry-block', + data: { + target: { + sys: { type: 'Link', linkType: 'Entry', id: 'referenced-entry-id' }, + }, + }, + content: [], + }, + { + nodeType: 'embedded-asset-block', + data: { + target: { + sys: { type: 'Link', linkType: 'Asset', id: 'asset-id' }, + }, + }, + content: [], + }, + ], + }, + }, + }); + + updatedMainEntry = getMockEntry('cloned-main-entry-id', { + title: { 'en-US': '[CLONE] Main Entry Title' }, + body: { + 'en-US': { + nodeType: 'document', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'Before inline reference', + marks: [], + data: {}, + }, + { + nodeType: 'embedded-entry-inline', + data: { + target: { + sys: { + type: 'Link', + linkType: 'Entry', + id: 'cloned-inline-referenced-entry-id', + }, + }, + }, + content: [], + }, + ], + }, + { + nodeType: 'embedded-entry-block', + data: { + target: { + sys: { type: 'Link', linkType: 'Entry', id: 'cloned-referenced-entry-id' }, + }, + }, + content: [], + }, + { + nodeType: 'embedded-asset-block', + data: { + target: { + sys: { type: 'Link', linkType: 'Asset', id: 'asset-id' }, + }, + }, + content: [], + }, + ], + }, + }, + }); + + mockCma.entry.get + .mockResolvedValueOnce(mainEntry) + .mockResolvedValueOnce(inlineReferencedEntry) + .mockResolvedValueOnce(referencedEntry); + mockCma.entry.create + .mockResolvedValueOnce(clonedMainEntry) + .mockResolvedValueOnce(clonedInlineReferencedEntry) + .mockResolvedValueOnce(clonedReferencedEntry); + mockCma.entry.update.mockResolvedValueOnce(updatedMainEntry); + }); + + it('should clone embedded entries inside rich text and leave embedded assets unchanged', async () => { + mockCma.contentType.get.mockResolvedValue(contentType); + + const result = await entryCloner.cloneEntry(); + + expect(result).toEqual(updatedMainEntry); + expect(setReferencesCount).toHaveBeenCalledWith(3); + expect(setClonesCount).toHaveBeenCalledWith(3); + expect(setUpdatesCount).toHaveBeenCalledWith(1); + + expect(mockCma.entry.get).toHaveBeenCalledTimes(3); + expect(mockCma.entry.get).toHaveBeenNthCalledWith(1, { entryId: 'main-entry-id' }); + expect(mockCma.entry.get).toHaveBeenNthCalledWith(2, { + entryId: 'inline-referenced-entry-id', + }); + expect(mockCma.entry.get).toHaveBeenNthCalledWith(3, { entryId: 'referenced-entry-id' }); + expect(mockCma.entry.create).toHaveBeenCalledTimes(3); + expect(mockCma.entry.update).toHaveBeenCalledTimes(1); + + expect(mockCma.entry.update).toHaveBeenCalledWith( + { entryId: 'cloned-main-entry-id' }, + expect.objectContaining({ + fields: { + title: { 'en-US': '[CLONE] Main Entry Title' }, + body: { + 'en-US': { + nodeType: 'document', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'Before inline reference', + marks: [], + data: {}, + }, + { + nodeType: 'embedded-entry-inline', + data: { + target: { + sys: { + type: 'Link', + linkType: 'Entry', + id: 'cloned-inline-referenced-entry-id', + }, + }, + }, + content: [], + }, + ], + }, + { + nodeType: 'embedded-entry-block', + data: { + target: { + sys: { + type: 'Link', + linkType: 'Entry', + id: 'cloned-referenced-entry-id', + }, + }, + }, + content: [], + }, + { + nodeType: 'embedded-asset-block', + data: { + target: { + sys: { type: 'Link', linkType: 'Asset', id: 'asset-id' }, + }, + }, + content: [], + }, + ], + }, + }, + }, + }) + ); + }); + }); + describe('Handle deleted entries', () => { beforeEach(() => { contentType = getMockContentType([