Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +22,7 @@ interface PreviewFieldProps {
fieldDefinition: ContentTypeField;
locale: string;
compareValue?: unknown;
baseUrl: string;
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -90,7 +92,11 @@ const PreviewField = ({ value, fieldDefinition, locale, compareValue }: PreviewF
}

if (isEntryField(fieldDefinition) && isLinkValue(value)) {
return <PreviewBox>Reference</PreviewBox>;
return (
<PreviewBox>
<SingleEntryReferenceCard entryId={value.sys.id} locale={locale} baseUrl={baseUrl} />
</PreviewBox>
);
}

if (isAssetArrayField(fieldDefinition) && isLinkArray(value)) {
Expand All @@ -108,7 +114,16 @@ const PreviewField = ({ value, fieldDefinition, locale, compareValue }: PreviewF
if (isEntryArrayField(fieldDefinition) && isLinkArray(value)) {
return (
<PreviewBox>
<Box>{'Reference array'}</Box>
<Flex flexDirection="column" gap="spacingXs">
{value.map((link) => (
<SingleEntryReferenceCard
key={link.sys.id}
entryId={link.sys.id}
locale={locale}
baseUrl={baseUrl}
/>
))}
</Flex>
</PreviewBox>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface PreviewFieldRowProps {
isAdopted: boolean;
onAdoptedChange: (adopted: boolean) => void;
isDisabled?: boolean;
baseUrl: string;
}

const PreviewFieldRow = ({
Expand All @@ -23,6 +24,7 @@ const PreviewFieldRow = ({
isAdopted,
onAdoptedChange,
isDisabled = false,
baseUrl,
}: PreviewFieldRowProps) => {
return (
<Box padding="spacingS" className={styles.fieldBox}>
Expand All @@ -45,7 +47,12 @@ const PreviewFieldRow = ({
<Paragraph marginBottom="spacingXs" fontWeight="fontWeightMedium">
Source
</Paragraph>
<PreviewField value={sourceValue} fieldDefinition={field} locale={sourceLocale} />
<PreviewField
value={sourceValue}
fieldDefinition={field}
locale={sourceLocale}
baseUrl={baseUrl}
/>
</Box>
<Box>
<Paragraph marginBottom="spacingXs" fontWeight="fontWeightMedium">
Expand All @@ -56,6 +63,7 @@ const PreviewFieldRow = ({
fieldDefinition={field}
locale={targetLocale}
compareValue={sourceValue}
baseUrl={baseUrl}
/>
</Box>
</Grid>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -184,6 +183,7 @@ const ReferenceEntrySection = ({
isAdopted={adoptedFields[field.id] ?? true}
onAdoptedChange={(adopted) => onAdoptedFieldChange(field.id, adopted)}
isDisabled={isDisabled}
baseUrl={baseUrl}
/>
))}
</Flex>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DialogAppSDK>();
const [title, setTitle] = useState<string | null>(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 (
<Skeleton.Container>
<Skeleton.BodyText numberOfLines={1} />
</Skeleton.Container>
);
}

return (
<Box>
<Flex alignItems="center" gap="spacingXs">
<TextLink
href={`${baseUrl}/entries/${entryId}`}
target="_blank"
rel="noopener noreferrer"
icon={<ArrowSquareOutIcon variant="muted" size="tiny" />}
alignIcon="end">
<Text fontColor="blue600">{title ?? entryId}</Text>
</TextLink>
</Flex>
</Box>
);
};

export default SingleEntryReferenceCard;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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]);
Expand All @@ -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));
};

Expand Down Expand Up @@ -234,10 +229,6 @@ const PreviewStepComponent = ({
{/* Main entry field rows */}
<Flex flexDirection="column" gap="spacingS">
{contentType.fields.map((field) => {
if (isEntryField(field) || isEntryArrayField(field)) {
return null;
}

if (field.localized) {
return (
<PreviewFieldRow
Expand All @@ -250,6 +241,7 @@ const PreviewStepComponent = ({
isAdopted={adoptedFields[entry.sys.id]?.[field.id] ?? true}
onAdoptedChange={(adopted) => handleFieldAdopted(entry.sys.id, field.id, adopted)}
isDisabled={isDisabled}
baseUrl={baseUrl}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading