feat: duplicate doc moved from frontend to backend concern (#5342)

BREAKING CHANGE: collection.admin.hooks.beforeDuplicate removed and instead should be handled using field beforeDuplicate hooks which take the full field hook arguments.

* feat: duplicate doc moved from frontend to backend concern

* feat: default beforeDuplicate hook functions on unique fields

* docs: beforeDuplicate field hook

* test: duplicate doc local api

* chore: fix build errors

* chore: add access.create call to duplicate operation

* chore: perfectionist reorder imports
This commit is contained in:
Dan Ribbens
2024-03-19 11:25:19 -04:00
committed by GitHub
parent b259bc60a2
commit ed01ee1e2d
90 changed files with 748 additions and 269 deletions

View File

@@ -167,53 +167,6 @@ those three fields plus the ID field.
so your admin queries can remain performant.
</Banner>
### Admin Hooks
In addition to collection hooks themselves, Payload provides for admin UI-specific hooks that you can leverage.
**`beforeDuplicate`**
The `beforeDuplicate` hook is an async function that accepts an object containing the data to duplicate, as well as the
locale of the doc to duplicate. Within this hook, you can modify the data to be duplicated, which is useful in cases
where you have unique fields that need to be incremented or similar, as well as if you want to automatically modify a
document's `title`.
Example:
```ts
import { BeforeDuplicate, CollectionConfig } from 'payload/types'
// Your auto-generated Page type
import { Page } from '../payload-types.ts'
const beforeDuplicate: BeforeDuplicate<Page> = ({ data }) => {
return {
...data,
title: `${data.title} Copy`,
uniqueField: data.uniqueField ? `${data.uniqueField}-copy` : '',
}
}
export const Page: CollectionConfig = {
slug: 'pages',
admin: {
hooks: {
beforeDuplicate,
},
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'uniqueField',
type: 'text',
unique: true,
},
],
}
```
### TypeScript
You can import collection types as follows:

View File

@@ -21,6 +21,7 @@ functionalities to be easily reusable across your projects.
- [beforeValidate](#beforevalidate)
- [beforeChange](#beforechange)
- beforeDuplicate(#beforeduplicate)
- [afterChange](#afterchange)
- [afterRead](#afterread)
@@ -38,6 +39,7 @@ const ExampleField: Field = {
hooks: {
beforeValidate: [(args) => {...}],
beforeChange: [(args) => {...}],
beforeDuplicate: [(args) => {...}],
afterChange: [(args) => {...}],
afterRead: [(args) => {...}],
}
@@ -217,6 +219,27 @@ Here, the `afterRead` hook for the `dateField` is used to format the date into a
using `toLocaleDateString()`. This hook modifies the way the date is presented to the user, making it more
user-friendly.
### beforeDuplicate
The `beforeDuplicate` field hook is only called when duplicating a document. It may be used when documents having the
exact same properties may cause issue. This gives you a way to avoid duplicate names on `unique`, `required` fields or
to unset values by returning `null`. This is called immediately after `defaultValue` and before validation occurs.
```ts
import { Field } from 'payload/types'
const numberField: Field = {
name: 'number',
type: 'number',
hooks: {
// increment existing value by 1
beforeDuplicate: [({ value }) => {
return (value ?? 0) + 1
}],
}
}
```
## TypeScript
Payload exports a type for field hooks which can be accessed and used as follows:

View File

@@ -0,0 +1,45 @@
import type { GeneratedTypes } from 'payload'
import type { PayloadRequest } from 'payload/types'
import type { Collection } from 'payload/types'
import { duplicateOperation } from 'payload/operations'
import { isolateObjectProperty } from 'payload/utilities'
import type { Context } from '../types.js'
export type Resolver<T> = (
_: unknown,
args: {
draft: boolean
fallbackLocale?: string
id: string
locale?: string
},
context: {
req: PayloadRequest
},
) => Promise<T>
export default function duplicateResolver<T extends keyof GeneratedTypes['collections']>(
collection: Collection,
): Resolver<GeneratedTypes['collections'][T]> {
return async function resolver(_, args, context: Context) {
const { req } = context
const locale = req.locale
const fallbackLocale = req.fallbackLocale
req.locale = args.locale || locale
req.fallbackLocale = args.fallbackLocale || fallbackLocale
const options = {
id: args.id,
collection,
depth: 0,
draft: args.draft,
req: isolateObjectProperty(req, 'transactionID'),
}
const result = await duplicateOperation(options)
return result
}
}

View File

@@ -27,6 +27,7 @@ import verifyEmail from '../resolvers/auth/verifyEmail.js'
import createResolver from '../resolvers/collections/create.js'
import getDeleteResolver from '../resolvers/collections/delete.js'
import { docAccessResolver } from '../resolvers/collections/docAccess.js'
import duplicateResolver from '../resolvers/collections/duplicate.js'
import findResolver from '../resolvers/collections/find.js'
import findByIDResolver from '../resolvers/collections/findByID.js'
import findVersionByIDResolver from '../resolvers/collections/findVersionByID.js'
@@ -237,6 +238,14 @@ function initCollectionsGraphQL({ config, graphqlResult }: InitCollectionsGraphQ
resolve: getDeleteResolver(collection),
}
graphqlResult.Mutation.fields[`duplicate${singularName}`] = {
type: collection.graphQL.type,
args: {
id: { type: new GraphQLNonNull(idType) },
},
resolve: duplicateResolver(collection),
}
if (collectionConfig.versions) {
const versionIDType = config.db.defaultIDType === 'text' ? GraphQLString : GraphQLInt
const versionCollectionFields: Field[] = [

View File

@@ -0,0 +1,33 @@
import { getTranslation } from '@payloadcms/translations'
import httpStatus from 'http-status'
import { duplicateOperation } from 'payload/operations'
import { isNumber } from 'payload/utilities'
import type { CollectionRouteHandlerWithID } from '../types.js'
export const duplicate: CollectionRouteHandlerWithID = async ({ id, collection, req }) => {
const { searchParams } = req
const depth = searchParams.get('depth')
// draft defaults to true, unless explicitly set requested as false to prevent the newly duplicated document from being published
const draft = searchParams.get('draft') !== 'false'
const doc = await duplicateOperation({
id,
collection,
depth: isNumber(depth) ? Number(depth) : undefined,
draft,
req,
})
const message = req.t('general:successfullyDuplicated', {label: getTranslation(collection.config.labels.singular, req.i18n)})
return Response.json(
{
doc,
message,
},
{
status: httpStatus.OK,
},
)
}

View File

@@ -44,6 +44,7 @@ import { findVersionByID as findVersionByIdGlobal } from './globals/findVersionB
import { findVersions as findVersionsGlobal } from './globals/findVersions.js'
import { restoreVersion as restoreVersionGlobal } from './globals/restoreVersion.js'
import { update as updateGlobal } from './globals/update.js'
import { duplicate } from './collections/duplicate.js'
const endpoints = {
collection: {
@@ -71,6 +72,7 @@ const endpoints = {
'doc-access-by-id': docAccess,
'doc-verify-by-id': verifyEmail,
'doc-versions-by-id': restoreVersion,
duplicate,
'first-register': registerFirstUser,
'forgot-password': forgotPassword,
login,
@@ -356,6 +358,9 @@ export const POST =
res = await (
endpoints.collection.POST[`doc-${slug2}-by-id`] as CollectionRouteHandlerWithID
)({ id: slug3, collection, req })
} else if (slug3 === 'duplicate') {
// /:collection/:id/duplicate
res = await endpoints.collection.POST.duplicate({ id: slug2, collection, req })
}
break
}

View File

@@ -98,6 +98,9 @@ const sanitizeCollection = (
if (sanitized.upload) {
if (sanitized.upload === true) sanitized.upload = {}
// disable duplicate for uploads by default
sanitized.admin.disableDuplicate = sanitized.admin.disableDuplicate || true
sanitized.upload.staticDir = sanitized.upload.staticDir || sanitized.slug
sanitized.admin.useAsTitle =
sanitized.admin.useAsTitle && sanitized.admin.useAsTitle !== 'id'
@@ -136,6 +139,9 @@ const sanitizeCollection = (
}
}
// disable duplicate for auth enabled collections by default
sanitized.admin.disableDuplicate = sanitized.admin.disableDuplicate || true
if (!sanitized.auth.strategies) {
sanitized.auth.strategies = []
}

View File

@@ -1,11 +1,7 @@
import joi from 'joi'
import { endpointsSchema } from '../../config/schema.js'
import {
componentSchema,
customViewSchema,
livePreviewSchema,
} from '../../config/shared/componentSchema.js'
import { componentSchema, customViewSchema, livePreviewSchema, } from '../../config/shared/componentSchema.js'
const strategyBaseSchema = joi.object().keys({
logout: joi.boolean(),
@@ -65,9 +61,6 @@ const collectionSchema = joi.object().keys({
group: joi.alternatives().try(joi.string(), joi.object().pattern(joi.string(), [joi.string()])),
hidden: joi.alternatives().try(joi.boolean(), joi.func()),
hideAPIURL: joi.bool(),
hooks: joi.object({
beforeDuplicate: joi.func(),
}),
listSearchableFields: joi.array().items(joi.string()),
livePreview: joi.object(livePreviewSchema),
pagination: joi.object({

View File

@@ -195,15 +195,6 @@ export type AfterForgotPasswordHook = (args: {
context: RequestContext
}) => any
type BeforeDuplicateArgs<T> = {
/** The collection which this hook is being run on */
collection: SanitizedCollectionConfig
data: T
locale?: string
}
export type BeforeDuplicate<T = any> = (args: BeforeDuplicateArgs<T>) => Promise<T> | T
export type CollectionAdminOptions = {
/**
* Custom admin components
@@ -275,12 +266,6 @@ export type CollectionAdminOptions = {
* Hide the API URL within the Edit view
*/
hideAPIURL?: boolean
hooks?: {
/**
* Function that allows you to modify a document's data before it is duplicated
*/
beforeDuplicate?: BeforeDuplicate
}
/**
* Additional fields to be searched via the full text search
*/

View File

@@ -0,0 +1,366 @@
import type { DeepPartial } from 'ts-essentials'
import httpStatus from 'http-status'
import type { FindOneArgs } from '../../database/types.js'
import type { GeneratedTypes } from '../../index.js'
import type { PayloadRequest } from '../../types/index.js'
import type { Collection } from '../config/types.js'
import executeAccess from '../../auth/executeAccess.js'
import { hasWhereAccessResult } from '../../auth/types.js'
import { combineQueries } from '../../database/combineQueries.js'
import { APIError, Forbidden, NotFound } from '../../errors/index.js'
import { afterChange } from '../../fields/hooks/afterChange/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { beforeChange } from '../../fields/hooks/beforeChange/index.js'
import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion.js'
import { saveVersion } from '../../versions/saveVersion.js'
import { buildAfterOperation } from './utils.js'
export type Arguments = {
collection: Collection
depth?: number
draft?: boolean
id: number | string
overrideAccess?: boolean
req: PayloadRequest
showHiddenFields?: boolean
}
export const duplicateOperation = async <TSlug extends keyof GeneratedTypes['collections']>(
incomingArgs: Arguments,
): Promise<GeneratedTypes['collections'][TSlug]> => {
let args = incomingArgs
try {
const shouldCommit = await initTransaction(args.req)
// /////////////////////////////////////
// beforeOperation - Collection
// /////////////////////////////////////
await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => {
await priorHook
args =
(await hook({
args,
collection: args.collection.config,
context: args.req.context,
operation: 'update',
req: args.req,
})) || args
}, Promise.resolve())
const {
id,
collection: { config: collectionConfig },
depth,
draft: draftArg = true,
overrideAccess,
req: {
fallbackLocale,
payload: { config },
payload,
},
req,
showHiddenFields,
} = args
if (!id) {
throw new APIError('Missing ID of document to duplicate.', httpStatus.BAD_REQUEST)
}
const shouldSaveDraft = Boolean(draftArg && collectionConfig.versions.drafts)
// /////////////////////////////////////
// Read Access
// /////////////////////////////////////
const accessResults = !overrideAccess
? await executeAccess({ id, req }, collectionConfig.access.read)
: true
const hasWherePolicy = hasWhereAccessResult(accessResults)
// /////////////////////////////////////
// Retrieve document
// /////////////////////////////////////
const findOneArgs: FindOneArgs = {
collection: collectionConfig.slug,
locale: req.locale,
req,
where: combineQueries({ id: { equals: id } }, accessResults),
}
const docWithLocales = await getLatestCollectionVersion({
id,
config: collectionConfig,
payload,
query: findOneArgs,
req,
})
if (!docWithLocales && !hasWherePolicy) throw new NotFound(req.t)
if (!docWithLocales && hasWherePolicy) throw new Forbidden(req.t)
// remove the createdAt timestamp and rely on the db to default it
delete docWithLocales.createdAt
// for version enabled collections, override the current status with draft, unless draft is explicitly set to false
if (shouldSaveDraft) {
docWithLocales._status = 'draft'
}
// /////////////////////////////////////
// Iterate locales of document and call the db create or update functions
// /////////////////////////////////////
let locales = [undefined]
let versionDoc
if (config.localization) {
locales = config.localization.locales.map(({ code }) => code)
}
await locales.reduce(async (previousPromise, locale: string | undefined, i) => {
await previousPromise
const operation = i === 0 ? 'create' : 'update'
const originalDoc = await afterRead({
collection: collectionConfig,
context: req.context,
depth: 0,
doc: docWithLocales,
fallbackLocale: null,
global: null,
locale,
overrideAccess: true,
req,
showHiddenFields: true,
})
// /////////////////////////////////////
// Create Access
// /////////////////////////////////////
if (operation === 'create' && !overrideAccess) {
await executeAccess({ data: originalDoc, req }, collectionConfig.access.create)
}
// /////////////////////////////////////
// beforeValidate - Fields
// /////////////////////////////////////
let data = await beforeValidate<DeepPartial<GeneratedTypes['collections'][TSlug]>>({
id,
collection: collectionConfig,
context: req.context,
data: originalDoc,
doc: originalDoc,
duplicate: true,
global: null,
operation,
overrideAccess,
req,
})
// /////////////////////////////////////
// beforeValidate - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeValidate.reduce(async (priorHook, hook) => {
await priorHook
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation,
originalDoc,
req,
})) || data
}, Promise.resolve())
// /////////////////////////////////////
// beforeChange - Collection
// /////////////////////////////////////
await collectionConfig.hooks.beforeChange.reduce(async (priorHook, hook) => {
await priorHook
data =
(await hook({
collection: collectionConfig,
context: req.context,
data,
operation,
originalDoc,
req,
})) || data
}, Promise.resolve())
// /////////////////////////////////////
// beforeChange - Fields
// /////////////////////////////////////
const result = await beforeChange<GeneratedTypes['collections'][TSlug]>({
id,
collection: collectionConfig,
context: req.context,
data,
doc: originalDoc,
docWithLocales,
global: null,
operation,
req,
skipValidation: shouldSaveDraft,
})
// /////////////////////////////////////
// Handle potential password update
// /////////////////////////////////////
// const dataToUpdate: Record<string, unknown> = { ...result }
// if (shouldSavePassword && typeof password === 'string') {
// const { hash, salt } = await generatePasswordSaltHash({ password })
// dataToUpdate.salt = salt
// dataToUpdate.hash = hash
// delete dataToUpdate.password
// delete data.password
// }
// /////////////////////////////////////
// Create / Update
// /////////////////////////////////////
if (i === 0) {
versionDoc = await payload.db.create({
collection: collectionConfig.slug,
data: result,
req,
})
} else {
versionDoc = await req.payload.db.updateOne({
id: versionDoc.id,
collection: collectionConfig.slug,
data: result,
locale,
req,
})
}
}, Promise.resolve())
// /////////////////////////////////////
// Create version
// /////////////////////////////////////
let result = versionDoc
if (collectionConfig.versions) {
result = await saveVersion({
id: versionDoc.id,
collection: collectionConfig,
docWithLocales: {
...versionDoc,
createdAt: docWithLocales.createdAt,
},
draft: shouldSaveDraft,
payload,
req,
})
}
// /////////////////////////////////////
// afterRead - Fields
// /////////////////////////////////////
result = await afterRead({
collection: collectionConfig,
context: req.context,
depth,
doc: versionDoc,
fallbackLocale,
global: null,
locale: req.locale,
overrideAccess,
req,
showHiddenFields,
})
// /////////////////////////////////////
// afterRead - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
req,
})) || result
}, Promise.resolve())
// /////////////////////////////////////
// afterChange - Fields
// /////////////////////////////////////
result = await afterChange<GeneratedTypes['collections'][TSlug]>({
collection: collectionConfig,
context: req.context,
data: versionDoc,
doc: result,
global: null,
operation: 'create',
previousDoc: {},
req,
})
// /////////////////////////////////////
// afterChange - Collection
// /////////////////////////////////////
await collectionConfig.hooks.afterChange.reduce(async (priorHook, hook) => {
await priorHook
result =
(await hook({
collection: collectionConfig,
context: req.context,
doc: result,
operation: 'create',
previousDoc: {},
req,
})) || result
}, Promise.resolve())
// /////////////////////////////////////
// afterOperation - Collection
// /////////////////////////////////////
result = await buildAfterOperation<GeneratedTypes['collections'][TSlug]>({
args,
collection: collectionConfig,
operation: 'create',
result,
})
// /////////////////////////////////////
// Return results
// /////////////////////////////////////
if (shouldCommit) await commitTransaction(req)
return result
} catch (error: unknown) {
await killTransaction(args.req)
throw error
}
}

View File

@@ -0,0 +1,57 @@
import type { GeneratedTypes } from '../../..//index.js'
import type { Payload } from '../../../index.js'
import type { Document, PayloadRequest, RequestContext } from '../../../types/index.js'
import { APIError } from '../../../errors/index.js'
import { createLocalReq } from '../../../utilities/createLocalReq.js'
import { duplicateOperation } from '../duplicate.js'
export type Options<TSlug extends keyof GeneratedTypes['collections']> = {
collection: TSlug
/**
* context, which will then be passed to req.context, which can be read by hooks
*/
context?: RequestContext
depth?: number
draft?: boolean
fallbackLocale?: string
id: number | string
locale?: string
overrideAccess?: boolean
req?: PayloadRequest
showHiddenFields?: boolean
user?: Document
}
export async function duplicate<TSlug extends keyof GeneratedTypes['collections']>(
payload: Payload,
options: Options<TSlug>,
): Promise<GeneratedTypes['collections'][TSlug]> {
const {
id,
collection: collectionSlug,
depth,
draft,
overrideAccess = true,
showHiddenFields,
} = options
const collection = payload.collections[collectionSlug]
if (!collection) {
throw new APIError(
`The collection with slug ${String(collectionSlug)} can't be found. Duplicate Operation.`,
)
}
const req = await createLocalReq(options, payload)
return duplicateOperation<TSlug>({
id,
collection,
depth,
draft,
overrideAccess,
req,
showHiddenFields,
})
}

View File

@@ -1,6 +1,7 @@
import auth from '../../../auth/operations/local/index.js'
import create from './create.js'
import deleteLocal from './delete.js'
import { duplicate } from './duplicate.js'
import find from './find.js'
import findByID from './findByID.js'
import findVersionByID from './findVersionByID.js'
@@ -12,6 +13,7 @@ export default {
auth,
create,
deleteLocal,
duplicate,
find,
findByID,
findVersionByID,

View File

@@ -27,7 +27,7 @@ export type ServerOnlyCollectionProperties = keyof Pick<
export type ServerOnlyCollectionAdminProperties = keyof Pick<
SanitizedCollectionConfig['admin'],
'components' | 'hidden' | 'hooks' | 'preview'
'components' | 'hidden' | 'preview'
>
export type ServerOnlyGlobalProperties = keyof Pick<
@@ -193,7 +193,6 @@ const sanitizeCollections = (
'components',
'hidden',
'preview',
'hooks',
// `livePreview` is handled separately
]

View File

@@ -14,6 +14,7 @@ export { createOperation } from '../collections/operations/create.js'
export { deleteOperation } from '../collections/operations/delete.js'
export { deleteByIDOperation } from '../collections/operations/deleteByID.js'
export { docAccessOperation } from '../collections/operations/docAccess.js'
export { duplicateOperation } from '../collections/operations/duplicate.js'
export { findOperation } from '../collections/operations/find.js'
export { findByIDOperation } from '../collections/operations/findByID.js'
export { findVersionByIDOperation } from '../collections/operations/findVersionByID.js'

View File

@@ -25,7 +25,6 @@ export type {
AfterReadHook as CollectionAfterReadHook,
BeforeChangeHook as CollectionBeforeChangeHook,
BeforeDeleteHook as CollectionBeforeDeleteHook,
BeforeDuplicate,
BeforeLoginHook as CollectionBeforeLoginHook,
BeforeOperationHook as CollectionBeforeOperationHook,
BeforeReadHook as CollectionBeforeReadHook,

View File

@@ -10,6 +10,7 @@ import {
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
import { baseIDField } from '../baseFields/baseIDField.js'
import { setDefaultBeforeDuplicate } from '../setDefaultBeforeDuplicate.js'
import validations from '../validations.js'
import { fieldAffectsData, tabHasName } from './types.js'
@@ -129,6 +130,8 @@ export const sanitizeFields = ({
if (!field.hooks) field.hooks = {}
if (!field.access) field.access = {}
setDefaultBeforeDuplicate(field)
}
if (!field.admin) {

View File

@@ -45,6 +45,7 @@ export const baseField = joi
afterChange: joi.array().items(joi.func()).default([]),
afterRead: joi.array().items(joi.func()).default([]),
beforeChange: joi.array().items(joi.func()).default([]),
beforeDuplicate: joi.array().items(joi.func()).default([]),
beforeValidate: joi.array().items(joi.func()).default([]),
})
.default(),

View File

@@ -183,6 +183,10 @@ export interface FieldBase {
afterChange?: FieldHook[]
afterRead?: FieldHook[]
beforeChange?: FieldHook[]
/**
* Runs before a document is duplicated to prevent errors in unique fields or return null to use defaultValue.
*/
beforeDuplicate?: FieldHook[]
beforeValidate?: FieldHook[]
}
index?: boolean

View File

@@ -10,6 +10,7 @@ type Args<T> = {
context: RequestContext
data: Record<string, unknown> | T
doc?: Record<string, unknown> | T
duplicate?: boolean
global: SanitizedGlobalConfig | null
id?: number | string
operation: 'create' | 'update'
@@ -23,6 +24,7 @@ export const beforeValidate = async <T extends Record<string, unknown>>({
context,
data: incomingData,
doc,
duplicate = false,
global,
operation,
overrideAccess,
@@ -36,6 +38,7 @@ export const beforeValidate = async <T extends Record<string, unknown>>({
context,
data,
doc,
duplicate,
fields: collection?.fields || global?.fields,
global,
operation,

View File

@@ -15,6 +15,7 @@ type Args<T> = {
context: RequestContext
data: T
doc: T
duplicate: boolean
field: Field | TabAsField
global: SanitizedGlobalConfig | null
id?: number | string
@@ -38,6 +39,7 @@ export const promise = async <T>({
context,
data,
doc,
duplicate,
field,
global,
operation,
@@ -259,6 +261,32 @@ export const promise = async <T>({
})
}
}
// Execute beforeDuplicate hook
if (duplicate && field.hooks?.beforeDuplicate) {
if (field.hooks?.beforeDuplicate) {
await field.hooks.beforeDuplicate.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
req,
siblingData,
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
}
}
// Traverse subfields
@@ -276,6 +304,7 @@ export const promise = async <T>({
context,
data,
doc,
duplicate,
fields: field.fields,
global,
operation,
@@ -301,6 +330,7 @@ export const promise = async <T>({
context,
data,
doc,
duplicate,
fields: field.fields,
global,
operation,
@@ -336,6 +366,7 @@ export const promise = async <T>({
context,
data,
doc,
duplicate,
fields: block.fields,
global,
operation,
@@ -361,6 +392,7 @@ export const promise = async <T>({
context,
data,
doc,
duplicate,
fields: field.fields,
global,
operation,
@@ -393,6 +425,7 @@ export const promise = async <T>({
context,
data,
doc,
duplicate,
fields: field.fields,
global,
operation,
@@ -412,6 +445,7 @@ export const promise = async <T>({
context,
data,
doc,
duplicate,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
global,
operation,

View File

@@ -10,6 +10,7 @@ type Args<T> = {
context: RequestContext
data: T
doc: T
duplicate: boolean
fields: (Field | TabAsField)[]
global: SanitizedGlobalConfig | null
id?: number | string
@@ -26,6 +27,7 @@ export const traverseFields = async <T>({
context,
data,
doc,
duplicate,
fields,
global,
operation,
@@ -43,6 +45,7 @@ export const traverseFields = async <T>({
context,
data,
doc,
duplicate,
field,
global,
operation,

View File

@@ -0,0 +1,33 @@
import { extractTranslations } from '../translations/extractTranslations.js'
const copyTranslations = extractTranslations(['general:copy'])
// default beforeDuplicate hook for required and unique fields
import type { FieldAffectingData, FieldHook } from './config/types.js'
const unique: FieldHook = ({ value }) => (typeof value === 'string' ? `${value} - Copy` : undefined)
const localizedUnique: FieldHook = ({ req, value}) =>
(value ? `${value} - ${copyTranslations?.[req.locale]?.['general:copy'] ?? 'Copy'}` : undefined)
const uniqueRequired: FieldHook = ({ value }) => (`${value} - Copy`)
const localizedUniqueRequired: FieldHook = ({ req, value }) => (`${value} - ${copyTranslations?.[req.locale]?.['general:copy'] ?? 'Copy'}`)
export const setDefaultBeforeDuplicate = (field: FieldAffectingData) => {
if (
(('required' in field && field.required) || field.unique) &&
(!field.hooks?.beforeDuplicate || Array.isArray(field.hooks.beforeDuplicate) && field.hooks.beforeDuplicate.length === 0)
) {
if ((field.type === 'text' || field.type === 'textarea') && field.required && field.unique) {
field.hooks.beforeDuplicate = [
field.localized
? localizedUniqueRequired
: uniqueRequired
]
} else if (field.unique) {
field.hooks.beforeDuplicate = [
field.localized
? localizedUnique
: unique
]
}
}
}

View File

@@ -26,6 +26,7 @@ import type { Options as FindByIDOptions } from './collections/operations/local/
import type { Options as FindVersionByIDOptions } from './collections/operations/local/findVersionByID.js'
import type { Options as FindVersionsOptions } from './collections/operations/local/findVersions.js'
import type { Options as RestoreVersionOptions } from './collections/operations/local/restoreVersion.js'
import type { Options as DuplicateOptions } from './collections/operations/local/duplicate.js'
import type {
ByIDOptions as UpdateByIDOptions,
ManyOptions as UpdateManyOptions,
@@ -411,6 +412,13 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
const { update } = localOperations
return update<T>(this, options)
}
duplicate = async <T extends keyof TGeneratedTypes['collections']>(
options: DuplicateOptions<T>,
): Promise<TGeneratedTypes['collections'][T]> => {
const { duplicate } = localOperations
return duplicate<T>(this, options)
}
}
const initialized = new BasePayload()

View File

@@ -38,12 +38,14 @@ export default {
textToDisplay: 'النصّ الذي تريد إظهاره',
},
general: {
copy: 'نسخ',
createdAt: 'تمّ الإنشاء في',
deletedCountSuccessfully: 'تمّ حذف {{count}} {{label}} بنجاح.',
deletedSuccessfully: 'تمّ الحذف بنجاح.',
email: 'البريد الإلكتروني',
notFound: 'غير موجود',
successfullyCreated: '{{label}} تم إنشاؤها بنجاح.',
successfullyDuplicated: '{{label}} تم استنساخها بنجاح.',
thisLanguage: 'العربية',
updatedAt: 'تم التحديث في',
updatedCountSuccessfully: 'تم تحديث {{count}} {{label}} بنجاح.',

View File

@@ -40,12 +40,14 @@ export default {
textToDisplay: 'Göstəriləcək mətn',
},
general: {
copy: 'Kopyala',
createdAt: 'Yaradıldığı tarix',
deletedCountSuccessfully: '{{count}} {{label}} uğurla silindi.',
deletedSuccessfully: 'Uğurla silindi.',
email: 'Elektron poçt',
notFound: 'Tapılmadı',
successfullyCreated: '{{label}} uğurla yaradıldı.',
successfullyDuplicated: '{{label}} uğurla dublikatlandı.',
thisLanguage: 'Azərbaycan dili',
updatedAt: 'Yeniləndiyi tarix',
updatedCountSuccessfully: '{{count}} {{label}} uğurla yeniləndi.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Текст към дисплей',
},
general: {
copy: 'Копирай',
createdAt: 'Създаден на',
deletedCountSuccessfully: 'Изтрити {{count}} {{label}} успешно.',
deletedSuccessfully: 'Изтрито успешно.',
email: 'Имейл',
notFound: 'Няма открит',
successfullyCreated: '{{label}} успешно създаден.',
successfullyDuplicated: '{{label}} успешно дупликиран.',
thisLanguage: 'Български',
updatedAt: 'Обновен на',
updatedCountSuccessfully: 'Обновени {{count}} {{label}} успешно.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Text k zobrazení',
},
general: {
copy: 'Kopírovat',
createdAt: 'Vytvořeno v',
deletedCountSuccessfully: 'Úspěšně smazáno {{count}} {{label}}.',
deletedSuccessfully: 'Úspěšně odstraněno.',
email: 'E-mail',
notFound: 'Nenalezeno',
successfullyCreated: '{{label}} úspěšně vytvořeno.',
successfullyDuplicated: '{{label}} úspěšně duplikováno.',
thisLanguage: 'Čeština',
updatedAt: 'Aktualizováno v',
updatedCountSuccessfully: 'Úspěšně aktualizováno {{count}} {{label}}.',

View File

@@ -40,12 +40,14 @@ export default {
textToDisplay: 'Angezeigter Text',
},
general: {
copy: 'Kopieren',
createdAt: 'Erstellt am',
deletedCountSuccessfully: '{{count}} {{label}} erfolgreich gelöscht.',
deletedSuccessfully: 'Erfolgreich gelöscht.',
email: 'E-Mail',
notFound: 'Nicht gefunden',
successfullyCreated: '{{label}} erfolgreich erstellt.',
successfullyDuplicated: '{{label}} wurde erfolgreich dupliziert.',
thisLanguage: 'Deutsch',
updatedAt: 'Aktualisiert am',
updatedCountSuccessfully: '{{count}} {{label}} erfolgreich aktualisiert.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Text to display',
},
general: {
copy: 'Copy',
createdAt: 'Created At',
deletedCountSuccessfully: 'Deleted {{count}} {{label}} successfully.',
deletedSuccessfully: 'Deleted successfully.',
email: 'Email',
notFound: 'Not Found',
successfullyCreated: '{{label}} successfully created.',
successfullyDuplicated: '{{label}} successfully duplicated.',
thisLanguage: 'English',
updatedAt: 'Updated At',
updatedCountSuccessfully: 'Updated {{count}} {{label}} successfully.',

View File

@@ -40,12 +40,14 @@ export default {
textToDisplay: 'Texto a mostrar',
},
general: {
copy: 'Copiar',
createdAt: 'Fecha de creación',
deletedCountSuccessfully: 'Se eliminó {{count}} {{label}} con éxito.',
deletedSuccessfully: 'Borrado exitosamente.',
email: 'Correo electrónico',
notFound: 'No encontrado',
successfullyCreated: '{{label}} creado correctamente.',
successfullyDuplicated: '{{label}} duplicado correctamente.',
thisLanguage: 'Español',
updatedAt: 'Fecha de modificado',
updatedCountSuccessfully: '{{count}} {{label}} actualizado con éxito.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'متن برای نمایش',
},
general: {
copy: 'رونوشت',
createdAt: 'ساخته شده در',
deletedCountSuccessfully: 'تعداد {{count}} {{label}} با موفقیت پاک گردید.',
deletedSuccessfully: 'با موفقیت حذف شد.',
email: 'رایانامه',
notFound: 'یافت نشد',
successfullyCreated: '{{label}} با موفقیت ساخته شد.',
successfullyDuplicated: '{{label}} با موفقیت رونوشت شد.',
thisLanguage: 'فارسی',
updatedAt: 'بروز شده در',
updatedCountSuccessfully: 'تعداد {{count}} با عنوان {{label}} با موفقیت بروزرسانی شدند.',

View File

@@ -40,12 +40,14 @@ export default {
textToDisplay: 'Texte à afficher',
},
general: {
copy: 'Copie',
createdAt: 'Créé(e) à',
deletedCountSuccessfully: '{{count}} {{label}} supprimé avec succès.',
deletedSuccessfully: 'Supprimé(e) avec succès.',
email: 'E-mail',
notFound: 'Pas trouvé',
successfullyCreated: '{{label}} créé(e) avec succès.',
successfullyDuplicated: '{{label}} dupliqué(e) avec succès.',
thisLanguage: 'Français',
updatedAt: 'Modifié le',
updatedCountSuccessfully: '{{count}} {{label}} mis à jour avec succès.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Tekst za prikaz',
},
general: {
copy: 'Kopiraj',
createdAt: 'Kreirano u',
deletedCountSuccessfully: 'Uspješno izbrisano {{count}} {{label}}.',
deletedSuccessfully: 'Uspješno obrisano.',
email: 'Email',
notFound: 'Nije pronađeno',
successfullyCreated: '{{label}} uspješno kreirano.',
successfullyDuplicated: '{{label}} uspješno duplicirano.',
thisLanguage: 'Hrvatski',
updatedAt: 'Ažurirano u',
updatedCountSuccessfully: 'Uspješno ažurirano {{count}} {{label}}.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Megjelenítendő szöveg',
},
general: {
copy: 'Másolás',
createdAt: 'Létrehozva:',
deletedCountSuccessfully: '{{count}} {{label}} sikeresen törölve.',
deletedSuccessfully: 'Sikeresen törölve.',
email: 'E-mail',
notFound: 'Nem található',
successfullyCreated: '{{label}} sikeresen létrehozva.',
successfullyDuplicated: '{{label}} sikeresen duplikálódott.',
thisLanguage: 'Magyar',
updatedAt: 'Frissítve:',
updatedCountSuccessfully: '{{count}} {{label}} sikeresen frissítve.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Testo da visualizzare',
},
general: {
copy: 'Copia',
createdAt: 'Creato il',
deletedCountSuccessfully: '{{count}} {{label}} eliminato con successo.',
deletedSuccessfully: 'Eliminato con successo.',
email: 'Email',
notFound: 'Non Trovato',
successfullyCreated: '{{label}} creato con successo.',
successfullyDuplicated: '{{label}} duplicato con successo.',
thisLanguage: 'Italiano',
updatedAt: 'Aggiornato il',
updatedCountSuccessfully: '{{count}} {{label}} aggiornato con successo.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: '表示するテキスト',
},
general: {
copy: 'コピー',
createdAt: '作成日',
deletedCountSuccessfully: '{{count}}つの{{label}}を正常に削除しました。',
deletedSuccessfully: '正常に削除されました。',
email: 'メールアドレス',
notFound: 'Not Found',
successfullyCreated: '{{label}} が作成されました。',
successfullyDuplicated: '{{label}} が複製されました。',
thisLanguage: 'Japanese',
updatedAt: '更新日',
updatedCountSuccessfully: '{{count}}つの{{label}}を正常に更新しました。',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: '표시할 텍스트',
},
general: {
copy: '복사',
createdAt: '생성 일시',
deletedCountSuccessfully: '{{count}}개의 {{label}}를 삭제했습니다.',
deletedSuccessfully: '삭제되었습니다.',
email: '이메일',
notFound: '찾을 수 없음',
successfullyCreated: '{{label}}이(가) 생성되었습니다.',
successfullyDuplicated: '{{label}}이(가) 복제되었습니다.',
thisLanguage: '한국어',
updatedAt: '업데이트 일시',
updatedCountSuccessfully: '{{count}}개의 {{label}}을(를) 업데이트했습니다.',

View File

@@ -40,12 +40,14 @@ export default {
textToDisplay: 'ပြသရန် စာသား',
},
general: {
copy: 'ကူးယူမည်။',
createdAt: 'ဖန်တီးခဲ့သည့်အချိန်',
deletedCountSuccessfully: '{{count}} {{label}} ကို အောင်မြင်စွာ ဖျက်လိုက်ပါပြီ။',
deletedSuccessfully: 'အောင်မြင်စွာ ဖျက်လိုက်ပါပြီ။',
email: 'အီးမေးလ်',
notFound: 'ဘာမှ မရှိတော့ဘူး။',
successfullyCreated: '{{label}} အောင်မြင်စွာဖန်တီးခဲ့သည်။',
successfullyDuplicated: '{{label}} အောင်မြင်စွာ ပုံတူပွားခဲ့သည်။',
thisLanguage: 'မြန်မာစာ',
updatedAt: 'ပြင်ဆင်ခဲ့သည့်အချိန်',
updatedCountSuccessfully: '{{count}} {{label}} ကို အောင်မြင်စွာ အပ်ဒိတ်လုပ်ခဲ့သည်။',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Tekst som skal vises',
},
general: {
copy: 'Kopiér',
createdAt: 'Opprettet',
deletedCountSuccessfully: 'Slettet {{count}} {{label}}.',
deletedSuccessfully: 'Slettet.',
email: 'E-post',
notFound: 'Ikke funnet',
successfullyCreated: '{{label}} ble opprettet.',
successfullyDuplicated: '{{label}} ble duplisert.',
thisLanguage: 'Norsk',
updatedAt: 'Oppdatert',
updatedCountSuccessfully: 'Oppdaterte {{count}} {{label}} vellykket.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Tekst om weer te geven',
},
general: {
copy: 'Kopiëren',
createdAt: 'Aangemaakt op',
deletedCountSuccessfully: '{{count}} {{label}} succesvol verwijderd.',
deletedSuccessfully: 'Succesvol verwijderd.',
email: 'E-mail',
notFound: 'Niet gevonden',
successfullyCreated: '{{label}} succesvol aangemaakt.',
successfullyDuplicated: '{{label}} succesvol gedupliceerd.',
thisLanguage: 'Nederlands',
updatedAt: 'Aangepast op',
updatedCountSuccessfully: '{{count}} {{label}} succesvol bijgewerkt.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Tekst do wyświetlenia',
},
general: {
copy: 'Skopiuj',
createdAt: 'Data utworzenia',
deletedCountSuccessfully: 'Pomyślnie usunięto {{count}} {{label}}.',
deletedSuccessfully: 'Pomyślnie usunięto.',
email: 'Email',
notFound: 'Nie znaleziono',
successfullyCreated: 'Pomyślnie utworzono {{label}}.',
successfullyDuplicated: 'Pomyślnie zduplikowano {{label}}',
thisLanguage: 'Polski',
updatedAt: 'Data edycji',
updatedCountSuccessfully: 'Pomyślnie zaktualizowano {{count}} {{label}}.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Texto a ser exibido',
},
general: {
copy: 'Copiar',
createdAt: 'Criado Em',
deletedCountSuccessfully: 'Excluído {{count}} {{label}} com sucesso.',
deletedSuccessfully: 'Apagado com sucesso.',
email: 'Email',
notFound: 'Não Encontrado',
successfullyCreated: '{{label}} criado com sucesso.',
successfullyDuplicated: '{{label}} duplicado com sucesso.',
thisLanguage: 'Português',
updatedAt: 'Atualizado Em',
updatedCountSuccessfully: 'Atualizado {{count}} {{label}} com sucesso.',

View File

@@ -40,12 +40,14 @@ export default {
textToDisplay: 'Text de afișat',
},
general: {
copy: 'Copiați',
createdAt: 'Creat la',
deletedCountSuccessfully: 'Șterse cu succes {{count}} {{label}}.',
deletedSuccessfully: 'Șters cu succes.',
email: 'Email',
notFound: 'Nu a fost găsit',
successfullyCreated: '{{label}} creat(ă) cu succes.',
successfullyDuplicated: '{{label}} duplicat(ă) cu succes.',
thisLanguage: 'Română',
updatedAt: 'Actualizat la',
updatedCountSuccessfully: 'Actualizate {{count}} {{label}} cu succes.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Tekst za prikaz',
},
general: {
copy: 'Kopiraj',
createdAt: 'Kreirano u',
deletedCountSuccessfully: 'Uspešno izbrisano {{count}} {{label}}.',
deletedSuccessfully: 'Uspešno izbrisano.',
email: 'E-pošta',
notFound: 'Nije pronađeno',
successfullyCreated: '{{label}} uspešno kreirano.',
successfullyDuplicated: '{{label}} uspešno duplicirano.',
thisLanguage: 'Srpski (latinica)',
updatedAt: 'Ažurirano u',
updatedCountSuccessfully: 'Uspešno ažurirano {{count}} {{label}}.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Текст за приказ',
},
general: {
copy: 'Копирај',
createdAt: 'Креирано у',
deletedCountSuccessfully: 'Успешно избрисано {{count}} {{label}}.',
deletedSuccessfully: 'Успешно избрисано.',
email: 'Е-пошта',
notFound: 'Није пронађено',
successfullyCreated: '{{label}} успешно креирано.',
successfullyDuplicated: '{{label}} успешно дуплицирано.',
thisLanguage: 'Српски (ћирилица)',
updatedAt: 'Ажурирано у',
updatedCountSuccessfully: 'Успешно ажурирано {{count}} {{label}}.',

View File

@@ -40,12 +40,14 @@ export default {
textToDisplay: 'Текст для отображения',
},
general: {
copy: 'Скопировать',
createdAt: 'Дата создания',
deletedCountSuccessfully: 'Удалено {{count}} {{label}} успешно.',
deletedSuccessfully: 'Удален успешно.',
email: 'Email',
notFound: 'Не найдено',
successfullyCreated: '{{label}} успешно создан.',
successfullyDuplicated: '{{label}} успешно продублирован.',
thisLanguage: 'Русский',
updatedAt: 'Дата правки',
updatedCountSuccessfully: 'Обновлено {{count}} {{label}} успешно.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Text att visa',
},
general: {
copy: 'Kopiera',
createdAt: 'Skapad Vid',
deletedCountSuccessfully: 'Raderade {{count}} {{label}} framgångsrikt.',
deletedSuccessfully: 'Togs bort framgångsrikt.',
email: 'E-post',
notFound: 'Hittades inte',
successfullyCreated: '{{label}} skapades framgångsrikt.',
successfullyDuplicated: '{{label}} duplicerades framgångsrikt.',
thisLanguage: 'Svenska',
updatedAt: 'Uppdaterades Vid',
updatedCountSuccessfully: 'Uppdaterade {{count}} {{label}} framgångsrikt.',

View File

@@ -38,12 +38,14 @@ export default {
textToDisplay: 'ข้อความสำหรับแสดงผล',
},
general: {
copy: 'คัดลอก',
createdAt: 'สร้างเมื่อ',
deletedCountSuccessfully: 'Deleted {{count}} {{label}} successfully.',
deletedSuccessfully: 'ลบสำเร็จ',
email: 'อีเมล',
notFound: 'ไม่พบ',
successfullyCreated: 'สร้าง {{label}} สำเร็จ',
successfullyDuplicated: 'สำเนา {{label}} สำเร็จ',
thisLanguage: 'ไทย',
updatedAt: 'แก้ไขเมื่อ',
updatedCountSuccessfully: 'อัปเดต {{count}} {{label}} เรียบร้อยแล้ว',

View File

@@ -40,12 +40,14 @@ export default {
textToDisplay: 'Görüntülenecek metin',
},
general: {
copy: 'Kopyala',
createdAt: 'Oluşturma tarihi',
deletedCountSuccessfully: '{{count}} {{label}} başarıyla silindi.',
deletedSuccessfully: 'Başarıyla silindi.',
email: 'E-posta',
notFound: 'Bulunamadı',
successfullyCreated: '{{label}} başarıyla oluşturuldu.',
successfullyDuplicated: '{{label}} başarıyla kopyalandı.',
thisLanguage: 'Türkçe',
updatedAt: 'Güncellenme tarihi',
updatedCountSuccessfully: '{{count}} {{label}} başarıyla güncellendi.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Текст для відображення',
},
general: {
copy: 'Скопіювати',
createdAt: 'Дата створення',
deletedCountSuccessfully: 'Успішно видалено {{count}} {{label}}.',
deletedSuccessfully: 'Успішно видалено.',
email: 'Email',
notFound: 'Не знайдено',
successfullyCreated: '{{label}} успішно створено.',
successfullyDuplicated: '{{label}} успішно продубльовано.',
thisLanguage: 'Українська',
updatedAt: 'Змінено',
updatedCountSuccessfully: 'Успішно оновлено {{count}} {{label}}.',

View File

@@ -39,12 +39,14 @@ export default {
textToDisplay: 'Văn bản để hiển thị',
},
general: {
copy: 'Sao chép',
createdAt: 'Ngày tạo',
deletedCountSuccessfully: 'Đã xóa thành công {{count}} {{label}}.',
deletedSuccessfully: 'Đã xoá thành công.',
email: 'Email',
notFound: 'Không tìm thấy',
successfullyCreated: '{{label}} đã được tạo thành công.',
successfullyDuplicated: '{{label}} đã được sao chép thành công.',
thisLanguage: 'Vietnamese (Tiếng Việt)',
updatedAt: 'Ngày cập nhật',
updatedCountSuccessfully: 'Đã cập nhật thành công {{count}} {{label}}.',

View File

@@ -37,12 +37,14 @@ export default {
textToDisplay: '要顯示的文字',
},
general: {
copy: '複製',
createdAt: '建立於',
deletedCountSuccessfully: '已成功刪除 {{count}} 個 {{label}}。',
deletedSuccessfully: '已成功刪除。',
email: '電子郵件',
notFound: '未找到',
successfullyCreated: '成功建立{{label}}',
successfullyDuplicated: '成功複製{{label}}',
thisLanguage: '中文 (繁體)',
updatedAt: '更新於',
updatedCountSuccessfully: '已成功更新 {{count}} 個 {{label}}。',

View File

@@ -37,12 +37,14 @@ export default {
textToDisplay: '要显示的文本',
},
general: {
copy: '复制',
createdAt: '创建于',
deletedCountSuccessfully: '已成功删除 {{count}} {{label}}。',
deletedSuccessfully: '已成功删除。',
email: '电子邮件',
notFound: '未找到',
successfullyCreated: '成功创建{{label}}',
successfullyDuplicated: '成功复制{{label}}',
thisLanguage: '中文 (简体)',
updatedAt: '更新于',
updatedCountSuccessfully: '已成功更新 {{count}} {{label}}。',

View File

@@ -188,7 +188,6 @@ export default {
submissionSuccessful: 'تمت الإرسال بنجاح.',
submit: 'إرسال',
successfullyCreated: '{{label}} تم إنشاؤها بنجاح.',
successfullyDuplicated: '{{label}} تم استنساخها بنجاح.',
thisLanguage: 'العربية',
titleDeleted: 'تم حذف {{label}} "{{title}}" بنجاح.',
unauthorized: 'غير مصرح به',

View File

@@ -189,7 +189,6 @@ export default {
submissionSuccessful: 'Təqdimat uğurlu oldu.',
submit: 'Təqdim et',
successfullyCreated: '{{label}} uğurla yaradıldı.',
successfullyDuplicated: '{{label}} uğurla dublikatlandı.',
thisLanguage: 'Azərbaycan dili',
titleDeleted: '{{label}} "{{title}}" uğurla silindi.',
unauthorized: 'İcazəsiz',

View File

@@ -188,7 +188,6 @@ export default {
submissionSuccessful: 'Успешно подаване.',
submit: 'Подай',
successfullyCreated: '{{label}} успешно създаден.',
successfullyDuplicated: '{{label}} успешно дупликиран.',
thisLanguage: 'Български',
titleDeleted: '{{label}} "{{title}}" успешно изтрит.',
unauthorized: 'Неавторизиран',

View File

@@ -188,7 +188,6 @@ export default {
submissionSuccessful: 'Odeslání úspěšné.',
submit: 'Odeslat',
successfullyCreated: '{{label}} úspěšně vytvořeno.',
successfullyDuplicated: '{{label}} úspěšně duplikováno.',
thisLanguage: 'Čeština',
titleDeleted: '{{label}} "{{title}}" úspěšně smazáno.',
unauthorized: 'Neoprávněný',

View File

@@ -189,7 +189,6 @@ export default {
submissionSuccessful: 'Einrichung erfolgreich.',
submit: 'Senden',
successfullyCreated: '{{label}} erfolgreich erstellt.',
successfullyDuplicated: '{{label}} wurde erfolgreich dupliziert.',
thisLanguage: 'Deutsch',
titleDeleted: '{{label}} {{title}} wurde erfolgreich gelöscht.',
unauthorized: 'Nicht autorisiert',

View File

@@ -47,8 +47,6 @@ export default {
deletingTitle:
'There was an error while deleting {{title}}. Please check your connection and try again.',
loadingDocument: 'There was a problem loading the document with ID of {{id}}.',
localesNotSaved_one: 'The following locale could not be saved:',
localesNotSaved_other: 'The following locales could not be saved:',
noMatchedField: 'No matched field found for "{{label}}"',
notAllowedToAccessPage: 'You are not allowed to access this page.',
previewing: 'There was a problem previewing this document.',
@@ -191,7 +189,6 @@ export default {
submissionSuccessful: 'Submission Successful.',
submit: 'Submit',
successfullyCreated: '{{label}} successfully created.',
successfullyDuplicated: '{{label}} successfully duplicated.',
thisLanguage: 'English',
titleDeleted: '{{label}} "{{title}}" successfully deleted.',
unauthorized: 'Unauthorized',

View File

@@ -189,7 +189,6 @@ export default {
submissionSuccessful: 'Envío realizado correctamente.',
submit: 'Enviar',
successfullyCreated: '{{label}} creado correctamente.',
successfullyDuplicated: '{{label}} duplicado correctamente.',
thisLanguage: 'Español',
titleDeleted: '{{label}} {{title}} eliminado correctamente.',
unauthorized: 'No autorizado',

View File

@@ -188,7 +188,6 @@ export default {
submissionSuccessful: 'با موفقیت ثبت شد.',
submit: 'فرستادن',
successfullyCreated: '{{label}} با موفقیت ساخته شد.',
successfullyDuplicated: '{{label}} با موفقیت رونوشت شد.',
thisLanguage: 'فارسی',
titleDeleted: '{{label}} "{{title}}" با موفقیت پاک شد.',
unauthorized: 'غیرمجاز',

View File

@@ -191,7 +191,6 @@ export default {
submissionSuccessful: 'Soumission réussie.',
submit: 'Soumettre',
successfullyCreated: '{{label}} créé(e) avec succès.',
successfullyDuplicated: '{{label}} dupliqué(e) avec succès.',
thisLanguage: 'Français',
titleDeleted: '{{label}} "{{title}}" supprimé(e) avec succès.',
unauthorized: 'Non autorisé',

View File

@@ -188,7 +188,6 @@ export default {
submissionSuccessful: 'Uspješno slanje',
submit: 'Podnesi',
successfullyCreated: '{{label}} uspješno kreirano.',
successfullyDuplicated: '{{label}} uspješno duplicirano.',
thisLanguage: 'Hrvatski',
titleDeleted: '{{label}} "{{title}}" uspješno obrisano.',
unauthorized: 'Neovlašteno',

View File

@@ -189,7 +189,6 @@ export default {
submissionSuccessful: 'Beküldés sikeres.',
submit: 'Beküldés',
successfullyCreated: '{{label}} sikeresen létrehozva.',
successfullyDuplicated: '{{label}} sikeresen duplikálódott.',
thisLanguage: 'Magyar',
titleDeleted: '{{label}} "{{title}}" sikeresen törölve.',
unauthorized: 'Jogosulatlan',

View File

@@ -190,7 +190,6 @@ export default {
submissionSuccessful: 'Invio riuscito.',
submit: 'Invia',
successfullyCreated: '{{label}} creato con successo.',
successfullyDuplicated: '{{label}} duplicato con successo.',
thisLanguage: 'Italiano',
titleDeleted: '{{label}} {{title}} eliminato con successo.',
unauthorized: 'Non autorizzato',

View File

@@ -47,8 +47,6 @@ export default {
deletingTitle:
'{{title}} を削除する際にエラーが発生しました。接続を確認してからもう一度お試しください。',
loadingDocument: 'IDが {{id}} のデータを読み込む際に問題が発生しました。',
localesNotSaved_one: '次のロケールは保存できませんでした:',
localesNotSaved_other: '次のロケールは保存できませんでした:',
noMatchedField: '"{{label}}" に該当するフィールドがありません。',
notAllowedToAccessPage: 'この画面へのアクセスは許可されていません。',
previewing: 'このデータをプレビューする際に問題が発生しました。',
@@ -190,7 +188,6 @@ export default {
submissionSuccessful: '送信が成功しました。',
submit: '送信',
successfullyCreated: '{{label}} が作成されました。',
successfullyDuplicated: '{{label}} が複製されました。',
thisLanguage: 'Japanese',
titleDeleted: '{{label}} "{{title}}" が削除されました。',
unauthorized: '未認証',

View File

@@ -188,7 +188,6 @@ export default {
submissionSuccessful: '제출이 완료되었습니다.',
submit: '제출',
successfullyCreated: '{{label}}이(가) 생성되었습니다.',
successfullyDuplicated: '{{label}}이(가) 복제되었습니다.',
thisLanguage: '한국어',
titleDeleted: '{{label}} "{{title}}"을(를) 삭제했습니다.',
unauthorized: '권한 없음',

View File

@@ -190,7 +190,6 @@ export default {
submissionSuccessful: 'သိမ်းဆည်းမှု အောင်မြင်ပါသည်။',
submit: 'သိမ်းဆည်းမည်။',
successfullyCreated: '{{label}} အောင်မြင်စွာဖန်တီးခဲ့သည်။',
successfullyDuplicated: '{{label}} အောင်မြင်စွာ ပုံတူပွားခဲ့သည်။',
thisLanguage: 'မြန်မာစာ',
titleDeleted: '{{label}} {{title}} အောင်မြင်စွာ ဖျက်သိမ်းခဲ့သည်။',
unauthorized: 'အခွင့်မရှိပါ။',

View File

@@ -189,7 +189,6 @@ export default {
submissionSuccessful: 'Innsending vellykket.',
submit: 'Send inn',
successfullyCreated: '{{label}} ble opprettet.',
successfullyDuplicated: '{{label}} ble duplisert.',
thisLanguage: 'Norsk',
titleDeleted: '{{label}} "{{title}}" ble slettet.',
unauthorized: 'Ikke autorisert',

View File

@@ -190,7 +190,6 @@ export default {
submissionSuccessful: 'Indiening succesvol.',
submit: 'Indienen',
successfullyCreated: '{{label}} succesvol aangemaakt.',
successfullyDuplicated: '{{label}} succesvol gedupliceerd.',
thisLanguage: 'Nederlands',
titleDeleted: '{{label}} "{{title}}" succesvol verwijderd.',
unauthorized: 'Onbevoegd',

View File

@@ -189,7 +189,6 @@ export default {
submissionSuccessful: 'Zgłoszenie zakończone powodzeniem.',
submit: 'Zatwierdź',
successfullyCreated: 'Pomyślnie utworzono {{label}}.',
successfullyDuplicated: 'Pomyślnie zduplikowano {{label}}',
thisLanguage: 'Polski',
titleDeleted: 'Pomyślnie usunięto {{label}} {{title}}',
unauthorized: 'Brak autoryzacji',

View File

@@ -189,7 +189,6 @@ export default {
submissionSuccessful: 'Envio bem-sucedido.',
submit: 'Enviar',
successfullyCreated: '{{label}} criado com sucesso.',
successfullyDuplicated: '{{label}} duplicado com sucesso.',
thisLanguage: 'Português',
titleDeleted: '{{label}} {{title}} excluído com sucesso.',
unauthorized: 'Não autorizado',

View File

@@ -189,7 +189,6 @@ export default {
submissionSuccessful: 'Trimitere cu succes.',
submit: 'Trimite',
successfullyCreated: '{{label}} creat(ă) cu succes.',
successfullyDuplicated: '{{label}} duplicat(ă) cu succes.',
thisLanguage: 'Română',
titleDeleted: '{{label}} "{{title}}" șters cu succes.',
unauthorized: 'neautorizat(ă)',

View File

@@ -188,7 +188,6 @@ export default {
submissionSuccessful: 'Uspešno slanje',
submit: 'Potvrdi',
successfullyCreated: '{{label}} uspešno kreirano.',
successfullyDuplicated: '{{label}} uspešno duplicirano.',
thisLanguage: 'Srpski (latinica)',
titleDeleted: '{{label}} "{{title}}" uspešno obrisano.',
unauthorized: 'Niste autorizovani',

View File

@@ -188,7 +188,6 @@ export default {
submissionSuccessful: 'Успешно слање',
submit: 'Потврди',
successfullyCreated: '{{label}} успешно креирано.',
successfullyDuplicated: '{{label}} успешно дуплицирано.',
thisLanguage: 'Српски (ћирилица)',
titleDeleted: '{{label}} "{{title}}" успешно обрисано.',
unauthorized: 'Нисте ауторизовани',

View File

@@ -189,7 +189,6 @@ export default {
submissionSuccessful: 'Успешно отправлено.',
submit: 'Отправить',
successfullyCreated: '{{label}} успешно создан.',
successfullyDuplicated: '{{label}} успешно продублирован.',
thisLanguage: 'Русский',
titleDeleted: '{{label}} {{title}} успешно удалено.',
unauthorized: 'Нет доступа',

View File

@@ -189,7 +189,6 @@ export default {
submissionSuccessful: 'Inlämningen Lyckades.',
submit: 'Lämna in',
successfullyCreated: '{{label}} skapades framgångsrikt.',
successfullyDuplicated: '{{label}} duplicerades framgångsrikt.',
thisLanguage: 'Svenska',
titleDeleted: '{{label}} "{{title}}" togs bort framgångsrikt.',
unauthorized: 'Obehörig',

View File

@@ -187,7 +187,6 @@ export default {
submissionSuccessful: 'ส่งสำเร็จ',
submit: 'ส่ง',
successfullyCreated: 'สร้าง {{label}} สำเร็จ',
successfullyDuplicated: 'สำเนา {{label}} สำเร็จ',
thisLanguage: 'ไทย',
titleDeleted: 'ลบ {{label}} "{{title}}" สำเร็จ',
unauthorized: 'ไม่ได้รับอนุญาต',

View File

@@ -190,7 +190,6 @@ export default {
submissionSuccessful: 'Gönderme başarılı',
submit: 'Gönder',
successfullyCreated: '{{label}} başarıyla oluşturuldu.',
successfullyDuplicated: '{{label}} başarıyla kopyalandı.',
thisLanguage: 'Türkçe',
titleDeleted: '{{label}} {{title}} başarıyla silindi.',
unauthorized: 'Yetkisiz',

View File

@@ -188,7 +188,6 @@ export default {
submissionSuccessful: 'Успішно відправлено.',
submit: 'Відправити',
successfullyCreated: '{{label}} успішно створено.',
successfullyDuplicated: '{{label}} успішно продубльовано.',
thisLanguage: 'Українська',
titleDeleted: '{{label}} "{{title}}" успішно видалено.',
unauthorized: 'Немає доступу',

View File

@@ -187,7 +187,6 @@ export default {
submissionSuccessful: 'Gửi thành công.',
submit: 'Gửi',
successfullyCreated: '{{label}} đã được tạo thành công.',
successfullyDuplicated: '{{label}} đã được sao chép thành công.',
thisLanguage: 'Vietnamese (Tiếng Việt)',
titleDeleted: '{{label}} {{title}} đã được xóa thành công.',
unauthorized: 'Không có quyền truy cập.',

View File

@@ -46,8 +46,6 @@ export default {
correctInvalidFields: '請更正無效區塊。',
deletingTitle: '刪除{{title}}時出現了錯誤。請檢查您的網路連線並重試。',
loadingDocument: '加載ID為{{id}}的文件時出現了問題。',
localesNotSaved_one: '這個語言環境無法被儲存:',
localesNotSaved_other: '以下的語言環境無法被儲存:',
noMatchedField: '找不到與"{{label}}"匹配的字串',
notAllowedToAccessPage: '您沒有權限訪問此頁面。',
previewing: '預覽文件時出現了問題。',
@@ -188,7 +186,6 @@ export default {
submissionSuccessful: '成功送出。',
submit: '送出',
successfullyCreated: '成功建立{{label}}',
successfullyDuplicated: '成功複製{{label}}',
thisLanguage: '中文 (繁體)',
titleDeleted: '{{label}} "{{title}}"已被成功刪除。',
unauthorized: '未經授權',

View File

@@ -186,7 +186,6 @@ export default {
submissionSuccessful: '提交成功。',
submit: '提交',
successfullyCreated: '成功创建{{label}}',
successfullyDuplicated: '成功复制{{label}}',
thisLanguage: '中文 (简体)',
titleDeleted: '{{label}} "{{title}}"已被成功删除。',
unauthorized: '未经授权',

View File

@@ -77,8 +77,6 @@ export default {
invalidFileType: 'Invalid file type',
invalidFileTypeValue: 'Invalid file type: {{value}}',
loadingDocument: 'There was a problem loading the document with ID of {{id}}.',
localesNotSaved_one: 'The following locale could not be saved:',
localesNotSaved_other: 'The following locales could not be saved:',
missingEmail: 'Missing email.',
missingIDOfDocument: 'Missing ID of document to update.',
missingIDOfVersion: 'Missing ID of version.',

View File

@@ -78,8 +78,6 @@ export default {
invalidFileType: '無効なファイル形式',
invalidFileTypeValue: '無効なファイル形式: {{value}}',
loadingDocument: 'IDが {{id}} のデータを読み込む際に問題が発生しました。',
localesNotSaved_one: '次のロケールは保存できませんでした:',
localesNotSaved_other: '次のロケールは保存できませんでした:',
missingEmail: 'メールアドレスが不足しています。',
missingIDOfDocument: '更新するデータのIDが不足しています。',
missingIDOfVersion: 'バージョンIDが不足しています。',

View File

@@ -72,8 +72,6 @@ export default {
invalidFileType: '無效的文件類型',
invalidFileTypeValue: '無效的文件類型: {{value}}',
loadingDocument: '加載ID為{{id}}的文件時出現了問題。',
localesNotSaved_one: '這個語言環境無法被儲存:',
localesNotSaved_other: '以下的語言環境無法被儲存:',
missingEmail: '缺少電子郵件。',
missingIDOfDocument: '缺少需要更新的文檔的ID。',
missingIDOfVersion: '缺少版本的ID。',

View File

@@ -30,12 +30,14 @@ const serverTranslationKeys = [
'fields:chooseDocumentToLink',
'fields:openInNewTab',
'general:copy',
'general:createdAt',
'general:deletedCountSuccessfully',
'general:deletedSuccessfully',
'general:email',
'general:notFound',
'general:successfullyCreated',
'general:successfullyDuplicated',
'general:thisLanguage',
'general:user',
'general:users',
@@ -130,7 +132,6 @@ const clientTranslationKeys = [
'error:correctInvalidFields',
'error:deletingTitle',
'error:loadingDocument',
'error:localesNotSaved',
'error:noMatchedField',
'error:notAllowedToAccessPage',
'error:previewing',
@@ -266,7 +267,6 @@ const clientTranslationKeys = [
'general:submit',
'general:successfullyCreated',
'general:successfullyDeleted',
'general:successfullyDuplicated',
'general:thisLanguage',
'general:titleDeleted',
'general:unauthorized',

View File

@@ -7,11 +7,11 @@ import { toast } from 'react-toastify'
import type { Props } from './types.js'
// import { requests } from '../../../api'
import { useForm, useFormModified } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { MinimalTemplate } from '../../templates/Minimal/index.js'
import { requests } from '../../utilities/api.js'
import { Button } from '../Button/index.js'
import * as PopupList from '../Popup/PopupButtonList/index.js'
import './index.scss'
@@ -21,12 +21,11 @@ const baseClass = 'duplicate'
const Duplicate: React.FC<Props> = ({ id, slug, singularLabel }) => {
const { Modal, useModal } = facelessUIImport
const { push } = useRouter()
const router = useRouter()
const modified = useFormModified()
const { toggleModal } = useModal()
const { setModified } = useForm()
const {
localization,
routes: { api },
serverURL,
} = useConfig()
@@ -46,134 +45,35 @@ const Duplicate: React.FC<Props> = ({ id, slug, singularLabel }) => {
toggleModal(modalSlug)
return
}
const saveDocument = async ({
id,
duplicateID = '',
locale = '',
}): Promise<null | string> => {
const data = null
// const response = await requests.get(`${serverURL}${api}/${slug}/${id}`, {
// headers: {
// 'Accept-Language': i18n.language,
// },
// params: {
// depth: 0,
// draft: true,
// 'fallback-locale': 'none',
// locale,
// },
// })
// let data = await response.json()
// TODO: convert this into a server action
// if (typeof collection.admin.hooks?.beforeDuplicate === 'function') {
// data = await collection.admin.hooks.beforeDuplicate({
// collection,
// data,
// locale,
// })
// }
if (!duplicateID) {
if ('createdAt' in data) delete data.createdAt
if ('updatedAt' in data) delete data.updatedAt
}
// const result = await requests[duplicateID ? 'patch' : 'post'](
// `${serverURL}${api}/${slug}/${duplicateID}?locale=${locale}&fallback-locale=none`,
// {
// body: JSON.stringify(data),
// headers: {
// 'Accept-Language': i18n.language,
// 'Content-Type': 'application/json',
// },
// },
// )
// const json = await result.json()
// if (result.status === 201 || result.status === 200) {
// return json.doc.id
// }
// only show the error if this is the initial request failing
if (!duplicateID) {
// json.errors.forEach((error) => toast.error(error.message))
}
return null
}
let duplicateID: string
let abort = false
const localeErrors = []
if (localization) {
await localization.localeCodes.reduce(async (priorLocalePatch, locale) => {
await priorLocalePatch
if (abort) return
const localeResult = await saveDocument({
id,
duplicateID,
locale,
})
duplicateID = localeResult || duplicateID
if (duplicateID && !localeResult) {
localeErrors.push(locale)
}
if (!duplicateID) {
abort = true
}
}, Promise.resolve())
} else {
duplicateID = await saveDocument({ id })
}
if (!duplicateID) {
// document was not saved, error toast was displayed
return
}
await requests.post(`${serverURL}${api}/${slug}/${id}/duplicate`, {
body: JSON.stringify({}),
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
'credentials': 'include',
},
}).then(async (res) => {
const { doc, message } = await res.json()
if (res.status < 400) {
toast.success(
message ||
t('general:successfullyDuplicated', { label: getTranslation(singularLabel, i18n) }),
{
autoClose: 3000,
},
)
if (localeErrors.length > 0) {
setModified(false)
router.push(`${admin}/collections/${slug}/${doc.id}`)
} else {
toast.error(
`
${t('error:localesNotSaved', { count: localeErrors.length })}
${localeErrors.join(', ')}
`,
message ||
t('error:unspecific', { label: getTranslation(singularLabel, i18n) }),
{ autoClose: 5000 },
)
}
setModified(false)
setTimeout(() => {
push(`${admin}/collections/${slug}/${duplicateID}`)
}, 10)
})
},
[
modified,
localization,
t,
i18n,
setModified,
toggleModal,
modalSlug,
serverURL,
api,
slug,
id,
push,
admin,
],
[modified, serverURL, api, slug, id, i18n, toggleModal, modalSlug, t, singularLabel, setModified, router, admin],
)
const confirm = useCallback(async () => {

View File

@@ -1,31 +1,10 @@
import type { BeforeDuplicate, CollectionConfig } from 'payload/types'
import type { IndexedField } from '../../payload-types.js'
import type {
CollectionConfig} from 'payload/types'
import { indexedFieldsSlug } from '../../slugs.js'
const beforeDuplicate: BeforeDuplicate<IndexedField> = ({ data }) => {
return {
...data,
collapsibleLocalizedUnique: data.collapsibleLocalizedUnique
? `${data.collapsibleLocalizedUnique}-copy`
: '',
collapsibleTextUnique: data.collapsibleTextUnique ? `${data.collapsibleTextUnique}-copy` : '',
group: {
...(data.group || {}),
localizedUnique: data.group?.localizedUnique ? `${data.group?.localizedUnique}-copy` : '',
},
uniqueText: data.uniqueText ? `${data.uniqueText}-copy` : '',
}
}
const IndexedFields: CollectionConfig = {
slug: indexedFieldsSlug,
// used to assert that versions also get indexes
admin: {
hooks: {
beforeDuplicate,
},
},
fields: [
{
name: 'text',

View File

@@ -619,6 +619,23 @@ describe('Fields', () => {
expect(result.id).toBeDefined()
})
it('should duplicate with unique fields', async () => {
const data = {
text: 'a',
}
const doc = await payload.create({
collection: 'indexed-fields',
data,
})
const result = await payload.duplicate({
collection: 'indexed-fields',
id: doc.id,
})
expect(result.id).not.toEqual(doc.id)
expect(result.uniqueRequiredText).toStrictEqual('uniqueRequired - Copy')
})
})
describe('array', () => {