diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2619788045f..1b5edb636b3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -285,6 +285,8 @@ jobs: - fields__collections__Text - fields__collections__UI - fields__collections__Upload + - fields__collections__UploadPoly + - fields__collections__UploadMultiPoly - group-by - folders - hooks diff --git a/.gitignore b/.gitignore index 18a1bf6cc56..c7a67f9d25a 100644 --- a/.gitignore +++ b/.gitignore @@ -341,6 +341,7 @@ test/.localstack test/google-cloud-storage test/azurestoragedata/ /media-without-delete-access +/media-documents licenses.csv diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index 17e340f2a77..7dd15c460b7 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -47,7 +47,7 @@ export const MyUploadField: Field = { | Option | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`name`** \* | To be used as the property name when stored and retrieved from the database. [More details](/docs/fields/overview#field-names). | -| **`relationTo`** \* | Provide a single collection `slug` to allow this field to accept a relation to. **Note: the related collection must be configured to support Uploads.** | +| **`relationTo`** \* | Provide a single collection `slug` or an array of slugs to allow this field to accept a relation to. **Note: the related collections must be configured to support Uploads.** | | **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More details](#filtering-upload-options). | | **`hasMany`** | Boolean which, if set to true, allows this field to have many relations instead of only one. | | **`minRows`** | A number for the fewest allowed items during validation when a value is present. Used with hasMany. | @@ -140,3 +140,40 @@ The `upload` field on its own is used to reference documents in an upload collec relationship. If you wish to allow an editor to visit the upload document and see where it is being used, you may use the `join` field in the upload enabled collection. Read more about bi-directional relationships using the [Join field](./join) + +## Polymorphic Uploads + +Upload fields can reference multiple upload collections by providing an array of collection slugs to the `relationTo` property. + +```ts +import type { CollectionConfig } from 'payload' + +export const ExampleCollection: CollectionConfig = { + slug: 'example-collection', + fields: [ + { + name: 'media', + type: 'upload', + relationTo: ['images', 'documents', 'videos'], // references multiple upload collections + }, + ], +} +``` + +This can be combined with the `hasMany` property to allow multiple uploads from multiple collections. + +```ts +import type { CollectionConfig } from 'payload' + +export const ExampleCollection: CollectionConfig = { + slug: 'example-collection', + fields: [ + { + name: 'media', + type: 'upload', + relationTo: ['images', 'documents', 'videos'], // references multiple upload collections + hasMany: true, // allows multiple uploads + }, + ], +} +``` diff --git a/packages/graphql/src/schema/fieldToSchemaMap.ts b/packages/graphql/src/schema/fieldToSchemaMap.ts index 4d0d09e8cda..784c0a9ee9d 100644 --- a/packages/graphql/src/schema/fieldToSchemaMap.ts +++ b/packages/graphql/src/schema/fieldToSchemaMap.ts @@ -49,7 +49,7 @@ import { GraphQLJSON } from '../packages/graphql-type-json/index.js' import { combineParentName } from '../utilities/combineParentName.js' import { formatName } from '../utilities/formatName.js' import { formatOptions } from '../utilities/formatOptions.js' -import { resolveSelect} from '../utilities/select.js' +import { resolveSelect } from '../utilities/select.js' import { buildObjectType, type ObjectTypeConfig } from './buildObjectType.js' import { isFieldNullable } from './isFieldNullable.js' import { withNullableType } from './withNullableType.js' @@ -57,7 +57,11 @@ import { withNullableType } from './withNullableType.js' function formattedNameResolver({ field, ...rest -}: { field: Field } & GraphQLFieldConfig): GraphQLFieldConfig { +}: { field: Field } & GraphQLFieldConfig): GraphQLFieldConfig< + any, + Context, + any +> { if ('name' in field) { if (formatName(field.name) !== field.name) { return { @@ -973,6 +977,10 @@ export const fieldToSchemaMap: FieldToSchemaMap = { let type let relationToType = null + const graphQLCollections = config.collections.filter( + (collectionConfig) => collectionConfig.graphQL !== false, + ) + if (Array.isArray(relationTo)) { relationToType = new GraphQLEnumType({ name: `${relationshipName}_RelationTo`, @@ -1073,39 +1081,44 @@ export const fieldToSchemaMap: FieldToSchemaMap = { const createPopulationPromise = async (relatedDoc, i) => { let id = relatedDoc let collectionSlug = field.relationTo + const isValidGraphQLCollection = isRelatedToManyCollections + ? graphQLCollections.some((collection) => collectionSlug.includes(collection.slug)) + : graphQLCollections.some((collection) => collectionSlug === collection.slug) - if (isRelatedToManyCollections) { - collectionSlug = relatedDoc.relationTo - id = relatedDoc.value - } + if (isValidGraphQLCollection) { + if (isRelatedToManyCollections) { + collectionSlug = relatedDoc.relationTo + id = relatedDoc.value + } - const result = await context.req.payloadDataLoader.load( - createDataloaderCacheKey({ - collectionSlug, - currentDepth: 0, - depth: 0, - docID: id, - draft, - fallbackLocale, - locale, - overrideAccess: false, - select, - showHiddenFields: false, - transactionID: context.req.transactionID, - }), - ) + const result = await context.req.payloadDataLoader.load( + createDataloaderCacheKey({ + collectionSlug: collectionSlug as string, + currentDepth: 0, + depth: 0, + docID: id, + draft, + fallbackLocale, + locale, + overrideAccess: false, + select, + showHiddenFields: false, + transactionID: context.req.transactionID, + }), + ) - if (result) { - if (isRelatedToManyCollections) { - results.push({ - relationTo: collectionSlug, - value: { - ...result, - collection: collectionSlug, - }, - }) - } else { - results.push(result) + if (result) { + if (isRelatedToManyCollections) { + results.push({ + relationTo: collectionSlug, + value: { + ...result, + collection: collectionSlug, + }, + }) + } else { + results.push(result) + } } } } @@ -1127,34 +1140,36 @@ export const fieldToSchemaMap: FieldToSchemaMap = { } if (id) { - const relatedDocument = await context.req.payloadDataLoader.load( - createDataloaderCacheKey({ - collectionSlug: relatedCollectionSlug, - currentDepth: 0, - depth: 0, - docID: id, - draft, - fallbackLocale, - locale, - overrideAccess: false, - select, - showHiddenFields: false, - transactionID: context.req.transactionID, - }), - ) + if (graphQLCollections.some((collection) => collection.slug === relatedCollectionSlug)) { + const relatedDocument = await context.req.payloadDataLoader.load( + createDataloaderCacheKey({ + collectionSlug: relatedCollectionSlug as string, + currentDepth: 0, + depth: 0, + docID: id, + draft, + fallbackLocale, + locale, + overrideAccess: false, + select, + showHiddenFields: false, + transactionID: context.req.transactionID, + }), + ) - if (relatedDocument) { - if (isRelatedToManyCollections) { - return { - relationTo: relatedCollectionSlug, - value: { - ...relatedDocument, - collection: relatedCollectionSlug, - }, + if (relatedDocument) { + if (isRelatedToManyCollections) { + return { + relationTo: relatedCollectionSlug, + value: { + ...relatedDocument, + collection: relatedCollectionSlug, + }, + } } - } - return relatedDocument + return relatedDocument + } } return null diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx index 75f382ada9a..ccd5b44accb 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx @@ -15,7 +15,10 @@ import React from 'react' const baseClass = 'upload-diff' -type UploadDoc = (FileData & TypeWithID) | string +type NonPolyUploadDoc = (FileData & TypeWithID) | number | string +type PolyUploadDoc = { relationTo: string; value: (FileData & TypeWithID) | number | string } + +type UploadDoc = NonPolyUploadDoc | PolyUploadDoc export const Upload: UploadFieldDiffServerComponent = (args) => { const { @@ -27,14 +30,17 @@ export const Upload: UploadFieldDiffServerComponent = (args) => { req, versionValue: valueTo, } = args + const hasMany = 'hasMany' in field && field.hasMany && Array.isArray(valueTo) + const polymorphic = Array.isArray(field.relationTo) - if ('hasMany' in field && field.hasMany && Array.isArray(valueTo)) { + if (hasMany) { return ( { i18n={i18n} locale={locale} nestingLevel={nestingLevel} + polymorphic={polymorphic} req={req} valueFrom={valueFrom as UploadDoc} valueTo={valueTo as UploadDoc} @@ -60,11 +67,12 @@ export const HasManyUploadDiff: React.FC<{ i18n: I18nClient locale: string nestingLevel?: number + polymorphic: boolean req: PayloadRequest valueFrom: Array valueTo: Array }> = async (args) => { - const { field, i18n, locale, nestingLevel, req, valueFrom, valueTo } = args + const { field, i18n, locale, nestingLevel, polymorphic, req, valueFrom, valueTo } = args const ReactDOMServer = (await import('react-dom/server')).default let From: React.ReactNode = '' @@ -72,11 +80,22 @@ export const HasManyUploadDiff: React.FC<{ const showCollectionSlug = Array.isArray(field.relationTo) + const getUploadDocKey = (uploadDoc: UploadDoc): number | string => { + if (typeof uploadDoc === 'object' && 'relationTo' in uploadDoc) { + // Polymorphic case + const value = uploadDoc.value + return typeof value === 'object' ? value.id : value + } + // Non-polymorphic case + return typeof uploadDoc === 'object' ? uploadDoc.id : uploadDoc + } + const FromComponents = valueFrom ? valueFrom.map((uploadDoc) => ( ( = async (args) => { - const { field, i18n, locale, nestingLevel, req, valueFrom, valueTo } = args + const { field, i18n, locale, nestingLevel, polymorphic, req, valueFrom, valueTo } = args const ReactDOMServer = (await import('react-dom/server')).default @@ -155,6 +176,7 @@ export const SingleUploadDiff: React.FC<{ const FromComponent = valueFrom ? ( { - const { i18n, relationTo, req, showCollectionSlug, uploadDoc } = args + const { i18n, polymorphic, relationTo, req, showCollectionSlug, uploadDoc } = args let thumbnailSRC: string = '' - if (uploadDoc && typeof uploadDoc === 'object' && 'thumbnailURL' in uploadDoc) { + + const value = polymorphic + ? (uploadDoc as { relationTo: string; value: FileData & TypeWithID }).value + : (uploadDoc as FileData & TypeWithID) + + if (value && typeof value === 'object' && 'thumbnailURL' in value) { thumbnailSRC = - (typeof uploadDoc.thumbnailURL === 'string' && uploadDoc.thumbnailURL) || - (typeof uploadDoc.url === 'string' && uploadDoc.url) || + (typeof value.thumbnailURL === 'string' && value.thumbnailURL) || + (typeof value.url === 'string' && value.url) || '' } let filename: string - if (uploadDoc && typeof uploadDoc === 'object') { - filename = uploadDoc.filename + if (value && typeof value === 'object') { + filename = value.filename } else { filename = `${i18n.t('general:untitled')} - ID: ${uploadDoc as number | string}` } @@ -228,17 +257,33 @@ const UploadDocumentDiff = (args: { let pillLabel: null | string = null if (showCollectionSlug) { - const uploadConfig = req.payload.collections[relationTo].config + let collectionSlug: string + if (polymorphic && typeof uploadDoc === 'object' && 'relationTo' in uploadDoc) { + collectionSlug = uploadDoc.relationTo + } else { + collectionSlug = typeof relationTo === 'string' ? relationTo : relationTo[0] + } + const uploadConfig = req.payload.collections[collectionSlug].config pillLabel = uploadConfig.labels?.singular ? getTranslation(uploadConfig.labels.singular, i18n) : uploadConfig.slug } + let id: number | string | undefined + if (polymorphic && typeof uploadDoc === 'object' && 'relationTo' in uploadDoc) { + const polyValue = uploadDoc.value + id = typeof polyValue === 'object' ? polyValue.id : polyValue + } else if (typeof uploadDoc === 'object' && 'id' in uploadDoc) { + id = uploadDoc.id + } else if (typeof uploadDoc === 'string' || typeof uploadDoc === 'number') { + id = uploadDoc + } + return (
diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 425f8a99726..3b83c7980ea 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1048,9 +1048,9 @@ export type SingleUploadFieldClient = { } & Pick & SharedUploadPropertiesClient -export type UploadField = /* PolymorphicUploadField | */ SingleUploadField +export type UploadField = PolymorphicUploadField | SingleUploadField -export type UploadFieldClient = /* PolymorphicUploadFieldClient | */ SingleUploadFieldClient +export type UploadFieldClient = PolymorphicUploadFieldClient | SingleUploadFieldClient export type CodeField = { admin?: { diff --git a/packages/plugin-seo/src/fields/MetaImage/index.ts b/packages/plugin-seo/src/fields/MetaImage/index.ts index b709432eefd..4bb57804a5a 100644 --- a/packages/plugin-seo/src/fields/MetaImage/index.ts +++ b/packages/plugin-seo/src/fields/MetaImage/index.ts @@ -12,7 +12,7 @@ interface FieldFunctionProps { type FieldFunction = ({ hasGenerateFn, overrides }: FieldFunctionProps) => UploadField export const MetaImageField: FieldFunction = ({ hasGenerateFn = false, overrides, relationTo }) => { - return { + const imageField = { name: 'image', type: 'upload', admin: { @@ -30,5 +30,7 @@ export const MetaImageField: FieldFunction = ({ hasGenerateFn = false, overrides localized: true, relationTo, ...((overrides ?? {}) as { hasMany: boolean } & Partial), - } + } as UploadField + + return imageField } diff --git a/packages/ui/src/elements/ReactSelect/MultiValue/index.tsx b/packages/ui/src/elements/ReactSelect/MultiValue/index.tsx index a2f64c7d72d..94bdff60325 100644 --- a/packages/ui/src/elements/ReactSelect/MultiValue/index.tsx +++ b/packages/ui/src/elements/ReactSelect/MultiValue/index.tsx @@ -12,7 +12,7 @@ import './index.scss' const baseClass = 'multi-value' export function generateMultiValueDraggableID(optionData, valueFunction) { - return typeof valueFunction === 'function' ? valueFunction(optionData) : optionData.value + return typeof valueFunction === 'function' ? valueFunction(optionData) : optionData?.value } export const MultiValue: React.FC> = (props) => { const { @@ -24,8 +24,10 @@ export const MultiValue: React.FC> = (props) => { selectProps: { customProps: { disableMouseDown } = {}, getOptionValue, isSortable } = {}, } = props + const id = generateMultiValueDraggableID(data, getOptionValue) + const { attributes, isDragging, listeners, setNodeRef, transform } = useDraggableSortable({ - id: generateMultiValueDraggableID(data, getOptionValue), + id, disabled: !isSortable, }) diff --git a/packages/ui/src/elements/WhereBuilder/Condition/DefaultFilter/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/DefaultFilter/index.tsx index 44ba59267eb..4cef586b78f 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/DefaultFilter/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/DefaultFilter/index.tsx @@ -89,6 +89,19 @@ export const DefaultFilter: React.FC = ({ ) } + case 'upload': { + return ( + + ) + } + default: { return ( = (props) => { const { disabled, - field: { admin: { isSortable, placeholder } = {}, hasMany, relationTo }, + field: { admin = {}, hasMany, relationTo }, filterOptions, onChange, value, } = props + const placeholder = 'placeholder' in admin ? admin?.placeholder : undefined + const isSortable = admin?.isSortable + const { config: { routes: { api }, diff --git a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/types.ts b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/types.ts index a49a115a453..aa9de8049a0 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/Relationship/types.ts +++ b/packages/ui/src/elements/WhereBuilder/Condition/Relationship/types.ts @@ -4,12 +4,13 @@ import type { PaginatedDocs, RelationshipFieldClient, ResolvedFilterOptions, + UploadFieldClient, } from 'payload' import type { DefaultFilterProps } from '../types.js' export type RelationshipFilterProps = { - readonly field: RelationshipFieldClient + readonly field: RelationshipFieldClient | UploadFieldClient readonly filterOptions: ResolvedFilterOptions } & DefaultFilterProps diff --git a/packages/ui/src/elements/WhereBuilder/field-types.tsx b/packages/ui/src/elements/WhereBuilder/field-types.tsx index e683b75256e..9c29c1c6e16 100644 --- a/packages/ui/src/elements/WhereBuilder/field-types.tsx +++ b/packages/ui/src/elements/WhereBuilder/field-types.tsx @@ -143,7 +143,7 @@ export const fieldTypeConditions: { operators: [...base, like, notLike, contains], }, upload: { - component: 'Text', + component: 'Relationship', operators: [...base], }, } diff --git a/packages/ui/src/fields/Upload/HasMany/index.tsx b/packages/ui/src/fields/Upload/HasMany/index.tsx index 5bc6b695004..2ea36b1e2dc 100644 --- a/packages/ui/src/fields/Upload/HasMany/index.tsx +++ b/packages/ui/src/fields/Upload/HasMany/index.tsx @@ -30,6 +30,7 @@ type Props = { readonly readonly?: boolean readonly reloadDoc: ReloadDoc readonly serverURL: string + readonly showCollectionSlug?: boolean } export function UploadComponentHasMany(props: Props) { @@ -43,6 +44,7 @@ export function UploadComponentHasMany(props: Props) { readonly, reloadDoc, serverURL, + showCollectionSlug = false, } = props const moveRow = React.useCallback( @@ -147,6 +149,7 @@ export function UploadComponentHasMany(props: Props) { mimeType={value?.mimeType as string} onRemove={() => removeItem(index)} reloadDoc={reloadDoc} + showCollectionSlug={showCollectionSlug} src={src} thumbnailSrc={thumbnailSrc} withMeta={false} diff --git a/packages/ui/src/fields/Upload/HasOne/index.tsx b/packages/ui/src/fields/Upload/HasOne/index.tsx index 772e18701a2..6d0bebaa52d 100644 --- a/packages/ui/src/fields/Upload/HasOne/index.tsx +++ b/packages/ui/src/fields/Upload/HasOne/index.tsx @@ -24,10 +24,20 @@ type Props = { readonly readonly?: boolean readonly reloadDoc: ReloadDoc readonly serverURL: string + readonly showCollectionSlug?: boolean } export function UploadComponentHasOne(props: Props) { - const { className, displayPreview, fileDoc, onRemove, readonly, reloadDoc, serverURL } = props + const { + className, + displayPreview, + fileDoc, + onRemove, + readonly, + reloadDoc, + serverURL, + showCollectionSlug = false, + } = props const { relationTo, value } = fileDoc const id = String(value?.id) @@ -73,6 +83,7 @@ export function UploadComponentHasOne(props: Props) { mimeType={value?.mimeType as string} onRemove={onRemove} reloadDoc={reloadDoc} + showCollectionSlug={showCollectionSlug} src={src} thumbnailSrc={thumbnailSrc} x={value?.width as number} diff --git a/packages/ui/src/fields/Upload/Input.tsx b/packages/ui/src/fields/Upload/Input.tsx index 10af9482214..155d149f129 100644 --- a/packages/ui/src/fields/Upload/Input.tsx +++ b/packages/ui/src/fields/Upload/Input.tsx @@ -9,7 +9,7 @@ import type { StaticLabel, UploadFieldClient, UploadField as UploadFieldType, - Where, + ValueWithRelation, } from 'payload' import type { MarkOptional } from 'ts-essentials' @@ -18,7 +18,7 @@ import * as qs from 'qs-esm' import React, { useCallback, useEffect, useMemo } from 'react' import type { ListDrawerProps } from '../../elements/ListDrawer/types.js' -import type { PopulateDocs, ReloadDoc } from './types.js' +import type { ReloadDoc, ValueAsDataWithRelation } from './types.js' import { type BulkUploadContext, useBulkUpload } from '../../elements/BulkUpload/index.js' import { Button } from '../../elements/Button/index.js' @@ -74,7 +74,7 @@ export type UploadInputProps = { readonly serverURL?: string readonly showError?: boolean readonly style?: React.CSSProperties - readonly value?: (number | string)[] | (number | string) + readonly value?: (number | string)[] | number | string | ValueWithRelation | ValueWithRelation[] } export function UploadInput(props: UploadInputProps) { @@ -113,31 +113,88 @@ export function UploadInput(props: UploadInputProps) { }[] >() - const [activeRelationTo, setActiveRelationTo] = React.useState( + const [activeRelationTo] = React.useState( Array.isArray(relationTo) ? relationTo[0] : relationTo, ) const { openModal } = useModal() - const { drawerSlug, setCollectionSlug, setInitialFiles, setMaxFiles, setOnSuccess } = - useBulkUpload() + const { + drawerSlug, + setCollectionSlug, + setInitialFiles, + setMaxFiles, + setOnSuccess, + setSelectableCollections, + } = useBulkUpload() const { permissions } = useAuth() const { code } = useLocale() const { i18n, t } = useTranslation() + // This will be used by the bulk upload to allow you to select only collections you have create permissions for + const collectionSlugsWithCreatePermission = useMemo(() => { + if (Array.isArray(relationTo)) { + return relationTo.filter((relation) => { + if (permissions?.collections && permissions.collections?.[relation]?.create) { + return true + } + return false + }) + } + return [] + }, [relationTo, permissions]) + const filterOptions: FilterOptionsResult = useMemo(() => { - return { - ...filterOptionsFromProps, - [activeRelationTo]: { - ...((filterOptionsFromProps?.[activeRelationTo] as any) || {}), + const isPoly = Array.isArray(relationTo) + + if (!value) { + return filterOptionsFromProps + } + + // Group existing IDs by relation + const existingIdsByRelation: Record = {} + + const values = Array.isArray(value) ? value : [value] + + for (const val of values) { + if (isPoly && typeof val === 'object' && 'relationTo' in val) { + // Poly upload - group by relationTo + if (!existingIdsByRelation[val.relationTo]) { + existingIdsByRelation[val.relationTo] = [] + } + existingIdsByRelation[val.relationTo].push(val.value) + } else if (!isPoly) { + // Non-poly upload - all IDs belong to the single collection + const collection = relationTo + if (!existingIdsByRelation[collection]) { + existingIdsByRelation[collection] = [] + } + const id = typeof val === 'object' && 'value' in val ? val.value : val + if (typeof id === 'string' || typeof id === 'number') { + existingIdsByRelation[collection].push(id) + } + } + } + + // Build filter options for each collection + const newFilterOptions = { ...filterOptionsFromProps } + const relations = isPoly ? relationTo : [relationTo] + + relations.forEach((relation) => { + const existingIds = existingIdsByRelation[relation] || [] + + newFilterOptions[relation] = { + ...((filterOptionsFromProps?.[relation] as any) || {}), id: { - ...((filterOptionsFromProps?.[activeRelationTo] as any)?.id || {}), - not_in: ((filterOptionsFromProps?.[activeRelationTo] as any)?.id?.not_in || []).concat( - ...(Array.isArray(value) || value ? [value] : []), + ...((filterOptionsFromProps?.[relation] as any)?.id || {}), + not_in: ((filterOptionsFromProps?.[relation] as any)?.id?.not_in || []).concat( + existingIds, ), }, - }, - } - }, [value, activeRelationTo, filterOptionsFromProps]) + } + }) + + return newFilterOptions + }, [value, filterOptionsFromProps, relationTo]) const [ListDrawer, , { closeDrawer: closeListDrawer, openDrawer: openListDrawer }] = useListDrawer({ @@ -154,9 +211,11 @@ export function UploadInput(props: UploadInputProps) { }) /** - * Prevent initial retrieval of documents from running more than once + * Track the last loaded value to prevent unnecessary reloads */ - const loadedValueDocsRef = React.useRef(false) + const loadedValueRef = React.useRef< + (number | string)[] | null | number | string | ValueWithRelation | ValueWithRelation[] + >(null) const canCreate = useMemo(() => { if (!allowCreate) { @@ -181,76 +240,93 @@ export function UploadInput(props: UploadInputProps) { [onChangeFromProps], ) - const populateDocs = React.useCallback( - async (ids, relatedCollectionSlug) => { - if (!ids.length) { - return - } - - const query: { - [key: string]: unknown - where: Where - } = { - depth: 0, - draft: true, - limit: ids.length, - locale: code, - where: { - and: [ - { - id: { - in: ids, - }, - }, - ], - }, + const populateDocs = React.useCallback( + async (items: ValueWithRelation[]): Promise => { + if (!items?.length) { + return [] } - const response = await fetch(`${serverURL}${api}/${relatedCollectionSlug}`, { - body: qs.stringify(query), - credentials: 'include', - headers: { - 'Accept-Language': i18n.language, - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-Payload-HTTP-Method-Override': 'GET', - }, - method: 'POST', + // 1. Group IDs by collection + const grouped: Record = {} + items.forEach(({ relationTo, value }) => { + if (!grouped[relationTo]) { + grouped[relationTo] = [] + } + grouped[relationTo].push(value) }) - if (response.ok) { - const json = await response.json() - let sortedDocs = ids.map((id) => - json.docs.find((doc) => { - return String(doc.id) === String(id) - }), - ) - if (sortedDocs.includes(undefined) && hasMany) { - sortedDocs = sortedDocs.map((doc, index) => - doc - ? doc - : { - id: ids[index], - filename: `${t('general:untitled')} - ID: ${ids[index]}`, - isPlaceholder: true, + // 2. Fetch per collection + const fetches = Object.entries(grouped).map(async ([collection, ids]) => { + const query = { + depth: 0, + draft: true, + limit: ids.length, + locale: code, + where: { + and: [ + { + id: { + in: ids, }, - ) + }, + ], + }, } + const response = await fetch(`${serverURL}${api}/${collection}`, { + body: qs.stringify(query), + credentials: 'include', + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Payload-HTTP-Method-Override': 'GET', + }, + method: 'POST', + }) + let docs: any[] = [] + if (response.ok) { + const data = await response.json() + docs = data.docs + } + // Map docs by ID for fast lookup + const docsById = docs.reduce((acc, doc) => { + acc[doc.id] = doc + return acc + }, {}) + return { collection, docsById } + }) - return { ...json, docs: sortedDocs } - } + const results = await Promise.all(fetches) + + // 3. Build lookup + const lookup: Record> = {} + results.forEach(({ collection, docsById }) => { + lookup[collection] = docsById + }) + + // 4. Reconstruct in input order, add placeholders if missing + const sortedDocs = items.map(({ relationTo, value }) => { + const doc = lookup[relationTo]?.[value] || { + id: value, + filename: `${t('general:untitled')} - ID: ${value}`, + isPlaceholder: true, + } + return { relationTo, value: doc } + }) - return null + return sortedDocs }, - [code, serverURL, api, i18n.language, t, hasMany], + [serverURL, api, code, i18n.language, t], ) const onUploadSuccess: BulkUploadContext['onSuccess'] = useCallback( (uploadedForms) => { + const isPoly = Array.isArray(relationTo) + if (hasMany) { - const mergedValue = [ - ...(Array.isArray(value) ? value : []), - ...uploadedForms.map((form) => form.doc.id), - ] + const newValues = uploadedForms.map((form) => + isPoly ? { relationTo: form.collectionSlug, value: form.doc.id } : form.doc.id, + ) + const mergedValue = [...(Array.isArray(value) ? value : []), ...newValues] onChange(mergedValue) setPopulatedDocs((currentDocs) => [ ...(currentDocs || []), @@ -260,17 +336,20 @@ export function UploadInput(props: UploadInputProps) { })), ]) } else { - const firstDoc = uploadedForms[0].doc - onChange(firstDoc.id) + const firstDoc = uploadedForms[0] + const newValue = isPoly + ? { relationTo: firstDoc.collectionSlug, value: firstDoc.doc.id } + : firstDoc.doc.id + onChange(newValue) setPopulatedDocs([ { relationTo: firstDoc.collectionSlug, - value: firstDoc, + value: firstDoc.doc, }, ]) } }, - [value, onChange, hasMany], + [value, onChange, hasMany, relationTo], ) const onLocalFileSelection = React.useCallback( @@ -284,20 +363,31 @@ export function UploadInput(props: UploadInputProps) { if (fileListToUse) { setInitialFiles(fileListToUse) } - setCollectionSlug(relationTo) + // Use activeRelationTo for poly uploads, or relationTo as string for single collection + const collectionToUse = Array.isArray(relationTo) ? activeRelationTo : relationTo + + setCollectionSlug(collectionToUse) + if (Array.isArray(collectionSlugsWithCreatePermission)) { + setSelectableCollections(collectionSlugsWithCreatePermission) + } + if (typeof maxRows === 'number') { setMaxFiles(maxRows) } + openModal(drawerSlug) }, [ - drawerSlug, hasMany, - openModal, relationTo, + activeRelationTo, setCollectionSlug, - setInitialFiles, + collectionSlugsWithCreatePermission, maxRows, + openModal, + drawerSlug, + setInitialFiles, + setSelectableCollections, setMaxFiles, ], ) @@ -305,6 +395,7 @@ export function UploadInput(props: UploadInputProps) { // only hasMany can bulk select const onListBulkSelect = React.useCallback>( async (docs) => { + const isPoly = Array.isArray(relationTo) const selectedDocIDs = [] for (const [id, isSelected] of docs) { @@ -313,24 +404,27 @@ export function UploadInput(props: UploadInputProps) { } } - const loadedDocs = await populateDocs(selectedDocIDs, activeRelationTo) + const itemsToLoad = selectedDocIDs.map((id) => ({ + relationTo: activeRelationTo, + value: id, + })) + const loadedDocs = await populateDocs(itemsToLoad) if (loadedDocs) { - setPopulatedDocs((currentDocs) => [ - ...(currentDocs || []), - ...loadedDocs.docs.map((doc) => ({ - relationTo: activeRelationTo, - value: doc, - })), - ]) + setPopulatedDocs((currentDocs) => [...(currentDocs || []), ...loadedDocs]) } - onChange([...(Array.isArray(value) ? value : []), ...selectedDocIDs]) + + const newValues = selectedDocIDs.map((id) => + isPoly ? { relationTo: activeRelationTo, value: id } : id, + ) + onChange([...(Array.isArray(value) ? value : []), ...newValues]) closeListDrawer() }, - [activeRelationTo, closeListDrawer, onChange, populateDocs, value], + [activeRelationTo, closeListDrawer, onChange, populateDocs, value, relationTo], ) const onDocCreate = React.useCallback( (data) => { + const isPoly = Array.isArray(relationTo) if (data.doc) { setPopulatedDocs((currentDocs) => [ ...(currentDocs || []), @@ -340,67 +434,63 @@ export function UploadInput(props: UploadInputProps) { }, ]) - onChange(data.doc.id) + const newValue = isPoly ? { relationTo: activeRelationTo, value: data.doc.id } : data.doc.id + onChange(newValue) } closeCreateDocDrawer() }, - [closeCreateDocDrawer, activeRelationTo, onChange], + [closeCreateDocDrawer, activeRelationTo, onChange, relationTo], ) const onListSelect = useCallback>( async ({ collectionSlug, doc }) => { - const loadedDocs = await populateDocs([doc.id], collectionSlug) - const selectedDoc = loadedDocs ? loadedDocs.docs?.[0] : null + const isPoly = Array.isArray(relationTo) + + const loadedDocs = await populateDocs([{ relationTo: collectionSlug, value: doc.id }]) + const selectedDoc = loadedDocs?.[0] || null + setPopulatedDocs((currentDocs) => { if (selectedDoc) { if (hasMany) { - return [ - ...(currentDocs || []), - { - relationTo: activeRelationTo, - value: selectedDoc, - }, - ] + return [...(currentDocs || []), selectedDoc] } - return [ - { - relationTo: activeRelationTo, - value: selectedDoc, - }, - ] + return [selectedDoc] } return currentDocs }) + + const newValue = isPoly ? { relationTo: collectionSlug, value: doc.id } : doc.id + if (hasMany) { - onChange([...(Array.isArray(value) ? value : []), doc.id]) + const valueToUse = [...(Array.isArray(value) ? value : []), newValue] + onChange(valueToUse) } else { - onChange(doc.id) + const valueToUse = isPoly ? { relationTo: collectionSlug, value: doc.id } : doc.id + onChange(valueToUse) } closeListDrawer() }, - [closeListDrawer, hasMany, populateDocs, onChange, value, activeRelationTo], + [closeListDrawer, hasMany, populateDocs, onChange, value, relationTo], ) const reloadDoc = React.useCallback( async (docID, collectionSlug) => { - const { docs } = await populateDocs([docID], collectionSlug) + const docs = await populateDocs([{ relationTo: collectionSlug, value: docID }]) if (docs[0]) { let updatedDocsToPropogate = [] setPopulatedDocs((currentDocs) => { const existingDocIndex = currentDocs?.findIndex((doc) => { - const hasExisting = doc.value?.id === docs[0].id || doc.value?.isPlaceholder + const hasExisting = doc.value?.id === docs[0].value.id || doc.value?.isPlaceholder return hasExisting && doc.relationTo === collectionSlug }) if (existingDocIndex > -1) { const updatedDocs = [...currentDocs] - updatedDocs[existingDocIndex] = { - relationTo: collectionSlug, - value: docs[0], - } + updatedDocs[existingDocIndex] = docs[0] updatedDocsToPropogate = updatedDocs return updatedDocs } + return currentDocs }) if (updatedDocsToPropogate.length && hasMany) { @@ -414,42 +504,87 @@ export function UploadInput(props: UploadInputProps) { // only hasMany can reorder const onReorder = React.useCallback( (newValue) => { - const newValueIDs = newValue.map(({ value }) => value.id) - onChange(newValueIDs) + const isPoly = Array.isArray(relationTo) + const newValueToSave = newValue.map(({ relationTo: rel, value }) => + isPoly ? { relationTo: rel, value: value.id } : value.id, + ) + onChange(newValueToSave) setPopulatedDocs(newValue) }, - [onChange], + [onChange, relationTo], ) const onRemove = React.useCallback( (newValue?: PopulatedDocs) => { - const newValueIDs = newValue ? newValue.map(({ value }) => value.id) : null - onChange(hasMany ? newValueIDs : newValueIDs ? newValueIDs[0] : null) - setPopulatedDocs(newValue ? newValue : []) + const isPoly = Array.isArray(relationTo) + + if (!newValue || newValue.length === 0) { + onChange(hasMany ? [] : null) + setPopulatedDocs(hasMany ? [] : null) + return + } + + const newValueToSave = newValue.map(({ relationTo: rel, value }) => + isPoly ? { relationTo: rel, value: value.id } : value.id, + ) + + onChange(hasMany ? newValueToSave : newValueToSave[0]) + setPopulatedDocs(newValue) }, - [onChange, hasMany], + [onChange, hasMany, relationTo], ) useEffect(() => { async function loadInitialDocs() { if (value) { - loadedValueDocsRef.current = true - const loadedDocs = await populateDocs( - Array.isArray(value) ? value : [value], - activeRelationTo, - ) - if (loadedDocs) { - setPopulatedDocs( - loadedDocs.docs.map((doc) => ({ relationTo: activeRelationTo, value: doc })), + const isPoly = Array.isArray(relationTo) + const collectionSlug = relationTo as string + + let itemsToLoad: ValueWithRelation[] + if ( + isPoly && + ((typeof value === 'object' && 'relationTo' in value) || + (Array.isArray(value) && + value.length > 0 && + typeof value[0] === 'object' && + 'relationTo' in value[0])) + ) { + // For poly uploads, value should already be in the format { relationTo, value } + const values = Array.isArray(value) ? value : [value] + itemsToLoad = values.filter( + (v): v is ValueWithRelation => typeof v === 'object' && 'relationTo' in v, ) + } else { + // For single collection uploads, we need to wrap the IDs + const ids = Array.isArray(value) ? value : [value] + itemsToLoad = ids.map((id): ValueWithRelation => { + const idValue = typeof id === 'object' && 'value' in id ? id.value : id + return { + relationTo: collectionSlug, + value: idValue, + } + }) } + + const loadedDocs = await populateDocs(itemsToLoad) + + if (loadedDocs) { + setPopulatedDocs(loadedDocs) + loadedValueRef.current = value + } + } else { + // Clear populated docs when value is cleared + setPopulatedDocs([]) + loadedValueRef.current = null } } - if (!loadedValueDocsRef.current) { + // Only load if value has changed from what we last loaded + const valueChanged = loadedValueRef.current !== value + if (valueChanged) { void loadInitialDocs() } - }, [populateDocs, activeRelationTo, value]) + }, [populateDocs, value, relationTo]) useEffect(() => { setOnSuccess(onUploadSuccess) @@ -500,11 +635,12 @@ export function UploadInput(props: UploadInputProps) { readonly={readOnly} reloadDoc={reloadDoc} serverURL={serverURL} + showCollectionSlug={Array.isArray(relationTo)} /> ) : (
{value.map((id) => ( - + ))}
)} @@ -520,6 +656,7 @@ export function UploadInput(props: UploadInputProps) { readonly={readOnly} reloadDoc={reloadDoc} serverURL={serverURL} + showCollectionSlug={Array.isArray(relationTo)} /> ) : populatedDocs && value && !populatedDocs?.[0]?.value ? ( <> diff --git a/packages/ui/src/fields/Upload/RelationshipContent/index.tsx b/packages/ui/src/fields/Upload/RelationshipContent/index.tsx index 4d3dabf99cd..158d26076b3 100644 --- a/packages/ui/src/fields/Upload/RelationshipContent/index.tsx +++ b/packages/ui/src/fields/Upload/RelationshipContent/index.tsx @@ -2,16 +2,19 @@ import type { TypeWithID } from 'payload' -import { formatFilesize, isImage } from 'payload/shared' +import { getTranslation } from '@payloadcms/translations' +import { formatFilesize } from 'payload/shared' import React from 'react' import type { ReloadDoc } from '../types.js' import { Button } from '../../../elements/Button/index.js' import { useDocumentDrawer } from '../../../elements/DocumentDrawer/index.js' +import { Pill } from '../../../elements/Pill/index.js' import { ThumbnailComponent } from '../../../elements/Thumbnail/index.js' -import { useConfig } from '../../../providers/Config/index.js' import './index.scss' +import { useConfig } from '../../../providers/Config/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' const baseClass = 'upload-relationship-details' @@ -28,6 +31,7 @@ type Props = { readonly mimeType: string readonly onRemove: () => void readonly reloadDoc: ReloadDoc + readonly showCollectionSlug?: boolean readonly src: string readonly thumbnailSrc: string readonly withMeta?: boolean @@ -48,6 +52,7 @@ export function RelationshipContent(props: Props) { mimeType, onRemove, reloadDoc, + showCollectionSlug = false, src, thumbnailSrc, withMeta = true, @@ -56,6 +61,7 @@ export function RelationshipContent(props: Props) { } = props const { config } = useConfig() + const { i18n } = useTranslation() const collectionConfig = 'collections' in config ? config.collections.find((collection) => collection.slug === collectionSlug) @@ -103,6 +109,9 @@ export function RelationshipContent(props: Props) { size="small" /> )} + {showCollectionSlug && collectionConfig ? ( + {getTranslation(collectionConfig.labels.singular, i18n)} + ) : null}

{src ? ( diff --git a/packages/ui/src/fields/Upload/index.tsx b/packages/ui/src/fields/Upload/index.tsx index a1b4380a3ac..600a8a23650 100644 --- a/packages/ui/src/fields/Upload/index.tsx +++ b/packages/ui/src/fields/Upload/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { UploadFieldClientProps } from 'payload' +import type { UploadFieldClientProps, ValueWithRelation } from 'payload' import React, { useMemo } from 'react' @@ -26,7 +26,7 @@ export function UploadComponent(props: UploadFieldClientProps) { label, localized, maxRows, - relationTo, + relationTo: relationToFromProps, required, }, path: pathFromProps, @@ -60,6 +60,35 @@ export function UploadComponent(props: UploadFieldClientProps) { validate: memoizedValidate, }) + const isPolymorphic = Array.isArray(relationToFromProps) + + const memoizedValue: + | (number | string)[] + | number + | string + | ValueWithRelation + | ValueWithRelation[] = React.useMemo(() => { + if (hasMany === true) { + return ( + Array.isArray(value) + ? value.map((val) => { + return isPolymorphic + ? val + : { + relationTo: Array.isArray(relationToFromProps) + ? relationToFromProps[0] + : relationToFromProps, + value: val, + } + }) + : value + ) as ValueWithRelation[] + } else { + // Value comes in as string when not polymorphic and with the object with the right relationTo when it is polymorphic + return value + } + }, [hasMany, value, isPolymorphic, relationToFromProps]) + const styles = useMemo(() => mergeFieldStyles(field), [field]) return ( @@ -84,12 +113,12 @@ export function UploadComponent(props: UploadFieldClientProps) { onChange={setValue} path={path} readOnly={readOnly || disabled} - relationTo={relationTo} + relationTo={relationToFromProps} required={required} serverURL={config.serverURL} showError={showError} style={styles} - value={value} + value={memoizedValue} /> ) diff --git a/packages/ui/src/fields/Upload/types.ts b/packages/ui/src/fields/Upload/types.ts index a3c7d4518db..7a7ce2de0eb 100644 --- a/packages/ui/src/fields/Upload/types.ts +++ b/packages/ui/src/fields/Upload/types.ts @@ -1,8 +1,22 @@ -import type { PaginatedDocs } from 'payload' +import type { PaginatedDocs, ValueWithRelation } from 'payload' -export type PopulateDocs = ( +export type ValueAsDataWithRelation = { + relationTo: string + value: any +} + +type PopulateDocsDeprecated = ( ids: (number | string)[], - relatedCollectionSlug: string, + items: never, + collectionSlug?: string, // kept for compatibility, not used ) => Promise -export type ReloadDoc = (docID: number | string, collectionSlug: string) => Promise +type PopulateDocsNew = ( + items: ValueWithRelation[], + ids: never, + collectionSlug: never, +) => Promise + +export type PopulateDocs = PopulateDocsDeprecated | PopulateDocsNew + +export type ReloadDoc = (doc: number | string, collectionSlug: string) => Promise diff --git a/test/fields/collections/UploadMulti/index.ts b/test/fields/collections/UploadMulti/index.ts index e0438365d49..ef36390781a 100644 --- a/test/fields/collections/UploadMulti/index.ts +++ b/test/fields/collections/UploadMulti/index.ts @@ -1,6 +1,6 @@ import type { CollectionConfig } from 'payload' -import { uploadsMulti, uploadsSlug } from '../../slugs.js' +import { uploads2Slug, uploadsMulti, uploadsSlug } from '../../slugs.js' const Uploads: CollectionConfig = { slug: uploadsMulti, @@ -13,7 +13,7 @@ const Uploads: CollectionConfig = { name: 'media', type: 'upload', hasMany: true, - relationTo: uploadsSlug, + relationTo: [uploadsSlug, uploads2Slug], }, ], } diff --git a/test/fields/collections/UploadMultiPoly/e2e.spec.ts b/test/fields/collections/UploadMultiPoly/e2e.spec.ts new file mode 100644 index 00000000000..02440239cc4 --- /dev/null +++ b/test/fields/collections/UploadMultiPoly/e2e.spec.ts @@ -0,0 +1,109 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js' +import path from 'path' +import { wait } from 'payload/shared' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' +import type { Config } from '../../payload-types.js' + +import { + ensureCompilationIsDone, + exactText, + initPageConsoleErrorCatch, + saveDocAndAssert, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../helpers/reInitializeDB.js' +import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +import { uploadsMultiPoly } from '../../slugs.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let page: Page +let serverURL: string +// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' }) +let url: AdminUrlUtil + +describe('Upload polymorphic with hasMany', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ + dirname, + // prebuild, + })) + url = new AdminUrlUtil(serverURL, uploadsMultiPoly) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + + await ensureCompilationIsDone({ page, serverURL }) + }) + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + + await ensureCompilationIsDone({ page, serverURL }) + }) + + test('should upload in multi polymorphic field', async () => { + await page.goto(url.create) + + const multiPolyButton = page.locator('#field-media button', { + hasText: exactText('Create New'), + }) + await multiPolyButton.click() + + const uploadModal = page.locator('#media-bulk-upload-drawer-slug-1') + await expect(uploadModal).toBeVisible() + + await uploadModal + .locator('.dropzone input[type="file"]') + .setInputFiles(path.resolve(dirname, './collections/Upload/payload.jpg')) + + const saveButton = uploadModal.locator('.bulk-upload--actions-bar__saveButtons button') + await saveButton.click() + + const firstFileInList = page.locator('.upload-field-card').first() + await expect(firstFileInList.locator('.pill')).toContainText('Upload') + + await multiPolyButton.click() + await expect(uploadModal).toBeVisible() + await page.setInputFiles( + 'input[type="file"]', + path.resolve(dirname, './collections/Upload/payload.jpg'), + ) + + const collectionSelector = uploadModal.locator( + '.file-selections__header .file-selections__collectionSelect', + ) + + await expect(collectionSelector).toBeVisible() + const fieldSelector = collectionSelector.locator('.react-select') + await fieldSelector.click({ delay: 100 }) + const options = uploadModal.locator('.rs__option') + // Select an option + await options.locator('text=Upload 2').click() + + await expect(uploadModal.locator('.bulk-upload--drawer-header')).toContainText('Upload 2') + await saveButton.click() + + const svgItemInList = page.locator('.upload-field-card').nth(1) + await expect(svgItemInList.locator('.pill')).toContainText('Upload 2') + + await saveDocAndAssert(page) + }) +}) diff --git a/test/fields/collections/UploadPoly/e2e.spec.ts b/test/fields/collections/UploadPoly/e2e.spec.ts new file mode 100644 index 00000000000..bd63ebe0725 --- /dev/null +++ b/test/fields/collections/UploadPoly/e2e.spec.ts @@ -0,0 +1,86 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js' +import path from 'path' +import { wait } from 'payload/shared' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../../../helpers/sdk/index.js' +import type { Config } from '../../payload-types.js' + +import { + ensureCompilationIsDone, + exactText, + initPageConsoleErrorCatch, + saveDocAndAssert, +} from '../../../helpers.js' +import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' +import { reInitializeDB } from '../../../helpers/reInitializeDB.js' +import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js' +import { uploadsPoly } from '../../slugs.js' + +const filename = fileURLToPath(import.meta.url) +const currentFolder = path.dirname(filename) +const dirname = path.resolve(currentFolder, '../../') + +const { beforeAll, beforeEach, describe } = test + +let payload: PayloadTestSDK +let page: Page +let serverURL: string +// If we want to make this run in parallel: test.describe.configure({ mode: 'parallel' }) +let url: AdminUrlUtil + +describe('Upload polymorphic', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ + dirname, + // prebuild, + })) + url = new AdminUrlUtil(serverURL, uploadsPoly) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + + await ensureCompilationIsDone({ page, serverURL }) + }) + beforeEach(async () => { + await reInitializeDB({ + serverURL, + snapshotKey: 'fieldsTest', + uploadsDir: path.resolve(dirname, './collections/Upload/uploads'), + }) + + await ensureCompilationIsDone({ page, serverURL }) + }) + + test('should upload in single polymorphic field', async () => { + await page.goto(url.create) + + const singlePolyButton = page.locator('#field-media button', { + hasText: exactText('Create New'), + }) + await singlePolyButton.click() + + const uploadModal = page.locator('.payload__modal-item.drawer--is-open') + await expect(uploadModal).toBeVisible() + + await uploadModal + .locator('.dropzone input[type="file"]') + .setInputFiles(path.resolve(dirname, './collections/Upload/payload.jpg')) + + const saveButton = uploadModal.locator('#action-save') + await saveButton.click() + + const mediaPill = page.locator('#field-media .pill') + await expect(mediaPill).toBeVisible() + await expect(mediaPill).toContainText('Upload') + + await saveDocAndAssert(page) + }) +}) diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 5c51032358a..33dfbbfdeaa 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -1707,7 +1707,18 @@ export interface Uploads3 { export interface UploadsMulti { id: string; text?: string | null; - media?: (string | Upload)[] | null; + media?: + | ( + | { + relationTo: 'uploads'; + value: string | Upload; + } + | { + relationTo: 'uploads2'; + value: string | Uploads2; + } + )[] + | null; updatedAt: string; createdAt: string; } diff --git a/test/fields/seed.ts b/test/fields/seed.ts index ee42628de99..62225a80978 100644 --- a/test/fields/seed.ts +++ b/test/fields/seed.ts @@ -140,7 +140,10 @@ export const seed = async (_payload: Payload) => { await _payload.create({ collection: uploadsMulti, data: { - media: [createdPNGDoc.id, createdJPGDoc.id], + media: [ + { value: createdPNGDoc.id, relationTo: uploadsSlug }, + { value: createdJPGDoc.id, relationTo: uploadsSlug }, + ], }, }) diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index cf123cbaa12..f331e567d29 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -190,7 +190,7 @@ export interface Config { 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: number; + defaultIDType: string; }; globals: {}; globalsSelect: {}; @@ -226,14 +226,14 @@ export interface UserAuthOperations { * via the `definition` "relation". */ export interface Relation { - id: number; - image?: (number | null) | Media; - versionedImage?: (number | null) | Version; - hideFileInputOnCreate?: (number | null) | HideFileInputOnCreate; + id: string; + image?: (string | null) | Media; + versionedImage?: (string | null) | Version; + hideFileInputOnCreate?: (string | null) | HideFileInputOnCreate; blocks?: | { - media: number | Media; - relatedMedia?: (number | Media)[] | null; + media: string | Media; + relatedMedia?: (string | Media)[] | null; id?: string | null; blockName?: string | null; blockType: 'localizedMediaBlock'; @@ -248,7 +248,7 @@ export interface Relation { * via the `definition` "media". */ export interface Media { - id: number; + id: string; alt?: string | null; localized?: string | null; updatedAt: string; @@ -398,7 +398,7 @@ export interface Media { * via the `definition` "versions". */ export interface Version { - id: number; + id: string; title?: string | null; updatedAt: string; createdAt: string; @@ -418,7 +418,7 @@ export interface Version { * via the `definition` "hide-file-input-on-create". */ export interface HideFileInputOnCreate { - id: number; + id: string; title?: string | null; updatedAt: string; createdAt: string; @@ -437,8 +437,8 @@ export interface HideFileInputOnCreate { * via the `definition` "audio". */ export interface Audio { - id: number; - audio?: (number | null) | Media; + id: string; + audio?: (string | null) | Media; updatedAt: string; createdAt: string; } @@ -447,7 +447,7 @@ export interface Audio { * via the `definition` "gif-resize". */ export interface GifResize { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -483,7 +483,7 @@ export interface GifResize { * via the `definition` "filename-compound-index". */ export interface FilenameCompoundIndex { - id: number; + id: string; /** * Alt text to be used for compound index */ @@ -523,7 +523,7 @@ export interface FilenameCompoundIndex { * via the `definition` "no-image-sizes". */ export interface NoImageSize { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -541,7 +541,7 @@ export interface NoImageSize { * via the `definition` "object-fit". */ export interface ObjectFit { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -593,7 +593,7 @@ export interface ObjectFit { * via the `definition` "with-meta-data". */ export interface WithMetaDatum { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -621,7 +621,7 @@ export interface WithMetaDatum { * via the `definition` "without-meta-data". */ export interface WithoutMetaDatum { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -649,7 +649,7 @@ export interface WithoutMetaDatum { * via the `definition` "with-only-jpeg-meta-data". */ export interface WithOnlyJpegMetaDatum { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -677,7 +677,7 @@ export interface WithOnlyJpegMetaDatum { * via the `definition` "crop-only". */ export interface CropOnly { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -721,7 +721,7 @@ export interface CropOnly { * via the `definition` "focal-only". */ export interface FocalOnly { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -765,7 +765,7 @@ export interface FocalOnly { * via the `definition` "image-sizes-only". */ export interface ImageSizesOnly { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -801,7 +801,7 @@ export interface ImageSizesOnly { * via the `definition` "focal-no-sizes". */ export interface FocalNoSize { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -819,7 +819,7 @@ export interface FocalNoSize { * via the `definition` "allow-list-media". */ export interface AllowListMedia { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -837,7 +837,7 @@ export interface AllowListMedia { * via the `definition` "skip-safe-fetch-media". */ export interface SkipSafeFetchMedia { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -855,7 +855,7 @@ export interface SkipSafeFetchMedia { * via the `definition` "skip-safe-fetch-header-filter". */ export interface SkipSafeFetchHeaderFilter { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -873,7 +873,7 @@ export interface SkipSafeFetchHeaderFilter { * via the `definition` "skip-allow-list-safe-fetch-media". */ export interface SkipAllowListSafeFetchMedia { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -891,7 +891,7 @@ export interface SkipAllowListSafeFetchMedia { * via the `definition` "restrict-file-types". */ export interface RestrictFileType { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -909,7 +909,7 @@ export interface RestrictFileType { * via the `definition` "no-restrict-file-types". */ export interface NoRestrictFileType { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -927,7 +927,7 @@ export interface NoRestrictFileType { * via the `definition` "no-restrict-file-mime-types". */ export interface NoRestrictFileMimeType { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -945,7 +945,7 @@ export interface NoRestrictFileMimeType { * via the `definition` "animated-type-media". */ export interface AnimatedTypeMedia { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -997,7 +997,7 @@ export interface AnimatedTypeMedia { * via the `definition` "enlarge". */ export interface Enlarge { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1065,7 +1065,7 @@ export interface Enlarge { * via the `definition` "without-enlarge". */ export interface WithoutEnlarge { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1083,7 +1083,7 @@ export interface WithoutEnlarge { * via the `definition` "reduce". */ export interface Reduce { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1135,7 +1135,7 @@ export interface Reduce { * via the `definition` "media-trim". */ export interface MediaTrim { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1179,7 +1179,7 @@ export interface MediaTrim { * via the `definition` "custom-file-name-media". */ export interface CustomFileNameMedia { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1207,7 +1207,7 @@ export interface CustomFileNameMedia { * via the `definition` "unstored-media". */ export interface UnstoredMedia { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1225,7 +1225,7 @@ export interface UnstoredMedia { * via the `definition` "externally-served-media". */ export interface ExternallyServedMedia { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1243,16 +1243,16 @@ export interface ExternallyServedMedia { * via the `definition` "uploads-1". */ export interface Uploads1 { - id: number; - hasManyUpload?: (number | Uploads2)[] | null; - singleUpload?: (number | null) | Uploads2; - hasManyThumbnailUpload?: (number | AdminThumbnailSize)[] | null; - singleThumbnailUpload?: (number | null) | AdminThumbnailSize; + id: string; + hasManyUpload?: (string | Uploads2)[] | null; + singleUpload?: (string | null) | Uploads2; + hasManyThumbnailUpload?: (string | AdminThumbnailSize)[] | null; + singleThumbnailUpload?: (string | null) | AdminThumbnailSize; richText?: { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -1280,7 +1280,7 @@ export interface Uploads1 { * via the `definition` "uploads-2". */ export interface Uploads2 { - id: number; + id: string; prefix: string; title?: string | null; updatedAt: string; @@ -1300,7 +1300,7 @@ export interface Uploads2 { * via the `definition` "admin-thumbnail-size". */ export interface AdminThumbnailSize { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1336,7 +1336,7 @@ export interface AdminThumbnailSize { * via the `definition` "any-images". */ export interface AnyImage { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1354,7 +1354,7 @@ export interface AnyImage { * via the `definition` "admin-thumbnail-function". */ export interface AdminThumbnailFunction { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1372,7 +1372,7 @@ export interface AdminThumbnailFunction { * via the `definition` "admin-thumbnail-with-search-queries". */ export interface AdminThumbnailWithSearchQuery { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1390,7 +1390,7 @@ export interface AdminThumbnailWithSearchQuery { * via the `definition` "admin-upload-control". */ export interface AdminUploadControl { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1408,7 +1408,7 @@ export interface AdminUploadControl { * via the `definition` "optional-file". */ export interface OptionalFile { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1426,7 +1426,7 @@ export interface OptionalFile { * via the `definition` "required-file". */ export interface RequiredFile { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1444,7 +1444,7 @@ export interface RequiredFile { * via the `definition` "custom-upload-field". */ export interface CustomUploadField { - id: number; + id: string; alt?: string | null; updatedAt: string; createdAt: string; @@ -1463,7 +1463,7 @@ export interface CustomUploadField { * via the `definition` "media-with-relation-preview". */ export interface MediaWithRelationPreview { - id: number; + id: string; title?: string | null; updatedAt: string; createdAt: string; @@ -1482,7 +1482,7 @@ export interface MediaWithRelationPreview { * via the `definition` "media-without-cache-tags". */ export interface MediaWithoutCacheTag { - id: number; + id: string; title?: string | null; updatedAt: string; createdAt: string; @@ -1501,7 +1501,7 @@ export interface MediaWithoutCacheTag { * via the `definition` "media-without-relation-preview". */ export interface MediaWithoutRelationPreview { - id: number; + id: string; title?: string | null; updatedAt: string; createdAt: string; @@ -1520,13 +1520,13 @@ export interface MediaWithoutRelationPreview { * via the `definition` "relation-preview". */ export interface RelationPreview { - id: number; - imageWithPreview1?: (number | null) | MediaWithRelationPreview; - imageWithPreview2?: (number | null) | MediaWithRelationPreview; - imageWithoutPreview1?: (number | null) | MediaWithRelationPreview; - imageWithoutPreview2?: (number | null) | MediaWithoutRelationPreview; - imageWithPreview3?: (number | null) | MediaWithoutRelationPreview; - imageWithoutPreview3?: (number | null) | MediaWithoutRelationPreview; + id: string; + imageWithPreview1?: (string | null) | MediaWithRelationPreview; + imageWithPreview2?: (string | null) | MediaWithRelationPreview; + imageWithoutPreview1?: (string | null) | MediaWithRelationPreview; + imageWithoutPreview2?: (string | null) | MediaWithoutRelationPreview; + imageWithPreview3?: (string | null) | MediaWithoutRelationPreview; + imageWithoutPreview3?: (string | null) | MediaWithoutRelationPreview; updatedAt: string; createdAt: string; } @@ -1535,11 +1535,11 @@ export interface RelationPreview { * via the `definition` "best-fit". */ export interface BestFit { - id: number; - withAdminThumbnail?: (number | null) | AdminThumbnailFunction; - withinRange?: (number | null) | Enlarge; - nextSmallestOutOfRange?: (number | null) | FocalOnly; - original?: (number | null) | FocalOnly; + id: string; + withAdminThumbnail?: (string | null) | AdminThumbnailFunction; + withinRange?: (string | null) | Enlarge; + nextSmallestOutOfRange?: (string | null) | FocalOnly; + original?: (string | null) | FocalOnly; updatedAt: string; createdAt: string; } @@ -1548,10 +1548,10 @@ export interface BestFit { * via the `definition` "list-view-preview". */ export interface ListViewPreview { - id: number; + id: string; title?: string | null; - imageUpload?: (number | null) | MediaWithRelationPreview; - imageRelationship?: (number | null) | MediaWithRelationPreview; + imageUpload?: (string | null) | MediaWithRelationPreview; + imageRelationship?: (string | null) | MediaWithRelationPreview; updatedAt: string; createdAt: string; } @@ -1560,7 +1560,7 @@ export interface ListViewPreview { * via the `definition` "three-dimensional". */ export interface ThreeDimensional { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1576,7 +1576,7 @@ export interface ThreeDimensional { * via the `definition` "constructor-options". */ export interface ConstructorOption { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1594,11 +1594,11 @@ export interface ConstructorOption { * via the `definition` "bulk-uploads". */ export interface BulkUpload { - id: number; + id: string; title: string; relationship?: { relationTo: 'simple-relationship'; - value: number | SimpleRelationship; + value: string | SimpleRelationship; } | null; updatedAt: string; createdAt: string; @@ -1617,7 +1617,7 @@ export interface BulkUpload { * via the `definition` "simple-relationship". */ export interface SimpleRelationship { - id: number; + id: string; title?: string | null; updatedAt: string; createdAt: string; @@ -1627,7 +1627,7 @@ export interface SimpleRelationship { * via the `definition` "file-mime-type". */ export interface FileMimeType { - id: number; + id: string; title?: string | null; updatedAt: string; createdAt: string; @@ -1646,7 +1646,7 @@ export interface FileMimeType { * via the `definition` "svg-only". */ export interface SvgOnly { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1664,7 +1664,7 @@ export interface SvgOnly { * via the `definition` "media-without-delete-access". */ export interface MediaWithoutDeleteAccess { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1682,7 +1682,7 @@ export interface MediaWithoutDeleteAccess { * via the `definition` "media-with-image-size-admin-props". */ export interface MediaWithImageSizeAdminProp { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -1734,7 +1734,7 @@ export interface MediaWithImageSizeAdminProp { * via the `definition` "users". */ export interface User { - id: number; + id: string; updatedAt: string; createdAt: string; email: string; @@ -1758,236 +1758,236 @@ export interface User { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: number; + id: string; document?: | ({ relationTo: 'relation'; - value: number | Relation; + value: string | Relation; } | null) | ({ relationTo: 'audio'; - value: number | Audio; + value: string | Audio; } | null) | ({ relationTo: 'gif-resize'; - value: number | GifResize; + value: string | GifResize; } | null) | ({ relationTo: 'filename-compound-index'; - value: number | FilenameCompoundIndex; + value: string | FilenameCompoundIndex; } | null) | ({ relationTo: 'no-image-sizes'; - value: number | NoImageSize; + value: string | NoImageSize; } | null) | ({ relationTo: 'object-fit'; - value: number | ObjectFit; + value: string | ObjectFit; } | null) | ({ relationTo: 'with-meta-data'; - value: number | WithMetaDatum; + value: string | WithMetaDatum; } | null) | ({ relationTo: 'without-meta-data'; - value: number | WithoutMetaDatum; + value: string | WithoutMetaDatum; } | null) | ({ relationTo: 'with-only-jpeg-meta-data'; - value: number | WithOnlyJpegMetaDatum; + value: string | WithOnlyJpegMetaDatum; } | null) | ({ relationTo: 'crop-only'; - value: number | CropOnly; + value: string | CropOnly; } | null) | ({ relationTo: 'focal-only'; - value: number | FocalOnly; + value: string | FocalOnly; } | null) | ({ relationTo: 'image-sizes-only'; - value: number | ImageSizesOnly; + value: string | ImageSizesOnly; } | null) | ({ relationTo: 'focal-no-sizes'; - value: number | FocalNoSize; + value: string | FocalNoSize; } | null) | ({ relationTo: 'media'; - value: number | Media; + value: string | Media; } | null) | ({ relationTo: 'allow-list-media'; - value: number | AllowListMedia; + value: string | AllowListMedia; } | null) | ({ relationTo: 'skip-safe-fetch-media'; - value: number | SkipSafeFetchMedia; + value: string | SkipSafeFetchMedia; } | null) | ({ relationTo: 'skip-safe-fetch-header-filter'; - value: number | SkipSafeFetchHeaderFilter; + value: string | SkipSafeFetchHeaderFilter; } | null) | ({ relationTo: 'skip-allow-list-safe-fetch-media'; - value: number | SkipAllowListSafeFetchMedia; + value: string | SkipAllowListSafeFetchMedia; } | null) | ({ relationTo: 'restrict-file-types'; - value: number | RestrictFileType; + value: string | RestrictFileType; } | null) | ({ relationTo: 'no-restrict-file-types'; - value: number | NoRestrictFileType; + value: string | NoRestrictFileType; } | null) | ({ relationTo: 'no-restrict-file-mime-types'; - value: number | NoRestrictFileMimeType; + value: string | NoRestrictFileMimeType; } | null) | ({ relationTo: 'animated-type-media'; - value: number | AnimatedTypeMedia; + value: string | AnimatedTypeMedia; } | null) | ({ relationTo: 'enlarge'; - value: number | Enlarge; + value: string | Enlarge; } | null) | ({ relationTo: 'without-enlarge'; - value: number | WithoutEnlarge; + value: string | WithoutEnlarge; } | null) | ({ relationTo: 'reduce'; - value: number | Reduce; + value: string | Reduce; } | null) | ({ relationTo: 'media-trim'; - value: number | MediaTrim; + value: string | MediaTrim; } | null) | ({ relationTo: 'custom-file-name-media'; - value: number | CustomFileNameMedia; + value: string | CustomFileNameMedia; } | null) | ({ relationTo: 'unstored-media'; - value: number | UnstoredMedia; + value: string | UnstoredMedia; } | null) | ({ relationTo: 'externally-served-media'; - value: number | ExternallyServedMedia; + value: string | ExternallyServedMedia; } | null) | ({ relationTo: 'uploads-1'; - value: number | Uploads1; + value: string | Uploads1; } | null) | ({ relationTo: 'uploads-2'; - value: number | Uploads2; + value: string | Uploads2; } | null) | ({ relationTo: 'any-images'; - value: number | AnyImage; + value: string | AnyImage; } | null) | ({ relationTo: 'admin-thumbnail-function'; - value: number | AdminThumbnailFunction; + value: string | AdminThumbnailFunction; } | null) | ({ relationTo: 'admin-thumbnail-with-search-queries'; - value: number | AdminThumbnailWithSearchQuery; + value: string | AdminThumbnailWithSearchQuery; } | null) | ({ relationTo: 'admin-thumbnail-size'; - value: number | AdminThumbnailSize; + value: string | AdminThumbnailSize; } | null) | ({ relationTo: 'admin-upload-control'; - value: number | AdminUploadControl; + value: string | AdminUploadControl; } | null) | ({ relationTo: 'optional-file'; - value: number | OptionalFile; + value: string | OptionalFile; } | null) | ({ relationTo: 'required-file'; - value: number | RequiredFile; + value: string | RequiredFile; } | null) | ({ relationTo: 'versions'; - value: number | Version; + value: string | Version; } | null) | ({ relationTo: 'custom-upload-field'; - value: number | CustomUploadField; + value: string | CustomUploadField; } | null) | ({ relationTo: 'media-with-relation-preview'; - value: number | MediaWithRelationPreview; + value: string | MediaWithRelationPreview; } | null) | ({ relationTo: 'media-without-cache-tags'; - value: number | MediaWithoutCacheTag; + value: string | MediaWithoutCacheTag; } | null) | ({ relationTo: 'media-without-relation-preview'; - value: number | MediaWithoutRelationPreview; + value: string | MediaWithoutRelationPreview; } | null) | ({ relationTo: 'relation-preview'; - value: number | RelationPreview; + value: string | RelationPreview; } | null) | ({ relationTo: 'hide-file-input-on-create'; - value: number | HideFileInputOnCreate; + value: string | HideFileInputOnCreate; } | null) | ({ relationTo: 'best-fit'; - value: number | BestFit; + value: string | BestFit; } | null) | ({ relationTo: 'list-view-preview'; - value: number | ListViewPreview; + value: string | ListViewPreview; } | null) | ({ relationTo: 'three-dimensional'; - value: number | ThreeDimensional; + value: string | ThreeDimensional; } | null) | ({ relationTo: 'constructor-options'; - value: number | ConstructorOption; + value: string | ConstructorOption; } | null) | ({ relationTo: 'bulk-uploads'; - value: number | BulkUpload; + value: string | BulkUpload; } | null) | ({ relationTo: 'simple-relationship'; - value: number | SimpleRelationship; + value: string | SimpleRelationship; } | null) | ({ relationTo: 'file-mime-type'; - value: number | FileMimeType; + value: string | FileMimeType; } | null) | ({ relationTo: 'svg-only'; - value: number | SvgOnly; + value: string | SvgOnly; } | null) | ({ relationTo: 'media-without-delete-access'; - value: number | MediaWithoutDeleteAccess; + value: string | MediaWithoutDeleteAccess; } | null) | ({ relationTo: 'media-with-image-size-admin-props'; - value: number | MediaWithImageSizeAdminProp; + value: string | MediaWithImageSizeAdminProp; } | null) | ({ relationTo: 'users'; - value: number | User; + value: string | User; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; updatedAt: string; createdAt: string; @@ -1997,10 +1997,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: number; + id: string; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; key?: string | null; value?: @@ -2020,7 +2020,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: number; + id: string; name?: string | null; batch?: number | null; updatedAt: string;