diff --git a/packages/payload/src/admin/components/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx b/packages/payload/src/admin/components/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx index 55d51413b2..3364e31044 100644 --- a/packages/payload/src/admin/components/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx +++ b/packages/payload/src/admin/components/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx @@ -25,7 +25,13 @@ const generateLabelFromValue = ( locale: string, value: { relationTo: string; value: RelationshipValue } | RelationshipValue, ): string => { - let relation: string + if (Array.isArray(value)) { + return value + .map((v) => generateLabelFromValue(collections, field, locale, v)) + .filter(Boolean) // Filters out any undefined or empty values + .join(', ') + } + let relatedDoc: RelationshipValue let valueToReturn = '' as any @@ -33,38 +39,58 @@ const generateLabelFromValue = ( return String(value) } - if (Array.isArray(field.relationTo)) { - if (typeof value === 'object') { - relation = value.relationTo - relatedDoc = value.value - } + const relationTo = 'relationTo' in field ? field.relationTo : undefined + + if (value === null || typeof value === 'undefined') { + return String(value) + } + + if (typeof value === 'object' && 'relationTo' in value) { + relatedDoc = value.value } else { - relation = field.relationTo + // Non-polymorphic relationship relatedDoc = value } - const relatedCollection = collections.find((c) => c.slug === relation) + const relatedCollection = relationTo + ? collections.find( + (c) => + c.slug === + (typeof value === 'object' && 'relationTo' in value ? value.relationTo : relationTo), + ) + : null if (relatedCollection) { const useAsTitle = relatedCollection?.admin?.useAsTitle - - // eslint-disable-next-line react-hooks/rules-of-hooks const useAsTitleField = useUseTitleField(relatedCollection) - let titleFieldIsLocalized = false - if (useAsTitleField && fieldAffectsData(useAsTitleField)) + if (useAsTitleField && fieldAffectsData(useAsTitleField)) { titleFieldIsLocalized = useAsTitleField.localized + } if (typeof relatedDoc?.[useAsTitle] !== 'undefined') { valueToReturn = relatedDoc[useAsTitle] } else if (typeof relatedDoc?.id !== 'undefined') { valueToReturn = relatedDoc.id + } else { + valueToReturn = relatedDoc } if (typeof valueToReturn === 'object' && titleFieldIsLocalized) { valueToReturn = valueToReturn[locale] } + } else if (relatedDoc) { + // Handle non-polymorphic `hasMany` relationships or fallback + if (typeof relatedDoc.id !== 'undefined') { + valueToReturn = relatedDoc.id + } else { + valueToReturn = relatedDoc + } + } + + if (typeof valueToReturn === 'object' && valueToReturn !== null) { + valueToReturn = JSON.stringify(valueToReturn) } return valueToReturn @@ -79,25 +105,31 @@ const Relationship: React.FC = ({ const { i18n, t } = useTranslation('general') const { code: locale } = useLocale() - let placeholder = '' + const placeholder = `[${t('noValue')}]` - if (version === comparison) placeholder = `[${t('noValue')}]` + let versionToRender: string | undefined = placeholder + let comparisonToRender: string | undefined = placeholder - let versionToRender = version - let comparisonToRender = comparison + if (version) { + if ('hasMany' in field && field.hasMany && Array.isArray(version)) { + versionToRender = + version.map((val) => generateLabelFromValue(collections, field, locale, val)).join(', ') || + placeholder + } else { + versionToRender = generateLabelFromValue(collections, field, locale, version) || placeholder + } + } - if (field.hasMany) { - if (Array.isArray(version)) - versionToRender = version - .map((val) => generateLabelFromValue(collections, field, locale, val)) - .join(', ') - if (Array.isArray(comparison)) - comparisonToRender = comparison - .map((val) => generateLabelFromValue(collections, field, locale, val)) - .join(', ') - } else { - versionToRender = generateLabelFromValue(collections, field, locale, version) - comparisonToRender = generateLabelFromValue(collections, field, locale, comparison) + if (comparison) { + if ('hasMany' in field && field.hasMany && Array.isArray(comparison)) { + comparisonToRender = + comparison + .map((val) => generateLabelFromValue(collections, field, locale, val)) + .join(', ') || placeholder + } else { + comparisonToRender = + generateLabelFromValue(collections, field, locale, comparison) || placeholder + } } return ( diff --git a/packages/payload/src/admin/components/views/Version/Version.tsx b/packages/payload/src/admin/components/views/Version/Version.tsx index 6c8475acf5..32ed9cd46a 100644 --- a/packages/payload/src/admin/components/views/Version/Version.tsx +++ b/packages/payload/src/admin/components/views/Version/Version.tsx @@ -108,6 +108,8 @@ const VersionView: React.FC = ({ collection, global }) => { initialParams: { depth: 1, draft: 'true', locale: '*' }, }) + const hasDraftsEnabled = collection?.versions?.drafts || global?.versions?.drafts + const sharedParams = (status) => { return { depth: 0, @@ -122,24 +124,26 @@ const VersionView: React.FC = ({ collection, global }) => { } const [{ data: draft }] = usePayloadAPI(compareBaseURL, { - initialParams: { ...sharedParams('draft') }, + initialParams: hasDraftsEnabled ? { ...sharedParams('draft') } : {}, }) const [{ data: published }] = usePayloadAPI(compareBaseURL, { - initialParams: { ...sharedParams('published') }, + initialParams: hasDraftsEnabled ? { ...sharedParams('published') } : {}, }) useEffect(() => { - const formattedPublished = published?.docs?.length > 0 && published?.docs[0] - const formattedDraft = draft?.docs?.length > 0 && draft?.docs[0] + if (hasDraftsEnabled) { + const formattedPublished = published?.docs?.length > 0 && published?.docs[0] + const formattedDraft = draft?.docs?.length > 0 && draft?.docs[0] - if (!formattedPublished || !formattedDraft) return + if (!formattedPublished || !formattedDraft) return - const publishedNewerThanDraft = formattedPublished?.updatedAt > formattedDraft?.updatedAt + const publishedNewerThanDraft = formattedPublished?.updatedAt > formattedDraft?.updatedAt - setLatestDraftVersion(publishedNewerThanDraft ? undefined : formattedDraft?.id) - setLatestPublishedVersion(formattedPublished.latest ? formattedPublished?.id : undefined) - }, [draft, published]) + setLatestDraftVersion(publishedNewerThanDraft ? undefined : formattedDraft?.id) + setLatestPublishedVersion(formattedPublished.latest ? formattedPublished?.id : undefined) + } + }, [hasDraftsEnabled, draft, published]) useEffect(() => { let nav: StepNavItem[] = [] diff --git a/packages/payload/src/admin/components/views/Versions/columns.tsx b/packages/payload/src/admin/components/views/Versions/columns.tsx index 5d4a75ed5e..e20b19378c 100644 --- a/packages/payload/src/admin/components/views/Versions/columns.tsx +++ b/packages/payload/src/admin/components/views/Versions/columns.tsx @@ -47,45 +47,57 @@ export const buildVersionColumns = ( t: TFunction, latestDraftVersion?: string, latestPublishedVersion?: string, -): Column[] => [ - { - name: '', - accessor: 'updatedAt', - active: true, - components: { - Heading: , - renderCell: (row, data) => ( - - ), - }, - label: '', - }, - { - name: '', - accessor: 'id', - active: true, - components: { - Heading: , - renderCell: (row, data) => {data}, - }, - label: '', - }, - { - name: '', - accessor: 'autosave', - active: true, - components: { - Heading: , - renderCell: (row) => { - return ( - - ) +): Column[] => { + const entityConfig = collection || global + + const columns: Column[] = [ + { + name: '', + accessor: 'updatedAt', + active: true, + components: { + Heading: , + renderCell: (row, data) => ( + + ), }, + label: '', }, - label: '', - }, -] + { + name: '', + accessor: 'id', + active: true, + components: { + Heading: , + renderCell: (row, data) => {data}, + }, + label: '', + }, + ] + + if ( + entityConfig?.versions?.drafts || + (entityConfig?.versions?.drafts && entityConfig.versions.drafts?.autosave) + ) { + columns.push({ + name: '', + accessor: 'autosave', + active: true, + components: { + Heading: , + renderCell: (row) => { + return ( + + ) + }, + }, + label: '', + }) + } + + return columns +} diff --git a/packages/payload/src/admin/components/views/Versions/index.tsx b/packages/payload/src/admin/components/views/Versions/index.tsx index e8ed64ef37..d07de45cfe 100644 --- a/packages/payload/src/admin/components/views/Versions/index.tsx +++ b/packages/payload/src/admin/components/views/Versions/index.tsx @@ -94,6 +94,8 @@ const VersionsView: React.FC = (props) => { const [{ data: versionsData, isLoading: isLoadingVersions }, { setParams }] = usePayloadAPI(fetchURL) + const hasDraftsEnabled = collection?.versions?.drafts || global?.versions?.drafts + const sharedParams = (status) => { return { depth: 0, @@ -108,23 +110,25 @@ const VersionsView: React.FC = (props) => { } const [{ data: draft }] = usePayloadAPI(fetchURL, { - initialParams: { ...sharedParams('draft') }, + initialParams: hasDraftsEnabled ? { ...sharedParams('draft') } : {}, }) const [{ data: published }] = usePayloadAPI(fetchURL, { - initialParams: { ...sharedParams('published') }, + initialParams: hasDraftsEnabled ? { ...sharedParams('published') } : {}, }) useEffect(() => { - const formattedPublished = published?.docs?.length > 0 && published?.docs[0] - const formattedDraft = draft?.docs?.length > 0 && draft?.docs[0] + if (hasDraftsEnabled) { + const formattedPublished = published?.docs?.length > 0 && published?.docs[0] + const formattedDraft = draft?.docs?.length > 0 && draft?.docs[0] - if (!formattedPublished || !formattedDraft) return + if (!formattedPublished || !formattedDraft) return - const publishedNewerThanDraft = formattedPublished?.updatedAt > formattedDraft?.updatedAt - setLatestDraftVersion(publishedNewerThanDraft ? undefined : formattedDraft?.id) - setLatestPublishedVersion(formattedPublished.latest ? formattedPublished?.id : undefined) - }, [draft, published]) + const publishedNewerThanDraft = formattedPublished?.updatedAt > formattedDraft?.updatedAt + setLatestDraftVersion(publishedNewerThanDraft ? undefined : formattedDraft?.id) + setLatestPublishedVersion(formattedPublished.latest ? formattedPublished?.id : undefined) + } + }, [hasDraftsEnabled, draft, published]) useEffect(() => { const params = { diff --git a/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts b/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts index 0593f4a756..5012e0013d 100644 --- a/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts +++ b/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts @@ -125,24 +125,25 @@ const relationshipPopulationPromise = async ({ if (fieldSupportsMany(field) && field.hasMany) { if ( + field.localized && locale === 'all' && typeof siblingDoc[field.name] === 'object' && siblingDoc[field.name] !== null ) { - Object.keys(siblingDoc[field.name]).forEach((key) => { - if (Array.isArray(siblingDoc[field.name][key])) { - siblingDoc[field.name][key].forEach((relatedDoc, index) => { + Object.keys(siblingDoc[field.name]).forEach((localeKey) => { + if (Array.isArray(siblingDoc[field.name][localeKey])) { + siblingDoc[field.name][localeKey].forEach((relatedDoc, index) => { const rowPromise = async () => { await populate({ currentDepth, - data: siblingDoc[field.name][key][index], + data: siblingDoc[field.name][localeKey][index], dataReference: resultingDoc, depth: populateDepth, draft, fallbackLocale, field, index, - key, + key: localeKey, locale, overrideAccess, req, @@ -178,21 +179,22 @@ const relationshipPopulationPromise = async ({ }) } } else if ( + field.localized && + locale === 'all' && typeof siblingDoc[field.name] === 'object' && - siblingDoc[field.name] !== null && - locale === 'all' + siblingDoc[field.name] !== null ) { - Object.keys(siblingDoc[field.name]).forEach((key) => { + Object.keys(siblingDoc[field.name]).forEach((localeKey) => { const rowPromise = async () => { await populate({ currentDepth, - data: siblingDoc[field.name][key], + data: siblingDoc[field.name][localeKey], dataReference: resultingDoc, depth: populateDepth, draft, fallbackLocale, field, - key, + key: localeKey, locale, overrideAccess, req, diff --git a/test/fields-relationship/collectionSlugs.ts b/test/fields-relationship/collectionSlugs.ts index 94ed552a0b..c8fe44657c 100644 --- a/test/fields-relationship/collectionSlugs.ts +++ b/test/fields-relationship/collectionSlugs.ts @@ -9,3 +9,4 @@ export const relationWithTitleSlug = 'relation-with-title' export const relationUpdatedExternallySlug = 'relation-updated-externally' export const collection1Slug = 'collection-1' export const collection2Slug = 'collection-2' +export const versionedRelationshipFieldSlug = 'versioned-relationship-field' diff --git a/test/fields-relationship/collections/VersionedRelationshipField/index.ts b/test/fields-relationship/collections/VersionedRelationshipField/index.ts new file mode 100644 index 0000000000..1cd1679671 --- /dev/null +++ b/test/fields-relationship/collections/VersionedRelationshipField/index.ts @@ -0,0 +1,21 @@ +import type { CollectionConfig } from '../../../../packages/payload/src/collections/config/types' + +import { collection1Slug, versionedRelationshipFieldSlug } from '../../collectionSlugs' + +export const VersionedRelationshipFieldCollection: CollectionConfig = { + slug: versionedRelationshipFieldSlug, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'relationshipField', + type: 'relationship', + relationTo: [collection1Slug], + hasMany: true, + }, + ], + versions: true, +} diff --git a/test/fields-relationship/config.ts b/test/fields-relationship/config.ts index a6bc8d7a12..89ba150849 100644 --- a/test/fields-relationship/config.ts +++ b/test/fields-relationship/config.ts @@ -1,7 +1,6 @@ import type { CollectionConfig } from '../../packages/payload/src/collections/config/types' import type { FilterOptionsProps } from '../../packages/payload/src/fields/config/types' -import { mapAsync } from '../../packages/payload/src/utilities/mapAsync' import { buildConfigWithDefaults } from '../buildConfigWithDefaults' import { devUser } from '../credentials' import { PrePopulateFieldUI } from './PrePopulateFieldUI' @@ -17,6 +16,7 @@ import { relationWithTitleSlug, slug, } from './collectionSlugs' +import { VersionedRelationshipFieldCollection } from './collections/VersionedRelationshipField' export interface FieldsRelationship { createdAt: Date @@ -301,6 +301,9 @@ export default buildConfigWithDefaults({ }, ], slug: collection1Slug, + admin: { + useAsTitle: 'name', + }, }, { fields: [ @@ -311,7 +314,13 @@ export default buildConfigWithDefaults({ ], slug: collection2Slug, }, + VersionedRelationshipFieldCollection, ], + localization: { + locales: ['en'], + defaultLocale: 'en', + fallback: true, + }, onInit: async (payload) => { await payload.create({ collection: 'users', @@ -319,6 +328,8 @@ export default buildConfigWithDefaults({ email: devUser.email, password: devUser.password, }, + depth: 0, + overrideAccess: true, }) // Create docs to relate to const { id: relationOneDocId } = await payload.create({ @@ -326,29 +337,35 @@ export default buildConfigWithDefaults({ data: { name: relationOneSlug, }, + depth: 0, + overrideAccess: true, }) const relationOneIDs: string[] = [] - await mapAsync([...Array(11)], async () => { + for (let i = 0; i < 11; i++) { const doc = await payload.create({ collection: relationOneSlug, data: { name: relationOneSlug, }, + depth: 0, + overrideAccess: true, }) relationOneIDs.push(doc.id) - }) + } const relationTwoIDs: string[] = [] - await mapAsync([...Array(11)], async () => { + for (let i = 0; i < 11; i++) { const doc = await payload.create({ collection: relationTwoSlug, data: { name: relationTwoSlug, }, + depth: 0, + overrideAccess: true, }) relationTwoIDs.push(doc.id) - }) + } // Existing relationships const { id: restrictedDocId } = await payload.create({ @@ -356,13 +373,17 @@ export default buildConfigWithDefaults({ data: { name: 'relation-restricted', }, + depth: 0, + overrideAccess: true, }) const relationsWithTitle: string[] = [] - await mapAsync(['relation-title', 'word boundary search'], async (title) => { + for (const title of ['relation-title', 'word boundary search']) { const { id } = await payload.create({ collection: relationWithTitleSlug, + depth: 0, + overrideAccess: true, data: { name: title, meta: { @@ -371,19 +392,24 @@ export default buildConfigWithDefaults({ }, }) relationsWithTitle.push(id) - }) + } await payload.create({ collection: slug, + depth: 0, + overrideAccess: true, data: { relationship: relationOneDocId, relationshipRestricted: restrictedDocId, relationshipWithTitle: relationsWithTitle[0], }, }) - await mapAsync([...Array(11)], async () => { + + for (let i = 0; i < 11; i++) { await payload.create({ collection: slug, + depth: 0, + overrideAccess: true, data: { relationship: relationOneDocId, relationshipHasManyMultiple: relationOneIDs.map((id) => ({ @@ -393,9 +419,9 @@ export default buildConfigWithDefaults({ relationshipRestricted: restrictedDocId, }, }) - }) + } - await mapAsync([...Array(15)], async () => { + for (let i = 0; i < 15; i++) { const relationOneID = relationOneIDs[Math.floor(Math.random() * 10)] const relationTwoID = relationTwoIDs[Math.floor(Math.random() * 10)] await payload.create({ @@ -408,20 +434,25 @@ export default buildConfigWithDefaults({ relationshipRestricted: restrictedDocId, }, }) - }) - ;[...Array(15)].forEach((_, i) => { - payload.create({ + } + + for (let i = 0; i < 15; i++) { + await payload.create({ collection: collection1Slug, + depth: 0, + overrideAccess: true, data: { name: `relationship-test ${i}`, }, }) - payload.create({ + await payload.create({ collection: collection2Slug, + depth: 0, + overrideAccess: true, data: { name: `relationship-test ${i}`, }, }) - }) + } }, }) diff --git a/test/fields-relationship/int.spec.ts b/test/fields-relationship/int.spec.ts new file mode 100644 index 0000000000..8ca477f045 --- /dev/null +++ b/test/fields-relationship/int.spec.ts @@ -0,0 +1,112 @@ +import type { Collection1 } from './payload-types' + +import payload from '../../packages/payload/src' +import { devUser } from '../credentials' +import { initPayloadTest } from '../helpers/configHelpers' +import { collection1Slug, versionedRelationshipFieldSlug } from './collectionSlugs' + +let apiUrl: string +let jwt + +const headers = { + 'Content-Type': 'application/json', +} +const { email, password } = devUser + +describe('Relationship Fields', () => { + beforeAll(async () => { + const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } }) + apiUrl = `${serverURL}/api` + + const response = await fetch(`${apiUrl}/users/login`, { + body: JSON.stringify({ + email, + password, + }), + headers, + method: 'post', + }) + + const data = await response.json() + jwt = data.token + }) + + afterAll(async () => { + if (typeof payload.db.destroy === 'function') { + await payload.db.destroy(payload) + } + }) + + describe('Versioned Relationship Field', () => { + let version2ID: string + const relatedDocName = 'Related Doc' + beforeAll(async () => { + const relatedDoc = await payload.create({ + collection: collection1Slug, + data: { + name: relatedDocName, + }, + }) + + const version1 = await payload.create({ + collection: versionedRelationshipFieldSlug, + data: { + title: 'Version 1 Title', + relationshipField: { + value: relatedDoc.id, + relationTo: collection1Slug, + }, + }, + }) + + const version2 = await payload.update({ + collection: versionedRelationshipFieldSlug, + id: version1.id, + data: { + title: 'Version 2 Title', + }, + }) + + const versions = await payload.findVersions({ + collection: versionedRelationshipFieldSlug, + where: { + parent: { + equals: version2.id, + }, + }, + sort: '-updatedAt', + limit: 1, + }) + + version2ID = versions.docs[0].id + }) + it('should return the correct versioned relationship field via REST', async () => { + const version2Data = await fetch( + `${apiUrl}/${versionedRelationshipFieldSlug}/versions/${version2ID}?locale=all`, + { + method: 'GET', + headers: { + ...headers, + Authorization: `JWT ${jwt}`, + }, + }, + ).then((res) => res.json()) + + expect(version2Data.version.title).toEqual('Version 2 Title') + expect(version2Data.version.relationshipField[0].value.name).toEqual(relatedDocName) + }) + + it('should return the correct versioned relationship field via LocalAPI', async () => { + const version2Data = await payload.findVersionByID({ + collection: versionedRelationshipFieldSlug, + id: version2ID, + locale: 'all', + }) + + expect(version2Data.version.title).toEqual('Version 2 Title') + expect((version2Data.version.relationshipField[0].value as Collection1).name).toEqual( + relatedDocName, + ) + }) + }) +}) diff --git a/test/fields-relationship/payload-types.ts b/test/fields-relationship/payload-types.ts index 7fe51f10b7..af4140dba8 100644 --- a/test/fields-relationship/payload-types.ts +++ b/test/fields-relationship/payload-types.ts @@ -16,11 +16,19 @@ export interface Config { 'relation-updated-externally': RelationUpdatedExternally 'collection-1': Collection1 'collection-2': Collection2 + 'versioned-relationship-field': VersionedRelationshipField users: User 'payload-preferences': PayloadPreference 'payload-migrations': PayloadMigration } + db: { + defaultIDType: string + } globals: {} + locale: 'en' + user: User & { + collection: 'users' + } } export interface FieldsRelationship { id: string @@ -126,6 +134,22 @@ export interface Collection2 { updatedAt: string createdAt: string } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "versioned-relationship-field". + */ +export interface VersionedRelationshipField { + id: string + title: string + relationshipField?: + | { + relationTo: 'collection-1' + value: string | Collection1 + }[] + | null + updatedAt: string + createdAt: string +} export interface User { id: string updatedAt: string