feat: upload hasmany (#7796)

Supports `hasMany` upload fields, similar to how `hasMany` works in
other fields, i.e.:

```ts
{
  type: 'upload',
  relationTo: 'media',
  hasMany: true
}
```

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
Co-authored-by: James <james@trbl.design>
This commit is contained in:
Paul
2024-08-21 18:44:04 -06:00
committed by GitHub
parent a687cb9c5b
commit d2571e10d6
57 changed files with 1624 additions and 432 deletions

View File

@@ -595,14 +595,77 @@ const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
config: SanitizedConfig, config: SanitizedConfig,
buildSchemaOptions: BuildSchemaOptions, buildSchemaOptions: BuildSchemaOptions,
): void => { ): void => {
const baseSchema = { const hasManyRelations = Array.isArray(field.relationTo)
...formatBaseSchema(field, buildSchemaOptions), let schemaToReturn: { [key: string]: any } = {}
type: mongoose.Schema.Types.Mixed,
ref: field.relationTo, if (field.localized && config.localization) {
schemaToReturn = {
type: config.localization.localeCodes.reduce((locales, locale) => {
let localeSchema: { [key: string]: any } = {}
if (hasManyRelations) {
localeSchema = {
...formatBaseSchema(field, buildSchemaOptions),
_id: false,
type: mongoose.Schema.Types.Mixed,
relationTo: { type: String, enum: field.relationTo },
value: {
type: mongoose.Schema.Types.Mixed,
refPath: `${field.name}.${locale}.relationTo`,
},
}
} else {
localeSchema = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
ref: field.relationTo,
}
}
return {
...locales,
[locale]: field.hasMany
? { type: [localeSchema], default: formatDefaultValue(field) }
: localeSchema,
}
}, {}),
localized: true,
}
} else if (hasManyRelations) {
schemaToReturn = {
...formatBaseSchema(field, buildSchemaOptions),
_id: false,
type: mongoose.Schema.Types.Mixed,
relationTo: { type: String, enum: field.relationTo },
value: {
type: mongoose.Schema.Types.Mixed,
refPath: `${field.name}.relationTo`,
},
}
if (field.hasMany) {
schemaToReturn = {
type: [schemaToReturn],
default: formatDefaultValue(field),
}
}
} else {
schemaToReturn = {
...formatBaseSchema(field, buildSchemaOptions),
type: mongoose.Schema.Types.Mixed,
ref: field.relationTo,
}
if (field.hasMany) {
schemaToReturn = {
type: [schemaToReturn],
default: formatDefaultValue(field),
}
}
} }
schema.add({ schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization), [field.name]: schemaToReturn,
}) })
}, },
} }

View File

