fix: duplicating localized nested arrays (#8220)
Fixes an issue where duplicating documents in Postgres / SQLite would crash because of a foreign key constraint / unique ID issue when you have nested arrays / blocks within localized arrays / blocks. We now run `beforeDuplicate` against all locales prior to `beforeValidate` and `beforeChange` hooks. This PR also fixes a series of issues in Postgres / SQLite where you have localized groups / named tabs, and then arrays / blocks within the localized groups / named tabs.
This commit is contained in:
@@ -200,7 +200,7 @@ user-friendly.
|
||||
The `beforeDuplicate` field hook is called on each locale (when using localization), 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 when external systems expect non-repeating values on documents.
|
||||
|
||||
This hook gets called after `beforeChange` hooks are called and before the document is saved to the database.
|
||||
This hook gets called before the `beforeValidate` and `beforeChange` hooks are called.
|
||||
|
||||
By Default, unique and required text fields Payload will append "- Copy" to the original document value. The default is not added if your field has its own, you must return non-unique values from your beforeDuplicate hook to avoid errors or enable the `disableDuplicate` option on the collection.
|
||||
Here is an example of a number field with a hook that increments the number to avoid unique constraint errors when duplicating a document:
|
||||
|
||||
@@ -166,7 +166,8 @@ export const traverseFields = ({
|
||||
if (field.hasMany) {
|
||||
const isLocalized =
|
||||
Boolean(field.localized && adapter.payload.config.localization) ||
|
||||
withinLocalizedArrayOrBlock
|
||||
withinLocalizedArrayOrBlock ||
|
||||
forceLocalized
|
||||
|
||||
if (isLocalized) {
|
||||
hasLocalizedManyTextField = true
|
||||
@@ -199,7 +200,8 @@ export const traverseFields = ({
|
||||
if (field.hasMany) {
|
||||
const isLocalized =
|
||||
Boolean(field.localized && adapter.payload.config.localization) ||
|
||||
withinLocalizedArrayOrBlock
|
||||
withinLocalizedArrayOrBlock ||
|
||||
forceLocalized
|
||||
|
||||
if (isLocalized) {
|
||||
hasLocalizedManyNumberField = true
|
||||
@@ -279,7 +281,8 @@ export const traverseFields = ({
|
||||
|
||||
const isLocalized =
|
||||
Boolean(field.localized && adapter.payload.config.localization) ||
|
||||
withinLocalizedArrayOrBlock
|
||||
withinLocalizedArrayOrBlock ||
|
||||
forceLocalized
|
||||
|
||||
if (isLocalized) {
|
||||
baseColumns.locale = text('locale', { enum: locales }).notNull()
|
||||
@@ -365,7 +368,8 @@ export const traverseFields = ({
|
||||
|
||||
const isLocalized =
|
||||
Boolean(field.localized && adapter.payload.config.localization) ||
|
||||
withinLocalizedArrayOrBlock
|
||||
withinLocalizedArrayOrBlock ||
|
||||
forceLocalized
|
||||
|
||||
if (isLocalized) {
|
||||
baseColumns._locale = text('_locale', { enum: locales }).notNull()
|
||||
@@ -503,7 +507,8 @@ export const traverseFields = ({
|
||||
|
||||
const isLocalized =
|
||||
Boolean(field.localized && adapter.payload.config.localization) ||
|
||||
withinLocalizedArrayOrBlock
|
||||
withinLocalizedArrayOrBlock ||
|
||||
forceLocalized
|
||||
|
||||
if (isLocalized) {
|
||||
baseColumns._locale = text('_locale', { enum: locales }).notNull()
|
||||
|
||||
@@ -172,7 +172,8 @@ export const traverseFields = ({
|
||||
if (field.hasMany) {
|
||||
const isLocalized =
|
||||
Boolean(field.localized && adapter.payload.config.localization) ||
|
||||
withinLocalizedArrayOrBlock
|
||||
withinLocalizedArrayOrBlock ||
|
||||
forceLocalized
|
||||
|
||||
if (isLocalized) {
|
||||
hasLocalizedManyTextField = true
|
||||
@@ -205,7 +206,8 @@ export const traverseFields = ({
|
||||
if (field.hasMany) {
|
||||
const isLocalized =
|
||||
Boolean(field.localized && adapter.payload.config.localization) ||
|
||||
withinLocalizedArrayOrBlock
|
||||
withinLocalizedArrayOrBlock ||
|
||||
forceLocalized
|
||||
|
||||
if (isLocalized) {
|
||||
hasLocalizedManyNumberField = true
|
||||
@@ -300,7 +302,8 @@ export const traverseFields = ({
|
||||
|
||||
const isLocalized =
|
||||
Boolean(field.localized && adapter.payload.config.localization) ||
|
||||
withinLocalizedArrayOrBlock
|
||||
withinLocalizedArrayOrBlock ||
|
||||
forceLocalized
|
||||
|
||||
if (isLocalized) {
|
||||
baseColumns.locale = adapter.enums.enum__locales('locale').notNull()
|
||||
@@ -382,7 +385,8 @@ export const traverseFields = ({
|
||||
|
||||
const isLocalized =
|
||||
Boolean(field.localized && adapter.payload.config.localization) ||
|
||||
withinLocalizedArrayOrBlock
|
||||
withinLocalizedArrayOrBlock ||
|
||||
forceLocalized
|
||||
|
||||
if (isLocalized) {
|
||||
baseColumns._locale = adapter.enums.enum__locales('_locale').notNull()
|
||||
@@ -516,7 +520,8 @@ export const traverseFields = ({
|
||||
|
||||
const isLocalized =
|
||||
Boolean(field.localized && adapter.payload.config.localization) ||
|
||||
withinLocalizedArrayOrBlock
|
||||
withinLocalizedArrayOrBlock ||
|
||||
forceLocalized
|
||||
|
||||
if (isLocalized) {
|
||||
baseColumns._locale = adapter.enums.enum__locales('_locale').notNull()
|
||||
|
||||
@@ -489,6 +489,7 @@ export const traverseFields = <T extends Record<string, unknown>>({
|
||||
valuesToTransform.push({
|
||||
ref: localizedFieldData,
|
||||
table: {
|
||||
...table,
|
||||
...localeRow,
|
||||
},
|
||||
})
|
||||
@@ -526,7 +527,7 @@ export const traverseFields = <T extends Record<string, unknown>>({
|
||||
relationships,
|
||||
table,
|
||||
texts,
|
||||
withinArrayOrBlockLocale,
|
||||
withinArrayOrBlockLocale: locale || withinArrayOrBlockLocale,
|
||||
})
|
||||
|
||||
if ('_order' in ref) {
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 { beforeDuplicate } from '../../fields/hooks/beforeDuplicate/index.js'
|
||||
import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js'
|
||||
import { commitTransaction } from '../../utilities/commitTransaction.js'
|
||||
import { initTransaction } from '../../utilities/initTransaction.js'
|
||||
@@ -93,7 +94,7 @@ export const duplicateOperation = async <TSlug extends CollectionSlug>(
|
||||
where: combineQueries({ id: { equals: id } }, accessResults),
|
||||
}
|
||||
|
||||
const docWithLocales = await getLatestCollectionVersion({
|
||||
let docWithLocales = await getLatestCollectionVersion({
|
||||
id,
|
||||
config: collectionConfig,
|
||||
payload,
|
||||
@@ -112,6 +113,15 @@ export const duplicateOperation = async <TSlug extends CollectionSlug>(
|
||||
delete docWithLocales.createdAt
|
||||
delete docWithLocales.id
|
||||
|
||||
docWithLocales = await beforeDuplicate({
|
||||
id,
|
||||
collection: collectionConfig,
|
||||
context: req.context,
|
||||
doc: docWithLocales,
|
||||
overrideAccess,
|
||||
req,
|
||||
})
|
||||
|
||||
// for version enabled collections, override the current status with draft, unless draft is explicitly set to false
|
||||
if (shouldSaveDraft) {
|
||||
docWithLocales._status = 'draft'
|
||||
@@ -205,7 +215,6 @@ export const duplicateOperation = async <TSlug extends CollectionSlug>(
|
||||
data,
|
||||
doc: originalDoc,
|
||||
docWithLocales,
|
||||
duplicate: true,
|
||||
global: null,
|
||||
operation,
|
||||
req,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import ObjectIdImport from 'bson-objectid'
|
||||
|
||||
import type { FieldHook } from '../config/types.js'
|
||||
|
||||
const ObjectId = (ObjectIdImport.default ||
|
||||
ObjectIdImport) as unknown as typeof ObjectIdImport.default
|
||||
/**
|
||||
* Arrays and Blocks need to clear ids beforeDuplicate
|
||||
*/
|
||||
export const baseBeforeDuplicateArrays: FieldHook = ({ value }) => {
|
||||
if (value) {
|
||||
value = value.map((item) => {
|
||||
item.id = new ObjectId().toHexString()
|
||||
return item
|
||||
})
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ export const baseIDField: TextField = {
|
||||
return value
|
||||
},
|
||||
],
|
||||
beforeDuplicate: [
|
||||
() => {
|
||||
return new ObjectId().toHexString()
|
||||
},
|
||||
],
|
||||
},
|
||||
label: 'ID',
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '../../errors/index.js'
|
||||
import { MissingEditorProp } from '../../errors/MissingEditorProp.js'
|
||||
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
|
||||
import { baseBeforeDuplicateArrays } from '../baseFields/baseBeforeDuplicateArrays.js'
|
||||
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
|
||||
import { baseIDField } from '../baseFields/baseIDField.js'
|
||||
import { setDefaultBeforeDuplicate } from '../setDefaultBeforeDuplicate.js'
|
||||
@@ -131,15 +130,6 @@ export const sanitizeFields = async ({
|
||||
|
||||
if (field.type === 'array' && field.fields) {
|
||||
field.fields.push(baseIDField)
|
||||
if (field.localized) {
|
||||
if (!field.hooks) {
|
||||
field.hooks = {}
|
||||
}
|
||||
if (!field.hooks.beforeDuplicate) {
|
||||
field.hooks.beforeDuplicate = []
|
||||
}
|
||||
field.hooks.beforeDuplicate.push(baseBeforeDuplicateArrays)
|
||||
}
|
||||
}
|
||||
|
||||
if ((field.type === 'blocks' || field.type === 'array') && field.label) {
|
||||
@@ -220,15 +210,6 @@ export const sanitizeFields = async ({
|
||||
}
|
||||
|
||||
if (field.type === 'blocks' && field.blocks) {
|
||||
if (field.localized) {
|
||||
if (!field.hooks) {
|
||||
field.hooks = {}
|
||||
}
|
||||
if (!field.hooks.beforeDuplicate) {
|
||||
field.hooks.beforeDuplicate = []
|
||||
}
|
||||
field.hooks.beforeDuplicate.push(baseBeforeDuplicateArrays)
|
||||
}
|
||||
for (const block of field.blocks) {
|
||||
if (block._sanitized === true) {
|
||||
continue
|
||||
|
||||
@@ -12,7 +12,6 @@ type Args<T extends JsonObject> = {
|
||||
data: T
|
||||
doc: T
|
||||
docWithLocales: JsonObject
|
||||
duplicate?: boolean
|
||||
global: null | SanitizedGlobalConfig
|
||||
id?: number | string
|
||||
operation: Operation
|
||||
@@ -26,7 +25,6 @@ type Args<T extends JsonObject> = {
|
||||
* - Execute field hooks
|
||||
* - Validate data
|
||||
* - Transform data for storage
|
||||
* - beforeDuplicate hooks (if duplicate)
|
||||
* - Unflatten locales. The input `data` is the normal document for one locale. The output result will become the document with locales.
|
||||
*/
|
||||
export const beforeChange = async <T extends JsonObject>({
|
||||
@@ -36,7 +34,6 @@ export const beforeChange = async <T extends JsonObject>({
|
||||
data: incomingData,
|
||||
doc,
|
||||
docWithLocales,
|
||||
duplicate = false,
|
||||
global,
|
||||
operation,
|
||||
req,
|
||||
@@ -53,7 +50,6 @@ export const beforeChange = async <T extends JsonObject>({
|
||||
data,
|
||||
doc,
|
||||
docWithLocales,
|
||||
duplicate,
|
||||
errors,
|
||||
fields: collection?.fields || global?.fields,
|
||||
global,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { MissingEditorProp } from '../../../errors/index.js'
|
||||
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
|
||||
import { fieldAffectsData, tabHasName } from '../../config/types.js'
|
||||
import { getFieldPaths } from '../../getFieldPaths.js'
|
||||
import { beforeDuplicate } from './beforeDuplicate.js'
|
||||
import { getExistingRowDoc } from './getExistingRowDoc.js'
|
||||
import { traverseFields } from './traverseFields.js'
|
||||
|
||||
@@ -18,7 +17,6 @@ type Args = {
|
||||
data: JsonObject
|
||||
doc: JsonObject
|
||||
docWithLocales: JsonObject
|
||||
duplicate: boolean
|
||||
errors: { field: string; message: string }[]
|
||||
field: Field | TabAsField
|
||||
global: null | SanitizedGlobalConfig
|
||||
@@ -55,7 +53,6 @@ export const promise = async ({
|
||||
data,
|
||||
doc,
|
||||
docWithLocales,
|
||||
duplicate,
|
||||
errors,
|
||||
field,
|
||||
global,
|
||||
@@ -176,16 +173,11 @@ export const promise = async ({
|
||||
const localeData = await localization.localeCodes.reduce(
|
||||
async (localizedValuesPromise: Promise<JsonObject>, locale) => {
|
||||
const localizedValues = await localizedValuesPromise
|
||||
let fieldValue =
|
||||
const fieldValue =
|
||||
locale === req.locale
|
||||
? siblingData[field.name]
|
||||
: siblingDocWithLocales?.[field.name]?.[locale]
|
||||
|
||||
if (duplicate && field.hooks?.beforeDuplicate?.length) {
|
||||
beforeDuplicateArgs.value = fieldValue
|
||||
fieldValue = await beforeDuplicate(beforeDuplicateArgs)
|
||||
}
|
||||
|
||||
// const result = await localizedValues
|
||||
// update locale value if it's not undefined
|
||||
if (typeof fieldValue !== 'undefined') {
|
||||
@@ -205,10 +197,6 @@ export const promise = async ({
|
||||
siblingData[field.name] = localeData
|
||||
}
|
||||
})
|
||||
} else if (duplicate && field.hooks?.beforeDuplicate?.length) {
|
||||
mergeLocaleActions.push(async () => {
|
||||
siblingData[field.name] = await beforeDuplicate(beforeDuplicateArgs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +238,6 @@ export const promise = async ({
|
||||
data,
|
||||
doc,
|
||||
docWithLocales,
|
||||
duplicate,
|
||||
errors,
|
||||
fields: field.fields,
|
||||
global,
|
||||
@@ -282,7 +269,6 @@ export const promise = async ({
|
||||
data,
|
||||
doc,
|
||||
docWithLocales,
|
||||
duplicate,
|
||||
errors,
|
||||
fields: field.fields,
|
||||
global,
|
||||
@@ -332,7 +318,6 @@ export const promise = async ({
|
||||
data,
|
||||
doc,
|
||||
docWithLocales,
|
||||
duplicate,
|
||||
errors,
|
||||
fields: block.fields,
|
||||
global,
|
||||
@@ -365,7 +350,6 @@ export const promise = async ({
|
||||
data,
|
||||
doc,
|
||||
docWithLocales,
|
||||
duplicate,
|
||||
errors,
|
||||
fields: field.fields,
|
||||
global,
|
||||
@@ -411,7 +395,6 @@ export const promise = async ({
|
||||
data,
|
||||
doc,
|
||||
docWithLocales,
|
||||
duplicate,
|
||||
errors,
|
||||
fields: field.fields,
|
||||
global,
|
||||
@@ -437,7 +420,6 @@ export const promise = async ({
|
||||
data,
|
||||
doc,
|
||||
docWithLocales,
|
||||
duplicate,
|
||||
errors,
|
||||
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
|
||||
global,
|
||||
@@ -474,7 +456,6 @@ export const promise = async ({
|
||||
context,
|
||||
data,
|
||||
docWithLocales,
|
||||
duplicate,
|
||||
errors,
|
||||
field,
|
||||
global,
|
||||
|
||||
@@ -17,7 +17,6 @@ type Args = {
|
||||
* The original data with locales (not modified by any hooks)
|
||||
*/
|
||||
docWithLocales: JsonObject
|
||||
duplicate: boolean
|
||||
errors: { field: string; message: string }[]
|
||||
fields: (Field | TabAsField)[]
|
||||
global: null | SanitizedGlobalConfig
|
||||
@@ -54,7 +53,6 @@ export const traverseFields = async ({
|
||||
data,
|
||||
doc,
|
||||
docWithLocales,
|
||||
duplicate,
|
||||
errors,
|
||||
fields,
|
||||
global,
|
||||
@@ -79,7 +77,6 @@ export const traverseFields = async ({
|
||||
data,
|
||||
doc,
|
||||
docWithLocales,
|
||||
duplicate,
|
||||
errors,
|
||||
field,
|
||||
global,
|
||||
|
||||
46
packages/payload/src/fields/hooks/beforeDuplicate/index.ts
Normal file
46
packages/payload/src/fields/hooks/beforeDuplicate/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
|
||||
import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js'
|
||||
|
||||
import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js'
|
||||
import { traverseFields } from './traverseFields.js'
|
||||
|
||||
type Args<T extends JsonObject> = {
|
||||
collection: null | SanitizedCollectionConfig
|
||||
context: RequestContext
|
||||
doc?: T
|
||||
id?: number | string
|
||||
overrideAccess: boolean
|
||||
req: PayloadRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is responsible for running beforeDuplicate hooks
|
||||
* against a document including all locale data.
|
||||
* It will run each field's beforeDuplicate hook
|
||||
* and return the resulting docWithLocales.
|
||||
*/
|
||||
export const beforeDuplicate = async <T extends JsonObject>({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
overrideAccess,
|
||||
req,
|
||||
}: Args<T>): Promise<T> => {
|
||||
const newDoc = deepCopyObjectSimple(doc)
|
||||
|
||||
await traverseFields({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc: newDoc,
|
||||
fields: collection?.fields,
|
||||
overrideAccess,
|
||||
path: [],
|
||||
req,
|
||||
schemaPath: [],
|
||||
siblingDoc: newDoc,
|
||||
})
|
||||
|
||||
return newDoc
|
||||
}
|
||||
351
packages/payload/src/fields/hooks/beforeDuplicate/promise.ts
Normal file
351
packages/payload/src/fields/hooks/beforeDuplicate/promise.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
|
||||
import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js'
|
||||
import type { Field, FieldHookArgs, TabAsField } from '../../config/types.js'
|
||||
|
||||
import { fieldAffectsData, tabHasName } from '../../config/types.js'
|
||||
import { getFieldPaths } from '../../getFieldPaths.js'
|
||||
import { runBeforeDuplicateHooks } from './runHook.js'
|
||||
import { traverseFields } from './traverseFields.js'
|
||||
|
||||
type Args<T> = {
|
||||
collection: null | SanitizedCollectionConfig
|
||||
context: RequestContext
|
||||
doc: T
|
||||
field: Field | TabAsField
|
||||
id?: number | string
|
||||
overrideAccess: boolean
|
||||
parentPath: (number | string)[]
|
||||
parentSchemaPath: string[]
|
||||
req: PayloadRequest
|
||||
siblingDoc: JsonObject
|
||||
}
|
||||
|
||||
export const promise = async <T>({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
field,
|
||||
overrideAccess,
|
||||
parentPath,
|
||||
parentSchemaPath,
|
||||
req,
|
||||
siblingDoc,
|
||||
}: Args<T>): Promise<void> => {
|
||||
const { localization } = req.payload.config
|
||||
|
||||
const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({
|
||||
field,
|
||||
parentPath,
|
||||
parentSchemaPath,
|
||||
})
|
||||
|
||||
if (fieldAffectsData(field)) {
|
||||
let fieldData = siblingDoc?.[field.name]
|
||||
const fieldIsLocalized = field.localized && localization
|
||||
|
||||
// Run field beforeDuplicate hooks
|
||||
if (Array.isArray(field.hooks?.beforeDuplicate)) {
|
||||
if (fieldIsLocalized) {
|
||||
const localeData = await localization.localeCodes.reduce(
|
||||
async (localizedValuesPromise: Promise<JsonObject>, locale) => {
|
||||
const localizedValues = await localizedValuesPromise
|
||||
|
||||
const beforeDuplicateArgs: FieldHookArgs = {
|
||||
collection,
|
||||
context,
|
||||
data: doc,
|
||||
field,
|
||||
global: undefined,
|
||||
path: fieldPath,
|
||||
previousSiblingDoc: siblingDoc,
|
||||
previousValue: siblingDoc[field.name]?.[locale],
|
||||
req,
|
||||
schemaPath: parentSchemaPath,
|
||||
siblingData: siblingDoc,
|
||||
siblingDocWithLocales: siblingDoc,
|
||||
value: siblingDoc[field.name]?.[locale],
|
||||
}
|
||||
|
||||
const hookResult = await runBeforeDuplicateHooks(beforeDuplicateArgs)
|
||||
|
||||
if (typeof hookResult !== 'undefined') {
|
||||
return {
|
||||
...localizedValues,
|
||||
[locale]: hookResult,
|
||||
}
|
||||
}
|
||||
|
||||
return localizedValuesPromise
|
||||
},
|
||||
Promise.resolve({}),
|
||||
)
|
||||
|
||||
siblingDoc[field.name] = localeData
|
||||
} else {
|
||||
const beforeDuplicateArgs: FieldHookArgs = {
|
||||
collection,
|
||||
context,
|
||||
data: doc,
|
||||
field,
|
||||
global: undefined,
|
||||
path: fieldPath,
|
||||
previousSiblingDoc: siblingDoc,
|
||||
previousValue: siblingDoc[field.name],
|
||||
req,
|
||||
schemaPath: parentSchemaPath,
|
||||
siblingData: siblingDoc,
|
||||
siblingDocWithLocales: siblingDoc,
|
||||
value: siblingDoc[field.name],
|
||||
}
|
||||
|
||||
const hookResult = await runBeforeDuplicateHooks(beforeDuplicateArgs)
|
||||
if (typeof hookResult !== 'undefined') {
|
||||
siblingDoc[field.name] = hookResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First, for any localized fields, we will loop over locales
|
||||
// and if locale data is present, traverse the sub fields.
|
||||
// There are only a few different fields where this is possible.
|
||||
if (fieldIsLocalized) {
|
||||
if (typeof fieldData !== 'object' || fieldData === null) {
|
||||
siblingDoc[field.name] = {}
|
||||
fieldData = siblingDoc[field.name]
|
||||
}
|
||||
|
||||
const promises = []
|
||||
|
||||
localization.localeCodes.forEach((locale) => {
|
||||
if (fieldData[locale]) {
|
||||
switch (field.type) {
|
||||
case 'tab':
|
||||
case 'group': {
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
fields: field.fields,
|
||||
overrideAccess,
|
||||
path: fieldSchemaPath,
|
||||
req,
|
||||
schemaPath: fieldSchemaPath,
|
||||
siblingDoc: fieldData[locale],
|
||||
}),
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'array': {
|
||||
const rows = fieldData[locale]
|
||||
|
||||
if (Array.isArray(rows)) {
|
||||
const promises = []
|
||||
rows.forEach((row, i) => {
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
fields: field.fields,
|
||||
overrideAccess,
|
||||
path: [...fieldPath, i],
|
||||
req,
|
||||
schemaPath: fieldSchemaPath,
|
||||
siblingDoc: row,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'blocks': {
|
||||
const rows = fieldData[locale]
|
||||
|
||||
if (Array.isArray(rows)) {
|
||||
const promises = []
|
||||
rows.forEach((row, i) => {
|
||||
const blockTypeToMatch = row.blockType
|
||||
|
||||
const block = field.blocks.find(
|
||||
(blockType) => blockType.slug === blockTypeToMatch,
|
||||
)
|
||||
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
fields: block.fields,
|
||||
overrideAccess,
|
||||
path: [...fieldPath, i],
|
||||
req,
|
||||
schemaPath: fieldSchemaPath,
|
||||
siblingDoc: row,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
} else {
|
||||
// If the field is not localized, but it affects data,
|
||||
// we need to further traverse its children
|
||||
// so the child fields can run beforeDuplicate hooks
|
||||
switch (field.type) {
|
||||
case 'tab':
|
||||
case 'group': {
|
||||
if (field.type === 'tab' && !tabHasName(field)) {
|
||||
await traverseFields({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
fields: field.fields,
|
||||
overrideAccess,
|
||||
path: fieldPath,
|
||||
req,
|
||||
schemaPath: fieldSchemaPath,
|
||||
siblingDoc,
|
||||
})
|
||||
} else {
|
||||
if (typeof siblingDoc[field.name] !== 'object') {
|
||||
siblingDoc[field.name] = {}
|
||||
}
|
||||
|
||||
const groupDoc = siblingDoc[field.name] as Record<string, unknown>
|
||||
|
||||
await traverseFields({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
fields: field.fields,
|
||||
overrideAccess,
|
||||
path: fieldPath,
|
||||
req,
|
||||
schemaPath: fieldSchemaPath,
|
||||
siblingDoc: groupDoc as JsonObject,
|
||||
})
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'array': {
|
||||
const rows = siblingDoc[field.name]
|
||||
|
||||
if (Array.isArray(rows)) {
|
||||
const promises = []
|
||||
rows.forEach((row, i) => {
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
fields: field.fields,
|
||||
overrideAccess,
|
||||
path: [...fieldPath, i],
|
||||
req,
|
||||
schemaPath: fieldSchemaPath,
|
||||
siblingDoc: row,
|
||||
}),
|
||||
)
|
||||
})
|
||||
await Promise.all(promises)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'blocks': {
|
||||
const rows = siblingDoc[field.name]
|
||||
|
||||
if (Array.isArray(rows)) {
|
||||
const promises = []
|
||||
rows.forEach((row, i) => {
|
||||
const blockTypeToMatch = row.blockType
|
||||
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
|
||||
|
||||
if (block) {
|
||||
;(row as JsonObject).blockType = blockTypeToMatch
|
||||
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
fields: block.fields,
|
||||
overrideAccess,
|
||||
path: [...fieldPath, i],
|
||||
req,
|
||||
schemaPath: fieldSchemaPath,
|
||||
siblingDoc: row,
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Finally, we traverse fields which do not affect data here
|
||||
switch (field.type) {
|
||||
case 'row':
|
||||
case 'collapsible': {
|
||||
await traverseFields({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
fields: field.fields,
|
||||
overrideAccess,
|
||||
path: fieldPath,
|
||||
req,
|
||||
schemaPath: fieldSchemaPath,
|
||||
siblingDoc,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'tabs': {
|
||||
await traverseFields({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
|
||||
overrideAccess,
|
||||
path: fieldPath,
|
||||
req,
|
||||
schemaPath: fieldSchemaPath,
|
||||
siblingDoc,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FieldHookArgs } from '../../config/types.js'
|
||||
|
||||
export const beforeDuplicate = async (args: FieldHookArgs) =>
|
||||
export const runBeforeDuplicateHooks = async (args: FieldHookArgs) =>
|
||||
await args.field.hooks.beforeDuplicate.reduce(async (priorHook, currentHook) => {
|
||||
await priorHook
|
||||
return await currentHook(args)
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
|
||||
import type { JsonObject, PayloadRequest, RequestContext } from '../../../types/index.js'
|
||||
import type { Field, TabAsField } from '../../config/types.js'
|
||||
|
||||
import { promise } from './promise.js'
|
||||
|
||||
type Args<T> = {
|
||||
collection: null | SanitizedCollectionConfig
|
||||
context: RequestContext
|
||||
doc: T
|
||||
fields: (Field | TabAsField)[]
|
||||
id?: number | string
|
||||
overrideAccess: boolean
|
||||
path: (number | string)[]
|
||||
req: PayloadRequest
|
||||
schemaPath: string[]
|
||||
siblingDoc: JsonObject
|
||||
}
|
||||
|
||||
export const traverseFields = async <T>({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
fields,
|
||||
overrideAccess,
|
||||
path,
|
||||
req,
|
||||
schemaPath,
|
||||
siblingDoc,
|
||||
}: Args<T>): Promise<void> => {
|
||||
const promises = []
|
||||
fields.forEach((field) => {
|
||||
promises.push(
|
||||
promise({
|
||||
id,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
field,
|
||||
overrideAccess,
|
||||
parentPath: path,
|
||||
parentSchemaPath: schemaPath,
|
||||
req,
|
||||
siblingDoc,
|
||||
}),
|
||||
)
|
||||
})
|
||||
await Promise.all(promises)
|
||||
}
|
||||
@@ -180,7 +180,6 @@ export type BeforeValidateNodeHookArgs<T extends SerializedLexicalNode> = {
|
||||
}
|
||||
|
||||
export type BeforeChangeNodeHookArgs<T extends SerializedLexicalNode> = {
|
||||
duplicate: boolean
|
||||
/**
|
||||
* Only available in `beforeChange` hooks.
|
||||
*/
|
||||
|
||||
@@ -414,7 +414,6 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
|
||||
const {
|
||||
collection,
|
||||
context: _context,
|
||||
duplicate,
|
||||
errors,
|
||||
field,
|
||||
global,
|
||||
@@ -494,7 +493,6 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
|
||||
}
|
||||
node = await hook({
|
||||
context,
|
||||
duplicate,
|
||||
errors,
|
||||
mergeLocaleActions,
|
||||
node,
|
||||
@@ -532,7 +530,6 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
|
||||
data,
|
||||
doc: originalData,
|
||||
docWithLocales: originalDataWithLocales ?? {},
|
||||
duplicate,
|
||||
errors,
|
||||
fields: subFields,
|
||||
global,
|
||||
@@ -635,7 +632,6 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
|
||||
* - afterChange
|
||||
* - beforeChange
|
||||
* - beforeValidate
|
||||
* - beforeDuplicate
|
||||
*
|
||||
* Other hooks are handled by the flattenedNodes. All nodes in the nodeIDMap are part of flattenedNodes.
|
||||
*/
|
||||
|
||||
@@ -131,6 +131,16 @@ export default buildConfigWithDefaults({
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'nestedArray',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
slug: 'text',
|
||||
},
|
||||
@@ -148,6 +158,41 @@ export default buildConfigWithDefaults({
|
||||
required: true,
|
||||
type: 'blocks',
|
||||
},
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
name: 'myTab',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
type: 'group',
|
||||
localized: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'nestedArray2',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'nestedText',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'nestedText',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
slug: withRequiredLocalizedFields,
|
||||
},
|
||||
|
||||
@@ -1120,6 +1120,13 @@ describe('Localization', () => {
|
||||
})
|
||||
|
||||
it('should duplicate with localized blocks', async () => {
|
||||
// This test covers a few things:
|
||||
// 1. make sure we can duplicate localized blocks
|
||||
// - in relational DBs, we need to create new block / array IDs
|
||||
// - and this needs to be done recursively for all block / array fields
|
||||
// 2. make sure localized arrays / blocks work inside of localized groups / tabs
|
||||
// - this is covered with myTab.group.nestedArray2
|
||||
|
||||
const englishText = 'english'
|
||||
const spanishText = 'spanish'
|
||||
const doc = await payload.create({
|
||||
@@ -1129,8 +1136,30 @@ describe('Localization', () => {
|
||||
{
|
||||
blockType: 'text',
|
||||
text: englishText,
|
||||
nestedArray: [
|
||||
{
|
||||
text: 'hello',
|
||||
},
|
||||
{
|
||||
text: 'goodbye',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
myTab: {
|
||||
text: 'hello',
|
||||
group: {
|
||||
nestedText: 'hello',
|
||||
nestedArray2: [
|
||||
{
|
||||
nestedText: 'hello',
|
||||
},
|
||||
{
|
||||
nestedText: 'goodbye',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
title: 'hello',
|
||||
},
|
||||
locale: defaultLocale,
|
||||
@@ -1144,9 +1173,31 @@ describe('Localization', () => {
|
||||
{
|
||||
blockType: 'text',
|
||||
text: spanishText,
|
||||
nestedArray: [
|
||||
{
|
||||
text: 'hola',
|
||||
},
|
||||
{
|
||||
text: 'adios',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
title: 'hello',
|
||||
myTab: {
|
||||
text: 'hola',
|
||||
group: {
|
||||
nestedText: 'hola',
|
||||
nestedArray2: [
|
||||
{
|
||||
nestedText: 'hola',
|
||||
},
|
||||
{
|
||||
nestedText: 'adios',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
locale: spanishLocale,
|
||||
})
|
||||
@@ -1168,6 +1219,14 @@ describe('Localization', () => {
|
||||
|
||||
expect(allLocales.layout.en[0].text).toStrictEqual(englishText)
|
||||
expect(allLocales.layout.es[0].text).toStrictEqual(spanishText)
|
||||
|
||||
expect(allLocales.myTab.group.en.nestedText).toStrictEqual('hello')
|
||||
expect(allLocales.myTab.group.en.nestedArray2[0].nestedText).toStrictEqual('hello')
|
||||
expect(allLocales.myTab.group.en.nestedArray2[1].nestedText).toStrictEqual('goodbye')
|
||||
|
||||
expect(allLocales.myTab.group.es.nestedText).toStrictEqual('hola')
|
||||
expect(allLocales.myTab.group.es.nestedArray2[0].nestedText).toStrictEqual('hola')
|
||||
expect(allLocales.myTab.group.es.nestedArray2[1].nestedText).toStrictEqual('adios')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user