diff --git a/apps/locale-field-populator/src/components/preview/PreviewField.tsx b/apps/locale-field-populator/src/components/preview/PreviewField.tsx index 25cefdf9d9..cc732d6af8 100644 --- a/apps/locale-field-populator/src/components/preview/PreviewField.tsx +++ b/apps/locale-field-populator/src/components/preview/PreviewField.tsx @@ -12,6 +12,7 @@ import { isLinkArray, } from '../../utils/fieldTypes'; import SingleAssetCard from './SingleAssetCard'; +import SingleEntryReferenceCard from './SingleEntryReferenceCard'; import DiffText from './DiffText'; import PreviewBox from './PreviewBox'; import RichTextDiff from './RichTextDiff'; @@ -21,6 +22,7 @@ interface PreviewFieldProps { fieldDefinition: ContentTypeField; locale: string; compareValue?: unknown; + baseUrl: string; } /** @@ -50,7 +52,7 @@ const valueToString = (value: unknown): string | null => { return String(value); }; -const PreviewField = ({ value, fieldDefinition, locale, compareValue }: PreviewFieldProps) => { +const PreviewField = ({ value, fieldDefinition, locale, compareValue, baseUrl }: PreviewFieldProps) => { const valueStr = valueToString(value); const compareValueStr = compareValue === undefined ? null : valueToString(compareValue); const showDiff = valueStr !== null && compareValueStr !== null && valueStr !== compareValueStr; @@ -90,7 +92,11 @@ const PreviewField = ({ value, fieldDefinition, locale, compareValue }: PreviewF } if (isEntryField(fieldDefinition) && isLinkValue(value)) { - return Reference; + return ( + + + + ); } if (isAssetArrayField(fieldDefinition) && isLinkArray(value)) { @@ -108,7 +114,16 @@ const PreviewField = ({ value, fieldDefinition, locale, compareValue }: PreviewF if (isEntryArrayField(fieldDefinition) && isLinkArray(value)) { return ( - {'Reference array'} + + {value.map((link) => ( + + ))} + ); } diff --git a/apps/locale-field-populator/src/components/preview/PreviewFieldRow.tsx b/apps/locale-field-populator/src/components/preview/PreviewFieldRow.tsx index e1c201ddaa..9d6ce5222c 100644 --- a/apps/locale-field-populator/src/components/preview/PreviewFieldRow.tsx +++ b/apps/locale-field-populator/src/components/preview/PreviewFieldRow.tsx @@ -12,6 +12,7 @@ interface PreviewFieldRowProps { isAdopted: boolean; onAdoptedChange: (adopted: boolean) => void; isDisabled?: boolean; + baseUrl: string; } const PreviewFieldRow = ({ @@ -23,6 +24,7 @@ const PreviewFieldRow = ({ isAdopted, onAdoptedChange, isDisabled = false, + baseUrl, }: PreviewFieldRowProps) => { return ( @@ -45,7 +47,12 @@ const PreviewFieldRow = ({ Source - + @@ -56,6 +63,7 @@ const PreviewFieldRow = ({ fieldDefinition={field} locale={targetLocale} compareValue={sourceValue} + baseUrl={baseUrl} /> diff --git a/apps/locale-field-populator/src/components/preview/ReferenceEntrySection.tsx b/apps/locale-field-populator/src/components/preview/ReferenceEntrySection.tsx index b8299556bf..e5ce030175 100644 --- a/apps/locale-field-populator/src/components/preview/ReferenceEntrySection.tsx +++ b/apps/locale-field-populator/src/components/preview/ReferenceEntrySection.tsx @@ -2,7 +2,6 @@ import { ContentTypeField } from '@contentful/app-sdk'; import { Accordion, Box, Checkbox, Flex, Note, Text, TextLink } from '@contentful/f36-components'; import { ContentTypeProps, EntryProps } from 'contentful-management'; import { useMemo } from 'react'; -import { isEntryArrayField, isEntryField } from '../../utils/fieldTypes'; import PreviewFieldRow from './PreviewFieldRow'; import { depthIndent, styles } from './ReferenceEntrySection.styles'; import { ArrowSquareOutIcon } from '@contentful/f36-icons'; @@ -54,10 +53,10 @@ const ReferenceEntrySection = ({ isDisabled = false, depth = 1, }: ReferenceEntrySectionProps) => { + // Reference fields (single and array) are localizable like any other field -- + // populating the link itself across locales is exactly what this app is for. const localizedFields = useMemo(() => { - return (contentType.fields as ContentTypeField[]).filter( - (field) => field.localized && !isEntryField(field) && !isEntryArrayField(field) - ); + return (contentType.fields as ContentTypeField[]).filter((field) => field.localized); }, [contentType.fields]); const fieldCount = localizedFields.length; @@ -184,6 +183,7 @@ const ReferenceEntrySection = ({ isAdopted={adoptedFields[field.id] ?? true} onAdoptedChange={(adopted) => onAdoptedFieldChange(field.id, adopted)} isDisabled={isDisabled} + baseUrl={baseUrl} /> ))} diff --git a/apps/locale-field-populator/src/components/preview/SingleEntryReferenceCard.tsx b/apps/locale-field-populator/src/components/preview/SingleEntryReferenceCard.tsx new file mode 100644 index 0000000000..a42d4a62df --- /dev/null +++ b/apps/locale-field-populator/src/components/preview/SingleEntryReferenceCard.tsx @@ -0,0 +1,99 @@ +import { DialogAppSDK } from '@contentful/app-sdk'; +import { Box, Flex, Skeleton, Text, TextLink } from '@contentful/f36-components'; +import { ArrowSquareOutIcon } from '@contentful/f36-icons'; +import { useAutoResizer, useSDK } from '@contentful/react-apps-toolkit'; +import { ContentTypeProps, EntryProps } from 'contentful-management'; +import { useEffect, useState } from 'react'; + +interface SingleEntryReferenceCardProps { + entryId: string; + locale: string; + baseUrl: string; +} + +const getEntryTitle = ( + entry: EntryProps, + contentType: ContentTypeProps, + locale: string, + defaultLocale: string +): string => { + const displayFieldId = contentType.displayField; + if (!displayFieldId) return 'Untitled'; + + const value = entry.fields[displayFieldId]?.[locale] ?? entry.fields[displayFieldId]?.[defaultLocale]; + if (value === undefined || value === null || value === '') { + return 'Untitled'; + } + return String(value); +}; + +/** + * Resolves and displays the title of a referenced entry, linking out to it. + * Falls back to the raw entry id if the entry can't be fetched (deleted, + * inaccessible, or the fetch simply fails) -- a reference should never look + * broken just because we couldn't resolve a friendly title for it. + */ +const SingleEntryReferenceCard = ({ entryId, locale, baseUrl }: SingleEntryReferenceCardProps) => { + const sdk = useSDK(); + const [title, setTitle] = useState(null); + const [loading, setLoading] = useState(true); + + useAutoResizer(); + + useEffect(() => { + let isMounted = true; + + const fetchEntryTitle = async () => { + try { + setLoading(true); + const entry = await sdk.cma.entry.get({ entryId }); + const contentType = await sdk.cma.contentType.get({ + contentTypeId: entry.sys.contentType.sys.id, + }); + if (isMounted) { + setTitle(getEntryTitle(entry, contentType, locale, sdk.locales.default)); + } + } catch (err) { + console.error('Error fetching referenced entry:', err); + if (isMounted) { + setTitle(null); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + fetchEntryTitle(); + + return () => { + isMounted = false; + }; + }, [entryId, locale, sdk.cma.entry, sdk.cma.contentType, sdk.locales.default]); + + if (loading) { + return ( + + + + ); + } + + return ( + + + } + alignIcon="end"> + {title ?? entryId} + + + + ); +}; + +export default SingleEntryReferenceCard; diff --git a/apps/locale-field-populator/src/components/steps/PreviewStep.tsx b/apps/locale-field-populator/src/components/steps/PreviewStep.tsx index ea0bfc312a..dd3d7ce6e6 100644 --- a/apps/locale-field-populator/src/components/steps/PreviewStep.tsx +++ b/apps/locale-field-populator/src/components/steps/PreviewStep.tsx @@ -22,7 +22,6 @@ import { setAllEntryFieldsAdopted, setFieldAdopted, } from '../../utils/adoptedFields'; -import { isEntryArrayField, isEntryField } from '../../utils/fieldTypes'; import { SimplifiedLocale } from '../../utils/locales'; import PreviewBox from '../preview/PreviewBox'; import PreviewFieldRow from '../preview/PreviewFieldRow'; @@ -110,10 +109,10 @@ const PreviewStepComponent = ({ return locale?.name || sourceLocale; }, [availableLocales, sourceLocale]); + // Reference fields (single and array) are localizable like any other field -- + // populating the link itself across locales is exactly what this app is for. const localizedFields = useMemo(() => { - return contentType.fields.filter( - (field) => field.localized && !isEntryField(field) && !isEntryArrayField(field) - ); + return contentType.fields.filter((field) => field.localized); }, [contentType.fields]); const allFieldsAdopted = useMemo(() => { @@ -123,9 +122,7 @@ const PreviewStepComponent = ({ const totalReferencedFields = useMemo(() => { return referencedEntries.reduce((count, ref) => { if (ref.isSelfReference || ref.isAlreadyIncluded) return count; - const fields = ref.contentType.fields.filter( - (f) => f.localized && !isEntryField(f) && !isEntryArrayField(f) - ); + const fields = ref.contentType.fields.filter((f) => f.localized); return count + fields.length; }, 0); }, [referencedEntries]); @@ -139,9 +136,7 @@ const PreviewStepComponent = ({ const hasMore = visibleCount < referencedEntries.length; const handleAdoptAll = (entryId: string, contentType: ContentTypeProps, adopted: boolean) => { - const fieldIds = contentType.fields - .filter((f) => f.localized && !isEntryField(f) && !isEntryArrayField(f)) - .map((f) => f.id); + const fieldIds = contentType.fields.filter((f) => f.localized).map((f) => f.id); onAdoptedFieldsChange(setAllEntryFieldsAdopted(adoptedFields, entryId, fieldIds, adopted)); }; @@ -234,10 +229,6 @@ const PreviewStepComponent = ({ {/* Main entry field rows */} {contentType.fields.map((field) => { - if (isEntryField(field) || isEntryArrayField(field)) { - return null; - } - if (field.localized) { return ( handleFieldAdopted(entry.sys.id, field.id, adopted)} isDisabled={isDisabled} + baseUrl={baseUrl} /> ); } diff --git a/apps/locale-field-populator/test/adoptedFields.reference.spec.ts b/apps/locale-field-populator/test/adoptedFields.reference.spec.ts new file mode 100644 index 0000000000..a5afb71c02 --- /dev/null +++ b/apps/locale-field-populator/test/adoptedFields.reference.spec.ts @@ -0,0 +1,86 @@ +// adoptedFields.reference.spec.ts +// +// Proof-of-concept coverage for CCS-3539: reference fields (single Link and +// Array-of-Link) must be selectable/adoptable like any other localized field. +// This exercises the field-selection helpers directly rather than mounting +// the full Dialog flow, since the behavior under test is "which fields does +// the app consider copyable", not full UI orchestration. +import { describe, it, expect } from 'vitest'; +import { ContentTypeProps } from 'contentful-management'; +import { + hasAnyAdoptedFields, + setAllEntryFieldsAdopted, + setFieldAdopted, +} from '../src/utils/adoptedFields'; + +const contentTypeWithReferenceFields: ContentTypeProps = { + sys: { id: 'article', type: 'ContentType' }, + name: 'Article', + displayField: 'title', + fields: [ + { id: 'title', name: 'Title', type: 'Symbol', localized: true }, + { + id: 'relatedArticle', + name: 'Related Article', + type: 'Link', + linkType: 'Entry', + localized: true, + }, + { + id: 'relatedArticles', + name: 'Related Articles', + type: 'Array', + items: { type: 'Link', linkType: 'Entry' }, + localized: true, + }, + { id: 'internalNote', name: 'Internal Note', type: 'Symbol', localized: false }, + ], +} as unknown as ContentTypeProps; + +describe('adoptedFields: reference field selection (CCS-3539)', () => { + it('setAllEntryFieldsAdopted marks localized reference fields as adopted', () => { + const localizedFieldIds = contentTypeWithReferenceFields.fields + .filter((f) => f.localized) + .map((f) => f.id); + + const result = setAllEntryFieldsAdopted({}, 'entry-1', localizedFieldIds, true); + + expect(result['entry-1']).toEqual({ + title: true, + relatedArticle: true, + relatedArticles: true, + }); + // Non-localized fields have exactly one value across all locales by + // definition -- there's nothing to copy, so they should never appear. + expect(result['entry-1']).not.toHaveProperty('internalNote'); + }); + + it('setFieldAdopted toggles a single reference field independently of other fields', () => { + const initial = setAllEntryFieldsAdopted( + {}, + 'entry-1', + ['title', 'relatedArticle', 'relatedArticles'], + true + ); + + const result = setFieldAdopted(initial, 'entry-1', 'relatedArticle', false); + + expect(result['entry-1']).toEqual({ + title: true, + relatedArticle: false, + relatedArticles: true, + }); + }); + + it('hasAnyAdoptedFields is true when only a reference field is adopted', () => { + const map = setFieldAdopted({}, 'entry-1', 'relatedArticle', true); + + expect(hasAnyAdoptedFields(map)).toBe(true); + }); + + it('hasAnyAdoptedFields is false when a reference field is explicitly not adopted', () => { + const map = setFieldAdopted({}, 'entry-1', 'relatedArticle', false); + + expect(hasAnyAdoptedFields(map)).toBe(false); + }); +}); diff --git a/apps/locale-field-populator/test/updateEntries.reference.spec.ts b/apps/locale-field-populator/test/updateEntries.reference.spec.ts new file mode 100644 index 0000000000..f0e1a69fa1 --- /dev/null +++ b/apps/locale-field-populator/test/updateEntries.reference.spec.ts @@ -0,0 +1,112 @@ +// updateEntries.reference.spec.ts +// +// Proof-of-concept coverage for CCS-3539: confirms updateEntries actually +// copies a reference Link value across locales when it's adopted, the same +// way it already copies plain text fields. This has always worked at the +// CMA-write layer (updateSingleEntry is field-type-agnostic) -- the bug was +// that reference fields never made it into the adopted-fields set upstream. +// This test locks in that the write path handles Link values correctly now +// that they can reach it. +import { describe, it, expect, vi } from 'vitest'; +import { updateEntries } from '../src/utils/entry'; + +describe('updateEntries: copies reference field links across locales (CCS-3539)', () => { + it('copies a single-entry Link value from the source locale to all target locales', async () => { + const entry = { + sys: { id: 'entry-1' }, + fields: { + relatedArticle: { + 'en-US': { sys: { type: 'Link', linkType: 'Entry', id: 'entry-2' } }, + }, + }, + }; + + const cma: any = { + entry: { + get: vi.fn().mockResolvedValue(entry), + update: vi.fn().mockResolvedValue(entry), + }, + }; + + const result = await updateEntries(cma, 'entry-1', 'en-US', ['fr', 'de'], { + 'entry-1': { relatedArticle: true }, + }); + + expect(result.errors).toBeUndefined(); + expect(result.fieldsUpdated).toBe(1); + expect(result.entriesUpdated).toBe(1); + + const updatedEntry = cma.entry.update.mock.calls[0][1]; + expect(updatedEntry.fields.relatedArticle.fr).toEqual({ + sys: { type: 'Link', linkType: 'Entry', id: 'entry-2' }, + }); + expect(updatedEntry.fields.relatedArticle.de).toEqual({ + sys: { type: 'Link', linkType: 'Entry', id: 'entry-2' }, + }); + // Source locale value is untouched. + expect(updatedEntry.fields.relatedArticle['en-US']).toEqual({ + sys: { type: 'Link', linkType: 'Entry', id: 'entry-2' }, + }); + }); + + it('copies an Array-of-Link value from the source locale to all target locales', async () => { + const entry = { + sys: { id: 'entry-1' }, + fields: { + relatedArticles: { + 'en-US': [ + { sys: { type: 'Link', linkType: 'Entry', id: 'entry-2' } }, + { sys: { type: 'Link', linkType: 'Entry', id: 'entry-3' } }, + ], + }, + }, + }; + + const cma: any = { + entry: { + get: vi.fn().mockResolvedValue(entry), + update: vi.fn().mockResolvedValue(entry), + }, + }; + + const result = await updateEntries(cma, 'entry-1', 'en-US', ['fr'], { + 'entry-1': { relatedArticles: true }, + }); + + expect(result.errors).toBeUndefined(); + expect(result.fieldsUpdated).toBe(1); + + const updatedEntry = cma.entry.update.mock.calls[0][1]; + expect(updatedEntry.fields.relatedArticles.fr).toEqual([ + { sys: { type: 'Link', linkType: 'Entry', id: 'entry-2' } }, + { sys: { type: 'Link', linkType: 'Entry', id: 'entry-3' } }, + ]); + }); + + it('does not touch a reference field that was not adopted', async () => { + const entry = { + sys: { id: 'entry-1' }, + fields: { + title: { 'en-US': 'Hello' }, + relatedArticle: { + 'en-US': { sys: { type: 'Link', linkType: 'Entry', id: 'entry-2' } }, + }, + }, + }; + + const cma: any = { + entry: { + get: vi.fn().mockResolvedValue(entry), + update: vi.fn().mockResolvedValue(entry), + }, + }; + + await updateEntries(cma, 'entry-1', 'en-US', ['fr'], { + 'entry-1': { title: true, relatedArticle: false }, + }); + + const updatedEntry = cma.entry.update.mock.calls[0][1]; + expect(updatedEntry.fields.title.fr).toBe('Hello'); + expect(updatedEntry.fields.relatedArticle?.fr).toBeUndefined(); + }); +});