@@ -717,7 +717,7 @@ export const traverseFields = ({
case 'upload': case 'upload':
if (Array.isArray(field.relationTo)) { if (Array.isArray(field.relationTo)) {
field.relationTo.forEach((relation) => relationships.add(relation)) field.relationTo.forEach((relation) => relationships.add(relation))
} else if (field.type === 'relationship' && field.hasMany) { } else if (field.hasMany) {
relationships.add(field.relationTo) relationships.add(field.relationTo)
} else { } else {
// simple relationships get a column on the targetTable with a foreign key to the relationTo table // simple relationships get a column on the targetTable with a foreign key to the relationTo table

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-param-reassign */
import type { Field } from 'payload' import type { Field } from 'payload'
import { fieldAffectsData, tabHasName } from 'payload/shared' import { fieldAffectsData, tabHasName } from 'payload/shared'
@@ -34,8 +33,9 @@ export const traverseFields = ({
// handle simple relationship // handle simple relationship
if ( if (
depth > 0 && depth > 0 &&
(field.type === 'upload' || (field.type === 'upload' || field.type === 'relationship') &&
(field.type === 'relationship' && !field.hasMany && typeof field.relationTo === 'string')) !field.hasMany &&
typeof field.relationTo === 'string'
) { ) {
if (field.localized) { if (field.localized) {
_locales.with[`${path}${field.name}`] = true _locales.with[`${path}${field.name}`] = true

View File

@@ -726,7 +726,7 @@ export const traverseFields = ({
case 'upload': case 'upload':
if (Array.isArray(field.relationTo)) { if (Array.isArray(field.relationTo)) {
field.relationTo.forEach((relation) => relationships.add(relation)) field.relationTo.forEach((relation) => relationships.add(relation))
} else if (field.type === 'relationship' && field.hasMany) { } else if (field.hasMany) {
relationships.add(field.relationTo) relationships.add(field.relationTo)
} else { } else {
// simple relationships get a column on the targetTable with a foreign key to the relationTo table // simple relationships get a column on the targetTable with a foreign key to the relationTo table

View File

@@ -445,7 +445,7 @@ export const getTableColumnFromPath = ({
case 'relationship': case 'relationship':
case 'upload': { case 'upload': {
const newCollectionPath = pathSegments.slice(1).join('.') const newCollectionPath = pathSegments.slice(1).join('.')
if (Array.isArray(field.relationTo) || (field.type === 'relationship' && field.hasMany)) { if (Array.isArray(field.relationTo) || field.hasMany) {
let relationshipFields let relationshipFields
const relationTableName = `${rootTableName}${adapter.relationshipsSuffix}` const relationTableName = `${rootTableName}${adapter.relationshipsSuffix}`
const { const {

View File

@@ -307,10 +307,51 @@ export function buildMutationInputType({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { type: withNullableType(field, GraphQLString, forceNullable) }, [field.name]: { type: withNullableType(field, GraphQLString, forceNullable) },
}), }),
upload: (inputObjectTypeConfig: InputObjectTypeConfig, field: UploadField) => ({ upload: (inputObjectTypeConfig: InputObjectTypeConfig, field: UploadField) => {
...inputObjectTypeConfig, const { relationTo } = field
[field.name]: { type: withNullableType(field, GraphQLString, forceNullable) }, type PayloadGraphQLRelationshipType =
}), | GraphQLInputObjectType
| GraphQLList<GraphQLScalarType>
| GraphQLScalarType
let type: PayloadGraphQLRelationshipType
if (Array.isArray(relationTo)) {
const fullName = `${combineParentName(
parentName,
toWords(field.name, true),
)}RelationshipInput`
type = new GraphQLInputObjectType({
name: fullName,
fields: {
relationTo: {
type: new GraphQLEnumType({
name: `${fullName}RelationTo`,
values: relationTo.reduce(
(values, option) => ({
...values,
[formatName(option)]: {
value: option,
},
}),
{},
),
}),
},
value: { type: GraphQLJSON },
},
})
} else {
type = getCollectionIDType(
config.db.defaultIDType,
graphqlResult.collections[relationTo].config,
)
}
return {
...inputObjectTypeConfig,
[field.name]: { type: field.hasMany ? new GraphQLList(type) : type },
}
},
} }
const fieldName = formatName(name) const fieldName = formatName(name)

View File

@@ -594,49 +594,164 @@ export function buildObjectType({
}), }),
upload: (objectTypeConfig: ObjectTypeConfig, field: UploadField) => { upload: (objectTypeConfig: ObjectTypeConfig, field: UploadField) => {
const { relationTo } = field const { relationTo } = field
const isRelatedToManyCollections = Array.isArray(relationTo)
const hasManyValues = field.hasMany
const relationshipName = combineParentName(parentName, toWords(field.name, true))
const uploadName = combineParentName(parentName, toWords(field.name, true)) let type
let relationToType = null
if (Array.isArray(relationTo)) {
relationToType = new GraphQLEnumType({
name: `${relationshipName}_RelationTo`,
values: relationTo.reduce(
(relations, relation) => ({
...relations,
[formatName(relation)]: {
value: relation,
},
}),
{},
),
})
const types = relationTo.map((relation) => graphqlResult.collections[relation].graphQL.type)
type = new GraphQLObjectType({
name: `${relationshipName}_Relationship`,
fields: {
relationTo: {
type: relationToType,
},
value: {
type: new GraphQLUnionType({
name: relationshipName,
resolveType(data, { req }) {
return graphqlResult.collections[data.collection].graphQL.type.name
},
types,
}),
},
},
})
} else {
;({ type } = graphqlResult.collections[relationTo].graphQL)
}
// If the relationshipType is undefined at this point, // If the relationshipType is undefined at this point,
// it can be assumed that this blockType can have a relationship // it can be assumed that this blockType can have a relationship
// to itself. Therefore, we set the relationshipType equal to the blockType // to itself. Therefore, we set the relationshipType equal to the blockType
// that is currently being created. // that is currently being created.
const type = withNullableType( type = type || newlyCreatedBlockType
field,
graphqlResult.collections[relationTo].graphQL.type || newlyCreatedBlockType, const relationshipArgs: {
forceNullable, draft?: unknown
fallbackLocale?: unknown
limit?: unknown
locale?: unknown
page?: unknown
where?: unknown
} = {}
const relationsUseDrafts = (Array.isArray(relationTo) ? relationTo : [relationTo]).some(
(relation) => graphqlResult.collections[relation].config.versions?.drafts,
) )
const uploadArgs = {} as LocaleInputType if (relationsUseDrafts) {
relationshipArgs.draft = {
type: GraphQLBoolean,
}
}
if (config.localization) { if (config.localization) {
uploadArgs.locale = { relationshipArgs.locale = {
type: graphqlResult.types.localeInputType, type: graphqlResult.types.localeInputType,
} }
uploadArgs.fallbackLocale = { relationshipArgs.fallbackLocale = {
type: graphqlResult.types.fallbackLocaleInputType, type: graphqlResult.types.fallbackLocaleInputType,
} }
} }
const relatedCollectionSlug = field.relationTo const relationship = {
type: withNullableType(
const upload = { field,
type, hasManyValues ? new GraphQLList(new GraphQLNonNull(type)) : type,
args: uploadArgs, forceNullable,
extensions: { complexity: 20 }, ),
args: relationshipArgs,
extensions: { complexity: 10 },
async resolve(parent, args, context: Context) { async resolve(parent, args, context: Context) {
const value = parent[field.name] const value = parent[field.name]
const locale = args.locale || context.req.locale const locale = args.locale || context.req.locale
const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale
const id = value let relatedCollectionSlug = field.relationTo
const draft = Boolean(args.draft ?? context.req.query?.draft) const draft = Boolean(args.draft ?? context.req.query?.draft)
if (hasManyValues) {
const results = []
const resultPromises = []
const createPopulationPromise = async (relatedDoc, i) => {
let id = relatedDoc
let collectionSlug = field.relationTo
if (isRelatedToManyCollections) {
collectionSlug = relatedDoc.relationTo
id = relatedDoc.value
}
const result = await context.req.payloadDataLoader.load(
createDataloaderCacheKey({
collectionSlug: collectionSlug as string,
currentDepth: 0,
depth: 0,
docID: id,
draft,
fallbackLocale,
locale,
overrideAccess: false,
showHiddenFields: false,
transactionID: context.req.transactionID,
}),
)
if (result) {
if (isRelatedToManyCollections) {
results[i] = {
relationTo: collectionSlug,
value: {
...result,
collection: collectionSlug,
},
}
} else {
results[i] = result
}
}
}
if (value) {
value.forEach((relatedDoc, i) => {
resultPromises.push(createPopulationPromise(relatedDoc, i))
})
}
await Promise.all(resultPromises)
return results
}
let id = value
if (isRelatedToManyCollections && value) {
id = value.value
relatedCollectionSlug = value.relationTo
}
if (id) { if (id) {
const relatedDocument = await context.req.payloadDataLoader.load( const relatedDocument = await context.req.payloadDataLoader.load(
createDataloaderCacheKey({ createDataloaderCacheKey({
collectionSlug: relatedCollectionSlug, collectionSlug: relatedCollectionSlug as string,
currentDepth: 0, currentDepth: 0,
depth: 0, depth: 0,
docID: id, docID: id,
@@ -649,26 +764,30 @@ export function buildObjectType({
}), }),
) )
return relatedDocument || null if (relatedDocument) {
if (isRelatedToManyCollections) {
return {
relationTo: relatedCollectionSlug,
value: {
...relatedDocument,
collection: relatedCollectionSlug,
},
}
}
return relatedDocument
}
return null
} }
return null return null
}, },
} }
const whereFields = graphqlResult.collections[relationTo].config.fields
upload.args.where = {
type: buildWhereInputType({
name: uploadName,
fields: whereFields,
parentName: uploadName,
}),
}
return { return {
...objectTypeConfig, ...objectTypeConfig,
[field.name]: upload, [field.name]: relationship,
} }
}, },
} }

View File

@@ -130,9 +130,36 @@ const fieldToSchemaMap = ({ nestedFieldName, parentName }: Args): any => ({
textarea: (field: TextareaField) => ({ textarea: (field: TextareaField) => ({
type: withOperators(field, parentName), type: withOperators(field, parentName),
}), }),
upload: (field: UploadField) => ({ upload: (field: UploadField) => {
type: withOperators(field, parentName), if (Array.isArray(field.relationTo)) {
}), return {
type: new GraphQLInputObjectType({
name: `${combineParentName(parentName, field.name)}_Relation`,
fields: {
relationTo: {
type: new GraphQLEnumType({
name: `${combineParentName(parentName, field.name)}_Relation_RelationTo`,
values: field.relationTo.reduce(
(values, relation) => ({
...values,
[formatName(relation)]: {
value: relation,
},
}),
{},
),
}),
},
value: { type: GraphQLJSON },
},
}),
}
}
return {
type: withOperators(field, parentName),
}
},
}) })
export default fieldToSchemaMap export default fieldToSchemaMap

View File

@@ -230,9 +230,9 @@ const defaults: DefaultsType = {
}, },
upload: { upload: {
operators: [ operators: [
...operators.equality.map((operator) => ({ ...[...operators.equality, ...operators.contains].map((operator) => ({
name: operator, name: operator,
type: GraphQLString, type: GraphQLJSON,
})), })),
], ],
}, },

View File

@@ -46,7 +46,16 @@ const baseClass = 'collection-list'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const DefaultListView: React.FC = () => { export const DefaultListView: React.FC = () => {
const { Header, collectionSlug, hasCreatePermission, newDocumentURL } = useListInfo() const {
Header,
beforeActions,
collectionSlug,
disableBulkDelete,
disableBulkEdit,
hasCreatePermission,
newDocumentURL,
} = useListInfo()
const { data, defaultLimit, handlePageChange, handlePerPageChange } = useListQuery() const { data, defaultLimit, handlePageChange, handlePerPageChange } = useListQuery()
const { searchParams } = useSearchParams() const { searchParams } = useSearchParams()
const { openModal } = useModal() const { openModal } = useModal()
@@ -221,10 +230,15 @@ export const DefaultListView: React.FC = () => {
<div className={`${baseClass}__list-selection`}> <div className={`${baseClass}__list-selection`}>
<ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} /> <ListSelection label={getTranslation(collectionConfig.labels.plural, i18n)} />
<div className={`${baseClass}__list-selection-actions`}> <div className={`${baseClass}__list-selection-actions`}>
<EditMany collection={collectionConfig} fields={fields} /> {beforeActions && beforeActions}
<PublishMany collection={collectionConfig} /> {!disableBulkEdit && (
<UnpublishMany collection={collectionConfig} /> <Fragment>
<DeleteMany collection={collectionConfig} /> <EditMany collection={collectionConfig} fields={fields} />
<PublishMany collection={collectionConfig} />
<UnpublishMany collection={collectionConfig} />
</Fragment>
)}
{!disableBulkDelete && <DeleteMany collection={collectionConfig} />}
</div> </div>
</div> </div>
)} )}

View File

@@ -105,7 +105,10 @@ export async function validateSearchParam({
fieldPath = path.slice(0, -(req.locale.length + 1)) fieldPath = path.slice(0, -(req.locale.length + 1))
} }
// remove ".value" from ends of polymorphic relationship paths // remove ".value" from ends of polymorphic relationship paths
if (field.type === 'relationship' && Array.isArray(field.relationTo)) { if (
(field.type === 'relationship' || field.type === 'upload') &&
Array.isArray(field.relationTo)
) {
fieldPath = fieldPath.replace('.value', '') fieldPath = fieldPath.replace('.value', '')
} }
const entityType: 'collections' | 'globals' = globalConfig ? 'globals' : 'collections' const entityType: 'collections' | 'globals' = globalConfig ? 'globals' : 'collections'

View File

@@ -99,19 +99,26 @@ export const sanitizeFields = async ({
}) })
} }
if (field.type === 'relationship') { if (field.min && !field.minRows) {
if (field.min && !field.minRows) { console.warn(
console.warn( `(payload): The "min" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "minRows" instead.`,
`(payload): The "min" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "minRows" instead.`, )
) }
if (field.max && !field.maxRows) {
console.warn(
`(payload): The "max" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "maxRows" instead.`,
)
}
field.minRows = field.minRows || field.min
field.maxRows = field.maxRows || field.max
}
if (field.type === 'upload') {
if (!field.admin || !('isSortable' in field.admin)) {
field.admin = {
isSortable: true,
...field.admin,
} }
if (field.max && !field.maxRows) {
console.warn(
`(payload): The "max" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "maxRows" instead.`,
)
}
field.minRows = field.minRows || field.min
field.maxRows = field.maxRows || field.max
} }
} }

View File

@@ -824,35 +824,106 @@ export type UIFieldClient = {
} & Omit<DeepUndefinable<FieldBaseClient>, '_isPresentational' | 'admin'> & // still include FieldBaseClient (even if it's undefinable) so that we don't need constant type checks (e.g. if('xy' in field)) } & Omit<DeepUndefinable<FieldBaseClient>, '_isPresentational' | 'admin'> & // still include FieldBaseClient (even if it's undefinable) so that we don't need constant type checks (e.g. if('xy' in field))
Pick<UIField, 'label' | 'name' | 'type'> Pick<UIField, 'label' | 'name' | 'type'>
export type UploadField = { type SharedUploadProperties = {
admin?: { /**
components?: { * Toggle the preview in the admin interface.
Error?: CustomComponent<UploadFieldErrorClientComponent | UploadFieldErrorServerComponent> */
Label?: CustomComponent<UploadFieldLabelClientComponent | UploadFieldLabelServerComponent>
} & Admin['components']
}
displayPreview?: boolean displayPreview?: boolean
filterOptions?: FilterOptions filterOptions?: FilterOptions
hasMany?: boolean
/** /**
* Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. * Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached.
* *
* {@link https://payloadcms.com/docs/getting-started/concepts#field-level-max-depth} * {@link https://payloadcms.com/docs/getting-started/concepts#field-level-max-depth}
*/ */
maxDepth?: number maxDepth?: number
relationTo: CollectionSlug
type: 'upload' type: 'upload'
validate?: Validate<unknown, unknown, unknown, UploadField> validate?: Validate<unknown, unknown, unknown, SharedUploadProperties>
} & FieldBase } & (
| {
hasMany: true
/**
* @deprecated Use 'maxRows' instead
*/
max?: number
maxRows?: number
/**
* @deprecated Use 'minRows' instead
*/
min?: number
minRows?: number
}
| {
hasMany?: false | undefined
/**
* @deprecated Use 'maxRows' instead
*/
max?: undefined
maxRows?: undefined
/**
* @deprecated Use 'minRows' instead
*/
min?: undefined
minRows?: undefined
}
) &
FieldBase
export type UploadFieldClient = { type SharedUploadPropertiesClient = FieldBaseClient &
Pick<
SharedUploadProperties,
'hasMany' | 'max' | 'maxDepth' | 'maxRows' | 'min' | 'minRows' | 'type'
>
type UploadAdmin = {
allowCreate?: boolean
components?: {
Error?: CustomComponent<
RelationshipFieldErrorClientComponent | RelationshipFieldErrorServerComponent
>
Label?: CustomComponent<
RelationshipFieldLabelClientComponent | RelationshipFieldLabelServerComponent
>
} & Admin['components']
isSortable?: boolean
} & Admin
type UploadAdminClient = {
components?: {
Error?: MappedComponent
Label?: MappedComponent
} & AdminClient['components']
} & AdminClient &
Pick<UploadAdmin, 'allowCreate' | 'isSortable'>
export type PolymorphicUploadField = {
admin?: { admin?: {
components?: { sortOptions?: { [collectionSlug: CollectionSlug]: string }
Error?: MappedComponent } & UploadAdmin
Label?: MappedComponent relationTo: CollectionSlug[]
} & AdminClient['components'] } & SharedUploadProperties
}
} & FieldBaseClient & export type PolymorphicUploadFieldClient = {
Pick<UploadField, 'displayPreview' | 'maxDepth' | 'relationTo' | 'type'> admin?: {
sortOptions?: Pick<PolymorphicUploadField['admin'], 'sortOptions'>
} & UploadAdminClient
} & Pick<PolymorphicUploadField, 'displayPreview' | 'maxDepth' | 'relationTo' | 'type'> &
SharedUploadPropertiesClient
export type SingleUploadField = {
admin?: {
sortOptions?: string
} & UploadAdmin
relationTo: CollectionSlug
} & SharedUploadProperties
export type SingleUploadFieldClient = {
admin?: Pick<SingleUploadField['admin'], 'sortOptions'> & UploadAdminClient
} & Pick<SingleUploadField, 'displayPreview' | 'maxDepth' | 'relationTo' | 'type'> &
SharedUploadPropertiesClient
export type UploadField = /* PolymorphicUploadField | */ SingleUploadField
export type UploadFieldClient = /* PolymorphicUploadFieldClient | */ SingleUploadFieldClient
export type CodeField = { export type CodeField = {
admin?: { admin?: {

View File

@@ -138,7 +138,7 @@ export const promise = async <T>({
siblingData[field.name] === 'null' || siblingData[field.name] === 'null' ||
siblingData[field.name] === null siblingData[field.name] === null
) { ) {
if (field.type === 'relationship' && field.hasMany === true) { if (field.hasMany === true) {
siblingData[field.name] = [] siblingData[field.name] = []
} else { } else {
siblingData[field.name] = null siblingData[field.name] = null
@@ -153,32 +153,32 @@ export const promise = async <T>({
const relatedCollection = req.payload.config.collections.find( const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === relatedDoc.relationTo, (collection) => collection.slug === relatedDoc.relationTo,
) )
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = {
...relatedDoc,
value: parseFloat(relatedDoc.value as string),
}
}
}
})
}
if (field.hasMany !== true && valueIsValueWithRelation(value)) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === value.relationTo,
)
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find( const relationshipIDField = relatedCollection.fields.find(
(collectionField) => (collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id', fieldAffectsData(collectionField) && collectionField.name === 'id',
) )
if (relationshipIDField?.type === 'number') { if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = { siblingData[field.name] = { ...value, value: parseFloat(value.value as string) }
...relatedDoc,
value: parseFloat(relatedDoc.value as string),
}
} }
})
}
if (
field.type === 'relationship' &&
field.hasMany !== true &&
valueIsValueWithRelation(value)
) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === value.relationTo,
)
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name] = { ...value, value: parseFloat(value.value as string) }
} }
} }
} else { } else {
@@ -187,25 +187,31 @@ export const promise = async <T>({
const relatedCollection = req.payload.config.collections.find( const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === field.relationTo, (collection) => collection.slug === field.relationTo,
) )
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = parseFloat(relatedDoc as string)
}
}
})
}
if (field.hasMany !== true && value) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === field.relationTo,
)
if (relatedCollection?.fields) {
const relationshipIDField = relatedCollection.fields.find( const relationshipIDField = relatedCollection.fields.find(
(collectionField) => (collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id', fieldAffectsData(collectionField) && collectionField.name === 'id',
) )
if (relationshipIDField?.type === 'number') { if (relationshipIDField?.type === 'number') {
siblingData[field.name][i] = parseFloat(relatedDoc as string) siblingData[field.name] = parseFloat(value as string)
} }
})
}
if (field.type === 'relationship' && field.hasMany !== true && value) {
const relatedCollection = req.payload.config.collections.find(
(collection) => collection.slug === field.relationTo,
)
const relationshipIDField = relatedCollection.fields.find(
(collectionField) =>
fieldAffectsData(collectionField) && collectionField.name === 'id',
)
if (relationshipIDField?.type === 'number') {
siblingData[field.name] = parseFloat(value as string)
} }
} }
} }

