diff --git a/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts b/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts deleted file mode 100644 index 0aadae2dc34..00000000000 --- a/packages/payload/src/fields/hooks/beforeChange/cloneDataFromOriginalDoc.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { JsonArray, JsonObject } from '../../../types/index.js' - -export const cloneDataFromOriginalDoc = ( - originalDocData: JsonArray | JsonObject, -): JsonArray | JsonObject => { - if (Array.isArray(originalDocData)) { - return originalDocData.map((row) => { - if (typeof row === 'object' && row != null) { - return { - ...row, - } - } - - return row - }) - } - - if (typeof originalDocData === 'object' && originalDocData !== null) { - return { ...originalDocData } - } - - return originalDocData -} diff --git a/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts b/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts index 2bf3c928a78..53b9e5fe3a3 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/getFallbackValue.ts @@ -1,8 +1,8 @@ import type { JsonObject, JsonValue, PayloadRequest } from '../../../types/index.js' import type { FieldAffectingData } from '../../config/types.js' +import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js' import { getDefaultValue } from '../../getDefaultValue.js' -import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc.js' export async function getFallbackValue({ field, @@ -18,7 +18,8 @@ export async function getFallbackValue({ let fallbackValue: JsonValue = undefined if ('name' in field && field.name) { if (isDocumentValueAllowed && typeof siblingDoc[field.name] !== 'undefined') { - fallbackValue = cloneDataFromOriginalDoc(siblingDoc[field.name]) + // Keep nested mutations from hooks and access control from changing the original document. + fallbackValue = deepCopyObjectSimple(siblingDoc[field.name]) } else if ('defaultValue' in field && typeof field.defaultValue !== 'undefined') { fallbackValue = await getDefaultValue({ defaultValue: field.defaultValue, diff --git a/test/access-control/collections/BlocksFieldAccess/index.ts b/test/access-control/collections/BlocksFieldAccess/index.ts index e0048e359b3..d79b84c2584 100644 --- a/test/access-control/collections/BlocksFieldAccess/index.ts +++ b/test/access-control/collections/BlocksFieldAccess/index.ts @@ -11,6 +11,29 @@ export const BlocksFieldAccess: CollectionConfig = { update: () => true, }, fields: [ + { + name: 'rows', + type: 'array', + fields: [ + { + name: 'children', + type: 'array', + fields: [ + { + name: 'allowCodeChanges', + type: 'checkbox', + defaultValue: false, + }, + { + name: 'code', + type: 'text', + access: { update: ({ siblingData }) => siblingData?.allowCodeChanges === true }, + defaultValue: 'default', + }, + ], + }, + ], + }, { name: 'title', type: 'text', diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index 78fb4c38e11..6b727846c64 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -79,6 +79,126 @@ test.suite('Access Control', { config: './config.ts', resetBetweenTests: false } }) test.describe('Fields', () => { + test.describe('fallback values', () => { + const createdIDs: (number | string)[] = [] + + test.afterEach(async ({ payload }) => { + for (const id of createdIDs) { + await payload.delete({ id, collection: blocksFieldAccessSlug, overrideAccess: true }) + } + createdIDs.length = 0 + }) + + test('should allow nested field updates when sibling access permits them', async ({ + payload, + }) => { + const doc = await payload.create({ + collection: blocksFieldAccessSlug, + data: { + rows: [{ children: [{ allowCodeChanges: true, code: 'stored' }] }], + title: 'original', + }, + }) + + const row = doc.rows![0]! + + createdIDs.push(doc.id) + + await payload.update({ + id: doc.id, + collection: blocksFieldAccessSlug, + data: { + rows: [ + { + id: row.id, + children: [{ id: row.children![0]!.id, allowCodeChanges: true, code: 'changed' }], + }, + ], + }, + overrideAccess: false, + }) + + const stored = await payload.findByID({ + id: doc.id, + collection: blocksFieldAccessSlug, + }) + + expect(stored.rows?.[0]?.children?.[0]?.code).toBe('changed') + }) + + for (const draft of [false, true]) { + const operation = draft ? 'draft save' : 'published update' + + test(`should preserve omitted protected nested fields after a ${operation}`, async ({ + payload, + }) => { + const doc = await payload.create({ + collection: blocksFieldAccessSlug, + data: { + _status: draft ? 'draft' : 'published', + rows: [{ children: [{ code: 'stored' }] }], + title: 'original', + }, + draft, + }) + + createdIDs.push(doc.id) + + await payload.update({ + id: doc.id, + collection: blocksFieldAccessSlug, + data: { title: 'updated' }, + draft, + overrideAccess: false, + }) + + const stored = await payload.findByID({ + id: doc.id, + collection: blocksFieldAccessSlug, + draft, + }) + + expect(stored.rows?.[0]?.children?.[0]?.code).toBe('stored') + }) + + test(`should reject changes to protected nested fields during a ${operation}`, async ({ + payload, + }) => { + const doc = await payload.create({ + collection: blocksFieldAccessSlug, + data: { + _status: draft ? 'draft' : 'published', + rows: [{ children: [{ code: 'stored' }] }], + title: 'original', + }, + draft, + }) + + const row = doc.rows![0]! + + createdIDs.push(doc.id) + + await payload.update({ + id: doc.id, + collection: blocksFieldAccessSlug, + data: { + rows: [{ id: row.id, children: [{ id: row.children![0]!.id, code: 'changed' }] }], + }, + draft, + overrideAccess: false, + }) + + const stored = await payload.findByID({ + id: doc.id, + collection: blocksFieldAccessSlug, + draft, + }) + + expect(stored.rows?.[0]?.children?.[0]?.code).toBe('stored') + }) + } + }) + test('should not affect hidden fields when patching data', async ({ payload }) => { const doc = await payload.create({ collection: hiddenFieldsSlug, diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index a51995bb93b..734d9a1be00 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -1440,6 +1440,18 @@ export interface InheritedReadVersionsVirtual { */ export interface BlocksFieldAccess { id: string; + rows?: + | { + children?: + | { + allowCodeChanges?: boolean | null; + code?: string | null; + id?: string | null; + }[] + | null; + id?: string | null; + }[] + | null; title: string; editableBlocks?: TestBlock[] | null; readOnlyBlocks?: TestBlock2[] | null; @@ -1477,6 +1489,7 @@ export interface BlocksFieldAccess { } | null); updatedAt: string; createdAt: string; + _status?: ('draft' | 'published') | null; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -2811,6 +2824,18 @@ export interface InheritedReadVersionsVirtualSelect { * via the `definition` "blocks-field-access_select". */ export interface BlocksFieldAccessSelect { + rows?: + | T + | { + children?: + | T + | { + allowCodeChanges?: T; + code?: T; + id?: T; + }; + id?: T; + }; title?: T; editableBlocks?: | T @@ -2859,6 +2884,7 @@ export interface BlocksFieldAccessSelect { updatedBy?: T; updatedAt?: T; createdAt?: T; + _status?: T; } /** * This interface was referenced by `Config`'s JSON-Schema diff --git a/test/fields/collections/SelectVersions/index.ts b/test/fields/collections/SelectVersions/index.ts index 553440ee40c..915d0ffa647 100644 --- a/test/fields/collections/SelectVersions/index.ts +++ b/test/fields/collections/SelectVersions/index.ts @@ -6,6 +6,7 @@ const SelectVersionsFields: CollectionConfig = { slug: selectVersionsFieldsSlug, versions: { drafts: { autosave: true } }, fields: [ + { name: 'customJSON', type: 'json', label: 'Custom JSON' }, { type: 'select', hasMany: true, diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index ba6981b77d2..058a928de20 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -35,8 +35,10 @@ import { dateFieldsSlug, duplicateFieldsSlug, groupFieldsSlug, + jsonFieldsSlug, numberFieldsSlug, relationshipFieldsSlug, + selectVersionsFieldsSlug, tabsFieldsSlug, textFieldsSlug, } from './slugs.js' @@ -5468,6 +5470,60 @@ test.suite('Fields', { config: './config.ts', resetBetweenTests: false }, () => }) test.describe('json', () => { + test.describe('fallback values', () => { + const createdDocs: { + collection: typeof jsonFieldsSlug | typeof selectVersionsFieldsSlug + id: number | string + }[] = [] + + test.afterEach(async ({ payload }) => { + for (const doc of createdDocs) { + await payload.delete({ ...doc, overrideAccess: true }) + } + createdDocs.length = 0 + }) + + for (const draft of [false, true]) { + const operation = draft ? 'draft save' : 'partial update' + const collection = draft ? selectVersionsFieldsSlug : jsonFieldsSlug + + test(`should preserve omitted JSON tuples after a ${operation}`, async ({ payload }) => { + const doc = await payload.create({ + collection, + data: { + customJSON: [ + [100, 200], + [300, 400], + ], + }, + draft, + overrideAccess: true, + }) + createdDocs.push({ collection, id: doc.id }) + + await payload.update({ + id: doc.id, + collection, + data: draft ? { hasMany: ['a'] } : { json: { foo: 'bar' } }, + draft, + overrideAccess: true, + }) + + const stored = await payload.findByID({ + id: doc.id, + collection, + draft, + overrideAccess: true, + }) + + expect(stored.customJSON).toStrictEqual([ + [100, 200], + [300, 400], + ]) + }) + } + }) + test('should save json data', async ({ payload }) => { const json = { foo: 'bar' } const doc = await payload.create({ diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 0fda6a4ad0f..e9501f08e45 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -413,6 +413,15 @@ export interface User { */ export interface SelectVersionsField { id: string; + customJSON?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; hasMany?: ('a' | 'b' | 'c' | 'd')[] | null; array?: | { @@ -2713,6 +2722,7 @@ export interface UsersSelect { * via the `definition` "select-versions-fields_select". */ export interface SelectVersionsFieldsSelect { + customJSON?: T; hasMany?: T; array?: | T