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();
+ });
+});