View File

@@ -571,18 +571,76 @@ const validateFilterOptions: Validate<
} }
export type UploadFieldValidation = Validate<unknown, unknown, unknown, UploadField> export type UploadFieldValidation = Validate<unknown, unknown, unknown, UploadField>
export const upload: UploadFieldValidation = (value: string, options) => {
if (!value && options.required) { export const upload: UploadFieldValidation = async (value, options) => {
return options?.req?.t('validation:required') const {
maxRows,
minRows,
relationTo,
req: { payload, t },
required,
} = options
if (
((!value && typeof value !== 'number') || (Array.isArray(value) && value.length === 0)) &&
required
) {
return t('validation:required')
}
if (Array.isArray(value) && value.length > 0) {
if (minRows && value.length < minRows) {
return t('validation:lessThanMin', {
label: t('general:rows'),
min: minRows,
value: value.length,
})
}
if (maxRows && value.length > maxRows) {
return t('validation:greaterThanMax', {
label: t('general:rows'),
max: maxRows,
value: value.length,
})
}
} }
if (typeof value !== 'undefined' && value !== null) { if (typeof value !== 'undefined' && value !== null) {
const idType = const values = Array.isArray(value) ? value : [value]
options?.req?.payload?.collections[options.relationTo]?.customIDType ||
options?.req?.payload?.db?.defaultIDType
if (!isValidID(value, idType)) { const invalidRelationships = values.filter((val) => {
return options.req?.t('validation:validUploadID') let collectionSlug: string
let requestedID
if (typeof relationTo === 'string') {
collectionSlug = relationTo
// custom id
if (val || typeof val === 'number') {
requestedID = val
}
}
if (Array.isArray(relationTo) && typeof val === 'object' && val?.relationTo) {
collectionSlug = val.relationTo
requestedID = val.value
}
if (requestedID === null) return false
const idType =
payload.collections[collectionSlug]?.customIDType || payload?.db?.defaultIDType || 'text'
return !isValidID(requestedID, idType)
})
if (invalidRelationships.length > 0) {
return `This relationship field has the following invalid relationships: ${invalidRelationships
.map((err, invalid) => {
return `${err} ${JSON.stringify(invalid)}`
})
.join(', ')}`
} }
} }

View File

@@ -291,6 +291,7 @@ export function fieldsToJSONSchema(
break break
} }
case 'upload':
case 'relationship': { case 'relationship': {
if (Array.isArray(field.relationTo)) { if (Array.isArray(field.relationTo)) {
if (field.hasMany) { if (field.hasMany) {
@@ -380,21 +381,6 @@ export function fieldsToJSONSchema(
break break
} }
case 'upload': {
fieldSchema = {
oneOf: [
{
type: collectionIDFieldTypes[field.relationTo],
},
{
$ref: `#/definitions/${field.relationTo}`,
},
],
}
if (!isRequired) fieldSchema.oneOf.push({ type: 'null' })
break
}
case 'blocks': { case 'blocks': {
// Check for a case where no blocks are provided. // Check for a case where no blocks are provided.
// We need to generate an empty array for this case, note that JSON schema 4 doesn't support empty arrays // We need to generate an empty array for this case, note that JSON schema 4 doesn't support empty arrays

View File

@@ -1,4 +1,4 @@
@import '../../../scss/styles.scss'; @import '../../scss/styles.scss';
.relationship-add-new { .relationship-add-new {
display: flex; display: flex;
@@ -10,8 +10,12 @@
height: 100%; height: 100%;
} }
&__add-button, &__add-button {
&__add-button.doc-drawer__toggler { position: relative;
}
&__add-button--unstyled,
&__add-button--unstyled.doc-drawer__toggler {
@include formInput; @include formInput;
margin: 0; margin: 0;
border-top-left-radius: 0; border-top-left-radius: 0;

View File

@@ -4,29 +4,30 @@ import type { ClientCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import React, { Fragment, useCallback, useEffect, useState } from 'react' import React, { Fragment, useCallback, useEffect, useState } from 'react'
import type { DocumentInfoContext } from '../../../providers/DocumentInfo/types.js' import type { Value } from '../../fields/Relationship/types.js'
import type { Value } from '../types.js' import type { DocumentInfoContext } from '../../providers/DocumentInfo/types.js'
import type { Props } from './types.js' import type { Props } from './types.js'
import { Button } from '../../../elements/Button/index.js' import { PlusIcon } from '../../icons/Plus/index.js'
import { useDocumentDrawer } from '../../../elements/DocumentDrawer/index.js' import { useAuth } from '../../providers/Auth/index.js'
import * as PopupList from '../../../elements/Popup/PopupButtonList/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { Popup } from '../../../elements/Popup/index.js' import { Button } from '../Button/index.js'
import { Tooltip } from '../../../elements/Tooltip/index.js' import { useDocumentDrawer } from '../DocumentDrawer/index.js'
import { PlusIcon } from '../../../icons/Plus/index.js' import * as PopupList from '../Popup/PopupButtonList/index.js'
import { useAuth } from '../../../providers/Auth/index.js' import { Popup } from '../Popup/index.js'
import { useTranslation } from '../../../providers/Translation/index.js' import { Tooltip } from '../Tooltip/index.js'
import './index.scss' import './index.scss'
import { useRelatedCollections } from './useRelatedCollections.js' import { useRelatedCollections } from './useRelatedCollections.js'
const baseClass = 'relationship-add-new' const baseClass = 'relationship-add-new'
export const AddNewRelation: React.FC<Props> = ({ export const AddNewRelation: React.FC<Props> = ({
// dispatchOptions, Button: ButtonFromProps,
hasMany, hasMany,
path, path,
relationTo, relationTo,
setValue, setValue,
unstyled,
value, value,
}) => { }) => {
const relatedCollections = useRelatedCollections(relationTo) const relatedCollections = useRelatedCollections(relationTo)
@@ -129,23 +130,34 @@ export const AddNewRelation: React.FC<Props> = ({
} }
}, [isDrawerOpen, relatedToMany]) }, [isDrawerOpen, relatedToMany])
const label = t('fields:addNewLabel', {
label: getTranslation(relatedCollections[0].labels.singular, i18n),
})
if (show) { if (show) {
return ( return (
<div className={baseClass} id={`${path}-add-new`}> <div className={baseClass} id={`${path}-add-new`}>
{relatedCollections.length === 1 && ( {relatedCollections.length === 1 && (
<Fragment> <Fragment>
<DocumentDrawerToggler <DocumentDrawerToggler
className={`${baseClass}__add-button`} className={[
`${baseClass}__add-button`,
!unstyled && `${baseClass}__add-button--styled`,
].join(' ')}
onClick={() => setShowTooltip(false)} onClick={() => setShowTooltip(false)}
onMouseEnter={() => setShowTooltip(true)} onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)} onMouseLeave={() => setShowTooltip(false)}
> >
<Tooltip className={`${baseClass}__tooltip`} show={showTooltip}> {ButtonFromProps ? (
{t('fields:addNewLabel', { ButtonFromProps
label: getTranslation(relatedCollections[0].labels.singular, i18n), ) : (
})} <Fragment>
</Tooltip> <Tooltip className={`${baseClass}__tooltip`} show={showTooltip}>
<PlusIcon /> {label}
</Tooltip>
<PlusIcon />
</Fragment>
)}
</DocumentDrawerToggler> </DocumentDrawerToggler>
<DocumentDrawer onSave={onSave} /> <DocumentDrawer onSave={onSave} />
</Fragment> </Fragment>
@@ -154,13 +166,17 @@ export const AddNewRelation: React.FC<Props> = ({
<Fragment> <Fragment>
<Popup <Popup
button={ button={
<Button ButtonFromProps ? (
buttonStyle="none" ButtonFromProps
className={`${baseClass}__add-button`} ) : (
tooltip={popupOpen ? undefined : t('fields:addNew')} <Button
> buttonStyle="none"
<PlusIcon /> className={`${baseClass}__add-button`}
</Button> tooltip={popupOpen ? undefined : t('fields:addNew')}
>
<PlusIcon />
</Button>
)
} }
buttonType="custom" buttonType="custom"
horizontalAlign="center" horizontalAlign="center"

View File

@@ -0,0 +1,11 @@
import type { Value } from '../../fields/Relationship/types.js'
export type Props = {
readonly Button?: React.ReactNode
readonly hasMany: boolean
readonly path: string
readonly relationTo: string | string[]
readonly setValue: (value: unknown) => void
readonly unstyled?: boolean
readonly value: Value | Value[]
}

View File

@@ -3,7 +3,7 @@ import type { ClientCollectionConfig } from 'payload'
import { useState } from 'react' import { useState } from 'react'
import { useConfig } from '../../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
export const useRelatedCollections = (relationTo: string | string[]): ClientCollectionConfig[] => { export const useRelatedCollections = (relationTo: string | string[]): ClientCollectionConfig[] => {
const { config } = useConfig() const { config } = useConfig()

View File

@@ -6,13 +6,13 @@ import { toast } from 'sonner'
import type { DocumentDrawerProps } from './types.js' import type { DocumentDrawerProps } from './types.js'
import { useRelatedCollections } from '../../fields/Relationship/AddNew/useRelatedCollections.js'
import { XIcon } from '../../icons/X/index.js' import { XIcon } from '../../icons/X/index.js'
import { RenderComponent } from '../../providers/Config/RenderComponent.js' import { RenderComponent } from '../../providers/Config/RenderComponent.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useLocale } from '../../providers/Locale/index.js' import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { useRelatedCollections } from '../AddNewRelation/useRelatedCollections.js'
import { Gutter } from '../Gutter/index.js' import { Gutter } from '../Gutter/index.js'
import { IDLabel } from '../IDLabel/index.js' import { IDLabel } from '../IDLabel/index.js'
import { RenderTitle } from '../RenderTitle/index.js' import { RenderTitle } from '../RenderTitle/index.js'

View File

@@ -5,9 +5,9 @@ import React, { useCallback, useEffect, useId, useMemo, useState } from 'react'
import type { DocumentDrawerProps, DocumentTogglerProps, UseDocumentDrawer } from './types.js' import type { DocumentDrawerProps, DocumentTogglerProps, UseDocumentDrawer } from './types.js'
import { useRelatedCollections } from '../../fields/Relationship/AddNew/useRelatedCollections.js'
import { useEditDepth } from '../../providers/EditDepth/index.js' import { useEditDepth } from '../../providers/EditDepth/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { useRelatedCollections } from '../AddNewRelation/useRelatedCollections.js'
import { Drawer, DrawerToggler } from '../Drawer/index.js' import { Drawer, DrawerToggler } from '../Drawer/index.js'
import { DocumentDrawerContent } from './DrawerContent.js' import { DocumentDrawerContent } from './DrawerContent.js'
import './index.scss' import './index.scss'

View File

@@ -0,0 +1,33 @@
@import '../../../scss/styles.scss';
.file-details-draggable {
display: flex;
gap: 0.6rem;
//justify-content: space-between;
align-items: center;
background: var(--theme-elevation-50);
border-radius: 3px;
padding: 0.7rem 0.8rem;
&--drag-wrapper {
display: flex;
gap: 0.6rem;
align-items: center;
}
&__thumbnail {
max-width: 1.5rem;
}
&__actions {
flex-grow: 2;
display: flex;
gap: 0.6rem;
align-items: center;
justify-content: flex-end;
}
&__remove.btn--style-icon-label {
margin: 0;
}
}

View File

@@ -0,0 +1,115 @@
'use client'
import React from 'react'
import { Button } from '../../Button/index.js'
import { Thumbnail } from '../../Thumbnail/index.js'
import { UploadActions } from '../../Upload/index.js'
import { FileMeta } from '../FileMeta/index.js'
import './index.scss'
const baseClass = 'file-details-draggable'
import type { Data, FileSizes, SanitizedCollectionConfig } from 'payload'
import { DraggableSortableItem } from '../../../elements/DraggableSortable/DraggableSortableItem/index.js'
import { DragHandleIcon } from '../../../icons/DragHandle/index.js'
import { EditIcon } from '../../../icons/Edit/index.js'
import { useDocumentDrawer } from '../../DocumentDrawer/index.js'
export type DraggableFileDetailsProps = {
collectionSlug: string
customUploadActions?: React.ReactNode[]
doc: {
sizes?: FileSizes
} & Data
enableAdjustments?: boolean
hasImageSizes?: boolean
hasMany: boolean
imageCacheTag?: string
isSortable?: boolean
removeItem?: (index: number) => void
rowIndex: number
uploadConfig: SanitizedCollectionConfig['upload']
}
export const DraggableFileDetails: React.FC<DraggableFileDetailsProps> = (props) => {
const {
collectionSlug,
customUploadActions,
doc,
enableAdjustments,
hasImageSizes,
hasMany,
imageCacheTag,
isSortable,
removeItem,
rowIndex,
uploadConfig,
} = props
const { id, filename, filesize, height, mimeType, thumbnailURL, url, width } = doc
const [DocumentDrawer, DocumentDrawerToggler] = useDocumentDrawer({
id,
collectionSlug,
})
return (
<DraggableSortableItem id={id} key={id}>
{(draggableSortableItemProps) => (
<div
className={[
baseClass,
draggableSortableItemProps && isSortable && `${baseClass}--has-drag-handle`,
]
.filter(Boolean)
.join(' ')}
ref={draggableSortableItemProps.setNodeRef}
style={{
transform: draggableSortableItemProps.transform,
transition: draggableSortableItemProps.transition,
zIndex: draggableSortableItemProps.isDragging ? 1 : undefined,
}}
>
<div className={`${baseClass}--drag-wrapper`}>
{isSortable && draggableSortableItemProps && (
<div
className={`${baseClass}__drag`}
{...draggableSortableItemProps.attributes}
{...draggableSortableItemProps.listeners}
>
<DragHandleIcon />
</div>
)}
<Thumbnail
className={`${baseClass}__thumbnail`}
collectionSlug={collectionSlug}
doc={doc}
fileSrc={thumbnailURL || url}
imageCacheTag={imageCacheTag}
uploadConfig={uploadConfig}
/>
</div>
<div className={`${baseClass}__main-detail`}>{filename}</div>
<div className={`${baseClass}__actions`}>
<DocumentDrawer />
<DocumentDrawerToggler>
<EditIcon />
</DocumentDrawerToggler>
{removeItem && (
<Button
buttonStyle="icon-label"
className={`${baseClass}__remove`}
icon="x"
iconStyle="none"
onClick={() => removeItem(rowIndex)}
round
/>
)}
</div>
</div>
)}
</DraggableSortableItem>
)
}

View File

@@ -1,4 +1,4 @@
@import '../../scss/styles.scss'; @import '../../../scss/styles.scss';
.file-details { .file-details {
background: var(--theme-elevation-50); background: var(--theme-elevation-50);

View File

@@ -0,0 +1,87 @@
'use client'
import React from 'react'
import { Button } from '../../Button/index.js'
import { Thumbnail } from '../../Thumbnail/index.js'
import { UploadActions } from '../../Upload/index.js'
import { FileMeta } from '../FileMeta/index.js'
import './index.scss'
const baseClass = 'file-details'
import type { Data, FileSizes, SanitizedCollectionConfig } from 'payload'
export type StaticFileDetailsProps = {
collectionSlug: string
customUploadActions?: React.ReactNode[]
doc: {
sizes?: FileSizes
} & Data
enableAdjustments?: boolean
handleRemove?: () => void
hasImageSizes?: boolean
imageCacheTag?: string
uploadConfig: SanitizedCollectionConfig['upload']
}
export const StaticFileDetails: React.FC<StaticFileDetailsProps> = (props) => {
const {
collectionSlug,
customUploadActions,
doc,
enableAdjustments,
handleRemove,
hasImageSizes,
imageCacheTag,
uploadConfig,
} = props
const { id, filename, filesize, height, mimeType, thumbnailURL, url, width } = doc
return (
<div className={baseClass}>
<header>
<Thumbnail
// size="small"
className={`${baseClass}__thumbnail`}
collectionSlug={collectionSlug}
doc={doc}
fileSrc={thumbnailURL || url}
imageCacheTag={imageCacheTag}
uploadConfig={uploadConfig}
/>
<div className={`${baseClass}__main-detail`}>
<FileMeta
collection={collectionSlug}
filename={filename as string}
filesize={filesize as number}
height={height as number}
id={id as string}
mimeType={mimeType as string}
url={url as string}
width={width as number}
/>
{(enableAdjustments || customUploadActions) && (
<UploadActions
customActions={customUploadActions}
enableAdjustments={Boolean(enableAdjustments)}
enablePreviewSizes={hasImageSizes && doc.filename}
mimeType={mimeType}
/>
)}
</div>
{handleRemove && (
<Button
buttonStyle="icon-label"
className={`${baseClass}__remove`}
icon="x"
iconStyle="with-border"
onClick={handleRemove}
round
/>
)}
</header>
</div>
)
}

View File

@@ -1,87 +1,49 @@
'use client' 'use client'
import React from 'react'
import { UploadActions } from '../../elements/Upload/index.js'
import { Button } from '../Button/index.js'
import { Thumbnail } from '../Thumbnail/index.js'
import { FileMeta } from './FileMeta/index.js'
import './index.scss'
const baseClass = 'file-details'
import type { Data, FileSizes, SanitizedCollectionConfig } from 'payload' import type { Data, FileSizes, SanitizedCollectionConfig } from 'payload'
export type FileDetailsProps = { import React from 'react'
import { DraggableFileDetails } from './DraggableFileDetails/index.js'
import { StaticFileDetails } from './StaticFileDetails/index.js'
type SharedFileDetailsProps = {
collectionSlug: string collectionSlug: string
customUploadActions?: React.ReactNode[] customUploadActions?: React.ReactNode[]
doc: { doc: {
sizes?: FileSizes sizes?: FileSizes
} & Data } & Data
enableAdjustments?: boolean enableAdjustments?: boolean
handleRemove?: () => void
hasImageSizes?: boolean hasImageSizes?: boolean
imageCacheTag?: string imageCacheTag?: string
uploadConfig: SanitizedCollectionConfig['upload'] uploadConfig: SanitizedCollectionConfig['upload']
} }
export const FileDetails: React.FC<FileDetailsProps> = (props) => { type StaticFileDetailsProps = {
const { draggableItemProps?: never
collectionSlug, handleRemove?: () => void
customUploadActions, hasMany?: never
doc, isSortable?: never
enableAdjustments, removeItem?: never
handleRemove, rowIndex?: never
hasImageSizes, }
imageCacheTag,
uploadConfig, type DraggableFileDetailsProps = {
} = props handleRemove?: never
hasMany: boolean
const { id, filename, filesize, height, mimeType, thumbnailURL, url, width } = doc isSortable?: boolean
removeItem?: (index: number) => void
return ( rowIndex: number
<div className={baseClass}> }
<header>
<Thumbnail export type FileDetailsProps = (DraggableFileDetailsProps | StaticFileDetailsProps) &
// size="small" SharedFileDetailsProps
className={`${baseClass}__thumbnail`}
collectionSlug={collectionSlug} export const FileDetails: React.FC<FileDetailsProps> = (props) => {
doc={doc} const { hasMany } = props
fileSrc={thumbnailURL || url}
imageCacheTag={imageCacheTag} if (hasMany) {
uploadConfig={uploadConfig} return <DraggableFileDetails {...props} />
/> }
<div className={`${baseClass}__main-detail`}>
<FileMeta return <StaticFileDetails {...props} />
collection={collectionSlug}
filename={filename as string}
filesize={filesize as number}
height={height as number}
id={id as string}
mimeType={mimeType as string}
url={url as string}
width={width as number}
/>
{(enableAdjustments || customUploadActions) && (
<UploadActions
customActions={customUploadActions}
enableAdjustments={enableAdjustments}
enablePreviewSizes={hasImageSizes && doc.filename}
mimeType={mimeType}
/>
)}
</div>
{handleRemove && (
<Button
buttonStyle="icon-label"
className={`${baseClass}__remove`}
icon="x"
iconStyle="with-border"
onClick={handleRemove}
round
/>
)}
</header>
</div>
)
} }

View File

@@ -3,7 +3,7 @@ import type { ClientCollectionConfig, ClientField, Where } from 'payload'
import { useWindowInfo } from '@faceless-ui/window-info' import { useWindowInfo } from '@faceless-ui/window-info'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import React, { useEffect, useRef, useState } from 'react' import React, { Fragment, useEffect, useRef, useState } from 'react'
import AnimateHeightImport from 'react-animate-height' import AnimateHeightImport from 'react-animate-height'
const AnimateHeight = (AnimateHeightImport.default || const AnimateHeight = (AnimateHeightImport.default ||
@@ -49,7 +49,7 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
const { collectionConfig, enableColumns = true, enableSort = false, fields } = props const { collectionConfig, enableColumns = true, enableSort = false, fields } = props
const { handleSearchChange } = useListQuery() const { handleSearchChange } = useListQuery()
const { collectionSlug } = useListInfo() const { beforeActions, collectionSlug, disableBulkDelete, disableBulkEdit } = useListInfo()
const { searchParams } = useSearchParams() const { searchParams } = useSearchParams()
const titleField = useUseTitleField(collectionConfig, fields) const titleField = useUseTitleField(collectionConfig, fields)
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
@@ -144,10 +144,15 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
<div className={`${baseClass}__buttons-wrap`}> <div className={`${baseClass}__buttons-wrap`}>
{!smallBreak && ( {!smallBreak && (
<React.Fragment> <React.Fragment>
<EditMany collection={collectionConfig} fields={fields} /> {beforeActions && beforeActions}
<PublishMany collection={collectionConfig} /> {!disableBulkEdit && (
<UnpublishMany collection={collectionConfig} /> <Fragment>
<DeleteMany collection={collectionConfig} /> <EditMany collection={collectionConfig} fields={fields} />
<PublishMany collection={collectionConfig} />
<UnpublishMany collection={collectionConfig} />
</Fragment>
)}
{!disableBulkDelete && <DeleteMany collection={collectionConfig} />}
</React.Fragment> </React.Fragment>
)} )}
{enableColumns && ( {enableColumns && (

View File

@@ -7,6 +7,7 @@ import React, { useCallback, useEffect, useReducer, useState } from 'react'
import type { ListDrawerProps } from './types.js' import type { ListDrawerProps } from './types.js'
import { SelectMany } from '../../elements/SelectMany/index.js'
import { FieldLabel } from '../../fields/FieldLabel/index.js' import { FieldLabel } from '../../fields/FieldLabel/index.js'
import { usePayloadAPI } from '../../hooks/usePayloadAPI.js' import { usePayloadAPI } from '../../hooks/usePayloadAPI.js'
import { XIcon } from '../../icons/X/index.js' import { XIcon } from '../../icons/X/index.js'
@@ -45,7 +46,9 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
collectionSlugs, collectionSlugs,
customHeader, customHeader,
drawerSlug, drawerSlug,
enableRowSelections,
filterOptions, filterOptions,
onBulkSelect,
onSelect, onSelect,
selectedCollection, selectedCollection,
}) => { }) => {
@@ -276,8 +279,13 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
)} )}
</header> </header>
} }
beforeActions={
enableRowSelections ? [<SelectMany key="select-many" onClick={onBulkSelect} />] : undefined
}
collectionConfig={selectedCollectionConfig} collectionConfig={selectedCollectionConfig}
collectionSlug={selectedCollectionConfig.slug} collectionSlug={selectedCollectionConfig.slug}
disableBulkDelete
disableBulkEdit
hasCreatePermission={hasCreatePermission} hasCreatePermission={hasCreatePermission}
newDocumentURL={null} newDocumentURL={null}
> >
@@ -309,6 +317,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}, },
]} ]}
collectionSlug={selectedCollectionConfig.slug} collectionSlug={selectedCollectionConfig.slug}
enableRowSelections={enableRowSelections}
preferenceKey={preferenceKey} preferenceKey={preferenceKey}
> >
<RenderComponent mappedComponent={List} /> <RenderComponent mappedComponent={List} />

View File

@@ -87,6 +87,7 @@ export const useListDrawer: UseListDrawer = ({
setCollectionSlugs(filteredCollectionSlugs.map(({ slug }) => slug)) setCollectionSlugs(filteredCollectionSlugs.map(({ slug }) => slug))
} }
}, [collectionSlugs, uploads, collections]) }, [collectionSlugs, uploads, collections])
const toggleDrawer = useCallback(() => { const toggleDrawer = useCallback(() => {
toggleModal(drawerSlug) toggleModal(drawerSlug)
}, [toggleModal, drawerSlug]) }, [toggleModal, drawerSlug])

View File

@@ -2,11 +2,15 @@ import type { FilterOptionsResult, SanitizedCollectionConfig } from 'payload'
import type React from 'react' import type React from 'react'
import type { HTMLAttributes } from 'react' import type { HTMLAttributes } from 'react'
import type { useSelection } from '../../providers/Selection/index.js'
export type ListDrawerProps = { export type ListDrawerProps = {
readonly collectionSlugs: string[] readonly collectionSlugs: string[]
readonly customHeader?: React.ReactNode readonly customHeader?: React.ReactNode
readonly drawerSlug?: string readonly drawerSlug?: string
readonly enableRowSelections?: boolean
readonly filterOptions?: FilterOptionsResult readonly filterOptions?: FilterOptionsResult
readonly onBulkSelect?: (selected: ReturnType<typeof useSelection>['selected']) => void
readonly onSelect?: (args: { readonly onSelect?: (args: {
collectionSlug: SanitizedCollectionConfig['slug'] collectionSlug: SanitizedCollectionConfig['slug']
docID: string docID: string
@@ -27,7 +31,7 @@ export type UseListDrawer = (args: {
selectedCollection?: string selectedCollection?: string
uploads?: boolean // finds all collections with upload: true uploads?: boolean // finds all collections with upload: true
}) => [ }) => [
React.FC<Pick<ListDrawerProps, 'onSelect'>>, // drawer React.FC<Pick<ListDrawerProps, 'enableRowSelections' | 'onBulkSelect' | 'onSelect'>>, // drawer
React.FC<Pick<ListTogglerProps, 'children' | 'className' | 'disabled'>>, // toggler React.FC<Pick<ListTogglerProps, 'children' | 'className' | 'disabled'>>, // toggler
{ {
closeDrawer: () => void closeDrawer: () => void

View File

@@ -0,0 +1,32 @@
import React from 'react'
import { useSelection } from '../../providers/Selection/index.js'
// import { useTranslation } from '../../providers/Translation/index.js'
import { Pill } from '../Pill/index.js'
export const SelectMany: React.FC<{
onClick?: (ids: ReturnType<typeof useSelection>['selected']) => void
}> = (props) => {
const { onClick } = props
const { count, selected } = useSelection()
// const { t } = useTranslation()
if (!selected || !count) {
return null
}
return (
<Pill
// className={`${baseClass}__toggle`}
onClick={() => {
if (typeof onClick === 'function') {
onClick(selected)
}
}}
pillStyle="white"
>
{`Select ${count}`}
</Pill>
)
}

View File

@@ -1,13 +0,0 @@
import type React from 'react'
import type { Action, OptionGroup, Value } from '../types.js'
export type Props = {
dispatchOptions: React.Dispatch<Action>
hasMany: boolean
options: OptionGroup[]
path: string
relationTo: string | string[]
setValue: (value: unknown) => void
value: Value | Value[]
}

View File

@@ -8,6 +8,7 @@ import React, { useCallback, useEffect, useReducer, useRef, useState } from 'rea
import type { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js' import type { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js'
import type { GetResults, Option, Value } from './types.js' import type { GetResults, Option, Value } from './types.js'
import { AddNewRelation } from '../../elements/AddNewRelation/index.js'
import { ReactSelect } from '../../elements/ReactSelect/index.js' import { ReactSelect } from '../../elements/ReactSelect/index.js'
import { useFieldProps } from '../../forms/FieldPropsProvider/index.js' import { useFieldProps } from '../../forms/FieldPropsProvider/index.js'
import { useField } from '../../forms/useField/index.js' import { useField } from '../../forms/useField/index.js'
@@ -21,7 +22,6 @@ import { FieldDescription } from '../FieldDescription/index.js'
import { FieldError } from '../FieldError/index.js' import { FieldError } from '../FieldError/index.js'
import { FieldLabel } from '../FieldLabel/index.js' import { FieldLabel } from '../FieldLabel/index.js'
import { fieldBaseClass } from '../shared/index.js' import { fieldBaseClass } from '../shared/index.js'
import { AddNewRelation } from './AddNew/index.js'
import { createRelationMap } from './createRelationMap.js' import { createRelationMap } from './createRelationMap.js'
import { findOptionsByValue } from './findOptionsByValue.js' import { findOptionsByValue } from './findOptionsByValue.js'
import './index.scss' import './index.scss'
@@ -591,15 +591,11 @@ const RelationshipFieldComponent: React.FC<RelationshipFieldProps> = (props) =>
/> />
{!readOnly && allowCreate && ( {!readOnly && allowCreate && (
<AddNewRelation <AddNewRelation
{...{ hasMany={hasMany}
dispatchOptions, path={path}
hasMany, relationTo={relationTo}
options, setValue={setValue}
path, value={value}
relationTo,
setValue,
value,
}}
/> />
)} )}
</div> </div>

View File

@@ -0,0 +1,47 @@
@import '../../../scss/styles.scss';
.upload--has-many {
position: relative;
max-width: 100%;
&__controls {
display: flex;
gap: base(2);
margin-top: base(1);
}
&__buttons {
display: flex;
gap: base(1);
}
&__add-new {
display: flex;
gap: base(0.5);
}
&__clear-all {
all: unset;
flex-shrink: 0;
font-size: inherit;
font-family: inherit;
color: inherit;
cursor: pointer;
}
&__draggable-rows {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
}
html[data-theme='light'] {
.upload {
}
}
html[data-theme='dark'] {
.upload {
}
}

View File

@@ -0,0 +1,256 @@
'use client'
import type { FilterOptionsResult, Where } from 'payload'
import * as qs from 'qs-esm'
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import type { useSelection } from '../../../providers/Selection/index.js'
import type { UploadFieldPropsWithContext } from '../HasOne/index.js'
import { AddNewRelation } from '../../../elements/AddNewRelation/index.js'
import { Button } from '../../../elements/Button/index.js'
import { DraggableSortable } from '../../../elements/DraggableSortable/index.js'
import { FileDetails } from '../../../elements/FileDetails/index.js'
import { useListDrawer } from '../../../elements/ListDrawer/index.js'
import { useConfig } from '../../../providers/Config/index.js'
import { useLocale } from '../../../providers/Locale/index.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { FieldLabel } from '../../FieldLabel/index.js'
const baseClass = 'upload upload--has-many'
import './index.scss'
export const UploadComponentHasMany: React.FC<UploadFieldPropsWithContext<string[]>> = (props) => {
const {
canCreate,
field,
field: {
_path,
admin: {
components: { Label },
isSortable,
},
hasMany,
label,
relationTo,
},
fieldHookResult: { filterOptions: filterOptionsFromProps, setValue, value },
readOnly,
} = props
const { i18n, t } = useTranslation()
const {
config: {
collections,
routes: { api },
serverURL,
},
} = useConfig()
const filterOptions: FilterOptionsResult = useMemo(() => {
if (typeof relationTo === 'string') {
return {
...filterOptionsFromProps,
[relationTo]: {
...((filterOptionsFromProps?.[relationTo] as any) || {}),
id: {
...((filterOptionsFromProps?.[relationTo] as any)?.id || {}),
not_in: (filterOptionsFromProps?.[relationTo] as any)?.id?.not_in || value,
},
},
}
}
}, [value, relationTo, filterOptionsFromProps])
const [fileDocs, setFileDocs] = useState([])
const [missingFiles, setMissingFiles] = useState(false)
const { code } = useLocale()
useEffect(() => {
if (value !== null && typeof value !== 'undefined' && value.length !== 0) {
const query: {
[key: string]: unknown
where: Where
} = {
depth: 0,
draft: true,
locale: code,
where: {
and: [
{
id: {
in: value,
},
},
],
},
}
const fetchFile = async () => {
const response = await fetch(`${serverURL}${api}/${relationTo}`, {
body: qs.stringify(query),
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/x-www-form-urlencoded',
'X-HTTP-Method-Override': 'GET',
},
method: 'POST',
})
if (response.ok) {
const json = await response.json()
setFileDocs(json.docs)
} else {
setMissingFiles(true)
setFileDocs([])
}
}
void fetchFile()
} else {
setFileDocs([])
}
}, [value, relationTo, api, serverURL, i18n, code])
function moveItemInArray<T>(array: T[], moveFromIndex: number, moveToIndex: number): T[] {
const newArray = [...array]
const [item] = newArray.splice(moveFromIndex, 1)
newArray.splice(moveToIndex, 0, item)
return newArray
}
const moveRow = useCallback(
(moveFromIndex: number, moveToIndex: number) => {
const updatedArray = moveItemInArray(value, moveFromIndex, moveToIndex)
setValue(updatedArray)
},
[value, setValue],
)
const removeItem = useCallback(
(index: number) => {
const updatedArray = [...value]
updatedArray.splice(index, 1)
setValue(updatedArray)
},
[value, setValue],
)
const [ListDrawer, ListDrawerToggler] = useListDrawer({
collectionSlugs:
typeof relationTo === 'string'
? [relationTo]
: collections.map((collection) => collection.slug),
filterOptions,
})
const collection = collections.find((coll) => coll.slug === relationTo)
const onBulkSelect = useCallback(
(selections: ReturnType<typeof useSelection>['selected']) => {
const selectedIDs = Object.entries(selections).reduce(
(acc, [key, value]) => (value ? [...acc, key] : acc),
[] as string[],
)
setValue([...value, ...selectedIDs])
},
[setValue, value],
)
return (
<Fragment>
<div className={[baseClass].join(' ')}>
<FieldLabel Label={Label} field={field} label={label} />
<div className={[baseClass].join(' ')}>
<div>
<DraggableSortable
className={`${baseClass}__draggable-rows`}
ids={value}
onDragEnd={({ moveFromIndex, moveToIndex }) => moveRow(moveFromIndex, moveToIndex)}
>
{Boolean(value.length) &&
value.map((id, index) => {
const doc = fileDocs.find((doc) => doc.id === id)
const uploadConfig = collection?.upload
if (!doc) {
return null
}
return (
<FileDetails
collectionSlug={relationTo}
doc={doc}
hasMany={true}
isSortable={isSortable}
key={id}
removeItem={removeItem}
rowIndex={index}
uploadConfig={uploadConfig}
/>
)
})}
</DraggableSortable>
</div>
</div>
<div className={[`${baseClass}__controls`].join(' ')}>
<div className={[`${baseClass}__buttons`].join(' ')}>
{canCreate && (
<div className={[`${baseClass}__add-new`].join(' ')}>
<AddNewRelation
Button={
<Button
buttonStyle="icon-label"
el="span"
icon="plus"
iconPosition="left"
iconStyle="with-border"
>
{t('fields:addNew')}
</Button>
}
hasMany={hasMany}
path={_path}
relationTo={relationTo}
setValue={setValue}
unstyled
value={value}
/>
</div>
)}
<ListDrawerToggler className={`${baseClass}__toggler`} disabled={readOnly}>
<div>
<Button
buttonStyle="icon-label"
el="span"
icon="plus"
iconPosition="left"
iconStyle="with-border"
>
{t('fields:chooseFromExisting')}
</Button>
</div>
</ListDrawerToggler>
</div>
<button className={`${baseClass}__clear-all`} onClick={() => setValue([])} type="button">
Clear all
</button>
</div>
</div>
<ListDrawer
enableRowSelections
onBulkSelect={onBulkSelect}
onSelect={(selection) => {
setValue([...value, selection.docID])
}}
/>
</Fragment>
)
}

View File

@@ -17,22 +17,21 @@ import type { MarkOptional } from 'ts-essentials'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import type { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js' import type { DocumentDrawerProps } from '../../../elements/DocumentDrawer/types.js'
import type { ListDrawerProps } from '../../elements/ListDrawer/types.js' import type { ListDrawerProps } from '../../../elements/ListDrawer/types.js'
import { Button } from '../../elements/Button/index.js' import { Button } from '../../../elements/Button/index.js'
import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js' import { useDocumentDrawer } from '../../../elements/DocumentDrawer/index.js'
import { FileDetails } from '../../elements/FileDetails/index.js' import { FileDetails } from '../../../elements/FileDetails/index.js'
import { useListDrawer } from '../../elements/ListDrawer/index.js' import { useListDrawer } from '../../../elements/ListDrawer/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../../providers/Translation/index.js'
import { FieldDescription } from '../FieldDescription/index.js' import { FieldDescription } from '../../FieldDescription/index.js'
import { FieldError } from '../FieldError/index.js' import { FieldError } from '../../FieldError/index.js'
import { FieldLabel } from '../FieldLabel/index.js' import { FieldLabel } from '../../FieldLabel/index.js'
import { fieldBaseClass } from '../shared/index.js' import { fieldBaseClass } from '../../shared/index.js'
import { baseClass } from '../index.js'
import './index.scss' import './index.scss'
const baseClass = 'upload'
export type UploadInputProps = { export type UploadInputProps = {
readonly Description?: MappedComponent readonly Description?: MappedComponent
readonly Error?: MappedComponent readonly Error?: MappedComponent
@@ -63,7 +62,7 @@ export type UploadInputProps = {
readonly width?: string readonly width?: string
} }
export const UploadInput: React.FC<UploadInputProps> = (props) => { export const UploadInputHasOne: React.FC<UploadInputProps> = (props) => {
const { const {
Description, Description,
Error, Error,
@@ -149,7 +148,7 @@ export const UploadInput: React.FC<UploadInputProps> = (props) => {
[onChange, closeListDrawer], [onChange, closeListDrawer],
) )
if (collection.upload) { if (collection.upload && typeof relationTo === 'string') {
return ( return (
<div <div
className={[ className={[

View File

@@ -1,4 +1,4 @@
@import '../../scss/styles.scss'; @import '../../../scss/styles.scss';
.upload { .upload {
position: relative; position: relative;

View File

@@ -0,0 +1,76 @@
'use client'
import type { UploadFieldProps } from 'payload'
import React from 'react'
import type { useField } from '../../../forms/useField/index.js'
import { useConfig } from '../../../providers/Config/index.js'
import { UploadInputHasOne } from './Input.js'
import './index.scss'
export type UploadFieldPropsWithContext<TValue extends string | string[] = string> = {
readonly canCreate: boolean
readonly disabled: boolean
readonly fieldHookResult: ReturnType<typeof useField<TValue>>
readonly onChange: (value: unknown) => void
} & UploadFieldProps
export const UploadComponentHasOne: React.FC<UploadFieldPropsWithContext> = (props) => {
const {
canCreate,
descriptionProps,
disabled,
errorProps,
field,
field: { admin: { className, style, width } = {}, label, relationTo, required },
fieldHookResult,
labelProps,
onChange,
} = props
const {
config: {
collections,
routes: { api: apiRoute },
serverURL,
},
} = useConfig()
if (typeof relationTo === 'string') {
const collection = collections.find((coll) => coll.slug === relationTo)
if (collection.upload) {
return (
<UploadInputHasOne
Description={field?.admin?.components?.Description}
Error={field?.admin?.components?.Error}
Label={field?.admin?.components?.Label}
allowNewUpload={canCreate}
api={apiRoute}
className={className}
collection={collection}
descriptionProps={descriptionProps}
errorProps={errorProps}
filterOptions={fieldHookResult.filterOptions}
label={label}
labelProps={labelProps}
onChange={onChange}
readOnly={disabled}
relationTo={relationTo}
required={required}
serverURL={serverURL}
showError={fieldHookResult.showError}
style={style}
value={fieldHookResult.value}
width={width}
/>
)
}
} else {
return <div>Polymorphic Has One Uploads Go Here</div>
}
return null
}

View File

@@ -4,50 +4,38 @@ import type { UploadFieldProps } from 'payload'
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import type { UploadInputProps } from './Input.js' import type { UploadInputProps } from './HasOne/Input.js'
import { useFieldProps } from '../../forms/FieldPropsProvider/index.js' import { useFieldProps } from '../../forms/FieldPropsProvider/index.js'
import { useField } from '../../forms/useField/index.js' import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js' import { withCondition } from '../../forms/withCondition/index.js'
import { useAuth } from '../../providers/Auth/index.js' import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js' import { UploadComponentHasMany } from './HasMany/index.js'
import { UploadInput } from './Input.js' import { UploadInputHasOne } from './HasOne/Input.js'
import './index.scss' import { UploadComponentHasOne } from './HasOne/index.js'
export { UploadFieldProps, UploadInput } export { UploadFieldProps, UploadInputHasOne as UploadInput }
export type { UploadInputProps } export type { UploadInputProps }
export const baseClass = 'upload'
const UploadComponent: React.FC<UploadFieldProps> = (props) => { const UploadComponent: React.FC<UploadFieldProps> = (props) => {
const { const {
descriptionProps,
errorProps,
field,
field: { field: {
_path: pathFromProps, _path: pathFromProps,
admin: { className, readOnly: readOnlyFromAdmin, style, width } = {}, admin: { readOnly: readOnlyFromAdmin } = {},
label, hasMany,
relationTo, relationTo,
required, required,
}, },
labelProps,
readOnly: readOnlyFromTopLevelProps, readOnly: readOnlyFromTopLevelProps,
validate, validate,
} = props } = props
const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin const readOnlyFromProps = readOnlyFromTopLevelProps || readOnlyFromAdmin
const {
config: {
collections,
routes: { api: apiRoute },
serverURL,
},
} = useConfig()
const { permissions } = useAuth() const { permissions } = useAuth()
const collection = collections.find((coll) => coll.slug === relationTo)
const memoizedValidate = useCallback( const memoizedValidate = useCallback(
(value, options) => { (value, options) => {
if (typeof validate === 'function') { if (typeof validate === 'function') {
@@ -61,22 +49,29 @@ const UploadComponent: React.FC<UploadFieldProps> = (props) => {
// Checks if the user has permissions to create a new document in the related collection // Checks if the user has permissions to create a new document in the related collection
const canCreate = useMemo(() => { const canCreate = useMemo(() => {
if (permissions?.collections && permissions.collections?.[relationTo]?.create) { if (typeof relationTo === 'string') {
if (permissions.collections[relationTo].create?.permission === true) { if (permissions?.collections && permissions.collections?.[relationTo]?.create) {
return true if (permissions.collections[relationTo].create?.permission === true) {
return true
}
} }
} }
return false return false
}, [relationTo, permissions]) }, [relationTo, permissions])
const { filterOptions, formInitializing, formProcessing, setValue, showError, value } = const fieldHookResult = useField<string | string[]>({
useField<string>({ path: pathFromContext ?? pathFromProps,
path: pathFromContext ?? pathFromProps, validate: memoizedValidate,
validate: memoizedValidate, })
})
const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing const setValue = useMemo(() => fieldHookResult.setValue, [fieldHookResult])
const disabled =
readOnlyFromProps ||
readOnlyFromContext ||
fieldHookResult.formProcessing ||
fieldHookResult.formInitializing
const onChange = useCallback( const onChange = useCallback(
(incomingValue) => { (incomingValue) => {
@@ -86,35 +81,31 @@ const UploadComponent: React.FC<UploadFieldProps> = (props) => {
[setValue], [setValue],
) )
if (collection.upload) { if (hasMany) {
return ( return (
<UploadInput <UploadComponentHasMany
Description={field?.admin?.components?.Description} {...props}
Error={field?.admin?.components?.Error} canCreate={canCreate}
Label={field?.admin?.components?.Label} disabled={disabled}
allowNewUpload={canCreate} // Note: the below TS error is thrown bc the field hook return result varies based on `hasMany`
api={apiRoute} // @ts-expect-error
className={className} fieldHookResult={fieldHookResult}
collection={collection}
descriptionProps={descriptionProps}
errorProps={errorProps}
filterOptions={filterOptions}
label={label}
labelProps={labelProps}
onChange={onChange} onChange={onChange}
readOnly={disabled}
relationTo={relationTo}
required={required}
serverURL={serverURL}
showError={showError}
style={style}
value={value}
width={width}
/> />
) )
} }
return null return (
<UploadComponentHasOne
{...props}
canCreate={canCreate}
disabled={disabled}
// Note: the below TS error is thrown bc the field hook return result varies based on `hasMany`
// @ts-expect-error
fieldHookResult={fieldHookResult}
onChange={onChange}
/>
)
} }
export const UploadField = withCondition(UploadComponent) export const UploadField = withCondition(UploadComponent)

View File

@@ -381,7 +381,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
break break
} }
case 'upload':
case 'relationship': { case 'relationship': {
if (field.filterOptions) { if (field.filterOptions) {
if (typeof field.filterOptions === 'object') { if (typeof field.filterOptions === 'object') {
@@ -467,41 +467,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
break break
} }
case 'upload': {
if (field.filterOptions) {
if (typeof field.filterOptions === 'object') {
fieldState.filterOptions = {
[field.relationTo]: field.filterOptions,
}
}
if (typeof field.filterOptions === 'function') {
const query = await getFilterOptionsQuery(field.filterOptions, {
id,
data: fullData,
relationTo: field.relationTo,
siblingData: data,
user: req.user,
})
fieldState.filterOptions = query
}
}
const relationshipValue =
data[field.name] && typeof data[field.name] === 'object' && 'id' in data[field.name]
? data[field.name].id
: data[field.name]
fieldState.value = relationshipValue
fieldState.initialValue = relationshipValue
if (!filter || filter(args)) {
state[`${path}${field.name}`] = fieldState
}
break
}
default: { default: {
fieldState.value = data[field.name] fieldState.value = data[field.name]
fieldState.initialValue = data[field.name] fieldState.initialValue = data[field.name]

View File

@@ -9,8 +9,11 @@ export type ColumnPreferences = Pick<Column, 'accessor' | 'active'>[]
export type ListInfoProps = { export type ListInfoProps = {
readonly Header?: React.ReactNode readonly Header?: React.ReactNode
readonly beforeActions?: React.ReactNode[]
readonly collectionConfig: ClientConfig['collections'][0] readonly collectionConfig: ClientConfig['collections'][0]
readonly collectionSlug: SanitizedCollectionConfig['slug'] readonly collectionSlug: SanitizedCollectionConfig['slug']
readonly disableBulkDelete?: boolean
readonly disableBulkEdit?: boolean
readonly hasCreatePermission: boolean readonly hasCreatePermission: boolean
readonly newDocumentURL: string readonly newDocumentURL: string
readonly titleField?: FieldAffectingData readonly titleField?: FieldAffectingData
@@ -18,7 +21,10 @@ export type ListInfoProps = {
export type ListInfoContext = { export type ListInfoContext = {
readonly Header?: React.ReactNode readonly Header?: React.ReactNode
readonly beforeActions?: React.ReactNode[]
readonly collectionSlug: string readonly collectionSlug: string
readonly disableBulkDelete?: boolean
readonly disableBulkEdit?: boolean
readonly hasCreatePermission: boolean readonly hasCreatePermission: boolean
readonly newDocumentURL: string readonly newDocumentURL: string
} & ListInfoProps } & ListInfoProps

View File

@@ -16,6 +16,8 @@ export enum SelectAllStatus {
type SelectionContext = { type SelectionContext = {
count: number count: number
disableBulkDelete?: boolean
disableBulkEdit?: boolean
getQueryParams: (additionalParams?: Where) => string getQueryParams: (additionalParams?: Where) => string
selectAll: SelectAllStatus selectAll: SelectAllStatus
selected: Record<number | string, boolean> selected: Record<number | string, boolean>
@@ -27,10 +29,11 @@ type SelectionContext = {
const Context = createContext({} as SelectionContext) const Context = createContext({} as SelectionContext)
type Props = { type Props = {
children: React.ReactNode readonly children: React.ReactNode
docs: any[] readonly docs: any[]
totalDocs: number readonly totalDocs: number
} }
export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalDocs }) => { export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalDocs }) => {
const contextRef = useRef({} as SelectionContext) const contextRef = useRef({} as SelectionContext)

View File

@@ -0,0 +1 @@
uploads

View File

@@ -0,0 +1,21 @@
import type { CollectionConfig } from 'payload'
import { uploadsMulti, uploadsSlug } from '../../slugs.js'
const Uploads: CollectionConfig = {
slug: uploadsMulti,
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'media',
type: 'upload',
hasMany: true,
relationTo: uploadsSlug,
},
],
}
export default Uploads

View File

@@ -0,0 +1,5 @@
import type { Upload } from '../../payload-types.js'
export const uploadsDoc: Partial<Upload> = {
text: 'An upload here',
}

View File

@@ -0,0 +1 @@
uploads

View File

@@ -0,0 +1,21 @@
import type { CollectionConfig } from 'payload'
import { uploads2Slug, uploadsMultiPoly, uploadsSlug } from '../../slugs.js'
const Uploads: CollectionConfig = {
slug: uploadsMultiPoly,
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'media',
type: 'upload',
hasMany: true,
relationTo: [uploadsSlug, uploads2Slug],
},
],
}
export default Uploads

View File

@@ -0,0 +1,5 @@
import type { Upload } from '../../payload-types.js'
export const uploadsDoc: Partial<Upload> = {
text: 'An upload here',
}

View File

@@ -0,0 +1 @@
uploads

View File

@@ -0,0 +1,20 @@
import type { CollectionConfig } from 'payload'
import { uploads2Slug, uploadsPoly, uploadsSlug } from '../../slugs.js'
const Uploads: CollectionConfig = {
slug: uploadsPoly,
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'media',
type: 'upload',
relationTo: [uploadsSlug, uploads2Slug],
},
],
}
export default Uploads

View File

@@ -0,0 +1,5 @@
import type { Upload } from '../../payload-types.js'
export const uploadsDoc: Partial<Upload> = {
text: 'An upload here',
}

View File

@@ -33,6 +33,9 @@ import TextFields from './collections/Text/index.js'
import UIFields from './collections/UI/index.js' import UIFields from './collections/UI/index.js'
import Uploads from './collections/Upload/index.js' import Uploads from './collections/Upload/index.js'
import Uploads2 from './collections/Upload2/index.js' import Uploads2 from './collections/Upload2/index.js'
import UploadsMulti from './collections/UploadMulti/index.js'
import UploadsMultiPoly from './collections/UploadMultiPoly/index.js'
import UploadsPoly from './collections/UploadPoly/index.js'
import Uploads3 from './collections/Uploads3/index.js' import Uploads3 from './collections/Uploads3/index.js'
import TabsWithRichText from './globals/TabsWithRichText.js' import TabsWithRichText from './globals/TabsWithRichText.js'
import { clearAndSeedEverything } from './seed.js' import { clearAndSeedEverything } from './seed.js'
@@ -79,6 +82,9 @@ export const collectionSlugs: CollectionConfig[] = [
Uploads, Uploads,
Uploads2, Uploads2,
Uploads3, Uploads3,
UploadsMulti,
UploadsPoly,
UploadsMultiPoly,
UIFields, UIFields,
] ]

View File

@@ -56,6 +56,9 @@ export interface Config {
uploads: Upload; uploads: Upload;
uploads2: Uploads2; uploads2: Uploads2;
uploads3: Uploads3; uploads3: Uploads3;
'uploads-multi': UploadsMulti;
'uploads-poly': UploadsPoly;
'uploads-multi-poly': UploadsMultiPoly;
'ui-fields': UiField; 'ui-fields': UiField;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration; 'payload-migrations': PayloadMigration;
@@ -1311,7 +1314,7 @@ export interface TabsField {
export interface Upload { export interface Upload {
id: string; id: string;
text?: string | null; text?: string | null;
media?: string | Upload | null; media?: (string | null) | Upload;
richText?: { richText?: {
root: { root: {
type: string; type: string;
@@ -1346,7 +1349,7 @@ export interface Upload {
export interface Uploads2 { export interface Uploads2 {
id: string; id: string;
text?: string | null; text?: string | null;
media?: string | Uploads2 | null; media?: (string | null) | Uploads2;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -1365,7 +1368,7 @@ export interface Uploads2 {
*/ */
export interface Uploads3 { export interface Uploads3 {
id: string; id: string;
media?: string | Uploads3 | null; media?: (string | null) | Uploads3;
richText?: { richText?: {
root: { root: {
type: string; type: string;
@@ -1393,6 +1396,58 @@ export interface Uploads3 {
focalX?: number | null; focalX?: number | null;
focalY?: number | null; focalY?: number | null;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uploads-multi".
*/
export interface UploadsMulti {
id: string;
text?: string | null;
media?: (string | Upload)[] | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uploads-poly".
*/
export interface UploadsPoly {
id: string;
text?: string | null;
media?:
| ({
relationTo: 'uploads';
value: string | Upload;
} | null)
| ({
relationTo: 'uploads2';
value: string | Uploads2;
} | null);
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uploads-multi-poly".
*/
export interface UploadsMultiPoly {
id: string;
text?: string | null;
media?:
| (
| {
relationTo: 'uploads';
value: string | Upload;
}
| {
relationTo: 'uploads2';
value: string | Uploads2;
}
)[]
| null;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ui-fields". * via the `definition` "ui-fields".

View File

@@ -49,6 +49,10 @@ import {
tabsFieldsSlug, tabsFieldsSlug,
textFieldsSlug, textFieldsSlug,
uiSlug, uiSlug,
uploads2Slug,
uploadsMulti,
uploadsMultiPoly,
uploadsPoly,
uploadsSlug, uploadsSlug,
usersSlug, usersSlug,
} from './slugs.js' } from './slugs.js'
@@ -123,6 +127,50 @@ export const seed = async (_payload: Payload) => {
overrideAccess: true, overrideAccess: true,
}) })
// const createdJPGDocSlug2 = await _payload.create({
// collection: uploads2Slug,
// data: {
// ...uploadsDoc,
// },
// file: jpgFile,
// depth: 0,
// overrideAccess: true,
// })
// Create hasMany upload
await _payload.create({
collection: uploadsMulti,
data: {
media: [createdPNGDoc.id, createdJPGDoc.id],
},
})
// Create hasMany poly upload
// await _payload.create({
// collection: uploadsMultiPoly,
// data: {
// media: [
// { value: createdJPGDocSlug2.id, relationTo: uploads2Slug },
// { value: createdJPGDoc.id, relationTo: uploadsSlug },
// ],
// },
// })
// Create poly upload
await _payload.create({
collection: uploadsPoly,
data: {
media: { value: createdJPGDoc.id, relationTo: uploadsSlug },
},
})
// Create poly upload
// await _payload.create({
// collection: uploadsPoly,
// data: {
// media: { value: createdJPGDocSlug2.id, relationTo: uploads2Slug },
// },
// })
const formattedID = const formattedID =
_payload.db.defaultIDType === 'number' ? createdArrayDoc.id : `"${createdArrayDoc.id}"` _payload.db.defaultIDType === 'number' ? createdArrayDoc.id : `"${createdArrayDoc.id}"`

View File

@@ -26,6 +26,9 @@ export const textFieldsSlug = 'text-fields'
export const uploadsSlug = 'uploads' export const uploadsSlug = 'uploads'
export const uploads2Slug = 'uploads2' export const uploads2Slug = 'uploads2'
export const uploads3Slug = 'uploads3' export const uploads3Slug = 'uploads3'
export const uploadsMulti = 'uploads-multi'
export const uploadsMultiPoly = 'uploads-multi-poly'
export const uploadsPoly = 'uploads-poly'
export const uiSlug = 'ui-fields' export const uiSlug = 'ui-fields'
export const collectionSlugs = [ export const collectionSlugs = [

View File

@@ -47,7 +47,7 @@ export interface Config {
'payload-migrations': PayloadMigration; 'payload-migrations': PayloadMigration;
}; };
db: { db: {
defaultIDType: number; defaultIDType: string;
}; };
globals: {}; globals: {};
locale: null; locale: null;
@@ -78,9 +78,9 @@ export interface UserAuthOperations {
* via the `definition` "relation". * via the `definition` "relation".
*/ */
export interface Relation { export interface Relation {
id: number; id: string;
image?: number | Media | null; image?: (string | null) | Media;
versionedImage?: number | Version | null; versionedImage?: (string | null) | Version;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -89,7 +89,7 @@ export interface Relation {
* via the `definition` "media". * via the `definition` "media".
*/ */
export interface Media { export interface Media {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -229,7 +229,7 @@ export interface Media {
* via the `definition` "versions". * via the `definition` "versions".
*/ */
export interface Version { export interface Version {
id: number; id: string;
title?: string | null; title?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -249,8 +249,8 @@ export interface Version {
* via the `definition` "audio". * via the `definition` "audio".
*/ */
export interface Audio { export interface Audio {
id: number; id: string;
audio?: number | Media | null; audio?: (string | null) | Media;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -259,7 +259,7 @@ export interface Audio {
* via the `definition` "gif-resize". * via the `definition` "gif-resize".
*/ */
export interface GifResize { export interface GifResize {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -295,7 +295,7 @@ export interface GifResize {
* via the `definition` "filename-compound-index". * via the `definition` "filename-compound-index".
*/ */
export interface FilenameCompoundIndex { export interface FilenameCompoundIndex {
id: number; id: string;
alt?: string | null; alt?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -332,7 +332,7 @@ export interface FilenameCompoundIndex {
* via the `definition` "no-image-sizes". * via the `definition` "no-image-sizes".
*/ */
export interface NoImageSize { export interface NoImageSize {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -350,7 +350,7 @@ export interface NoImageSize {
* via the `definition` "object-fit". * via the `definition` "object-fit".
*/ */
export interface ObjectFit { export interface ObjectFit {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -402,7 +402,7 @@ export interface ObjectFit {
* via the `definition` "with-meta-data". * via the `definition` "with-meta-data".
*/ */
export interface WithMetaDatum { export interface WithMetaDatum {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -430,7 +430,7 @@ export interface WithMetaDatum {
* via the `definition` "without-meta-data". * via the `definition` "without-meta-data".
*/ */
export interface WithoutMetaDatum { export interface WithoutMetaDatum {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -458,7 +458,7 @@ export interface WithoutMetaDatum {
* via the `definition` "with-only-jpeg-meta-data". * via the `definition` "with-only-jpeg-meta-data".
*/ */
export interface WithOnlyJpegMetaDatum { export interface WithOnlyJpegMetaDatum {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -486,7 +486,7 @@ export interface WithOnlyJpegMetaDatum {
* via the `definition` "crop-only". * via the `definition` "crop-only".
*/ */
export interface CropOnly { export interface CropOnly {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -530,7 +530,7 @@ export interface CropOnly {
* via the `definition` "focal-only". * via the `definition` "focal-only".
*/ */
export interface FocalOnly { export interface FocalOnly {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -574,7 +574,7 @@ export interface FocalOnly {
* via the `definition` "focal-no-sizes". * via the `definition` "focal-no-sizes".
*/ */
export interface FocalNoSize { export interface FocalNoSize {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -592,7 +592,7 @@ export interface FocalNoSize {
* via the `definition` "animated-type-media". * via the `definition` "animated-type-media".
*/ */
export interface AnimatedTypeMedia { export interface AnimatedTypeMedia {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -644,7 +644,7 @@ export interface AnimatedTypeMedia {
* via the `definition` "enlarge". * via the `definition` "enlarge".
*/ */
export interface Enlarge { export interface Enlarge {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -704,7 +704,7 @@ export interface Enlarge {
* via the `definition` "reduce". * via the `definition` "reduce".
*/ */
export interface Reduce { export interface Reduce {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -756,7 +756,7 @@ export interface Reduce {
* via the `definition` "media-trim". * via the `definition` "media-trim".
*/ */
export interface MediaTrim { export interface MediaTrim {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -800,7 +800,7 @@ export interface MediaTrim {
* via the `definition` "custom-file-name-media". * via the `definition` "custom-file-name-media".
*/ */
export interface CustomFileNameMedia { export interface CustomFileNameMedia {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -828,7 +828,7 @@ export interface CustomFileNameMedia {
* via the `definition` "unstored-media". * via the `definition` "unstored-media".
*/ */
export interface UnstoredMedia { export interface UnstoredMedia {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -846,7 +846,7 @@ export interface UnstoredMedia {
* via the `definition` "externally-served-media". * via the `definition` "externally-served-media".
*/ */
export interface ExternallyServedMedia { export interface ExternallyServedMedia {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -864,8 +864,8 @@ export interface ExternallyServedMedia {
* via the `definition` "uploads-1". * via the `definition` "uploads-1".
*/ */
export interface Uploads1 { export interface Uploads1 {
id: number; id: string;
media?: number | Uploads2 | null; media?: (string | null) | Uploads2;
richText?: { richText?: {
root: { root: {
type: string; type: string;
@@ -898,7 +898,7 @@ export interface Uploads1 {
* via the `definition` "uploads-2". * via the `definition` "uploads-2".
*/ */
export interface Uploads2 { export interface Uploads2 {
id: number; id: string;
title?: string | null; title?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -917,7 +917,7 @@ export interface Uploads2 {
* via the `definition` "admin-thumbnail-function". * via the `definition` "admin-thumbnail-function".
*/ */
export interface AdminThumbnailFunction { export interface AdminThumbnailFunction {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -935,7 +935,7 @@ export interface AdminThumbnailFunction {
* via the `definition` "admin-thumbnail-size". * via the `definition` "admin-thumbnail-size".
*/ */
export interface AdminThumbnailSize { export interface AdminThumbnailSize {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -971,7 +971,7 @@ export interface AdminThumbnailSize {
* via the `definition` "optional-file". * via the `definition` "optional-file".
*/ */
export interface OptionalFile { export interface OptionalFile {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -989,7 +989,7 @@ export interface OptionalFile {
* via the `definition` "required-file". * via the `definition` "required-file".
*/ */
export interface RequiredFile { export interface RequiredFile {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -1007,7 +1007,7 @@ export interface RequiredFile {
* via the `definition` "custom-upload-field". * via the `definition` "custom-upload-field".
*/ */
export interface CustomUploadField { export interface CustomUploadField {
id: number; id: string;
alt?: string | null; alt?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -1026,7 +1026,7 @@ export interface CustomUploadField {
* via the `definition` "media-with-relation-preview". * via the `definition` "media-with-relation-preview".
*/ */
export interface MediaWithRelationPreview { export interface MediaWithRelationPreview {
id: number; id: string;
title?: string | null; title?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -1045,7 +1045,7 @@ export interface MediaWithRelationPreview {
* via the `definition` "media-without-relation-preview". * via the `definition` "media-without-relation-preview".
*/ */
export interface MediaWithoutRelationPreview { export interface MediaWithoutRelationPreview {
id: number; id: string;
title?: string | null; title?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -1064,13 +1064,13 @@ export interface MediaWithoutRelationPreview {
* via the `definition` "relation-preview". * via the `definition` "relation-preview".
*/ */
export interface RelationPreview { export interface RelationPreview {
id: number; id: string;
imageWithPreview1?: number | MediaWithRelationPreview | null; imageWithPreview1?: (string | null) | MediaWithRelationPreview;
imageWithPreview2?: number | MediaWithRelationPreview | null; imageWithPreview2?: (string | null) | MediaWithRelationPreview;
imageWithoutPreview1?: number | MediaWithRelationPreview | null; imageWithoutPreview1?: (string | null) | MediaWithRelationPreview;
imageWithoutPreview2?: number | MediaWithoutRelationPreview | null; imageWithoutPreview2?: (string | null) | MediaWithoutRelationPreview;
imageWithPreview3?: number | MediaWithoutRelationPreview | null; imageWithPreview3?: (string | null) | MediaWithoutRelationPreview;
imageWithoutPreview3?: number | MediaWithoutRelationPreview | null; imageWithoutPreview3?: (string | null) | MediaWithoutRelationPreview;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -1079,7 +1079,7 @@ export interface RelationPreview {
* via the `definition` "users". * via the `definition` "users".
*/ */
export interface User { export interface User {
id: number; id: string;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
email: string; email: string;
@@ -1096,10 +1096,10 @@ export interface User {
* via the `definition` "payload-preferences". * via the `definition` "payload-preferences".
*/ */
export interface PayloadPreference { export interface PayloadPreference {
id: number; id: string;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: number | User; value: string | User;
}; };
key?: string | null; key?: string | null;
value?: value?:
@@ -1119,7 +1119,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations". * via the `definition` "payload-migrations".
*/ */
export interface PayloadMigration { export interface PayloadMigration {
id: number; id: string;
name?: string | null; name?: string | null;
batch?: number | null; batch?: number | null;
updatedAt: string; updatedAt: string;