diff --git a/packages/plugin-import-export/src/components/ImportPreview/index.tsx b/packages/plugin-import-export/src/components/ImportPreview/index.tsx index 412417b4159..3369efb4a31 100644 --- a/packages/plugin-import-export/src/components/ImportPreview/index.tsx +++ b/packages/plugin-import-export/src/components/ImportPreview/index.tsx @@ -14,7 +14,6 @@ import { useFormFields, useTranslation, } from '@payloadcms/ui' -import { formatDocTitle } from '@payloadcms/ui/shared' import { fieldAffectsData, getObjectDotNotation } from 'payload/shared' import React, { useState, useTransition } from 'react' @@ -25,6 +24,7 @@ import type { import type { ImportPreviewResponse } from '../../types.js' import { DEFAULT_PREVIEW_LIMIT, PREVIEW_LIMIT_OPTIONS } from '../../constants.js' +import { RelationshipCell } from '../RelationshipCell/index.js' import './index.css' const baseClass = 'import-preview' @@ -239,7 +239,7 @@ export const ImportPreview: React.FC = () => { active: true, field, Heading: label, - renderedCells: docs.map((doc) => { + renderedCells: docs.map((doc, rowIndex) => { const value = getObjectDotNotation(doc, fieldPath) if (value === undefined || value === null) { @@ -248,62 +248,13 @@ export const ImportPreview: React.FC = () => { // Format based on field type if (field.type === 'relationship' || field.type === 'upload') { - // Handle relationships - if (typeof value === 'object' && !Array.isArray(value)) { - // Single relationship - const relationTo = Array.isArray(field.relationTo) - ? (value as any).relationTo - : field.relationTo - - const relatedConfig = config.collections.find((c) => c.slug === relationTo) - if (relatedConfig && relatedConfig.admin?.useAsTitle) { - const titleValue = (value as any)[relatedConfig.admin.useAsTitle] - if (titleValue) { - return formatDocTitle({ - collectionConfig: relatedConfig, - data: value as any, - dateFormat: config.admin.dateFormat, - i18n, - }) - } - } - - // Fallback to ID - const id = (value as any).id || value - return `${getTranslation(relatedConfig?.labels?.singular || relationTo, i18n)}: ${id}` - } else if (Array.isArray(value)) { - // Multiple relationships - return value - .map((item) => { - if (typeof item === 'object') { - const relationTo = Array.isArray(field.relationTo) - ? item.relationTo - : field.relationTo - const relatedConfig = config.collections.find( - (c) => c.slug === relationTo, - ) - - if (relatedConfig && relatedConfig.admin?.useAsTitle) { - const titleValue = item[relatedConfig.admin.useAsTitle] - if (titleValue) { - return formatDocTitle({ - collectionConfig: relatedConfig, - data: item, - dateFormat: config.admin.dateFormat, - i18n, - }) - } - } - - return item.id || item - } - return item - }) - .join(', ') - } - - // Just an ID - return String(value) + return ( + + ) } else if (field.type === 'date') { // Display date as string to avoid wrong locale/timezone conversion return String(value) diff --git a/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.spec.ts b/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.spec.ts new file mode 100644 index 00000000000..a337be750eb --- /dev/null +++ b/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.spec.ts @@ -0,0 +1,203 @@ +import type { ClientCollectionConfig } from 'payload' + +import { describe, expect, it } from 'vitest' + +import { getRelationshipGroups } from './getRelationshipGroups.js' + +const collections = [ + { + slug: 'posts', + admin: { useAsTitle: 'title' }, + fields: [{ name: 'title', type: 'text' }], + labels: { plural: 'Posts', singular: 'Post' }, + }, + { + slug: 'users', + admin: { useAsTitle: 'email' }, + fields: [{ name: 'email', type: 'email' }], + labels: { plural: 'Users', singular: 'User' }, + }, +] as unknown as ClientCollectionConfig[] + +const i18n = { t: (key: string) => key } as any + +const group = ({ relationTo, value }: { relationTo: string | string[]; value: unknown }) => + getRelationshipGroups({ collections, dateFormat: 'MMMM do yyyy', i18n, relationTo, value }) + +/** Collapses each group to `Label: value, value (+N)` so assertions stay readable. */ +const summarize = (groups: ReturnType) => + groups.map(({ label, options, remaining }) => { + const values = options.join(', ') + + return remaining ? `${label}: ${values} (+${remaining})` : `${label}: ${values}` + }) + +describe('getRelationshipGroups', () => { + describe('monomorphic', () => { + it('should build a single group labeled with the plural collection label', () => { + expect(group({ relationTo: 'posts', value: 3 })).toEqual([ + { + label: 'Posts', + options: ['3'], + remaining: 0, + }, + ]) + }) + + it('should collect a list of bare IDs into one group', () => { + expect(summarize(group({ relationTo: 'posts', value: [1, 2] }))).toEqual(['Posts: 1, 2']) + }) + + it('should title a populated document using useAsTitle', () => { + expect( + summarize(group({ relationTo: 'posts', value: { id: 3, title: 'The Wall' } })), + ).toEqual(['Posts: The Wall']) + }) + + it('should fall back to untitled and ID for a document with no useAsTitle value', () => { + expect(summarize(group({ relationTo: 'posts', value: { id: 3 } }))).toEqual([ + 'Posts: general:untitled - ID: 3', + ]) + }) + }) + + describe('polymorphic', () => { + it('should label a wrapped ID with its collection', () => { + expect( + summarize( + group({ relationTo: ['posts', 'users'], value: { relationTo: 'posts', value: 3 } }), + ), + ).toEqual(['Posts: 3']) + }) + + it('should title a wrapped populated document using useAsTitle', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: { relationTo: 'posts', value: { id: 3, title: 'The Wall' } }, + }), + ), + ).toEqual(['Posts: The Wall']) + }) + + it('should group entries that share a collection', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: [ + { relationTo: 'users', value: 7 }, + { relationTo: 'posts', value: 3 }, + { relationTo: 'users', value: 9 }, + ], + }), + ), + ).toEqual(['Users: 7, 9', 'Posts: 3']) + }) + + it('should order groups by where their collection first appears', () => { + expect( + group({ + relationTo: ['posts', 'users'], + value: [ + { relationTo: 'posts', value: 3 }, + { relationTo: 'users', value: 7 }, + ], + }).map(({ label }) => label), + ).toEqual(['Posts', 'Users']) + }) + + it('should fall back to the collection slug when the collection has no labels', () => { + expect( + summarize(group({ relationTo: ['pages'], value: { relationTo: 'pages', value: 4 } })), + ).toEqual(['pages: 4']) + }) + + it('should keep entries whose collection is unknown in their own unlabeled group', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: [{ relationTo: 'posts', value: 3 }, 4], + }), + ), + ).toEqual(['Posts: 3', ': 4']) + }) + }) + + describe('capping', () => { + it('should not report remaining options when the group fits', () => { + expect(summarize(group({ relationTo: 'posts', value: [1, 2, 3] }))).toEqual([ + 'Posts: 1, 2, 3', + ]) + }) + + it('should cap a group at three options and count the rest', () => { + expect(summarize(group({ relationTo: 'posts', value: [1, 2, 3, 4, 5] }))).toEqual([ + 'Posts: 1, 2, 3 (+2)', + ]) + }) + + it('should cap each collection independently rather than across the cell', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: [ + { relationTo: 'users', value: 7 }, + { relationTo: 'posts', value: 3 }, + { relationTo: 'users', value: 9 }, + { relationTo: 'posts', value: 4 }, + ], + }), + ), + ).toEqual(['Users: 7, 9', 'Posts: 3, 4']) + }) + + it('should count overflow per collection', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: [ + ...[1, 2, 3, 4, 5].map((value) => ({ relationTo: 'posts', value })), + ...[7, 8].map((value) => ({ relationTo: 'users', value })), + ], + }), + ), + ).toEqual(['Posts: 1, 2, 3 (+2)', 'Users: 7, 8']) + }) + + it('should not let a leading collection crowd out a later one', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: [ + ...[1, 2, 3, 4].map((value) => ({ relationTo: 'posts', value })), + { relationTo: 'users', value: 7 }, + ], + }), + ), + ).toEqual(['Posts: 1, 2, 3 (+1)', 'Users: 7']) + }) + }) + + describe('unresolvable values', () => { + it('should render an object carrying no ID as JSON rather than [object Object]', () => { + expect(summarize(group({ relationTo: 'posts', value: { slug: 'no-id-here' } }))).toEqual([ + 'Posts: {"slug":"no-id-here"}', + ]) + }) + + it('should never render [object Object] for a wrapped value', () => { + const groups = group({ + relationTo: ['posts', 'users'], + value: { relationTo: 'posts', value: { title: 'No ID' } }, + }) + + expect(groups[0]?.options[0]).not.toContain('[object Object]') + }) + }) +}) diff --git a/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.ts b/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.ts new file mode 100644 index 00000000000..28ffde12760 --- /dev/null +++ b/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.ts @@ -0,0 +1,153 @@ +import type { I18n } from '@payloadcms/translations' +import type { ClientCollectionConfig, SanitizedConfig } from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import { formatDocTitle } from '@payloadcms/ui/shared' + +/** + * Labels rendered per collection before the rest collapse into `and N more`, + * matching `totalToShow` in the list view's relationship cell. + */ +export const TOTAL_TO_SHOW = 3 + +type Args = { + collections: ClientCollectionConfig[] + dateFormat: SanitizedConfig['admin']['dateFormat'] + i18n: I18n + /** `field.relationTo` — an array when the field is polymorphic. */ + relationTo: string | string[] + value: unknown +} + +export type RelationshipGroup = { + /** Plural label of the target collection, empty when it cannot be determined. */ + label: string + /** One rendered label per document, capped at `TOTAL_TO_SHOW`. */ + options: string[] + /** Options past `TOTAL_TO_SHOW` dropped from this group, to render as `and N more`. */ + remaining: number +} + +/** + * Groups a relationship or upload value by target collection — one group per + * collection, labeled with its plural label, holding one label per document. + * + * Import files carry relationships in whichever shape produced them, so every + * entry is one of: a bare ID, a populated document, or the polymorphic + * `{ relationTo, value }` pair — whose `value` is itself an ID or a document. + * + * Each group caps at `TOTAL_TO_SHOW` options and counts the rest into `remaining`, + * so a polymorphic field shows the same depth per collection rather than spending + * one cell's worth of rows on whichever collection happens to come first. Groups are + * returned in the order their collection first appears, and entries whose collection + * cannot be determined collect into a single unlabeled group. Options keep their order + * in the file rather than being sorted, so the preview reflects what will be imported. + */ +export const getRelationshipGroups = ({ + collections, + dateFormat, + i18n, + relationTo, + value, +}: Args): RelationshipGroup[] => { + const entries = Array.isArray(value) ? value : [value] + const configsBySlug = new Map(collections.map((collection) => [collection.slug, collection])) + + // Insertion order is the order each collection first appears, which is the order to render. + const groupsBySlug = new Map() + + entries.forEach((entry) => { + const { slug, target } = resolveEntry({ entry, relationTo }) + + const key = slug ?? '' + let group = groupsBySlug.get(key) + + if (!group) { + group = { + label: slug ? getTranslation(configsBySlug.get(slug)?.labels?.plural || slug, i18n) : '', + options: [], + remaining: 0, + } + + groupsBySlug.set(key, group) + } + + // Past the cap only the count matters, so skip the cost of titling the document. + if (group.options.length < TOTAL_TO_SHOW) { + group.options.push( + getOptionLabel({ + collectionConfig: slug ? configsBySlug.get(slug) : undefined, + dateFormat, + entry, + i18n, + target, + }), + ) + } else { + group.remaining++ + } + }) + + return [...groupsBySlug.values()] +} + +/** + * Splits an entry into the collection it targets — `undefined` when that cannot be + * determined — and the ID or document it points at. + */ +const resolveEntry = ({ + entry, + relationTo, +}: { entry: unknown } & Pick): { slug?: string; target: unknown } => { + if (isPolymorphicRelationship(entry)) { + return { slug: entry.relationTo, target: entry.value } + } + + return { slug: Array.isArray(relationTo) ? undefined : relationTo, target: entry } +} + +type GetOptionLabelArgs = { + collectionConfig?: ClientCollectionConfig + /** The original entry, kept for the JSON fallback when `target` holds no usable ID. */ + entry: unknown + target: unknown +} & Pick + +const getOptionLabel = ({ + collectionConfig, + dateFormat, + entry, + i18n, + target, +}: GetOptionLabelArgs): string => { + const doc = isRecord(target) ? target : undefined + const id = doc ? doc.id : target + + // Fall back to JSON for anything that did not resolve to a usable ID. Interpolating an + // object into a template is the `[object Object]` this function exists to avoid. + if (typeof id !== 'string' && typeof id !== 'number') { + return JSON.stringify(entry) ?? '' + } + + if (!doc) { + // A bare ID carries no document to title. Showing the ID alone is more honest than + // the field's `Untitled` fallback, which would assert the document has no title. + return `${id}` + } + + return formatDocTitle({ + collectionConfig, + data: doc as Parameters[0]['data'], + dateFormat, + fallback: `${i18n.t('general:untitled')} - ID: ${id}`, + i18n, + }) +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const isPolymorphicRelationship = ( + value: unknown, +): value is { relationTo: string; value: unknown } => + isRecord(value) && typeof value.relationTo === 'string' && 'value' in value diff --git a/packages/plugin-import-export/src/components/RelationshipCell/index.css b/packages/plugin-import-export/src/components/RelationshipCell/index.css new file mode 100644 index 00000000000..98c21a5128f --- /dev/null +++ b/packages/plugin-import-export/src/components/RelationshipCell/index.css @@ -0,0 +1,32 @@ +@layer payload-default { + .import-preview-relationship { + display: flex; + flex-direction: column; + gap: var(--spacer-2); + } + + .import-preview-relationship__group { + display: flex; + flex-direction: column; + align-items: flex-start; + } + + .import-preview-relationship__collection { + color: var(--color-text-secondary); + font-family: var(--text-body-small-strong-font-family); + font-size: var(--text-body-medium-strong-font-size); + font-weight: var(--text-body-large-font-weight); + letter-spacing: var(--text-body-small-strong-letter-spacing); + line-height: var(--text-body-small-strong-line-height); + white-space: nowrap; + } + + .import-preview-relationship__more { + display: block; + color: var(--color-text-secondary); + font-family: var(--text-body-small-font-family); + font-size: var(--text-body-small-font-size); + font-weight: var(--text-body-small-font-weight); + line-height: var(--text-body-small-line-height); + } +} diff --git a/packages/plugin-import-export/src/components/RelationshipCell/index.tsx b/packages/plugin-import-export/src/components/RelationshipCell/index.tsx new file mode 100644 index 00000000000..403393bac60 --- /dev/null +++ b/packages/plugin-import-export/src/components/RelationshipCell/index.tsx @@ -0,0 +1,59 @@ +'use client' +import { useConfig, useTranslation } from '@payloadcms/ui' +import React from 'react' + +import { getRelationshipGroups } from './getRelationshipGroups.js' +import './index.css' + +const baseClass = 'import-preview-relationship' + +type Props = { + /** `field.relationTo` — an array when the field is polymorphic. */ + relationTo: string | string[] + value: unknown +} + +/** + * Renders a relationship or upload value for the import preview table, stacking + * one row per target collection. + */ +export const RelationshipCell: React.FC = ({ relationTo, value }) => { + const { config } = useConfig() + const { i18n } = useTranslation() + + const groups = getRelationshipGroups({ + collections: config.collections, + dateFormat: config.admin.dateFormat, + i18n, + relationTo, + value, + }) + + // The column heading already names a monomorphic field, whose collection never varies. + const showCollectionLabels = Array.isArray(relationTo) + + if (!groups.length) { + return null + } + + return ( +
+ {groups.map(({ label, options, remaining }, index) => ( +
+ {/* A `label` element would be unassociated here — the cell holds no control */} + {showCollectionLabels && label && ( + {label} + )} +
+ {options.join(', ')} + {remaining > 0 && ( + + {i18n.t('fields:itemsAndMore', { count: remaining, items: '' }).trim()} + + )} +
+
+ ))} +
+ ) +} diff --git a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts index 0b03cb4448a..1f501e909ad 100644 --- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts +++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts @@ -1,9 +1,19 @@ -import { FlattenedField } from 'payload' +import type { FlattenedField, PayloadRequest } from 'payload' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { applyFieldHooks } from './applyFieldHooks.js' +import { flattenObject } from './flattenObject.js' import { getExportFieldFunctions } from './getExportFieldFunctions.js' +const mockReq = { + payload: { + logger: { + error: vi.fn(), + }, + }, +} as unknown as PayloadRequest + describe('getExportFieldFunctions registration', () => { it('should not collide bare-key entries when two same-named fields with built-in handlers exist in different positions', () => { const fields: FlattenedField[] = [ @@ -37,3 +47,143 @@ describe('getExportFieldFunctions registration', () => { expect(result['topLevelData']).toBeDefined() }) }) + +describe('hasMany polymorphic CSV columns', () => { + it('should pin columns to the source index when an earlier relationship is orphaned', () => { + const fields: FlattenedField[] = [ + { + name: 'rel', + type: 'relationship', + hasMany: true, + relationTo: ['posts', 'users'], + } as unknown as FlattenedField, + ] + + // Real CSV export never calls applyFieldHooks — it goes through flattenObject, + // which (unlike applyFieldHooks) drops the key entirely when an array hook + // returns null instead of writing a literal `rel: null`. + const result = flattenObject({ + // Exports populate at depth 1, so `value: null` is an orphaned reference — + // the target doc was deleted out from under it. + data: { + rel: [ + { relationTo: 'users', value: null }, + { relationTo: 'posts', value: 'p1' }, + ], + }, + exportFieldHooks: getExportFieldFunctions({ fields }), + format: 'csv', + req: mockReq, + }) + + // The surviving entry stays at index 1 — shifting it to 0 would silently + // rewrite column names for every consumer of the CSV. There is no bare `rel` + // column: a hasMany field never gets one, orphaned or not. + expect(result).toEqual({ + rel_1_id: 'p1', + rel_1_relationTo: 'posts', + }) + }) +}) + +describe('dangling references in JSON exports', () => { + it('should keep an orphaned hasMany entry as null so the array stays index-aligned with CSV', () => { + const fields: FlattenedField[] = [ + { + name: 'rel', + type: 'relationship', + hasMany: true, + relationTo: 'posts', + } as unknown as FlattenedField, + ] + + const result = applyFieldHooks({ + type: 'beforeExport', + // Population resolves a soft-deleted target to null. + data: { rel: [null, 'p1'] }, + fieldHooks: getExportFieldFunctions({ fields }), + fields, + format: 'json', + operation: 'export', + req: mockReq, + }) + + expect(result).toEqual({ rel: [null, 'p1'] }) + }) + + it('should keep an orphaned hasMany polymorphic entry as null at its source index', () => { + const fields: FlattenedField[] = [ + { + name: 'rel', + type: 'relationship', + hasMany: true, + relationTo: ['posts', 'users'], + } as unknown as FlattenedField, + ] + + const result = applyFieldHooks({ + type: 'beforeExport', + data: { + rel: [ + { relationTo: 'users', value: null }, + { relationTo: 'posts', value: 'p1' }, + ], + }, + fieldHooks: getExportFieldFunctions({ fields }), + fields, + format: 'json', + operation: 'export', + req: mockReq, + }) + + expect(result).toEqual({ + rel: [null, { relationTo: 'posts', value: 'p1' }], + }) + }) + + it('should clear an orphaned single polymorphic reference', () => { + const fields: FlattenedField[] = [ + { + name: 'rel', + type: 'relationship', + relationTo: ['posts', 'users'], + } as unknown as FlattenedField, + ] + + const result = applyFieldHooks({ + type: 'beforeExport', + data: { rel: { relationTo: 'posts', value: null } }, + fieldHooks: getExportFieldFunctions({ fields }), + fields, + format: 'json', + operation: 'export', + req: mockReq, + }) + + expect(result).toEqual({ rel: null }) + }) + + it('should leave a shape it does not recognize untouched', () => { + const fields: FlattenedField[] = [ + { + name: 'rel', + type: 'relationship', + relationTo: ['posts', 'users'], + } as unknown as FlattenedField, + ] + + const result = applyFieldHooks({ + type: 'beforeExport', + // Destroying a value this handler cannot interpret would lose data that a + // custom beforeExport hook or a hand-edited file may depend on. + data: { rel: { slug: 'not-a-relationship' } }, + fieldHooks: getExportFieldFunctions({ fields }), + fields, + format: 'json', + operation: 'export', + req: mockReq, + }) + + expect(result).toEqual({ rel: { slug: 'not-a-relationship' } }) + }) +}) diff --git a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts index 5f635d40dda..8ddb3cd81f9 100644 --- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts +++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts @@ -68,46 +68,79 @@ const registerExportHandler = ( return } - registerHandler(({ siblingData, value }) => { - if (isPolymorphicRelValue(value)) { - const id = getPolymorphicRelId(value) - if (id !== undefined) { - siblingData[`${fullKey}_id`] = id - siblingData[`${fullKey}_relationTo`] = value.relationTo - } + registerHandler(({ format, siblingData, value }) => { + // A shape this handler does not recognize is left untouched for JSON, which holds the + // source value verbatim. CSV still clears it, since its id/relationTo would otherwise + // land in sibling columns. + if (!isPolymorphicRelValue(value)) { + return format === 'json' ? undefined : null } + + // A recognized reference that resolves to no id is dangling, and a dangling reference + // cannot be imported back — clear it for both formats. + const id = getPolymorphicRelId(value) + if (id === undefined) { + return null + } + + if (format === 'json') { + return { relationTo: value.relationTo, value: id } + } + siblingData[`${fullKey}_id`] = id + siblingData[`${fullKey}_relationTo`] = value.relationTo return null }) return } if (!Array.isArray(field.relationTo)) { - registerHandler(({ siblingData, value }) => { - if (Array.isArray(value)) { - value.forEach((val, i) => { - const id = typeof val === 'object' && val ? (val as { id: unknown }).id : val - siblingData[`${fullKey}_${i}_id`] = id - }) - return null + registerHandler(({ format, siblingData, value }) => { + if (!Array.isArray(value)) { + return undefined } - return undefined + const ids = value.map((val) => + typeof val === 'object' && val ? (val as { id: unknown }).id : val, + ) + // A dangling reference is kept as null rather than dropped, so JSON preserves the same + // length and positions as CSV, which pins each entry to its own column. + if (format === 'json') { + return ids.map((id) => id ?? null) + } + ids.forEach((id, i) => { + siblingData[`${fullKey}_${i}_id`] = id + }) + return null }) return } - registerHandler(({ siblingData, value }) => { - if (Array.isArray(value)) { - value.forEach((val, i) => { - if (isPolymorphicRelValue(val)) { - const id = getPolymorphicRelId(val) - if (id !== undefined) { - siblingData[`${fullKey}_${i}_id`] = id - siblingData[`${fullKey}_${i}_relationTo`] = val.relationTo - } - } + registerHandler(({ format, siblingData, value }) => { + if (!Array.isArray(value)) { + return undefined + } + // `index` is carried so CSV columns stay pinned to the source position: an entry + // that cannot be resolved to an id leaves a gap rather than shifting its siblings. + const rels = value.flatMap((val, index) => { + if (!isPolymorphicRelValue(val)) { + return [] + } + const id = getPolymorphicRelId(val) + return id === undefined ? [] : [{ id, index, relationTo: val.relationTo }] + }) + + if (format === 'json') { + // Each entry stays at its source index, leaving a null where one could not be resolved, + // so JSON preserves the same length and positions as CSV. + const jsonRels: unknown[] = new Array(value.length).fill(null) + rels.forEach(({ id, index, relationTo }) => { + jsonRels[index] = { relationTo, value: id } }) - return null + return jsonRels } - return undefined + rels.forEach(({ id, index, relationTo }) => { + siblingData[`${fullKey}_${index}_id`] = id + siblingData[`${fullKey}_${index}_relationTo`] = relationTo + }) + return null }) } diff --git a/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts b/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts index 681f1f35359..bb305e4342f 100644 --- a/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts +++ b/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts @@ -405,6 +405,60 @@ describe('unflattenObject', () => { }) }) + describe('hasMany with gaps in the column indices', () => { + // Export pins each column to its source index, so an entry that could not be + // resolved to an id leaves a gap. Import must absorb the gap rather than + // emitting a null array entry. + const hasManyFields: FlattenedField[] = [ + { + name: 'rels', + type: 'relationship', + hasMany: true, + relationTo: ['posts', 'pages'], + } as FlattenedField, + ] + + const expected = { + rels: [{ relationTo: 'posts', value: 'p1' }], + } + + it('should absorb a leading gap left by an unresolvable entry', () => { + const data = { + rels_1_id: 'p1', + rels_1_relationTo: 'posts', + } + + expect(unflattenObject({ data, fields: hasManyFields, req: mockReq })).toEqual(expected) + }) + + it('should absorb a gap padded with empty strings by schema columns', () => { + const data = { + rels_0_id: '', + rels_0_relationTo: '', + rels_1_id: 'p1', + rels_1_relationTo: 'posts', + } + + expect(unflattenObject({ data, fields: hasManyFields, req: mockReq })).toEqual(expected) + }) + + it('should absorb a gap strictly between two present entries', () => { + const data = { + rels_0_id: 'p1', + rels_0_relationTo: 'posts', + rels_2_id: 'p2', + rels_2_relationTo: 'pages', + } + + expect(unflattenObject({ data, fields: hasManyFields, req: mockReq })).toEqual({ + rels: [ + { relationTo: 'posts', value: 'p1' }, + { relationTo: 'pages', value: 'p2' }, + ], + }) + }) + }) + it('should skip polymorphic relationships with undefined values', () => { const data = { polymorphic_id: undefined, diff --git a/test/plugin-import-export/e2e.spec.ts b/test/plugin-import-export/e2e.spec.ts index e99065a280b..281850defd0 100644 --- a/test/plugin-import-export/e2e.spec.ts +++ b/test/plugin-import-export/e2e.spec.ts @@ -666,7 +666,10 @@ test.describe('Import Export Plugin', () => { }) test('should import a CSV file successfully', async () => { - test.skip(process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.') + test.skip( + process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', + 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.', + ) const csvContent = 'title,excerpt\n"E2E Import Test 1","Test excerpt 1"\n"E2E Import Test 2","Test excerpt 2"' const csvPath = path.join(__dirname, 'uploads', 'e2e-test-import.csv') @@ -704,7 +707,10 @@ test.describe('Import Export Plugin', () => { }) test('should import a JSON file successfully', async () => { - test.skip(process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.') + test.skip( + process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', + 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.', + ) const jsonContent = JSON.stringify([ { excerpt: 'JSON excerpt 1', title: 'E2E JSON Import 1' }, { excerpt: 'JSON excerpt 2', title: 'E2E JSON Import 2' }, @@ -824,9 +830,12 @@ test.describe('Import Export Plugin', () => { // one-shot native event with no auto-retry. await expect(async () => { await page.setInputFiles('input[type="file"]', csvPath) - await expect(page.locator('#field-filemanager-filename')).toHaveValue('e2e-update-test.csv', { - timeout: 2000, - }) + await expect(page.locator('#field-filemanager-filename')).toHaveValue( + 'e2e-update-test.csv', + { + timeout: 2000, + }, + ) }).toPass({ timeout: POLL_TOPASS_TIMEOUT }) const collectionField = page.locator('#field-collectionSlug') @@ -858,7 +867,10 @@ test.describe('Import Export Plugin', () => { }) test('should import documents as published by default', async () => { - test.skip(process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.') + test.skip( + process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', + 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.', + ) const csvContent = 'title,excerpt\n"E2E Published Status Test 1","Test excerpt 1"\n"E2E Published Status Test 2","Test excerpt 2"' const csvPath = path.join(__dirname, 'uploads', 'e2e-published-status-test.csv') @@ -902,7 +914,10 @@ test.describe('Import Export Plugin', () => { }) test('should respect explicit _status column values in CSV', async () => { - test.skip(process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.') + test.skip( + process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', + 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.', + ) const csvContent = 'title,excerpt,_status\n"E2E Explicit Draft Test","Draft excerpt","draft"\n"E2E Explicit Published Test","Published excerpt","published"' const csvPath = path.join(__dirname, 'uploads', 'e2e-explicit-status-test.csv') @@ -1074,11 +1089,154 @@ test.describe('Import Export Plugin', () => { }).toPass({ timeout: POLL_TOPASS_TIMEOUT }) }) }) + + test.describe('Relationship Preview', () => { + const relationshipBaseClass = 'import-preview-relationship' + + // The preview renders the file as-is without populating relationships, so these + // IDs never need to exist — nothing here reaches the database. + const uploadPreviewFile = async ({ + docs, + name, + }: { + docs: Record[] + name: string + }) => { + await page.goto(importsURL.create) + await expect(page.locator('.collection-edit')).toBeVisible() + + const collectionField = page.locator('#field-collectionSlug') + await collectionField.locator('.rs__control').click() + await getSelectMenu({ page }).locator('.rs__option:text-is("Pages")').click() + + await page.locator('input[type="file"]').setInputFiles({ + name, + buffer: Buffer.from(JSON.stringify(docs)), + mimeType: 'application/json', + }) + + await expect(async () => { + await expect(page.locator('.import-preview table')).toBeVisible() + }).toPass({ timeout: POLL_TOPASS_TIMEOUT }) + } + + /** The first row's cell under the given column heading. */ + const getPreviewCell = async ({ heading }: { heading: string }) => { + const previewTable = page.locator('.import-preview table') + const headings = await previewTable.locator('thead th').allTextContents() + const columnIndex = headings.findIndex((text) => text.trim() === heading) + + expect(columnIndex).toBeGreaterThan(-1) + + return previewTable.locator('tbody tr').first().locator('td').nth(columnIndex) + } + + test('should group polymorphic relationship values by collection', async () => { + await uploadPreviewFile({ + docs: [ + { + title: 'Polymorphic Preview', + hasManyPolymorphic: [ + { relationTo: 'users', value: 'preview-user-1' }, + { relationTo: 'posts', value: 'preview-post-1' }, + ], + }, + ], + name: 'polymorphic-preview.json', + }) + + const cell = await getPreviewCell({ heading: 'Has Many Polymorphic' }) + const groups = cell.locator(`.${relationshipBaseClass}__group`) + + await expect(groups).toHaveCount(2) + await expect(groups.nth(0).locator(`.${relationshipBaseClass}__collection`)).toHaveText( + 'Users', + ) + await expect(groups.nth(0).locator(`.${relationshipBaseClass}__values`)).toHaveText( + 'preview-user-1', + ) + await expect(groups.nth(1).locator(`.${relationshipBaseClass}__collection`)).toHaveText( + 'Posts', + ) + await expect(groups.nth(1).locator(`.${relationshipBaseClass}__values`)).toHaveText( + 'preview-post-1', + ) + }) + + test('should not label the collection for monomorphic relationship values', async () => { + await uploadPreviewFile({ + docs: [ + { + title: 'Monomorphic Preview', + hasManyMonomorphic: ['preview-post-1', 'preview-post-2'], + }, + ], + name: 'monomorphic-preview.json', + }) + + const cell = await getPreviewCell({ heading: 'Has Many Monomorphic' }) + + // The column heading already names the only collection this field can target + await expect(cell.locator(`.${relationshipBaseClass}__collection`)).toHaveCount(0) + await expect(cell.locator(`.${relationshipBaseClass}__values`)).toHaveText( + 'preview-post-1, preview-post-2', + ) + }) + + test('should show the document title for populated relationship values', async () => { + await uploadPreviewFile({ + docs: [ + { + title: 'Populated Preview', + hasManyMonomorphic: [{ id: 'preview-post-1', title: 'Populated Post Title' }], + }, + ], + name: 'populated-relationship-preview.json', + }) + + const cell = await getPreviewCell({ heading: 'Has Many Monomorphic' }) + + await expect(cell.locator(`.${relationshipBaseClass}__values`)).toHaveText( + 'Populated Post Title', + ) + }) + + test('should collapse relationship values past the third into a count', async () => { + await uploadPreviewFile({ + docs: [ + { + title: 'Overflow Preview', + hasManyMonomorphic: [ + 'preview-post-1', + 'preview-post-2', + 'preview-post-3', + 'preview-post-4', + 'preview-post-5', + ], + }, + ], + name: 'overflow-relationship-preview.json', + }) + + const cell = await getPreviewCell({ heading: 'Has Many Monomorphic' }) + + await expect(cell.locator(`.${relationshipBaseClass}__values`)).toContainText( + 'preview-post-1, preview-post-2, preview-post-3', + ) + await expect(cell.locator(`.${relationshipBaseClass}__values`)).not.toContainText( + 'preview-post-4', + ) + await expect(cell.locator(`.${relationshipBaseClass}__more`)).toHaveText('and 2 more') + }) + }) }) test.describe('S3 Storage', () => { test('should import CSV file stored in S3 via jobs queue', async () => { - test.skip(process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.') + test.skip( + process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', + 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.', + ) const uniqueId = Date.now() const csvFilename = `s3-e2e-import-${uniqueId}.csv` const csvPath = path.join(__dirname, 'uploads', csvFilename) @@ -1672,7 +1830,10 @@ test.describe('Import Export Plugin', () => { }) test('should import a CSV with foreign column headers through the admin UI', async () => { - test.skip(process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.') + test.skip( + process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', + 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.', + ) const csvContent = '"Post Title","Summary","View Count"\n' + '"E2E Foreign A","e2e summary a","11"\n' + diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index 97ab2f1348d..57ccc0587a4 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -4698,6 +4698,438 @@ describe('@payloadcms/plugin-import-export', () => { }) }) + describe('relationship roundtrips', () => { + const createdPostIDs: (number | string)[] = [] + + afterEach(async () => { + for (const id of createdPostIDs) { + try { + await payload.delete({ + collection: 'posts', + id, + }) + } catch { + // Ignore cleanup errors + } + } + createdPostIDs.length = 0 + }) + + it('should roundtrip a hasMany monomorphic relationship through CSV export/import', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { + title: 'hasMany Monomorphic Post 1', + }, + }) + const post2 = await payload.create({ + collection: 'posts', + data: { + title: 'hasMany Monomorphic Post 2', + }, + }) + + createdPostIDs.push(post1.id, post2.id) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasMany Monomorphic Roundtrip', + hasManyMonomorphic: [post1.id, post2.id], + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyMonomorphic'], + format: 'csv', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const csvPath = path.join(dirname, './uploads', exportedDoc.filename as string) + const exportedRows = await readCSV(csvPath) + + // CSV keeps each relationship flattened into indexed sibling columns + expect(exportedRows).toEqual([ + { + id: String(testPage.id), + title: 'hasMany Monomorphic Roundtrip', + hasManyMonomorphic_0_id: String(post1.id), + hasManyMonomorphic_1_id: String(post2.id), + }, + ]) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(csvPath), + mimetype: 'text/csv', + name: 'hasmany-monomorphic-roundtrip.csv', + size: fs.statSync(csvPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasMany Monomorphic Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + expect(importDoc.status).toBe('completed') + expect(importDoc.summary?.imported).toBe(1) + expect(importDoc.summary?.issues).toBe(0) + expect(importedPages.docs).toHaveLength(1) + expect(imported?.title).toBe('hasMany Monomorphic Roundtrip') + expect(imported?.hasManyMonomorphic).toHaveLength(2) + expect((imported?.hasManyMonomorphic ?? []).map(extractID)).toEqual([post1.id, post2.id]) + // The flattened columns are consumed by the import, not carried onto the document + expect(imported).not.toHaveProperty('hasManyMonomorphic_0_id') + expect(imported).not.toHaveProperty('hasManyMonomorphic_1_id') + }) + + it('should roundtrip a hasMany monomorphic relationship through JSON export/import', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasMany JSON Post 1' }, + }) + const post2 = await payload.create({ + collection: 'posts', + data: { title: 'hasMany JSON Post 2' }, + }) + + createdPostIDs.push(post1.id, post2.id) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasMany JSON Roundtrip', + hasManyMonomorphic: [post1.id, post2.id], + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyMonomorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + const exportedDocs = await readJSON(jsonPath) + + // JSON holds the relationship natively — an array of IDs, with no flattened siblings + expect(exportedDocs).toEqual([ + { + id: testPage.id, + title: 'hasMany JSON Roundtrip', + hasManyMonomorphic: [post1.id, post2.id], + }, + ]) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasmany-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasMany JSON Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + expect(importDoc.status).toBe('completed') + expect(importDoc.summary?.imported).toBe(1) + expect(importDoc.summary?.issues).toBe(0) + expect(importedPages.docs).toHaveLength(1) + expect(imported?.title).toBe('hasMany JSON Roundtrip') + expect(imported?.hasManyMonomorphic).toHaveLength(2) + expect((imported?.hasManyMonomorphic ?? []).map(extractID)).toEqual([post1.id, post2.id]) + }) + + it('should roundtrip a single polymorphic relationship through JSON export/import', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasOnePolymorphic JSON Post' }, + }) + + createdPostIDs.push(post1.id) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasOnePolymorphic JSON Roundtrip', + hasOnePolymorphic: { + relationTo: 'posts', + value: post1.id, + }, + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasOnePolymorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + const exportedDocs = await readJSON(jsonPath) + + // The `relationTo`/`value` pair survives intact rather than splitting into siblings + expect(exportedDocs).toEqual([ + { + id: testPage.id, + title: 'hasOnePolymorphic JSON Roundtrip', + hasOnePolymorphic: { relationTo: 'posts', value: post1.id }, + }, + ]) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasone-polymorphic-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasOnePolymorphic JSON Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + expect(importDoc.status).toBe('completed') + expect(importDoc.summary?.imported).toBe(1) + expect(importDoc.summary?.issues).toBe(0) + expect(importedPages.docs).toHaveLength(1) + expect(imported?.title).toBe('hasOnePolymorphic JSON Roundtrip') + expect(imported?.hasOnePolymorphic).toEqual({ + relationTo: 'posts', + value: post1.id, + }) + }) + + it('should roundtrip a hasMany polymorphic relationship through JSON export/import', async () => { + const testUser = await payload.find({ + collection: 'users', + limit: 1, + }) + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasManyPolymorphic JSON Post' }, + }) + + createdPostIDs.push(post1.id) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasManyPolymorphic JSON Roundtrip', + hasManyPolymorphic: [ + { relationTo: 'users', value: testUser.docs[0]?.id }, + { relationTo: 'posts', value: post1.id }, + ], + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyPolymorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + const exportedDocs = await readJSON(jsonPath) + + // Each entry keeps its own `relationTo`, so a mixed list stays unambiguous + expect(exportedDocs).toEqual([ + { + id: testPage.id, + title: 'hasManyPolymorphic JSON Roundtrip', + hasManyPolymorphic: [ + { relationTo: 'users', value: testUser.docs[0]?.id }, + { relationTo: 'posts', value: post1.id }, + ], + }, + ]) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasmany-polymorphic-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasManyPolymorphic JSON Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + expect(importDoc.status).toBe('completed') + expect(importDoc.summary?.imported).toBe(1) + expect(importDoc.summary?.issues).toBe(0) + expect(importedPages.docs).toHaveLength(1) + expect(imported?.title).toBe('hasManyPolymorphic JSON Roundtrip') + expect(imported?.hasManyPolymorphic).toHaveLength(2) + expect(imported?.hasManyPolymorphic?.[0]).toEqual({ + relationTo: 'users', + value: testUser.docs[0]?.id, + }) + expect(imported?.hasManyPolymorphic?.[1]).toEqual({ + relationTo: 'posts', + value: post1.id, + }) + }) + }) + describe('batch processing', () => { it('should process large imports in batches', async () => { const rows = ['title,excerpt'] diff --git a/test/plugin-import-export/payload-types.ts b/test/plugin-import-export/payload-types.ts index fd249a78cd8..24730550bb4 100644 --- a/test/plugin-import-export/payload-types.ts +++ b/test/plugin-import-export/payload-types.ts @@ -198,11 +198,17 @@ export interface Config { | null | ('en' | 'es' | 'de' | 'he') | ('en' | 'es' | 'de' | 'he')[]; - globals: {}; - globalsSelect: {}; + globals: { + 'payload-jobs-stats': PayloadJobsStat; + }; + globalsSelect: { + 'payload-jobs-stats': PayloadJobsStatsSelect | PayloadJobsStatsSelect; + }; locale: 'en' | 'es' | 'de' | 'he'; widgets: { collections: CollectionsWidget; + 'collection-query': CollectionQueryWidget; + activity: ActivityWidget; }; user: User; jobs: { @@ -1179,6 +1185,15 @@ export interface PayloadJob { | number | boolean | null; + meta?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; completedAt?: string | null; totalTried?: number | null; /** @@ -1240,7 +1255,8 @@ export interface PayloadJob { taskSlug?: ('inline' | 'createCollectionExport' | 'createCollectionImport') | null; queue?: string | null; waitUntil?: string | null; - processing?: boolean | null; + processingUntil?: string | null; + processingToken?: string | null; updatedAt: string; createdAt: string; } @@ -2089,6 +2105,7 @@ export interface PayloadKvSelect { export interface PayloadJobsSelect { input?: T; taskStatus?: T; + meta?: T; completedAt?: T; totalTried?: T; hasError?: T; @@ -2109,7 +2126,8 @@ export interface PayloadJobsSelect { taskSlug?: T; queue?: T; waitUntil?: T; - processing?: T; + processingUntil?: T; + processingToken?: T; updatedAt?: T; createdAt?: T; } @@ -2145,6 +2163,34 @@ export interface PayloadMigrationsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-jobs-stats". + */ +export interface PayloadJobsStat { + id: string; + stats?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt?: string | null; + createdAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-jobs-stats_select". + */ +export interface PayloadJobsStatsSelect { + stats?: T; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "collections_widget". @@ -2155,6 +2201,100 @@ export interface CollectionsWidget { }; width: 'full'; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collection-query_widget". + */ +export interface CollectionQueryWidget { + data?: { + title?: string | null; + relatedCollection: + | 'users' + | 'pages' + | 'posts' + | 'posts-exports-only' + | 'posts-imports-only' + | 'posts-no-jobs-queue' + | 'posts-with-limits' + | 'posts-with-s3' + | 'posts-with-hooks' + | 'posts-with-field-hooks' + | 'posts-with-column-map' + | 'media' + | 'custom-id-pages' + | 'exports' + | 'posts-export' + | 'posts-no-jobs-queue-export' + | 'posts-with-s3-export' + | 'posts-with-limits-export' + | 'posts-with-hooks-export' + | 'posts-with-field-hooks-export' + | 'posts-with-column-map-export' + | 'imports' + | 'posts-import' + | 'posts-with-s3-import' + | 'posts-with-limits-import' + | 'posts-with-hooks-import' + | 'posts-with-field-hooks-import' + | 'posts-with-column-map-import' + | 'payload-jobs'; + where?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + sortField?: string | null; + sortDirection?: ('asc' | 'desc') | null; + limit?: number | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "activity_widget". + */ +export interface ActivityWidget { + data?: { + excludedCollections?: + | ( + | 'users' + | 'pages' + | 'posts' + | 'posts-exports-only' + | 'posts-imports-only' + | 'posts-no-jobs-queue' + | 'posts-with-limits' + | 'posts-with-s3' + | 'posts-with-hooks' + | 'posts-with-field-hooks' + | 'posts-with-column-map' + | 'media' + | 'custom-id-pages' + | 'exports' + | 'posts-export' + | 'posts-no-jobs-queue-export' + | 'posts-with-s3-export' + | 'posts-with-limits-export' + | 'posts-with-hooks-export' + | 'posts-with-field-hooks-export' + | 'posts-with-column-map-export' + | 'imports' + | 'posts-import' + | 'posts-with-s3-import' + | 'posts-with-limits-import' + | 'posts-with-hooks-import' + | 'posts-with-field-hooks-import' + | 'posts-with-column-map-import' + | 'payload-jobs' + )[] + | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "TaskCreateCollectionExport".