From 40c68c16dfbc6889395738f1cd3507fa151499a9 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 17:20:41 +0100 Subject: [PATCH 01/47] feat(payload): prepare validation lifecycle types --- packages/payload/src/admin/RichText.ts | 6 +- .../payload/src/collections/config/types.ts | 6 +- .../src/errors/ValidationError.spec.ts | 36 ++++++ .../payload/src/errors/ValidationError.ts | 1 + packages/payload/src/fields/config/types.ts | 8 +- .../src/fields/hooks/beforeChange/index.ts | 4 +- .../fields/hooks/beforeChange/promise.spec.ts | 111 ++++++++++++++++++ .../src/fields/hooks/beforeChange/promise.ts | 6 +- .../hooks/beforeChange/traverseFields.ts | 4 +- .../src/fields/hooks/beforeValidate/index.ts | 2 +- .../fields/hooks/beforeValidate/promise.ts | 4 +- .../hooks/beforeValidate/traverseFields.ts | 2 +- packages/payload/src/globals/config/types.ts | 6 + .../src/globals/operations/restoreVersion.ts | 1 + .../payload/src/globals/operations/update.ts | 3 + packages/payload/src/types/index.ts | 3 + test/types/types.spec.ts | 40 +++++++ 17 files changed, 224 insertions(+), 19 deletions(-) create mode 100644 packages/payload/src/errors/ValidationError.spec.ts create mode 100644 packages/payload/src/fields/hooks/beforeChange/promise.spec.ts diff --git a/packages/payload/src/admin/RichText.ts b/packages/payload/src/admin/RichText.ts index 18a8c5b2c37..b3dbf488f0d 100644 --- a/packages/payload/src/admin/RichText.ts +++ b/packages/payload/src/admin/RichText.ts @@ -13,7 +13,7 @@ import type { } from '../fields/config/types.js' import type { SanitizedGlobalConfig } from '../globals/config/types.js' import type { RequestContext, TypedFallbackLocale } from '../index.js' -import type { JsonObject, PayloadRequest, PopulateType } from '../types/index.js' +import type { FieldOperation, JsonObject, PayloadRequest, PopulateType } from '../types/index.js' import type { FieldsToJSONSchemaArgs } from '../utilities/configToJSONSchema.js' import type { RichTextFieldClientProps, RichTextFieldServerProps } from './fields/RichText.js' import type { FieldDiffClientProps, FieldDiffServerProps, FieldSchemaMap } from './types.js' @@ -77,7 +77,7 @@ export type BeforeValidateRichTextHookArgs< TSiblingData = any, > = { /** A string relating to which operation the field type is currently executing within. */ - operation: 'create' | 'update' + operation: 'create' | 'update' | 'validate' overrideAccess?: boolean /** The sibling data of the document before changes being applied. */ previousSiblingDoc?: TSiblingData @@ -108,7 +108,7 @@ export type BeforeChangeRichTextHookArgs< /** Only available in `beforeChange` field hooks */ mergeLocaleActions?: (() => Promise | void)[] /** A string relating to which operation the field type is currently executing within. */ - operation?: 'create' | 'delete' | 'read' | 'update' + operation?: FieldOperation overrideAccess: boolean /** The sibling data of the document before changes being applied. */ previousSiblingDoc?: TSiblingData diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index b12a23383e3..f1220f5ce53 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -170,8 +170,10 @@ export type HookOperationType = | 'resetPassword' | 'restoreVersion' | 'update' + | 'validate' type CreateOrUpdateOperation = Extract +type CreateUpdateOrValidateOperation = Extract export type BeforeOperationHook = ( arg: BeforeOperationArg, @@ -189,7 +191,7 @@ export type BeforeValidateHook = (args: { /** * Hook operation being performed */ - operation: CreateOrUpdateOperation + operation: CreateUpdateOrValidateOperation /** * Original document before change * @@ -207,7 +209,7 @@ export type BeforeChangeHook = (args: { /** * Hook operation being performed */ - operation: CreateOrUpdateOperation + operation: CreateUpdateOrValidateOperation /** * Original document before change * diff --git a/packages/payload/src/errors/ValidationError.spec.ts b/packages/payload/src/errors/ValidationError.spec.ts new file mode 100644 index 00000000000..e3dac9055da --- /dev/null +++ b/packages/payload/src/errors/ValidationError.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { ValidationError } from './ValidationError.js' + +describe('ValidationError', () => { + it('should retain locales while listing field labels and paths in the message', () => { + const error = new ValidationError({ + errors: [ + { + label: 'Title', + locale: 'de', + message: 'Title is required', + path: 'title', + }, + { + message: 'SEO description is required', + path: 'seo.description', + }, + ], + }) + + expect(error.message).toBe('The following fields are invalid: Title, seo.description') + expect(error.data.errors).toEqual([ + { + label: 'Title', + locale: 'de', + message: 'Title is required', + path: 'title', + }, + { + message: 'SEO description is required', + path: 'seo.description', + }, + ]) + }) +}) diff --git a/packages/payload/src/errors/ValidationError.ts b/packages/payload/src/errors/ValidationError.ts index 2e9b8d1f288..13143c6d064 100644 --- a/packages/payload/src/errors/ValidationError.ts +++ b/packages/payload/src/errors/ValidationError.ts @@ -13,6 +13,7 @@ export const ValidationErrorName = 'ValidationError' export type ValidationFieldError = { label?: LabelFunction | StaticLabel + locale?: string // The error message to display for this field message: string path: string diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index ec93f19efba..c601f905d92 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -141,8 +141,8 @@ import type { import type { DocumentPreferences } from '../../preferences/types.js' import type { DefaultValue, + FieldOperation, JsonObject, - Operation, PayloadRequest, PickPreserveOptional, Where, @@ -199,7 +199,7 @@ export type FieldHookArgs = ( /** * A string relating to which operation the field type is currently executing within. */ - operation: Operation + operation: FieldOperation /** * The path of the field, e.g. ["group", "myArray", 1, "textField"]. The path is the schemaPath but with indexes and would be used in the context of field data, not field schemas. */ @@ -437,7 +437,7 @@ export type BaseValidateOptions = { data: Partial event?: 'onChange' | 'submit' id?: number | string - operation?: Operation + operation?: FieldOperation /** * The `overrideAccess` flag that was attached to the request. This is used to bypass access control checks for fields. */ diff --git a/packages/payload/src/fields/hooks/beforeChange/index.ts b/packages/payload/src/fields/hooks/beforeChange/index.ts index c412b4311dd..46a22c1eed7 100644 --- a/packages/payload/src/fields/hooks/beforeChange/index.ts +++ b/packages/payload/src/fields/hooks/beforeChange/index.ts @@ -2,7 +2,7 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/type import type { ValidationFieldError } from '../../../errors/index.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' -import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js' +import type { FieldOperation, JsonObject, PayloadRequest } from '../../../types/index.js' import { ValidationError } from '../../../errors/index.js' import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js' @@ -16,7 +16,7 @@ export type Args = { docWithLocales: JsonObject global: null | SanitizedGlobalConfig id?: number | string - operation: Operation + operation: FieldOperation overrideAccess?: boolean req: PayloadRequest skipValidation?: boolean diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.spec.ts b/packages/payload/src/fields/hooks/beforeChange/promise.spec.ts new file mode 100644 index 00000000000..dbb406c8dd6 --- /dev/null +++ b/packages/payload/src/fields/hooks/beforeChange/promise.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' + +import type { PayloadRequest } from '../../../types/index.js' +import type { Field } from '../../config/types.js' + +import { promise } from './promise.js' + +const req = { + i18n: {}, + locale: 'de', + payload: { + blocks: {}, + config: {}, + }, + t: (key: string) => key, + user: null, +} as PayloadRequest + +describe('beforeChange field traversal', () => { + it('should add the active locale to ordinary field validation errors', async () => { + const errors = [] + + await promise({ + collection: null, + context: {}, + data: { title: '' }, + doc: {}, + docWithLocales: {}, + errors, + field: { + name: 'title', + type: 'text', + validate: () => 'Title is required', + } as Field, + fieldIndex: 0, + fieldLabelPath: '', + global: null, + mergeLocaleActions: [], + operation: 'update', + overrideAccess: false, + parentIndexPath: '', + parentIsLocalized: false, + parentPath: '', + parentSchemaPath: '', + req, + siblingData: { title: '' }, + siblingDoc: {}, + siblingDocWithLocales: {}, + skipValidation: false, + }) + + expect(errors).toEqual([ + { + label: 'Title', + locale: 'de', + message: 'Title is required', + path: 'title', + }, + ]) + }) + + it('should add the active locale to invalid block filter errors', async () => { + const errors = [] + + await promise({ + collection: null, + context: {}, + data: { content: [{ blockType: 'restricted' }] }, + doc: {}, + docWithLocales: {}, + errors, + field: { + blocks: [ + { + fields: [], + labels: { plural: 'Restricted', singular: 'Restricted' }, + slug: 'restricted', + }, + ], + filterOptions: [], + name: 'content', + type: 'blocks', + validate: () => 'Invalid block', + } as Field, + fieldIndex: 0, + fieldLabelPath: '', + global: null, + mergeLocaleActions: [], + operation: 'update', + overrideAccess: false, + parentIndexPath: '', + parentIsLocalized: false, + parentPath: '', + parentSchemaPath: '', + req, + siblingData: { content: [{ blockType: 'restricted' }] }, + siblingDoc: {}, + siblingDocWithLocales: {}, + skipValidation: false, + }) + + expect(errors).toEqual([ + { + label: 'Content > fields:block 1 (Restricted)', + locale: 'de', + message: 'validation:invalidBlock', + path: 'content.0.id', + }, + ]) + }) +}) diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index e91fe1bf640..987d171e5ab 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -2,7 +2,7 @@ import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { ValidationFieldError } from '../../../errors/index.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js' +import type { FieldOperation, JsonObject, PayloadRequest } from '../../../types/index.js' import type { Block, Field, TabAsField, Validate } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' @@ -46,7 +46,7 @@ type Args = { global: null | SanitizedGlobalConfig id?: number | string mergeLocaleActions: (() => Promise | void)[] - operation: Operation + operation: FieldOperation overrideAccess: boolean parentIndexPath: string parentIsLocalized: boolean @@ -239,6 +239,7 @@ export const promise = async ({ errors.push({ label: blockLabelPath, + locale: req.locale ?? undefined, message: req.t('validation:invalidBlock', { block: block.blockType }), path: `${path}.${rowIndex}.id`, }) @@ -257,6 +258,7 @@ export const promise = async ({ errors.push({ label: fieldLabel, + locale: req.locale ?? undefined, message: validationResult, path, }) diff --git a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts index 58cac4b2e99..97b90dd03a2 100644 --- a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts @@ -2,7 +2,7 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/type import type { ValidationFieldError } from '../../../errors/index.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' -import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js' +import type { FieldOperation, JsonObject, PayloadRequest } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { promise } from './promise.js' @@ -34,7 +34,7 @@ type Args = { global: null | SanitizedGlobalConfig id?: number | string mergeLocaleActions: (() => Promise | void)[] - operation: Operation + operation: FieldOperation overrideAccess: boolean parentIndexPath: string /** diff --git a/packages/payload/src/fields/hooks/beforeValidate/index.ts b/packages/payload/src/fields/hooks/beforeValidate/index.ts index 5b3a6b18c4c..724028ed46e 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/index.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/index.ts @@ -13,7 +13,7 @@ type Args = { duplicate?: boolean global: null | SanitizedGlobalConfig id?: number | string - operation: 'create' | 'update' + operation: 'create' | 'update' | 'validate' overrideAccess: boolean req: PayloadRequest } diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index 85bd04d3c44..5a0ffcde12a 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -29,7 +29,7 @@ type Args = { fieldIndex: number global: null | SanitizedGlobalConfig id?: number | string - operation: 'create' | 'update' + operation: 'create' | 'update' | 'validate' overrideAccess: boolean parentIndexPath: string parentIsLocalized: boolean @@ -318,7 +318,7 @@ export const promise = async ({ } // Execute access control - if (field.access && field.access[operation]) { + if (operation !== 'validate' && field.access && field.access[operation]) { const result = overrideAccess ? true : await field.access[operation]( diff --git a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts index 3b654e51ff6..60fe65d24d4 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts @@ -21,7 +21,7 @@ type Args = { fields: (Field | TabAsField)[] global: null | SanitizedGlobalConfig id?: number | string - operation: 'create' | 'update' + operation: 'create' | 'update' | 'validate' overrideAccess: boolean parentIndexPath: string /** diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index 2a5788f79c7..33d65333500 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -71,6 +71,8 @@ export type BeforeValidateHook = (args: { data?: any /** The global which this hook is being run on */ global: SanitizedGlobalConfig + /** Hook operation being performed. */ + operation: 'update' | 'validate' originalDoc?: any /** * Whether access control is being overridden for this operation @@ -84,6 +86,8 @@ export type BeforeChangeHook = (args: { data: any /** The global which this hook is being run on */ global: SanitizedGlobalConfig + /** Hook operation being performed. */ + operation: 'update' | 'validate' originalDoc?: any /** * Whether access control is being overridden for this operation @@ -98,6 +102,8 @@ export type AfterChangeHook = (args: { doc: any /** The global which this hook is being run on */ global: SanitizedGlobalConfig + /** Hook operation being performed. */ + operation: 'update' /** * Whether access control is being overridden for this operation */ diff --git a/packages/payload/src/globals/operations/restoreVersion.ts b/packages/payload/src/globals/operations/restoreVersion.ts index 19373546c1e..9f829ae6ba5 100644 --- a/packages/payload/src/globals/operations/restoreVersion.ts +++ b/packages/payload/src/globals/operations/restoreVersion.ts @@ -195,6 +195,7 @@ export const restoreVersionOperation = async = any data: result, doc: result, global: globalConfig, + operation: 'update', overrideAccess, previousDoc, req, diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 85461cf379a..977590f1898 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -212,6 +212,7 @@ export const updateOperation = async < context: req.context, data, global: globalConfig, + operation: 'update', originalDoc, overrideAccess, req, @@ -230,6 +231,7 @@ export const updateOperation = async < context: req.context, data, global: globalConfig, + operation: 'update', originalDoc, overrideAccess, req, @@ -478,6 +480,7 @@ export const updateOperation = async < data, doc: result, global: globalConfig, + operation: 'update', overrideAccess, previousDoc: originalDoc, req, diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index df58f3c5505..19dde193a2e 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -130,6 +130,8 @@ export interface PayloadRequest Partial, PayloadRequestData { headers: Request['headers'] + /** The active Payload operation. */ + operation?: FieldOperation } export type { Operator } @@ -191,6 +193,7 @@ export type JoinQuery = export type Document = any export type Operation = 'create' | 'delete' | 'read' | 'update' +export type FieldOperation = 'validate' | Operation export type VersionOperations = 'readVersions' export type AuthOperations = 'unlock' export type AllOperations = AuthOperations | Operation | VersionOperations diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index 9d9d166777c..3829afdb5f9 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -2,10 +2,17 @@ import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical import type { AuthenticatedUser, BulkOperationResult, + CollectionAfterChangeHook, + CollectionBeforeChangeHook, + CollectionBeforeValidateHook, CollectionSlug, CustomDocumentViewConfig, DefaultDocumentViewConfig, + FieldHookArgs, GeneratedTypes, + GlobalAfterChangeHook, + GlobalBeforeChangeHook, + GlobalBeforeValidateHook, JoinQuery, MeOperationResult, PaginatedDocs, @@ -15,6 +22,8 @@ import type { TypedCollectionSelect, TypeWithVersion, UntypedPayloadTypes, + Validate, + ValidationFieldError, Where, } from 'payload' @@ -79,6 +88,37 @@ import type { } from './payload-types.js' describe('Types testing', () => { + describe('validate operation types', () => { + test('should expose validate only to validation lifecycle types', () => { + expect<{ + locale?: string + message: string + path: string + }>().type.toBeAssignableTo() + expect<'validate'>().type.toBeAssignableTo() + expect<'validate'>().type.toBeAssignableTo() + expect<'validate'>().type.toBeAssignableTo[1]['operation']>() + expect<'validate'>().type.toBeAssignableTo< + Parameters[0]['operation'] + >() + expect<'validate'>().type.toBeAssignableTo< + Parameters[0]['operation'] + >() + expect<'validate'>().type.not.toBeAssignableTo< + Parameters[0]['operation'] + >() + expect<'validate'>().type.toBeAssignableTo< + Parameters[0]['operation'] + >() + expect<'validate'>().type.toBeAssignableTo< + Parameters[0]['operation'] + >() + expect<'validate'>().type.not.toBeAssignableTo< + Parameters[0]['operation'] + >() + }) + }) + describe('authenticated user', () => { test('should use AuthenticatedUser for request and me operation users', () => { expect().type.toBe() From 365bdbbb00fdabb6c60194d4e9dc6cff2f8fc1f5 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 17:28:11 +0100 Subject: [PATCH 02/47] fix(payload): preserve field access during validation --- .../src/collections/operations/create.ts | 1 + .../collections/operations/restoreVersion.ts | 1 + .../operations/utilities/update.ts | 1 + .../fields/hooks/beforeValidate/index.spec.ts | 50 +++++++++++++++++++ .../src/fields/hooks/beforeValidate/index.ts | 5 +- .../fields/hooks/beforeValidate/promise.ts | 19 +++++-- .../hooks/beforeValidate/traverseFields.ts | 5 +- .../payload/src/globals/operations/update.ts | 1 + packages/payload/src/types/index.ts | 1 + 9 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 packages/payload/src/fields/hooks/beforeValidate/index.spec.ts diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 67f24751309..f0b82af8f4b 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -176,6 +176,7 @@ export const createOperation = async < // ///////////////////////////////////// data = await beforeValidate({ + accessOperation: 'create', collection: collectionConfig, context: req.context, data, diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index 2d2a6c4206f..a23a2a1d588 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -190,6 +190,7 @@ export const restoreVersionOperation = async < let data = await beforeValidate({ id: parentDocID, + accessOperation: 'update', collection: collectionConfig, context: req.context, data: deepCopyObjectSimple(prevVersionDoc), diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index f604129ecf1..0505ccc59e8 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -192,6 +192,7 @@ export const updateDocument = async < data = await beforeValidate>>({ id, + accessOperation: 'update', collection: collectionConfig, context: req.context, data, diff --git a/packages/payload/src/fields/hooks/beforeValidate/index.spec.ts b/packages/payload/src/fields/hooks/beforeValidate/index.spec.ts new file mode 100644 index 00000000000..e8f1e956f79 --- /dev/null +++ b/packages/payload/src/fields/hooks/beforeValidate/index.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import type { PayloadRequest } from '../../../types/index.js' +import type { Field } from '../../config/types.js' + +import { beforeValidate } from './index.js' + +describe('beforeValidate', () => { + it('should apply the selected field access policy while hooks receive validate', async () => { + const hookOperations: string[] = [] + const accessOperations: string[] = [] + const data = { title: 'restricted' } + + await beforeValidate({ + accessOperation: 'update', + collection: { + fields: [ + { + access: { + update: () => { + accessOperations.push('update') + return false + }, + }, + hooks: { + beforeValidate: [ + ({ operation }) => { + hookOperations.push(operation!) + }, + ], + }, + name: 'title', + type: 'text', + } as Field, + ], + } as any, + context: {}, + data, + doc: {}, + global: null, + operation: 'validate', + overrideAccess: false, + req: { payload: {} } as PayloadRequest, + }) + + expect(hookOperations).toEqual(['validate']) + expect(accessOperations).toEqual(['update']) + expect(data).toEqual({}) + }) +}) diff --git a/packages/payload/src/fields/hooks/beforeValidate/index.ts b/packages/payload/src/fields/hooks/beforeValidate/index.ts index 724028ed46e..e911fad319b 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/index.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/index.ts @@ -1,11 +1,12 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { JsonObject, PayloadRequest } from '../../../types/index.js' +import type { FieldAccessOperation, JsonObject, PayloadRequest } from '../../../types/index.js' import { type RequestContext } from '../../../index.js' import { traverseFields } from './traverseFields.js' type Args = { + accessOperation: FieldAccessOperation collection: null | SanitizedCollectionConfig context: RequestContext data: T @@ -28,6 +29,7 @@ type Args = { */ export const beforeValidate = async ({ id, + accessOperation, collection, context, data: incomingData, @@ -39,6 +41,7 @@ export const beforeValidate = async ({ }: Args): Promise => { await traverseFields({ id, + accessOperation, collection, context, data: incomingData, diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index 5a0ffcde12a..4970c666ed9 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -2,7 +2,12 @@ import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' -import type { JsonObject, JsonValue, PayloadRequest } from '../../../types/index.js' +import type { + FieldAccessOperation, + JsonObject, + JsonValue, + PayloadRequest, +} from '../../../types/index.js' import type { Block, Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' @@ -14,6 +19,7 @@ import { stripNullRows } from './stripNullRows.js' import { traverseFields } from './traverseFields.js' type Args = { + accessOperation: FieldAccessOperation /** * Data of the nearest parent block. If no parent block exists, this will be the `undefined` */ @@ -53,6 +59,7 @@ type Args = { export const promise = async ({ id, + accessOperation, blockData, collection, context, @@ -318,10 +325,10 @@ export const promise = async ({ } // Execute access control - if (operation !== 'validate' && field.access && field.access[operation]) { + if (field.access && field.access[accessOperation]) { const result = overrideAccess ? true - : await field.access[operation]( + : await field.access[accessOperation]( collection ? { id, @@ -374,6 +381,7 @@ export const promise = async ({ promises.push( traverseFields({ id, + accessOperation, blockData, collection, context, @@ -428,6 +436,7 @@ export const promise = async ({ promises.push( traverseFields({ id, + accessOperation, blockData: row, collection, context, @@ -459,6 +468,7 @@ export const promise = async ({ case 'row': { await traverseFields({ id, + accessOperation, blockData, collection, context, @@ -501,6 +511,7 @@ export const promise = async ({ await traverseFields({ id, + accessOperation, blockData, collection, context, @@ -587,6 +598,7 @@ export const promise = async ({ await traverseFields({ id, + accessOperation, blockData, collection, context, @@ -611,6 +623,7 @@ export const promise = async ({ case 'tabs': { await traverseFields({ id, + accessOperation, blockData, collection, context, diff --git a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts index 60fe65d24d4..b6bcdb06839 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts @@ -1,12 +1,13 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' -import type { JsonObject, PayloadRequest } from '../../../types/index.js' +import type { FieldAccessOperation, JsonObject, PayloadRequest } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { promise } from './promise.js' type Args = { + accessOperation: FieldAccessOperation /** * Data of the nearest parent block. If no parent block exists, this will be the `undefined` */ @@ -40,6 +41,7 @@ type Args = { export const traverseFields = async ({ id, + accessOperation, blockData, collection, context, @@ -63,6 +65,7 @@ export const traverseFields = async ({ promises.push( promise({ id, + accessOperation, blockData, collection, context, diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 977590f1898..801132981ed 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -191,6 +191,7 @@ export const updateOperation = async < // ///////////////////////////////////// data = await beforeValidate({ + accessOperation: 'update', collection: null, context: req.context, data, diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index 19dde193a2e..66cecca0f62 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -194,6 +194,7 @@ export type Document = any export type Operation = 'create' | 'delete' | 'read' | 'update' export type FieldOperation = 'validate' | Operation +export type FieldAccessOperation = Extract export type VersionOperations = 'readVersions' export type AuthOperations = 'unlock' export type AllOperations = AuthOperations | Operation | VersionOperations From 717bacac59408ea0ba5d4f415c8042528092aea0 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 17:36:19 +0100 Subject: [PATCH 03/47] fix(payload): add validation access operation --- packages/payload/src/auth/getAccessResults.ts | 10 ++++++-- packages/payload/src/auth/types.ts | 7 ++++++ .../src/collections/config/defaults.ts | 2 ++ .../payload/src/collections/config/types.ts | 1 + .../src/collections/operations/create.ts | 1 - .../src/collections/operations/docAccess.ts | 2 +- .../collections/operations/restoreVersion.ts | 1 - .../operations/utilities/update.ts | 1 - packages/payload/src/fields/config/types.ts | 2 ++ .../fields/hooks/beforeValidate/index.spec.ts | 9 ++++--- .../src/fields/hooks/beforeValidate/index.ts | 5 +--- .../fields/hooks/beforeValidate/promise.ts | 19 +++------------ .../hooks/beforeValidate/traverseFields.ts | 5 +--- .../payload/src/globals/config/sanitize.ts | 4 ++++ packages/payload/src/globals/config/types.ts | 1 + .../src/globals/operations/docAccess.ts | 2 +- .../payload/src/globals/operations/update.ts | 1 - packages/payload/src/query-presets/access.ts | 8 ++++--- packages/payload/src/types/index.ts | 5 ++-- .../getEntityPermissions.ts | 3 ++- .../src/utilities/getFieldPermissions.ts | 4 +++- test/types/types.spec.ts | 24 +++++++++++++++++++ 22 files changed, 72 insertions(+), 45 deletions(-) diff --git a/packages/payload/src/auth/getAccessResults.ts b/packages/payload/src/auth/getAccessResults.ts index 39af581b976..2e46880a2fe 100644 --- a/packages/payload/src/auth/getAccessResults.ts +++ b/packages/payload/src/auth/getAccessResults.ts @@ -31,7 +31,13 @@ export async function getAccessResults({ await Promise.all( payload.config.collections.map(async (collection) => { - const collectionOperations: AllOperations[] = ['create', 'read', 'update', 'delete'] + const collectionOperations: AllOperations[] = [ + 'create', + 'read', + 'update', + 'delete', + 'validate', + ] if ( collection.auth && @@ -59,7 +65,7 @@ export async function getAccessResults({ await Promise.all( payload.config.globals.map(async (global) => { - const globalOperations: AllOperations[] = ['read', 'update'] + const globalOperations: AllOperations[] = ['read', 'update', 'validate'] if (global.versions) { globalOperations.push('readVersions') diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index 4d400824cf4..928d18524e0 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -20,6 +20,7 @@ export type BlockPermissions = { fields: FieldsPermissions read: Permission update: Permission + validate: Permission } export type SanitizedBlockPermissions = @@ -44,6 +45,7 @@ export type FieldPermissions = { fields?: FieldsPermissions read?: Permission update?: Permission + validate?: Permission } export type SanitizedFieldPermissions = @@ -53,6 +55,7 @@ export type SanitizedFieldPermissions = fields?: SanitizedFieldsPermissions read: true update: true + validate: true } | true @@ -71,6 +74,7 @@ export type CollectionPermission = { // Auth-enabled Collections only unlock?: Permission update?: Permission + validate?: Permission } export type SanitizedCollectionPermission = { @@ -82,6 +86,7 @@ export type SanitizedCollectionPermission = { // Auth-enabled Collections only unlock?: true update?: true + validate?: true } export type GlobalPermission = { @@ -89,6 +94,7 @@ export type GlobalPermission = { read?: Permission readVersions?: Permission update?: Permission + validate?: Permission } export type SanitizedGlobalPermission = { @@ -96,6 +102,7 @@ export type SanitizedGlobalPermission = { read?: true readVersions?: true update?: true + validate?: true } export type DocumentPermissions = CollectionPermission | GlobalPermission diff --git a/packages/payload/src/collections/config/defaults.ts b/packages/payload/src/collections/config/defaults.ts index b6805d2c1d5..6819d7039d9 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -13,6 +13,7 @@ export const defaults: Partial = { read: defaultAccess, unlock: defaultAccess, update: defaultAccess, + validate: defaultAccess, }, admin: { components: {}, @@ -61,6 +62,7 @@ export const addDefaultsToCollectionConfig = (collection: CollectionConfig): Col read: defaultAccess, unlock: defaultAccess, update: defaultAccess, + validate: defaultAccess, ...(collection.access || {}), } diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index f1220f5ce53..10b26e51b1d 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -550,6 +550,7 @@ export type CollectionConfig = { readVersions?: Access unlock?: Access update?: Access + validate?: Access } /** * Collection admin options diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index f0b82af8f4b..67f24751309 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -176,7 +176,6 @@ export const createOperation = async < // ///////////////////////////////////// data = await beforeValidate({ - accessOperation: 'create', collection: collectionConfig, context: req.context, data, diff --git a/packages/payload/src/collections/operations/docAccess.ts b/packages/payload/src/collections/operations/docAccess.ts index 61245dc69e5..f90fb55d512 100644 --- a/packages/payload/src/collections/operations/docAccess.ts +++ b/packages/payload/src/collections/operations/docAccess.ts @@ -6,7 +6,7 @@ import { getEntityPermissions } from '../../utilities/getEntityPermissions/getEn import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizePermissions } from '../../utilities/sanitizePermissions.js' -const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete'] +const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete', 'validate'] type Arguments = { collection: Collection diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index a23a2a1d588..2d2a6c4206f 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -190,7 +190,6 @@ export const restoreVersionOperation = async < let data = await beforeValidate({ id: parentDocID, - accessOperation: 'update', collection: collectionConfig, context: req.context, data: deepCopyObjectSimple(prevVersionDoc), diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 0505ccc59e8..f604129ecf1 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -192,7 +192,6 @@ export const updateDocument = async < data = await beforeValidate>>({ id, - accessOperation: 'update', collection: collectionConfig, context: req.context, data, diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index c601f905d92..15dcb1e73dc 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -504,6 +504,7 @@ export interface FieldBase { create?: FieldAccess read?: FieldAccess update?: FieldAccess + validate?: FieldAccess } admin?: FieldAdmin /** Extension point to add your custom data. Server only. */ @@ -1667,6 +1668,7 @@ export type JoinField = { create?: never read?: FieldAccess update?: never + validate?: never } admin?: { allowCreate?: boolean diff --git a/packages/payload/src/fields/hooks/beforeValidate/index.spec.ts b/packages/payload/src/fields/hooks/beforeValidate/index.spec.ts index e8f1e956f79..f86b1b6b321 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/index.spec.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/index.spec.ts @@ -6,19 +6,18 @@ import type { Field } from '../../config/types.js' import { beforeValidate } from './index.js' describe('beforeValidate', () => { - it('should apply the selected field access policy while hooks receive validate', async () => { + it('should apply the validate field access policy while hooks receive validate', async () => { const hookOperations: string[] = [] const accessOperations: string[] = [] const data = { title: 'restricted' } await beforeValidate({ - accessOperation: 'update', collection: { fields: [ { access: { - update: () => { - accessOperations.push('update') + validate: () => { + accessOperations.push('validate') return false }, }, @@ -44,7 +43,7 @@ describe('beforeValidate', () => { }) expect(hookOperations).toEqual(['validate']) - expect(accessOperations).toEqual(['update']) + expect(accessOperations).toEqual(['validate']) expect(data).toEqual({}) }) }) diff --git a/packages/payload/src/fields/hooks/beforeValidate/index.ts b/packages/payload/src/fields/hooks/beforeValidate/index.ts index e911fad319b..724028ed46e 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/index.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/index.ts @@ -1,12 +1,11 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' -import type { FieldAccessOperation, JsonObject, PayloadRequest } from '../../../types/index.js' +import type { JsonObject, PayloadRequest } from '../../../types/index.js' import { type RequestContext } from '../../../index.js' import { traverseFields } from './traverseFields.js' type Args = { - accessOperation: FieldAccessOperation collection: null | SanitizedCollectionConfig context: RequestContext data: T @@ -29,7 +28,6 @@ type Args = { */ export const beforeValidate = async ({ id, - accessOperation, collection, context, data: incomingData, @@ -41,7 +39,6 @@ export const beforeValidate = async ({ }: Args): Promise => { await traverseFields({ id, - accessOperation, collection, context, data: incomingData, diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index 4970c666ed9..0d2a02e27c3 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -2,12 +2,7 @@ import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' -import type { - FieldAccessOperation, - JsonObject, - JsonValue, - PayloadRequest, -} from '../../../types/index.js' +import type { JsonObject, JsonValue, PayloadRequest } from '../../../types/index.js' import type { Block, Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' @@ -19,7 +14,6 @@ import { stripNullRows } from './stripNullRows.js' import { traverseFields } from './traverseFields.js' type Args = { - accessOperation: FieldAccessOperation /** * Data of the nearest parent block. If no parent block exists, this will be the `undefined` */ @@ -59,7 +53,6 @@ type Args = { export const promise = async ({ id, - accessOperation, blockData, collection, context, @@ -325,10 +318,10 @@ export const promise = async ({ } // Execute access control - if (field.access && field.access[accessOperation]) { + if (field.access && field.access[operation]) { const result = overrideAccess ? true - : await field.access[accessOperation]( + : await field.access[operation]( collection ? { id, @@ -381,7 +374,6 @@ export const promise = async ({ promises.push( traverseFields({ id, - accessOperation, blockData, collection, context, @@ -436,7 +428,6 @@ export const promise = async ({ promises.push( traverseFields({ id, - accessOperation, blockData: row, collection, context, @@ -468,7 +459,6 @@ export const promise = async ({ case 'row': { await traverseFields({ id, - accessOperation, blockData, collection, context, @@ -511,7 +501,6 @@ export const promise = async ({ await traverseFields({ id, - accessOperation, blockData, collection, context, @@ -598,7 +587,6 @@ export const promise = async ({ await traverseFields({ id, - accessOperation, blockData, collection, context, @@ -623,7 +611,6 @@ export const promise = async ({ case 'tabs': { await traverseFields({ id, - accessOperation, blockData, collection, context, diff --git a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts index b6bcdb06839..60fe65d24d4 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts @@ -1,13 +1,12 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' -import type { FieldAccessOperation, JsonObject, PayloadRequest } from '../../../types/index.js' +import type { JsonObject, PayloadRequest } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' import { promise } from './promise.js' type Args = { - accessOperation: FieldAccessOperation /** * Data of the nearest parent block. If no parent block exists, this will be the `undefined` */ @@ -41,7 +40,6 @@ type Args = { export const traverseFields = async ({ id, - accessOperation, blockData, collection, context, @@ -65,7 +63,6 @@ export const traverseFields = async ({ promises.push( promise({ id, - accessOperation, blockData, collection, context, diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 988d042f2a3..542e935740a 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -56,6 +56,10 @@ export const sanitizeGlobal = async ( global.access.update = defaultAccess } + if (!global.access.validate) { + global.access.validate = defaultAccess + } + if (!global.hooks.beforeValidate) { global.hooks.beforeValidate = [] } diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index 33d65333500..eb1e394a89a 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -201,6 +201,7 @@ export type GlobalConfig = { read?: Access readVersions?: Access update?: Access + validate?: Access } admin?: GlobalAdminOptions /** Extension point to add your custom data. Server only. */ diff --git a/packages/payload/src/globals/operations/docAccess.ts b/packages/payload/src/globals/operations/docAccess.ts index a56e4ede6cb..55842acee83 100644 --- a/packages/payload/src/globals/operations/docAccess.ts +++ b/packages/payload/src/globals/operations/docAccess.ts @@ -20,7 +20,7 @@ type Arguments = { export const docAccessOperation = async (args: Arguments): Promise => { const { data, globalConfig, req } = args - const globalOperations: AllOperations[] = ['read', 'update'] + const globalOperations: AllOperations[] = ['read', 'update', 'validate'] if (globalConfig.versions) { globalOperations.push('readVersions') diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 801132981ed..977590f1898 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -191,7 +191,6 @@ export const updateOperation = async < // ///////////////////////////////////// data = await beforeValidate({ - accessOperation: 'update', collection: null, context: req.context, data, diff --git a/packages/payload/src/query-presets/access.ts b/packages/payload/src/query-presets/access.ts index e8068cbd6fe..0a93b4f72e6 100644 --- a/packages/payload/src/query-presets/access.ts +++ b/packages/payload/src/query-presets/access.ts @@ -3,7 +3,9 @@ import type { Operation } from '../types/index.js' import { defaultAccess } from '../auth/defaultAccess.js' -const operations: Operation[] = ['delete', 'read', 'update', 'create'] as const +type QueryPresetOperation = Exclude + +const operations: QueryPresetOperation[] = ['delete', 'read', 'update', 'create'] as const const defaultCollectionAccess = { create: defaultAccess, @@ -13,7 +15,7 @@ const defaultCollectionAccess = { update: defaultAccess, } -export const getAccess = (config: Config): Record => +export const getAccess = (config: Config): Record => operations.reduce( (acc, operation) => { acc[operation] = async (args) => { @@ -100,5 +102,5 @@ export const getAccess = (config: Config): Record => return acc }, - {} as Record, + {} as Record, ) diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index 66cecca0f62..e7cd4da9002 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -192,9 +192,8 @@ export type JoinQuery = // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Document = any -export type Operation = 'create' | 'delete' | 'read' | 'update' -export type FieldOperation = 'validate' | Operation -export type FieldAccessOperation = Extract +export type Operation = 'create' | 'delete' | 'read' | 'update' | 'validate' +export type FieldOperation = Operation export type VersionOperations = 'readVersions' export type AuthOperations = 'unlock' export type AllOperations = AuthOperations | Operation | VersionOperations diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index a2bd2de3782..4cde10bc0a8 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -58,9 +58,10 @@ const topLevelCollectionPermissions = [ 'read', 'readVersions', 'update', + 'validate', 'unlock', ] -const topLevelGlobalPermissions = ['read', 'readVersions', 'update'] +const topLevelGlobalPermissions = ['read', 'readVersions', 'update', 'validate'] /** * Build up permissions object for an entity (collection or global). diff --git a/packages/payload/src/utilities/getFieldPermissions.ts b/packages/payload/src/utilities/getFieldPermissions.ts index fca4fe39294..07f442b2618 100644 --- a/packages/payload/src/utilities/getFieldPermissions.ts +++ b/packages/payload/src/utilities/getFieldPermissions.ts @@ -10,7 +10,7 @@ import type { Operation } from '../types/index.js' /** * Gets read and operation-level permissions for a given field based on cascading field permissions. * @returns An object with the following properties: - * - `operation`: Whether the user has permission to perform the operation on the field (`create` or `update`). + * - `operation`: Whether the user has permission to perform the operation on the field. * - `permissions`: The field-level permissions. * - `read`: Whether the user has permission to read the field. */ @@ -79,6 +79,8 @@ export const getFieldPermissions = ({ collectionOperation = Boolean(collectionPermissions.create) } else if (operation === 'update') { collectionOperation = Boolean(collectionPermissions.update) + } else if (operation === 'validate') { + collectionOperation = Boolean(collectionPermissions.validate) } return { diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index 3829afdb5f9..398c88d6c96 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -5,14 +5,20 @@ import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionBeforeValidateHook, + CollectionConfig, + CollectionPermission, CollectionSlug, CustomDocumentViewConfig, DefaultDocumentViewConfig, + Field, FieldHookArgs, + FieldPermissions, GeneratedTypes, GlobalAfterChangeHook, GlobalBeforeChangeHook, GlobalBeforeValidateHook, + GlobalConfig, + GlobalPermission, JoinQuery, MeOperationResult, PaginatedDocs, @@ -116,6 +122,24 @@ describe('Types testing', () => { expect<'validate'>().type.not.toBeAssignableTo< Parameters[0]['operation'] >() + expect().type.toBeAssignableFrom<{ + access: { validate: () => true } + fields: [] + slug: 'posts' + }>() + expect().type.toBeAssignableFrom<{ + access: { validate: () => true } + fields: [] + slug: 'settings' + }>() + expect().type.toBeAssignableFrom<{ + access: { validate: () => true } + name: 'title' + type: 'text' + }>() + expect().type.toHaveProperty('validate') + expect().type.toHaveProperty('validate') + expect().type.toHaveProperty('validate') }) }) From 1df014f01cad3827a8a5887c999a3c8c74984999 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 19:02:51 +0100 Subject: [PATCH 04/47] feat(payload): add validation Local API --- .../src/collections/operations/create.ts | 3 + .../src/collections/operations/delete.ts | 3 + .../src/collections/operations/deleteByID.ts | 3 + .../collections/operations/local/validate.ts | 116 +++++ .../src/collections/operations/update.ts | 3 + .../src/collections/operations/updateByID.ts | 3 + .../src/collections/operations/validate.ts | 156 +++++++ .../src/globals/operations/local/validate.ts | 68 +++ .../payload/src/globals/operations/update.ts | 3 + .../src/globals/operations/validate.ts | 133 ++++++ packages/payload/src/index.ts | 35 +- packages/payload/src/uploads/uploadFiles.ts | 3 + .../src/utilities/assertNoValidationWrite.ts | 13 + .../src/utilities/flattenDataByLocale.ts | 191 +++++++++ packages/payload/src/versions/saveVersion.ts | 3 + test/types/types.spec.ts | 53 +++ test/validate/config.ts | 292 +++++++++++++ test/validate/int.spec.ts | 401 ++++++++++++++++++ test/validate/tsconfig.json | 4 + 19 files changed, 1485 insertions(+), 1 deletion(-) create mode 100644 packages/payload/src/collections/operations/local/validate.ts create mode 100644 packages/payload/src/collections/operations/validate.ts create mode 100644 packages/payload/src/globals/operations/local/validate.ts create mode 100644 packages/payload/src/globals/operations/validate.ts create mode 100644 packages/payload/src/utilities/assertNoValidationWrite.ts create mode 100644 packages/payload/src/utilities/flattenDataByLocale.ts create mode 100644 test/validate/config.ts create mode 100644 test/validate/int.spec.ts create mode 100644 test/validate/tsconfig.json diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 67f24751309..bcd56a2d228 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -28,6 +28,7 @@ import { saveVersion } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' import { uploadFiles } from '../../uploads/uploadFiles.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { hasDraftsEnabled, @@ -68,6 +69,8 @@ export const createOperation = async < ): Promise> => { let args = incomingArgs + assertNoValidationWrite(args.req) + try { const shouldCommit = !args.disableTransaction && (await initTransaction(args.req)) diff --git a/packages/payload/src/collections/operations/delete.ts b/packages/payload/src/collections/operations/delete.ts index 9bd2d0f7e50..06bb1b1a451 100644 --- a/packages/payload/src/collections/operations/delete.ts +++ b/packages/payload/src/collections/operations/delete.ts @@ -19,6 +19,7 @@ import { afterRead } from '../../fields/hooks/afterRead/index.js' import { deleteUserPreferences } from '../../preferences/deleteUserPreferences.js' import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { checkDocumentLockStatus } from '../../utilities/checkDocumentLockStatus.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { hasScheduledPublishEnabled } from '../../utilities/getVersionsConfig.js' @@ -53,6 +54,8 @@ export const deleteOperation = async < ): Promise> => { let args = incomingArgs + assertNoValidationWrite(args.req) + if (args.collection.config.disableBulkDelete && !args.overrideAccess) { throw new APIError(`Collection ${args.collection.config.slug} has disabled bulk delete`, 403) } diff --git a/packages/payload/src/collections/operations/deleteByID.ts b/packages/payload/src/collections/operations/deleteByID.ts index d7ccb0dd869..6d22b2b27ee 100644 --- a/packages/payload/src/collections/operations/deleteByID.ts +++ b/packages/payload/src/collections/operations/deleteByID.ts @@ -15,6 +15,7 @@ import { afterRead } from '../../fields/hooks/afterRead/index.js' import { deleteUserPreferences } from '../../preferences/deleteUserPreferences.js' import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { checkDocumentLockStatus } from '../../utilities/checkDocumentLockStatus.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { hasScheduledPublishEnabled } from '../../utilities/getVersionsConfig.js' @@ -45,6 +46,8 @@ export const deleteByIDOperation = async > => { let args = incomingArgs + assertNoValidationWrite(args.req) + try { const shouldCommit = !args.disableTransaction && (await initTransaction(args.req)) diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts new file mode 100644 index 00000000000..b8fdf31c98e --- /dev/null +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -0,0 +1,116 @@ +import type { DeepPartial } from 'ts-essentials' + +import { status as httpStatus } from 'http-status' + +import type { + CollectionSlug, + Payload, + RequestContext, + TypedLocale, + User, + ValidationFieldError, +} from '../../../index.js' +import type { PayloadRequest } from '../../../types/index.js' +import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' +import type { + DataFromCollectionSlug, + DraftFlagFromCollectionSlug, + RequiredDataFromCollectionSlug, +} from '../../config/types.js' + +import { APIError } from '../../../errors/index.js' +import { createLocalReq } from '../../../utilities/createLocalReq.js' +import { validateOperation } from '../validate.js' + +/** + * The result of validating a collection or global document without persisting it. + */ +export type ValidationResult = { + /** Field validation errors. Empty when {@link valid} is `true`. */ + errors: ValidationFieldError[] + /** Whether the simulated document passed field validation. */ + valid: boolean +} + +type BaseOptions = { + /** The collection slug to validate against. */ + collection: TSlug + /** + * Hook context merged into `req.context` for the validation lifecycle. + */ + context?: RequestContext + /** + * A fallback locale used while constructing the validation request. + */ + fallbackLocale?: false | TypedLocale + /** + * The single locale to validate. Arrays and `'all'` are not accepted by this operation. + */ + locale: TypedLocale + /** + * Skip collection and field access control. + * @default true + */ + overrideAccess?: boolean + /** + * An existing request to reuse for user, locale, and context. + */ + req?: Partial + /** + * The user used by access control when `overrideAccess` is `false`. + */ + user?: null | User +} & DraftFlagFromCollectionSlug + +/** + * Options for validating a collection document without persisting it. + * + * Omitting `id` simulates create and requires `data`. Supplying `id` simulates update and allows + * `data` to be omitted or partial because stored document data is loaded first. + */ +export type ValidateCollectionOptions = + | ({ + /** Candidate create data. The property is required, but fields may be incomplete or invalid. */ + data: DeepPartial> + /** Create simulation does not accept a document ID. */ + id?: never + } & BaseOptions) + | ({ + /** Optional partial data to merge over the stored document. */ + data?: DeepPartial> + /** The stored document ID used for update simulation. */ + id: DataFromCollectionSlug['id'] + } & BaseOptions) + +export async function validateLocal( + payload: Payload, + options: ValidateCollectionOptions, +): Promise { + const { id, collection: collectionSlug, data, locale, overrideAccess = true } = options + + if (locale === undefined || locale === 'all' || Array.isArray(locale)) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + if (id === undefined && data === undefined) { + throw new APIError('Validation create simulation requires data.', httpStatus.BAD_REQUEST) + } + + const collection = payload.collections[collectionSlug] + + if (!collection) { + throw new APIError( + `The collection with slug ${String(collectionSlug)} can't be found. Validate Operation.`, + ) + } + + const req = await createLocalReq(options as CreateLocalReqOptions, payload) + + return validateOperation({ + id, + collection, + data, + overrideAccess, + req, + }) +} diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index eb4248eb7b2..e6696f31840 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -21,6 +21,7 @@ import { type CollectionSlug, type FindOptions } from '../../index.js' import { generateFileData } from '../../uploads/generateFileData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { hasDraftsEnabled } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' @@ -72,6 +73,8 @@ export const updateOperation = async < ): Promise> => { let args = incomingArgs + assertNoValidationWrite(args.req) + if (args.collection.config.disableBulkEdit && !args.overrideAccess) { throw new APIError(`Collection ${args.collection.config.slug} has disabled bulk edit`, 403) } diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index 26dfe20cb3e..8e8e1853ed1 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -24,6 +24,7 @@ import { type CollectionSlug, deepCopyObjectSimple, type FindOptions } from '../ import { generateFileData } from '../../uploads/generateFileData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -62,6 +63,8 @@ export const updateByIDOperation = async < ): Promise> => { let args = incomingArgs + assertNoValidationWrite(args.req) + try { const shouldCommit = !args.disableTransaction && (await initTransaction(args.req)) diff --git a/packages/payload/src/collections/operations/validate.ts b/packages/payload/src/collections/operations/validate.ts new file mode 100644 index 00000000000..3867fadb474 --- /dev/null +++ b/packages/payload/src/collections/operations/validate.ts @@ -0,0 +1,156 @@ +import type { DeepPartial } from 'ts-essentials' + +import type { FindOneArgs } from '../../database/types.js' +import type { CollectionSlug, JsonObject } from '../../index.js' +import type { PayloadRequest } from '../../types/index.js' +import type { Collection, RequiredDataFromCollectionSlug, TypeWithID } from '../config/types.js' +import type { ValidationResult } from './local/validate.js' + +import { executeAccess } from '../../auth/executeAccess.js' +import { hasWhereAccessResult } from '../../auth/types.js' +import { combineQueries } from '../../database/combineQueries.js' +import { Forbidden, NotFound, ValidationError } from '../../errors/index.js' +import { beforeChange } from '../../fields/hooks/beforeChange/index.js' +import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' +import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' +import { flattenDataByLocale } from '../../utilities/flattenDataByLocale.js' +import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion.js' + +export type Arguments = { + collection: Collection + data?: DeepPartial> + id?: number | string + overrideAccess: boolean + req: PayloadRequest +} + +export async function validateOperation({ + id, + collection, + data: incomingData, + overrideAccess, + req, +}: Arguments): Promise { + const collectionConfig = collection.config + + req.operation = 'validate' + + const accessResult = !overrideAccess + ? await executeAccess({ id, data: incomingData, req }, collectionConfig.access.validate) + : true + const hasWherePolicy = hasWhereAccessResult(accessResult) + + let docWithLocales: JsonObject = {} + + if (id !== undefined) { + const where = appendNonTrashedFilter({ + enableTrash: collectionConfig.trash, + trash: false, + where: combineQueries({ id: { equals: id } }, accessResult), + }) + const query: FindOneArgs = { + collection: collectionConfig.slug, + locale: req.locale!, + req, + where, + } + + const storedDocument = await getLatestCollectionVersion< + RequiredDataFromCollectionSlug & TypeWithID + >({ + id, + config: collectionConfig, + payload: req.payload, + query, + req, + }) + + if (!storedDocument && hasWherePolicy) { + throw new Forbidden(req.t) + } + if (!storedDocument) { + throw new NotFound(req.t) + } + + docWithLocales = deepCopyObjectSimple(storedDocument) + } + + const originalDoc = flattenDataByLocale({ + configBlockReferences: req.payload.config.blocks, + docWithLocales, + fields: collectionConfig.fields, + locale: req.locale!, + }) + + let data = deepCopyObjectSimple(incomingData ?? {}) + + data = await beforeValidate({ + id, + collection: collectionConfig, + context: req.context, + data, + doc: originalDoc, + global: null, + operation: 'validate', + overrideAccess, + req, + }) + + if (collectionConfig.hooks.beforeValidate?.length) { + for (const hook of collectionConfig.hooks.beforeValidate) { + data = + (await hook({ + collection: collectionConfig, + context: req.context, + data, + operation: 'validate', + originalDoc, + req, + })) || data + } + } + + if (collectionConfig.hooks.beforeChange?.length) { + for (const hook of collectionConfig.hooks.beforeChange) { + data = + (await hook({ + collection: collectionConfig, + context: req.context, + data, + operation: 'validate', + originalDoc, + req, + })) || data + } + } + + try { + await beforeChange({ + id, + collection: collectionConfig, + context: req.context, + data: id === undefined ? data : { ...data, id }, + doc: originalDoc, + docWithLocales, + global: null, + operation: 'validate', + overrideAccess, + req, + }) + } catch (error) { + if (error instanceof ValidationError) { + return { + errors: error.data.errors, + valid: false, + } + } + + throw error + } + + return { + errors: [], + valid: true, + } +} diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts new file mode 100644 index 00000000000..aefaab734ce --- /dev/null +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -0,0 +1,68 @@ +import type { DeepPartial } from 'ts-essentials' + +import { status as httpStatus } from 'http-status' + +import type { ValidationResult } from '../../../collections/operations/local/validate.js' +import type { GlobalSlug, Payload, RequestContext, TypedLocale, User } from '../../../index.js' +import type { PayloadRequest } from '../../../types/index.js' +import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' +import type { DataFromGlobalSlug, DraftFlagFromGlobalSlug } from '../../config/types.js' + +import { APIError } from '../../../errors/index.js' +import { createLocalReq } from '../../../utilities/createLocalReq.js' +import { validateOperation } from '../validate.js' + +/** + * Options for validating a global document without persisting it. + * + * Global validation always simulates update. Candidate data is optional and, when provided, is + * merged over the stored global before validation. + */ +export type ValidateGlobalOptions = { + /** Hook context merged into `req.context` for the validation lifecycle. */ + context?: RequestContext + /** Optional partial data to merge over the stored global. */ + data?: DeepPartial, 'id'>> + /** A fallback locale used while constructing the validation request. */ + fallbackLocale?: false | TypedLocale + /** The single locale to validate. Arrays and `'all'` are not accepted. */ + locale: TypedLocale + /** + * Skip global and field access control. + * @default true + */ + overrideAccess?: boolean + /** An existing request to reuse for user, locale, and context. */ + req?: Partial + /** The global slug to validate against. */ + slug: TSlug + /** The user used by access control when `overrideAccess` is `false`. */ + user?: null | User +} & DraftFlagFromGlobalSlug + +export async function validateGlobalLocal( + payload: Payload, + options: ValidateGlobalOptions, +): Promise { + const { slug, data, locale, overrideAccess = true } = options + + if (locale === undefined || locale === 'all' || Array.isArray(locale)) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + const globalConfig = payload.globals.config.find((config) => config.slug === slug) + + if (!globalConfig) { + throw new APIError(`The global with slug ${String(slug)} can't be found. Validate Operation.`) + } + + const req = await createLocalReq(options as CreateLocalReqOptions, payload) + + return validateOperation({ + slug, + data, + globalConfig, + overrideAccess, + req, + }) +} diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 977590f1898..8ce6104ee57 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -22,6 +22,7 @@ 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 { deepCopyObjectSimple } from '../../index.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { checkDocumentLockStatus } from '../../utilities/checkDocumentLockStatus.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { getSelectMode } from '../../utilities/getSelectMode.js' @@ -60,6 +61,8 @@ export const updateOperation = async < >( args: Args, ): Promise> => { + assertNoValidationWrite(args.req) + const { slug, autosave, diff --git a/packages/payload/src/globals/operations/validate.ts b/packages/payload/src/globals/operations/validate.ts new file mode 100644 index 00000000000..64990798546 --- /dev/null +++ b/packages/payload/src/globals/operations/validate.ts @@ -0,0 +1,133 @@ +import type { DeepPartial } from 'ts-essentials' + +import type { ValidationResult } from '../../collections/operations/local/validate.js' +import type { GlobalSlug, JsonObject } from '../../index.js' +import type { PayloadRequest, Where } from '../../types/index.js' +import type { DataFromGlobalSlug, SanitizedGlobalConfig } from '../config/types.js' + +import { executeAccess } from '../../auth/executeAccess.js' +import { ValidationError } from '../../errors/index.js' +import { beforeChange } from '../../fields/hooks/beforeChange/index.js' +import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' +import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' +import { flattenDataByLocale } from '../../utilities/flattenDataByLocale.js' +import { getLatestGlobalVersion } from '../../versions/getLatestGlobalVersion.js' + +export type Arguments = { + data?: DeepPartial, 'id'>> + globalConfig: SanitizedGlobalConfig + overrideAccess: boolean + req: PayloadRequest + slug: string +} + +export async function validateOperation({ + slug, + data: incomingData, + globalConfig, + overrideAccess, + req, +}: Arguments): Promise { + req.operation = 'validate' + + const accessResult = !overrideAccess + ? await executeAccess({ data: incomingData, req }, globalConfig.access.validate) + : true + const where: Where = overrideAccess ? undefined! : (accessResult as Where) + const globalVersionResult = await getLatestGlobalVersion({ + slug, + config: globalConfig, + locale: req.locale!, + payload: req.payload, + req, + where, + }) + + let docWithLocales: JsonObject = {} + + if (globalVersionResult?.global) { + docWithLocales = deepCopyObjectSimple(globalVersionResult.global) + + if (docWithLocales._id) { + delete docWithLocales._id + } + } + + const originalDoc = flattenDataByLocale({ + configBlockReferences: req.payload.config.blocks, + docWithLocales, + fields: globalConfig.fields, + locale: req.locale!, + }) + + let data = deepCopyObjectSimple(incomingData ?? {}) + + data = await beforeValidate({ + collection: null, + context: req.context, + data, + doc: originalDoc, + global: globalConfig, + operation: 'validate', + overrideAccess, + req, + }) + + if (globalConfig.hooks.beforeValidate?.length) { + for (const hook of globalConfig.hooks.beforeValidate) { + data = + (await hook({ + context: req.context, + data, + global: globalConfig, + operation: 'validate', + originalDoc, + overrideAccess, + req, + })) || data + } + } + + if (globalConfig.hooks.beforeChange?.length) { + for (const hook of globalConfig.hooks.beforeChange) { + data = + (await hook({ + context: req.context, + data, + global: globalConfig, + operation: 'validate', + originalDoc, + overrideAccess, + req, + })) || data + } + } + + try { + await beforeChange({ + collection: null, + context: req.context, + data, + doc: originalDoc, + docWithLocales, + global: globalConfig, + operation: 'validate', + overrideAccess, + req, + }) + } catch (error) { + if (error instanceof ValidationError) { + return { + errors: error.data.errors, + valid: false, + } + } + + throw error + } + + return { + errors: [], + valid: true, + } +} diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index eb2402d061f..3659d0643e4 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -96,6 +96,11 @@ import { type ManyOptions as UpdateManyOptions, type Options as UpdateOptions, } from './collections/operations/local/update.js' +import { + type ValidateCollectionOptions, + validateLocal, + type ValidationResult, +} from './collections/operations/local/validate.js' import { countGlobalVersionsLocal, type CountGlobalVersionsOptions, @@ -120,6 +125,10 @@ import { updateGlobalLocal, type Options as UpdateGlobalOptions, } from './globals/operations/local/update.js' +import { + validateGlobalLocal, + type ValidateGlobalOptions, +} from './globals/operations/local/validate.js' export type { FieldState } from './admin/forms/Form.js' export type * from './admin/types.js' export { EntityType } from './admin/views/dashboard.js' @@ -745,6 +754,26 @@ export class BasePayload { return updateGlobalLocal(this, options) } + /** + * Validates a collection document for one locale without persisting data, versions, or files. + * + * Omit `id` to simulate create, or provide `id` to load a stored document and simulate update. + */ + validate = async ( + options: ValidateCollectionOptions, + ): Promise => { + return validateLocal(this, options) + } + + /** + * Validates a global document update for one locale without persisting data or versions. + */ + validateGlobal = async ( + options: ValidateGlobalOptions, + ): Promise => { + return validateGlobalLocal(this, options) + } + validationRules!: (args: OperationArgs) => ValidationRule[] verifyEmail = async ( @@ -1465,6 +1494,10 @@ export { findOperation } from './collections/operations/find.js' export { findByIDOperation } from './collections/operations/findByID.js' export { findVersionByIDOperation } from './collections/operations/findVersionByID.js' export { findVersionsOperation } from './collections/operations/findVersions.js' +export type { + ValidateCollectionOptions, + ValidationResult, +} from './collections/operations/local/validate.js' export { restoreVersionOperation } from './collections/operations/restoreVersion.js' export { updateOperation } from './collections/operations/update.js' export { updateByIDOperation } from './collections/operations/updateByID.js' @@ -1820,7 +1853,7 @@ export { docAccessOperation as docAccessOperationGlobal } from './globals/operat export { findOneOperation } from './globals/operations/findOne.js' export { findVersionByIDOperation as findVersionByIDOperationGlobal } from './globals/operations/findVersionByID.js' export { findVersionsOperation as findVersionsOperationGlobal } from './globals/operations/findVersions.js' - +export type { ValidateGlobalOptions } from './globals/operations/local/validate.js' export { restoreVersionOperation as restoreVersionOperationGlobal } from './globals/operations/restoreVersion.js' export { updateOperation as updateOperationGlobal } from './globals/operations/update.js' export { diff --git a/packages/payload/src/uploads/uploadFiles.ts b/packages/payload/src/uploads/uploadFiles.ts index beb38ef622b..20cae44b051 100644 --- a/packages/payload/src/uploads/uploadFiles.ts +++ b/packages/payload/src/uploads/uploadFiles.ts @@ -3,6 +3,7 @@ import type { PayloadRequest } from '../types/index.js' import type { FileToSave } from './types.js' import { FileUploadError } from '../errors/index.js' +import { assertNoValidationWrite } from '../utilities/assertNoValidationWrite.js' import { saveBufferToFile } from './saveBufferToFile.js' export const uploadFiles = async ( @@ -10,6 +11,8 @@ export const uploadFiles = async ( files: FileToSave[], req: PayloadRequest, ): Promise => { + assertNoValidationWrite(req) + try { await Promise.all( files.map(async ({ buffer, path }) => { diff --git a/packages/payload/src/utilities/assertNoValidationWrite.ts b/packages/payload/src/utilities/assertNoValidationWrite.ts new file mode 100644 index 00000000000..e30511626a2 --- /dev/null +++ b/packages/payload/src/utilities/assertNoValidationWrite.ts @@ -0,0 +1,13 @@ +import type { PayloadRequest } from '../types/index.js' + +import { APIError } from '../errors/index.js' + +/** + * Prevents Payload-managed mutation entry points from writing through an active validation + * request. Reads and writes made with a separate request are intentionally unaffected. + */ +export function assertNoValidationWrite(req?: Partial): void { + if (req?.operation === 'validate') { + throw new APIError('Payload writes are not allowed during validation.', 400) + } +} diff --git a/packages/payload/src/utilities/flattenDataByLocale.ts b/packages/payload/src/utilities/flattenDataByLocale.ts new file mode 100644 index 00000000000..32b583064f5 --- /dev/null +++ b/packages/payload/src/utilities/flattenDataByLocale.ts @@ -0,0 +1,191 @@ +import type { Block, Field, FlattenedBlock } from '../fields/config/types.js' +import type { SanitizedConfig } from '../index.js' +import type { JsonObject } from '../types/index.js' + +import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../fields/config/types.js' +import { deepCopyObjectSimple } from './deepCopyObject.js' + +type Args = { + configBlockReferences: SanitizedConfig['blocks'] + docWithLocales: JsonObject + fields: Field[] + locale: string + parentIsLocalized?: boolean +} + +/** + * Returns a copy of stored locale-keyed data flattened to one locale without running after-read + * hooks, access control, sanitization, or population. + */ +export function flattenDataByLocale({ + configBlockReferences, + docWithLocales, + fields, + locale, + parentIsLocalized = false, +}: Args): JsonObject { + const result = deepCopyObjectSimple(docWithLocales) + + flattenFields({ + configBlockReferences, + data: result, + fields, + locale, + parentIsLocalized, + }) + + return result +} + +type FlattenFieldsArgs = { + configBlockReferences: SanitizedConfig['blocks'] + data: JsonObject + fields: Field[] + locale: string + parentIsLocalized: boolean +} + +function flattenFields({ + configBlockReferences, + data, + fields, + locale, + parentIsLocalized, +}: FlattenFieldsArgs): void { + for (const field of fields) { + if (fieldAffectsData(field)) { + const isLocalized = fieldShouldBeLocalized({ field, parentIsLocalized }) + + if (isLocalized) { + data[field.name] = getLocaleValue({ + locale, + value: data[field.name], + }) + } + + const fieldValue = data[field.name] + const nestedParentIsLocalized = parentIsLocalized || Boolean(field.localized) + + switch (field.type) { + case 'array': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (row && typeof row === 'object') { + flattenFields({ + configBlockReferences, + data: row, + fields: field.fields, + locale, + parentIsLocalized: nestedParentIsLocalized, + }) + } + } + } + break + } + + case 'blocks': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (!row || typeof row !== 'object') { + continue + } + + const blockOrSlug = field.blocks.find((block) => { + const blockSlug = typeof block === 'string' ? block : block.slug + return blockSlug === row.blockType + }) + const block: Block | FlattenedBlock | undefined = + typeof blockOrSlug === 'string' + ? configBlockReferences?.find(({ slug }) => slug === blockOrSlug) + : blockOrSlug + + if (block) { + flattenFields({ + configBlockReferences, + data: row, + fields: block.fields, + locale, + parentIsLocalized: nestedParentIsLocalized, + }) + } + } + } + break + } + + case 'group': { + if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue)) { + flattenFields({ + configBlockReferences, + data: fieldValue, + fields: field.fields, + locale, + parentIsLocalized: nestedParentIsLocalized, + }) + } + break + } + } + + continue + } + + switch (field.type) { + case 'collapsible': + case 'group': + case 'row': + flattenFields({ + configBlockReferences, + data, + fields: field.fields, + locale, + parentIsLocalized, + }) + break + + case 'tabs': + for (const tab of field.tabs) { + if (tabHasName(tab)) { + const isLocalized = fieldShouldBeLocalized({ field: tab, parentIsLocalized }) + + if (isLocalized) { + data[tab.name] = getLocaleValue({ + locale, + value: data[tab.name], + }) + } + + const tabData = data[tab.name] + + if (tabData && typeof tabData === 'object' && !Array.isArray(tabData)) { + flattenFields({ + configBlockReferences, + data: tabData, + fields: tab.fields, + locale, + parentIsLocalized: parentIsLocalized || Boolean(tab.localized), + }) + } + } else { + flattenFields({ + configBlockReferences, + data, + fields: tab.fields, + locale, + parentIsLocalized, + }) + } + } + break + } + } +} + +function getLocaleValue({ locale, value }: { locale: string; value: unknown }): unknown { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return (value as Record)[locale] + } + + return value +} diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index eadc0fa29e9..d68cca14455 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -4,6 +4,7 @@ import type { CreateGlobalVersionArgs, CreateVersionArgs, Payload } from '../ind import type { JsonObject, PayloadRequest, SelectType } from '../types/index.js' import { deepCopyObjectSimple } from '../index.js' +import { assertNoValidationWrite } from '../utilities/assertNoValidationWrite.js' import { getVersionsMax, hasLocalizeStatusEnabled } from '../utilities/getVersionsConfig.js' import { sanitizeInternalFields } from '../utilities/sanitizeInternalFields.js' import { getQueryDraftsSelect } from './drafts/getQueryDraftsSelect.js' @@ -48,6 +49,8 @@ export async function saveVersion({ select, unpublish, }: Args): Promise { + assertNoValidationWrite(req) + let result: JsonObject | undefined let createdNewVersion = false const now = new Date().toISOString() diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index 398c88d6c96..bb087a58c77 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -29,7 +29,10 @@ import type { TypeWithVersion, UntypedPayloadTypes, Validate, + ValidateCollectionOptions, + ValidateGlobalOptions, ValidationFieldError, + ValidationResult, Where, } from 'payload' @@ -141,6 +144,56 @@ describe('Types testing', () => { expect().type.toHaveProperty('validate') expect().type.toHaveProperty('validate') }) + + test('should require collection create data and a locale', () => { + expect(payload.validate).type.toBeCallableWith({ + collection: 'pages', + data: {}, + locale: null, + }) + expect(payload.validate).type.not.toBeCallableWith({ + collection: 'pages', + data: {}, + }) + expect(payload.validate).type.not.toBeCallableWith({ + collection: 'pages', + locale: null, + }) + expect<{ + collection: 'pages' + data: Record + locale: null + }>().type.toBeAssignableTo>() + }) + + test('should allow collection update and global validation data to be omitted', () => { + expect(payload.validate).type.toBeCallableWith({ + id: 'document-id', + collection: 'pages', + locale: null, + }) + expect(payload.validateGlobal).type.toBeCallableWith({ + slug: 'menu', + locale: null, + }) + expect<{ + locale: null + slug: 'menu' + }>().type.toBeAssignableTo>() + expect( + payload.validate({ + collection: 'pages', + data: {}, + locale: null, + }), + ).type.toBe>() + expect( + payload.validateGlobal({ + slug: 'menu', + locale: null, + }), + ).type.toBe>() + }) }) describe('authenticated user', () => { diff --git a/test/validate/config.ts b/test/validate/config.ts new file mode 100644 index 00000000000..e18c725fcbd --- /dev/null +++ b/test/validate/config.ts @@ -0,0 +1,292 @@ +import type { CollectionBeforeChangeHook, CollectionConfig, GlobalConfig } from 'payload' + +import path from 'path' +import { saveVersion } from 'payload' +import { fileURLToPath } from 'url' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export const validationCollectionSlug = 'validation-items' +export const validationGlobalSlug = 'validation-settings' +export const writeTargetsSlug = 'validation-write-targets' +export const validationUploadsSlug = 'validation-uploads' +export const validationUploadsDir = path.resolve(dirname, 'validation-uploads') + +type HookEvent = { + context: Record + hook: string + operation: string + requestOperation: string | undefined +} + +export const hookEvents: HookEvent[] = [] +export const accessEvents: string[] = [] + +export function clearValidationEvents(): void { + accessEvents.length = 0 + hookEvents.length = 0 +} + +function recordHook({ context, hook, operation, requestOperation }: HookEvent): void { + hookEvents.push({ + context: { ...context }, + hook, + operation, + requestOperation, + }) +} + +const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, req }) => { + if (operation !== 'validate') { + return data + } + + const targetID = data.targetID as string | undefined + + switch (data.writeAttempt) { + case 'create': + await req.payload.create({ + collection: writeTargetsSlug, + data: { title: 'must not be created' }, + disableTransaction: true, + req, + }) + break + + case 'delete': + await req.payload.delete({ + id: targetID!, + collection: writeTargetsSlug, + disableTransaction: true, + req, + }) + break + + case 'update': + await req.payload.update({ + id: targetID!, + collection: writeTargetsSlug, + data: { title: 'must not be updated' }, + disableTransaction: true, + req, + }) + break + + case 'upload': { + const fileData = Buffer.from('must not be uploaded') + + await req.payload.create({ + collection: validationUploadsSlug, + data: {}, + disableTransaction: true, + file: { + name: 'blocked.txt', + data: fileData, + mimetype: 'text/plain', + size: fileData.length, + }, + req, + }) + break + } + + case 'version': + await saveVersion({ + id: targetID, + collection: req.payload.collections[writeTargetsSlug]!.config, + docWithLocales: { + id: targetID, + title: 'must not create a version', + }, + operation: 'update', + payload: req.payload, + req, + }) + break + } + + return data +} + +const validationCollection: CollectionConfig = { + slug: validationCollectionSlug, + access: { + validate: ({ req }) => { + accessEvents.push('collection') + return req.context.allowValidation === true + }, + }, + fields: [ + { + name: 'title', + type: 'text', + hooks: { + beforeChange: [ + ({ context, operation, req, value }) => { + recordHook({ + context, + hook: 'fieldBeforeChange', + operation, + requestOperation: req.operation, + }) + return value + }, + ], + beforeValidate: [ + ({ context, operation, req, value }) => { + recordHook({ + context, + hook: 'fieldBeforeValidate', + operation, + requestOperation: req.operation, + }) + return value + }, + ], + }, + localized: true, + required: true, + validate: (value, { operation, req }) => { + recordHook({ + context: req.context, + hook: 'fieldValidate', + operation, + requestOperation: req.operation, + }) + return typeof value === 'string' && value.length > 0 ? true : 'Title is required' + }, + }, + { + name: 'summary', + type: 'text', + required: true, + }, + { + name: 'status', + type: 'text', + defaultValue: 'draft', + required: true, + }, + { + name: 'writeAttempt', + type: 'select', + options: ['create', 'delete', 'update', 'upload', 'version'], + }, + { + name: 'targetID', + type: 'text', + }, + ], + hooks: { + beforeChange: [ + ({ context, data, operation, req }) => { + recordHook({ + context, + hook: 'collectionBeforeChange', + operation, + requestOperation: req.operation, + }) + return data + }, + runWriteAttempt, + ], + beforeValidate: [ + ({ context, data, operation, req }) => { + recordHook({ + context, + hook: 'collectionBeforeValidate', + operation, + requestOperation: req.operation, + }) + return data + }, + ], + }, + versions: false, +} + +const validationGlobal: GlobalConfig = { + slug: validationGlobalSlug, + access: { + validate: ({ req }) => { + accessEvents.push('global') + return req.context.allowValidation === true + }, + }, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + required: true, + }, + { + name: 'summary', + type: 'text', + required: true, + }, + ], + hooks: { + beforeChange: [ + ({ context, data, operation, req }) => { + recordHook({ + context, + hook: 'globalBeforeChange', + operation, + requestOperation: req.operation, + }) + return data + }, + ], + beforeValidate: [ + ({ context, data, operation, req }) => { + recordHook({ + context, + hook: 'globalBeforeValidate', + operation, + requestOperation: req.operation, + }) + return data + }, + ], + }, + versions: false, +} + +export default buildConfigWithDefaults({ + admin: { + importMap: { + baseDir: path.resolve(dirname), + }, + }, + collections: [ + validationCollection, + { + slug: writeTargetsSlug, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + ], + versions: true, + }, + { + slug: validationUploadsSlug, + fields: [], + upload: { + staticDir: validationUploadsDir, + }, + versions: false, + }, + ], + globals: [validationGlobal], + localization: { + defaultLocale: 'en', + locales: ['en', 'es'], + }, +}) diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts new file mode 100644 index 00000000000..7057259eb38 --- /dev/null +++ b/test/validate/int.spec.ts @@ -0,0 +1,401 @@ +import type { Payload } from 'payload' + +import fs from 'fs/promises' +import path from 'path' +import { fileURLToPath } from 'url' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js' +import { + accessEvents, + clearValidationEvents, + hookEvents, + validationCollectionSlug, + validationGlobalSlug, + validationUploadsDir, + validationUploadsSlug, + writeTargetsSlug, +} from './config.js' + +let payload: Payload + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +describe('validate Local API', () => { + beforeAll(async () => { + ;({ payload } = await initPayloadInt(dirname)) + + await payload.updateGlobal({ + slug: validationGlobalSlug, + data: { + summary: 'stored global summary', + title: 'Stored global title', + }, + locale: 'en', + }) + }) + + beforeEach(() => { + clearValidationEvents() + }) + + afterEach(async () => { + await Promise.all([ + payload.delete({ + collection: validationCollectionSlug, + disableTransaction: true, + where: { id: { exists: true } }, + }), + payload.delete({ + collection: validationUploadsSlug, + disableTransaction: true, + where: { id: { exists: true } }, + }), + payload.delete({ + collection: writeTargetsSlug, + disableTransaction: true, + where: { id: { exists: true } }, + }), + ]) + await fs.rm(validationUploadsDir, { force: true, recursive: true }) + }) + + afterAll(async () => { + await payload.destroy() + }) + + describe('collections', () => { + it('should return field errors for invalid create data without creating a document', async () => { + const result = await payload.validate({ + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + title: '', + }, + locale: 'en', + }) + + expect(result).toMatchObject({ + errors: [ + { + locale: 'en', + path: 'title', + }, + ], + valid: false, + }) + expect(await payload.count({ collection: validationCollectionSlug })).toEqual({ + totalDocs: 0, + }) + }) + + it('should return a successful result for valid create data', async () => { + const result = await payload.validate({ + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: 'en', + }) + + expect(result).toEqual({ + errors: [], + valid: true, + }) + }) + + it('should run validation hooks in order with the validate operation and unchanged context', async () => { + const result = await payload.validate({ + collection: validationCollectionSlug, + context: { + marker: 'caller context', + }, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: 'en', + }) + + expect(result.valid).toBe(true) + expect(hookEvents.map(({ hook }) => hook)).toEqual([ + 'fieldBeforeValidate', + 'collectionBeforeValidate', + 'collectionBeforeChange', + 'fieldBeforeChange', + 'fieldValidate', + ]) + expect(hookEvents.every(({ operation }) => operation === 'validate')).toBe(true) + expect(hookEvents.every(({ requestOperation }) => requestOperation === 'validate')).toBe(true) + expect( + hookEvents.every( + ({ context }) => + context.marker === 'caller context' && + !('dryRun' in context) && + !('isValidateOnly' in context), + ), + ).toBe(true) + }) + + it('should apply defaults before validating required fields', async () => { + const result = await payload.validate({ + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: 'en', + }) + + expect(result).toEqual({ + errors: [], + valid: true, + }) + expect(hookEvents.find(({ hook }) => hook === 'fieldValidate')).toBeDefined() + }) + + it('should reject create simulation without data at runtime', async () => { + await expect( + payload.validate({ + collection: validationCollectionSlug, + locale: 'en', + } as never), + ).rejects.toThrow('Validation create simulation requires data') + }) + + it('should reject a missing locale at runtime', async () => { + await expect( + payload.validate({ + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + } as never), + ).rejects.toThrow('Validation requires a locale') + }) + + it('should merge partial update data over the stored locale without persisting it', async () => { + const stored = await payload.create({ + collection: validationCollectionSlug, + data: { + summary: 'stored summary', + title: 'Stored title', + }, + locale: 'en', + }) + + const result = await payload.validate({ + id: stored.id, + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + }, + locale: 'en', + }) + const afterValidation = await payload.findByID({ + id: stored.id, + collection: validationCollectionSlug, + locale: 'en', + }) + + expect(result).toEqual({ + errors: [], + valid: true, + }) + expect(afterValidation).toMatchObject({ + summary: 'stored summary', + title: 'Stored title', + }) + }) + + it('should execute first-class collection validation access and throw on denial', async () => { + await expect( + payload.validate({ + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + }), + ).rejects.toMatchObject({ + status: 403, + }) + + expect(accessEvents).toEqual(['collection']) + }) + }) + + describe('globals', () => { + it('should validate valid partial global data without persisting it', async () => { + const result = await payload.validateGlobal({ + slug: validationGlobalSlug, + data: { + summary: 'candidate summary', + }, + locale: 'en', + }) + const afterValidation = await payload.findGlobal({ + slug: validationGlobalSlug, + locale: 'en', + }) + + expect(result).toEqual({ + errors: [], + valid: true, + }) + expect(afterValidation).toMatchObject({ + summary: 'stored global summary', + title: 'Stored global title', + }) + }) + + it('should return errors for invalid partial global data without persisting it', async () => { + const result = await payload.validateGlobal({ + slug: validationGlobalSlug, + data: { + title: '', + }, + locale: 'en', + }) + const afterValidation = await payload.findGlobal({ + slug: validationGlobalSlug, + locale: 'en', + }) + + expect(result).toMatchObject({ + errors: [ + { + locale: 'en', + path: 'title', + }, + ], + valid: false, + }) + expect(afterValidation.title).toBe('Stored global title') + }) + + it('should execute first-class global validation access and throw on denial', async () => { + await expect( + payload.validateGlobal({ + slug: validationGlobalSlug, + locale: 'en', + overrideAccess: false, + }), + ).rejects.toMatchObject({ + status: 403, + }) + + expect(accessEvents).toEqual(['global']) + }) + }) + + describe('write safety', () => { + it('should reject a create that reuses the validation request before a row is written', async () => { + await expect(runWriteAttempt('create')).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + expect(await payload.count({ collection: writeTargetsSlug })).toEqual({ totalDocs: 0 }) + }) + + it('should reject an update that reuses the validation request before a row is written', async () => { + const target = await createWriteTarget() + + await expect(runWriteAttempt('update', target.id)).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + await expect( + payload.findByID({ + id: target.id, + collection: writeTargetsSlug, + }), + ).resolves.toMatchObject({ + title: 'stored target', + }) + }) + + it('should reject a delete that reuses the validation request before a row is removed', async () => { + const target = await createWriteTarget() + + await expect(runWriteAttempt('delete', target.id)).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + await expect( + payload.findByID({ + id: target.id, + collection: writeTargetsSlug, + }), + ).resolves.toMatchObject({ + title: 'stored target', + }) + }) + + it('should reject an upload that reuses the validation request before a row or file is written', async () => { + await expect(runWriteAttempt('upload')).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + expect(await payload.count({ collection: validationUploadsSlug })).toEqual({ totalDocs: 0 }) + await expect(fs.stat(path.join(validationUploadsDir, 'blocked.txt'))).rejects.toThrow() + }) + + it('should reject a version save that reuses the validation request before a version is written', async () => { + const target = await createWriteTarget() + const versionsBefore = await payload.countVersions({ + collection: writeTargetsSlug, + where: { + parent: { + equals: target.id, + }, + }, + }) + + await expect(runWriteAttempt('version', target.id)).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + const versionsAfter = await payload.countVersions({ + collection: writeTargetsSlug, + where: { + parent: { + equals: target.id, + }, + }, + }) + expect(versionsAfter).toEqual(versionsBefore) + }) + }) +}) + +async function createWriteTarget() { + return payload.create({ + collection: writeTargetsSlug, + data: { + title: 'stored target', + }, + disableTransaction: true, + }) +} + +async function runWriteAttempt( + writeAttempt: 'create' | 'delete' | 'update' | 'upload' | 'version', + targetID?: number | string, +) { + return payload.validate({ + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + targetID: targetID?.toString(), + title: 'Candidate title', + writeAttempt, + }, + locale: 'en', + }) +} diff --git a/test/validate/tsconfig.json b/test/validate/tsconfig.json new file mode 100644 index 00000000000..b29da4479f9 --- /dev/null +++ b/test/validate/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts", "./**/*.tsx"] +} From 69ab76453f53ac7a8093a2d784f6a1bf560af697 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 19:25:18 +0100 Subject: [PATCH 05/47] fix(payload): harden local validation --- .../collections/operations/local/validate.ts | 12 +- .../src/collections/operations/validate.ts | 17 +- .../src/globals/operations/local/validate.ts | 10 +- .../src/globals/operations/validate.ts | 17 +- .../src/utilities/flattenDataByLocale.spec.ts | 184 ++++++++++++++++++ .../src/utilities/flattenDataByLocale.ts | 33 +++- test/types/types.spec.ts | 11 ++ test/validate/config.ts | 49 +++-- test/validate/int.spec.ts | 123 +++++++++++- 9 files changed, 428 insertions(+), 28 deletions(-) create mode 100644 packages/payload/src/utilities/flattenDataByLocale.spec.ts diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index b8fdf31c98e..b13f5e5b051 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -39,10 +39,6 @@ type BaseOptions = { * Hook context merged into `req.context` for the validation lifecycle. */ context?: RequestContext - /** - * A fallback locale used while constructing the validation request. - */ - fallbackLocale?: false | TypedLocale /** * The single locale to validate. Arrays and `'all'` are not accepted by this operation. */ @@ -104,7 +100,13 @@ export async function validateLocal( ) } - const req = await createLocalReq(options as CreateLocalReqOptions, payload) + const req = await createLocalReq( + { + ...(options as CreateLocalReqOptions), + fallbackLocale: false, + }, + payload, + ) return validateOperation({ id, diff --git a/packages/payload/src/collections/operations/validate.ts b/packages/payload/src/collections/operations/validate.ts index 3867fadb474..81ca97e9fd3 100644 --- a/packages/payload/src/collections/operations/validate.ts +++ b/packages/payload/src/collections/operations/validate.ts @@ -25,7 +25,20 @@ export type Arguments = { req: PayloadRequest } -export async function validateOperation({ +export async function validateOperation( + args: Arguments, +): Promise { + const previousOperation = args.req.operation + args.req.operation = 'validate' + + try { + return await validateOperationWithScopedRequest(args) + } finally { + args.req.operation = previousOperation + } +} + +async function validateOperationWithScopedRequest({ id, collection, data: incomingData, @@ -34,8 +47,6 @@ export async function validateOperation({ }: Arguments): Promise { const collectionConfig = collection.config - req.operation = 'validate' - const accessResult = !overrideAccess ? await executeAccess({ id, data: incomingData, req }, collectionConfig.access.validate) : true diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index aefaab734ce..fd8670d9bdc 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -23,8 +23,6 @@ export type ValidateGlobalOptions = { context?: RequestContext /** Optional partial data to merge over the stored global. */ data?: DeepPartial, 'id'>> - /** A fallback locale used while constructing the validation request. */ - fallbackLocale?: false | TypedLocale /** The single locale to validate. Arrays and `'all'` are not accepted. */ locale: TypedLocale /** @@ -56,7 +54,13 @@ export async function validateGlobalLocal( throw new APIError(`The global with slug ${String(slug)} can't be found. Validate Operation.`) } - const req = await createLocalReq(options as CreateLocalReqOptions, payload) + const req = await createLocalReq( + { + ...(options as CreateLocalReqOptions), + fallbackLocale: false, + }, + payload, + ) return validateOperation({ slug, diff --git a/packages/payload/src/globals/operations/validate.ts b/packages/payload/src/globals/operations/validate.ts index 64990798546..c6586854152 100644 --- a/packages/payload/src/globals/operations/validate.ts +++ b/packages/payload/src/globals/operations/validate.ts @@ -21,15 +21,26 @@ export type Arguments = { slug: string } -export async function validateOperation({ +export async function validateOperation( + args: Arguments, +): Promise { + const previousOperation = args.req.operation + args.req.operation = 'validate' + + try { + return await validateOperationWithScopedRequest(args) + } finally { + args.req.operation = previousOperation + } +} + +async function validateOperationWithScopedRequest({ slug, data: incomingData, globalConfig, overrideAccess, req, }: Arguments): Promise { - req.operation = 'validate' - const accessResult = !overrideAccess ? await executeAccess({ data: incomingData, req }, globalConfig.access.validate) : true diff --git a/packages/payload/src/utilities/flattenDataByLocale.spec.ts b/packages/payload/src/utilities/flattenDataByLocale.spec.ts new file mode 100644 index 00000000000..842837ef0d5 --- /dev/null +++ b/packages/payload/src/utilities/flattenDataByLocale.spec.ts @@ -0,0 +1,184 @@ +import type { Field, FlattenedBlock } from '../fields/config/types.js' + +import { describe, expect, it } from 'vitest' + +import { flattenDataByLocale } from './flattenDataByLocale.js' + +const localizedText = (name: string): Field => ({ + name, + type: 'text', +}) + +describe('flattenDataByLocale', () => { + it('should recursively flatten localized arrays, blocks, groups, named tabs, and referenced blocks', () => { + const referencedBlock = { + fields: [localizedText('referencedText')], + slug: 'referenced', + } as FlattenedBlock + const fields: Field[] = [ + { + name: 'localizedArray', + type: 'array', + fields: [localizedText('arrayText')], + localized: true, + }, + { + name: 'localizedBlocks', + type: 'blocks', + blocks: [ + { + fields: [localizedText('inlineText')], + slug: 'inline', + }, + ], + localized: true, + }, + { + name: 'localizedGroup', + type: 'group', + fields: [localizedText('groupText')], + localized: true, + }, + { + type: 'tabs', + tabs: [ + { + name: 'localizedTab', + fields: [localizedText('tabText')], + localized: true, + }, + ], + }, + { + name: 'localizedReferencedBlocks', + type: 'blocks', + blocks: ['referenced'], + localized: true, + }, + ] + + const result = flattenDataByLocale({ + configBlockReferences: [referencedBlock], + docWithLocales: { + localizedArray: { + en: [{ arrayText: 'array en', id: 'array-en' }], + es: [{ arrayText: 'array es', id: 'array-es' }], + }, + localizedBlocks: { + en: [{ blockType: 'inline', id: 'inline-en', inlineText: 'inline en' }], + es: [{ blockType: 'inline', id: 'inline-es', inlineText: 'inline es' }], + }, + localizedGroup: { + en: { groupText: 'group en' }, + es: { groupText: 'group es' }, + }, + localizedReferencedBlocks: { + en: [ + { + blockType: 'referenced', + id: 'referenced-en', + referencedText: 'referenced en', + }, + ], + es: [ + { + blockType: 'referenced', + id: 'referenced-es', + referencedText: 'referenced es', + }, + ], + }, + localizedTab: { + en: { tabText: 'tab en' }, + es: { tabText: 'tab es' }, + }, + }, + fields, + locale: 'es', + }) + + expect(result).toEqual({ + localizedArray: [{ arrayText: 'array es', id: 'array-es' }], + localizedBlocks: [{ blockType: 'inline', id: 'inline-es', inlineText: 'inline es' }], + localizedGroup: { groupText: 'group es' }, + localizedReferencedBlocks: [ + { + blockType: 'referenced', + id: 'referenced-es', + referencedText: 'referenced es', + }, + ], + localizedTab: { tabText: 'tab es' }, + }) + }) + + it('should convert stored point representations at top-level and nested paths', () => { + const referencedBlock = { + fields: [{ name: 'referencedPoint', type: 'point' }], + slug: 'point-reference', + } as FlattenedBlock + const fields: Field[] = [ + { + name: 'localizedPoint', + type: 'point', + localized: true, + }, + { + name: 'pointGroup', + type: 'group', + fields: [{ name: 'groupPoint', type: 'point' }], + }, + { + name: 'pointArray', + type: 'array', + fields: [{ name: 'arrayPoint', type: 'point' }], + }, + { + name: 'pointBlocks', + type: 'blocks', + blocks: ['point-reference'], + }, + ] + + const result = flattenDataByLocale({ + configBlockReferences: [referencedBlock], + docWithLocales: { + localizedPoint: { + en: { coordinates: [1, 2], type: 'Point' }, + es: { coordinates: [3, 4], type: 'Point' }, + }, + pointArray: [ + { + arrayPoint: { coordinates: [7, 8], type: 'Point' }, + id: 'point-row', + }, + ], + pointBlocks: [ + { + blockType: 'point-reference', + id: 'point-block', + referencedPoint: { coordinates: [9, 10], type: 'Point' }, + }, + ], + pointGroup: { + groupPoint: { coordinates: [5, 6], type: 'Point' }, + }, + }, + fields, + locale: 'es', + }) + + expect(result).toEqual({ + localizedPoint: [3, 4], + pointArray: [{ arrayPoint: [7, 8], id: 'point-row' }], + pointBlocks: [ + { + blockType: 'point-reference', + id: 'point-block', + referencedPoint: [9, 10], + }, + ], + pointGroup: { groupPoint: [5, 6] }, + }) + }) +}) diff --git a/packages/payload/src/utilities/flattenDataByLocale.ts b/packages/payload/src/utilities/flattenDataByLocale.ts index 32b583064f5..aae9e0b8716 100644 --- a/packages/payload/src/utilities/flattenDataByLocale.ts +++ b/packages/payload/src/utilities/flattenDataByLocale.ts @@ -14,8 +14,9 @@ type Args = { } /** - * Returns a copy of stored locale-keyed data flattened to one locale without running after-read - * hooks, access control, sanitization, or population. + * Returns a copy of stored locale-keyed data flattened to one locale and converts field storage + * representations needed by validators, without running after-read hooks, access control, + * sanitization, or population. */ export function flattenDataByLocale({ configBlockReferences, @@ -63,6 +64,11 @@ function flattenFields({ }) } + data[field.name] = transformStoredFieldValue({ + field, + value: data[field.name], + }) + const fieldValue = data[field.name] const nestedParentIsLocalized = parentIsLocalized || Boolean(field.localized) @@ -182,6 +188,29 @@ function flattenFields({ } } +function transformStoredFieldValue({ field, value }: { field: Field; value: unknown }): unknown { + switch (field.type) { + case 'point': { + if (Array.isArray(value)) { + return value + } + + if (value && typeof value === 'object') { + const coordinates = (value as Record).coordinates + + if (Array.isArray(coordinates) && coordinates.length === 2) { + return coordinates + } + } + + return undefined + } + + default: + return value + } +} + function getLocaleValue({ locale, value }: { locale: string; value: unknown }): unknown { if (value && typeof value === 'object' && !Array.isArray(value)) { return (value as Record)[locale] diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index bb087a58c77..46aed3ee84e 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -159,6 +159,12 @@ describe('Types testing', () => { collection: 'pages', locale: null, }) + expect(payload.validate).type.not.toBeCallableWith({ + collection: 'pages', + data: {}, + fallbackLocale: null, + locale: null, + }) expect<{ collection: 'pages' data: Record @@ -176,6 +182,11 @@ describe('Types testing', () => { slug: 'menu', locale: null, }) + expect(payload.validateGlobal).type.not.toBeCallableWith({ + fallbackLocale: null, + slug: 'menu', + locale: null, + }) expect<{ locale: null slug: 'menu' diff --git a/test/validate/config.ts b/test/validate/config.ts index e18c725fcbd..ede23916dce 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -4,6 +4,9 @@ import path from 'path' import { saveVersion } from 'payload' import { fileURLToPath } from 'url' +// Direct internal import intentionally exercises the upload write guard. +// eslint-disable-next-line payload/no-relative-monorepo-imports +import { uploadFiles } from '../../packages/payload/src/uploads/uploadFiles.js' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' const filename = fileURLToPath(import.meta.url) @@ -78,18 +81,16 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re case 'upload': { const fileData = Buffer.from('must not be uploaded') - await req.payload.create({ - collection: validationUploadsSlug, - data: {}, - disableTransaction: true, - file: { - name: 'blocked.txt', - data: fileData, - mimetype: 'text/plain', - size: fileData.length, - }, + await uploadFiles( + req.payload, + [ + { + buffer: fileData, + path: path.join(validationUploadsDir, 'blocked.txt'), + }, + ], req, - }) + ) break } @@ -170,6 +171,14 @@ const validationCollection: CollectionConfig = { defaultValue: 'draft', required: true, }, + { + name: 'location', + type: 'point', + validate: (value) => + value === undefined || (Array.isArray(value) && value.length === 2) + ? true + : 'Location must use the public point tuple representation', + }, { name: 'writeAttempt', type: 'select', @@ -189,6 +198,11 @@ const validationCollection: CollectionConfig = { operation, requestOperation: req.operation, }) + + if (req.context.throwValidationHook === true) { + throw new Error('collection validation hook failure') + } + return data }, runWriteAttempt, @@ -228,6 +242,14 @@ const validationGlobal: GlobalConfig = { type: 'text', required: true, }, + { + name: 'location', + type: 'point', + validate: (value) => + value === undefined || (Array.isArray(value) && value.length === 2) + ? true + : 'Location must use the public point tuple representation', + }, ], hooks: { beforeChange: [ @@ -238,6 +260,11 @@ const validationGlobal: GlobalConfig = { operation, requestOperation: req.operation, }) + + if (req.context.throwValidationHook === true) { + throw new Error('global validation hook failure') + } + return data }, ], diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 7057259eb38..7a65de04de2 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -1,4 +1,4 @@ -import type { Payload } from 'payload' +import type { Payload, PayloadRequest } from 'payload' import fs from 'fs/promises' import path from 'path' @@ -29,6 +29,7 @@ describe('validate Local API', () => { await payload.updateGlobal({ slug: validationGlobalSlug, data: { + location: [-0.12, 51.5], summary: 'stored global summary', title: 'Stored global title', }, @@ -67,6 +68,9 @@ describe('validate Local API', () => { describe('collections', () => { it('should return field errors for invalid create data without creating a document', async () => { + const req = { + operation: 'update', + } satisfies Partial const result = await payload.validate({ collection: validationCollectionSlug, data: { @@ -74,6 +78,7 @@ describe('validate Local API', () => { title: '', }, locale: 'en', + req, }) expect(result).toMatchObject({ @@ -88,9 +93,13 @@ describe('validate Local API', () => { expect(await payload.count({ collection: validationCollectionSlug })).toEqual({ totalDocs: 0, }) + expect(req.operation).toBe('update') }) it('should return a successful result for valid create data', async () => { + const req = { + operation: 'read', + } satisfies Partial const result = await payload.validate({ collection: validationCollectionSlug, data: { @@ -98,12 +107,14 @@ describe('validate Local API', () => { title: 'Candidate title', }, locale: 'en', + req, }) expect(result).toEqual({ errors: [], valid: true, }) + expect(req.operation).toBe('read') }) it('should run validation hooks in order with the validate operation and unchanged context', async () => { @@ -181,6 +192,7 @@ describe('validate Local API', () => { const stored = await payload.create({ collection: validationCollectionSlug, data: { + location: [-0.12, 51.5], summary: 'stored summary', title: 'Stored title', }, @@ -206,12 +218,43 @@ describe('validate Local API', () => { valid: true, }) expect(afterValidation).toMatchObject({ + location: [-0.12, 51.5], summary: 'stored summary', title: 'Stored title', }) }) + it('should validate a partial update with an unchanged stored point representation', async () => { + const stored = await payload.create({ + collection: validationCollectionSlug, + data: { + location: [-73.9857, 40.7484], + summary: 'stored summary', + title: 'Stored title', + }, + locale: 'en', + }) + + const result = await payload.validate({ + id: stored.id, + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + }, + locale: 'en', + }) + + expect(result).toEqual({ + errors: [], + valid: true, + }) + }) + it('should execute first-class collection validation access and throw on denial', async () => { + const req = { + operation: 'delete', + } satisfies Partial + await expect( payload.validate({ collection: validationCollectionSlug, @@ -221,23 +264,52 @@ describe('validate Local API', () => { }, locale: 'en', overrideAccess: false, + req, }), ).rejects.toMatchObject({ status: 403, }) expect(accessEvents).toEqual(['collection']) + expect(req.operation).toBe('delete') + }) + + it('should restore the caller request operation after a collection hook throws', async () => { + const req = { + operation: 'create', + } satisfies Partial + + await expect( + payload.validate({ + collection: validationCollectionSlug, + context: { + throwValidationHook: true, + }, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: 'en', + req, + }), + ).rejects.toThrow('collection validation hook failure') + + expect(req.operation).toBe('create') }) }) describe('globals', () => { it('should validate valid partial global data without persisting it', async () => { + const req = { + operation: 'read', + } satisfies Partial const result = await payload.validateGlobal({ slug: validationGlobalSlug, data: { summary: 'candidate summary', }, locale: 'en', + req, }) const afterValidation = await payload.findGlobal({ slug: validationGlobalSlug, @@ -249,18 +321,24 @@ describe('validate Local API', () => { valid: true, }) expect(afterValidation).toMatchObject({ + location: [-0.12, 51.5], summary: 'stored global summary', title: 'Stored global title', }) + expect(req.operation).toBe('read') }) it('should return errors for invalid partial global data without persisting it', async () => { + const req = { + operation: 'update', + } satisfies Partial const result = await payload.validateGlobal({ slug: validationGlobalSlug, data: { title: '', }, locale: 'en', + req, }) const afterValidation = await payload.findGlobal({ slug: validationGlobalSlug, @@ -277,20 +355,61 @@ describe('validate Local API', () => { valid: false, }) expect(afterValidation.title).toBe('Stored global title') + expect(req.operation).toBe('update') + }) + + it('should validate partial global data with an unchanged stored point representation', async () => { + const result = await payload.validateGlobal({ + slug: validationGlobalSlug, + data: { + summary: 'candidate summary', + }, + locale: 'en', + }) + + expect(result).toEqual({ + errors: [], + valid: true, + }) }) it('should execute first-class global validation access and throw on denial', async () => { + const req = { + operation: 'delete', + } satisfies Partial + await expect( payload.validateGlobal({ slug: validationGlobalSlug, locale: 'en', overrideAccess: false, + req, }), ).rejects.toMatchObject({ status: 403, }) expect(accessEvents).toEqual(['global']) + expect(req.operation).toBe('delete') + }) + + it('should restore the caller request operation after a global hook throws', async () => { + const req = { + operation: 'create', + } satisfies Partial + + await expect( + payload.validateGlobal({ + slug: validationGlobalSlug, + context: { + throwValidationHook: true, + }, + locale: 'en', + req, + }), + ).rejects.toThrow('global validation hook failure') + + expect(req.operation).toBe('create') }) }) @@ -338,6 +457,8 @@ describe('validate Local API', () => { }) it('should reject an upload that reuses the validation request before a row or file is written', async () => { + await fs.mkdir(validationUploadsDir, { recursive: true }) + await expect(runWriteAttempt('upload')).rejects.toThrow( 'Payload writes are not allowed during validation', ) From 0e77427c3a17f654bdb9d3dd5ffe60416c59acb8 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 19:40:56 +0100 Subject: [PATCH 06/47] feat(payload): add REST validation endpoints --- .../src/collections/endpoints/index.ts | 11 + .../src/collections/endpoints/validate.ts | 63 +++++ .../payload/src/globals/endpoints/index.ts | 6 + .../payload/src/globals/endpoints/validate.ts | 36 +++ .../src/utilities/parseValidationLocale.ts | 68 ++++++ test/validate/config.ts | 4 +- test/validate/int.spec.ts | 220 +++++++++++++++++- 7 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 packages/payload/src/collections/endpoints/validate.ts create mode 100644 packages/payload/src/globals/endpoints/validate.ts create mode 100644 packages/payload/src/utilities/parseValidationLocale.ts diff --git a/packages/payload/src/collections/endpoints/index.ts b/packages/payload/src/collections/endpoints/index.ts index 8cf6005cb02..daac726ec61 100644 --- a/packages/payload/src/collections/endpoints/index.ts +++ b/packages/payload/src/collections/endpoints/index.ts @@ -15,6 +15,7 @@ import { findVersionsHandler } from './findVersions.js' import { restoreVersionHandler } from './restoreVersion.js' import { updateHandler } from './update.js' import { updateByIDHandler } from './updateByID.js' +import { validateByIDHandler, validateHandler } from './validate.js' export const defaultCollectionEndpoints: Endpoint[] = [ ...wrapInternalEndpoints([ @@ -43,6 +44,16 @@ export const defaultCollectionEndpoints: Endpoint[] = [ method: 'post', path: '/access/:id?', }, + { + handler: validateHandler, + method: 'post', + path: '/validate', + }, + { + handler: validateByIDHandler, + method: 'post', + path: '/:id/validate', + }, { handler: findVersionsHandler, method: 'get', diff --git a/packages/payload/src/collections/endpoints/validate.ts b/packages/payload/src/collections/endpoints/validate.ts new file mode 100644 index 00000000000..6ad5ef25a3b --- /dev/null +++ b/packages/payload/src/collections/endpoints/validate.ts @@ -0,0 +1,63 @@ +import { status as httpStatus } from 'http-status' + +import type { PayloadHandler } from '../../config/types.js' + +import { + getRequestCollection, + getRequestCollectionWithID, +} from '../../utilities/getRequestEntity.js' +import { headersWithCors } from '../../utilities/headersWithCors.js' +import { + assertValidationData, + parseSingleValidationLocale, +} from '../../utilities/parseValidationLocale.js' +import { validateLocal } from '../operations/local/validate.js' + +export const validateHandler: PayloadHandler = async (req) => { + const collection = getRequestCollection(req) + const locale = parseSingleValidationLocale(req.query.locale) + + assertValidationData(req.data) + + const result = await validateLocal(req.payload, { + collection: collection.config.slug, + data: req.data, + locale, + overrideAccess: false, + req, + }) + + return Response.json(result, { + headers: headersWithCors({ + headers: new Headers(), + req, + }), + status: httpStatus.OK, + }) +} + +export const validateByIDHandler: PayloadHandler = async (req) => { + const { id, collection } = getRequestCollectionWithID(req) + const locale = parseSingleValidationLocale(req.query.locale) + + if (req.data !== undefined) { + assertValidationData(req.data) + } + + const result = await validateLocal(req.payload, { + id, + collection: collection.config.slug, + data: req.data, + locale, + overrideAccess: false, + req, + }) + + return Response.json(result, { + headers: headersWithCors({ + headers: new Headers(), + req, + }), + status: httpStatus.OK, + }) +} diff --git a/packages/payload/src/globals/endpoints/index.ts b/packages/payload/src/globals/endpoints/index.ts index 55341c583bf..9e8bcc620e5 100644 --- a/packages/payload/src/globals/endpoints/index.ts +++ b/packages/payload/src/globals/endpoints/index.ts @@ -7,6 +7,7 @@ import { findVersionByIDHandler } from './findVersionByID.js' import { findVersionsHandler } from './findVersions.js' import { restoreVersionHandler } from './restoreVersion.js' import { updateHandler } from './update.js' +import { validateHandler } from './validate.js' export const defaultGlobalEndpoints: Endpoint[] = wrapInternalEndpoints([ { @@ -14,6 +15,11 @@ export const defaultGlobalEndpoints: Endpoint[] = wrapInternalEndpoints([ method: 'post', path: '/access', }, + { + handler: validateHandler, + method: 'post', + path: '/validate', + }, { handler: findOneHandler, method: 'get', diff --git a/packages/payload/src/globals/endpoints/validate.ts b/packages/payload/src/globals/endpoints/validate.ts new file mode 100644 index 00000000000..fe36d35b7dd --- /dev/null +++ b/packages/payload/src/globals/endpoints/validate.ts @@ -0,0 +1,36 @@ +import { status as httpStatus } from 'http-status' + +import type { PayloadHandler } from '../../config/types.js' + +import { getRequestGlobal } from '../../utilities/getRequestEntity.js' +import { headersWithCors } from '../../utilities/headersWithCors.js' +import { + assertValidationData, + parseSingleValidationLocale, +} from '../../utilities/parseValidationLocale.js' +import { validateGlobalLocal } from '../operations/local/validate.js' + +export const validateHandler: PayloadHandler = async (req) => { + const globalConfig = getRequestGlobal(req) + const locale = parseSingleValidationLocale(req.query.locale) + + if (req.data !== undefined) { + assertValidationData(req.data) + } + + const result = await validateGlobalLocal(req.payload, { + slug: globalConfig.slug, + data: req.data, + locale, + overrideAccess: false, + req, + }) + + return Response.json(result, { + headers: headersWithCors({ + headers: new Headers(), + req, + }), + status: httpStatus.OK, + }) +} diff --git a/packages/payload/src/utilities/parseValidationLocale.ts b/packages/payload/src/utilities/parseValidationLocale.ts new file mode 100644 index 00000000000..894f5030cda --- /dev/null +++ b/packages/payload/src/utilities/parseValidationLocale.ts @@ -0,0 +1,68 @@ +import { status as httpStatus } from 'http-status' + +import type { TypedLocale } from '../index.js' + +import { APIError } from '../errors/index.js' + +type ValidationLocale = + | { + locale: TypedLocale + type: 'single' + } + | { + locales: TypedLocale[] + type: 'multiple' + } + | { + type: 'all' + } + +export function parseValidationLocale(locale: unknown): ValidationLocale { + if (typeof locale === 'string') { + if (locale.length === 0) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + if (locale === 'all') { + return { type: 'all' } + } + + return { + type: 'single', + locale: locale as TypedLocale, + } + } + + if ( + Array.isArray(locale) && + locale.length > 0 && + locale.every((value) => typeof value === 'string') + ) { + if (locale.some((value) => value.length === 0)) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + return { + type: 'multiple', + locales: locale as TypedLocale[], + } + } + + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) +} + +export function parseSingleValidationLocale(locale: unknown): TypedLocale { + const parsedLocale = parseValidationLocale(locale) + + if (parsedLocale.type !== 'single') { + throw new APIError('Validation requires a single locale.', httpStatus.BAD_REQUEST) + } + + return parsedLocale.locale +} + +export function assertValidationData(data: unknown): asserts data is Record { + if (!data || Array.isArray(data) || typeof data !== 'object') { + throw new APIError('Validation data must be an object.', httpStatus.BAD_REQUEST) + } +} diff --git a/test/validate/config.ts b/test/validate/config.ts index ede23916dce..4e7d1165eb8 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -117,7 +117,7 @@ const validationCollection: CollectionConfig = { access: { validate: ({ req }) => { accessEvents.push('collection') - return req.context.allowValidation === true + return req.payloadAPI === 'REST' || req.context.allowValidation === true }, }, fields: [ @@ -227,7 +227,7 @@ const validationGlobal: GlobalConfig = { access: { validate: ({ req }) => { accessEvents.push('global') - return req.context.allowValidation === true + return req.payloadAPI === 'REST' || req.context.allowValidation === true }, }, fields: [ diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 7a65de04de2..c01dbb6a1a9 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -5,6 +5,8 @@ import path from 'path' import { fileURLToPath } from 'url' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import type { NextRESTClient } from '../__helpers/shared/NextRESTClient.js' + import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js' import { accessEvents, @@ -18,13 +20,14 @@ import { } from './config.js' let payload: Payload +let restClient: NextRESTClient const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) describe('validate Local API', () => { beforeAll(async () => { - ;({ payload } = await initPayloadInt(dirname)) + ;({ payload, restClient } = await initPayloadInt(dirname)) await payload.updateGlobal({ slug: validationGlobalSlug, @@ -413,6 +416,221 @@ describe('validate Local API', () => { }) }) + describe('REST API', () => { + it('should return invalid collection create validation without creating a document', async () => { + const response = await restClient.POST(`/${validationCollectionSlug}/validate?locale=en`, { + body: JSON.stringify({ + summary: 'candidate summary', + title: '', + }), + }) + const result = await response.json() + + expect(response.status).toBe(200) + expect(result).toMatchObject({ + errors: [ + { + locale: 'en', + path: 'title', + }, + ], + valid: false, + }) + expect(await payload.count({ collection: validationCollectionSlug })).toEqual({ + totalDocs: 0, + }) + }) + + it('should return valid collection create validation', async () => { + const response = await restClient.POST(`/${validationCollectionSlug}/validate?locale=en`, { + body: JSON.stringify({ + summary: 'candidate summary', + title: 'Candidate title', + }), + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + errors: [], + valid: true, + }) + }) + + it('should return 400 for missing or empty locales and malformed data', async () => { + const missingLocale = await restClient.POST(`/${validationCollectionSlug}/validate`, { + body: JSON.stringify({ + summary: 'candidate summary', + title: 'Candidate title', + }), + }) + const emptyLocale = await restClient.POST(`/${validationCollectionSlug}/validate?locale=`, { + body: JSON.stringify({ + summary: 'candidate summary', + title: 'Candidate title', + }), + }) + const malformedData = await restClient.POST( + `/${validationCollectionSlug}/validate?locale=en`, + { + body: JSON.stringify([]), + }, + ) + const malformedJSON = await restClient.POST( + `/${validationCollectionSlug}/validate?locale=en`, + { + body: '{ invalid json', + }, + ) + + expect(missingLocale.status).toBe(400) + expect(emptyLocale.status).toBe(400) + expect(malformedData.status).toBe(400) + expect(malformedJSON.status).toBe(400) + }) + + it('should merge by-ID validation data without persisting it', async () => { + const stored = await payload.create({ + collection: validationCollectionSlug, + data: { + location: [-0.12, 51.5], + summary: 'stored summary', + title: 'Stored title', + }, + locale: 'en', + }) + const response = await restClient.POST( + `/${validationCollectionSlug}/${stored.id}/validate?locale=en`, + { + body: JSON.stringify({ + summary: 'candidate summary', + }), + }, + ) + const afterValidation = await payload.findByID({ + id: stored.id, + collection: validationCollectionSlug, + locale: 'en', + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + errors: [], + valid: true, + }) + expect(afterValidation).toMatchObject({ + location: [-0.12, 51.5], + summary: 'stored summary', + title: 'Stored title', + }) + }) + + it('should validate global data without persisting it', async () => { + const response = await restClient.POST( + `/globals/${validationGlobalSlug}/validate?locale=en`, + { + body: JSON.stringify({ + title: '', + }), + }, + ) + const afterValidation = await payload.findGlobal({ + slug: validationGlobalSlug, + locale: 'en', + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + errors: [ + { + locale: 'en', + path: 'title', + }, + ], + valid: false, + }) + expect(afterValidation.title).toBe('Stored global title') + }) + + it('should return valid global validation without persisting it', async () => { + const response = await restClient.POST( + `/globals/${validationGlobalSlug}/validate?locale=en`, + { + body: JSON.stringify({ + summary: 'candidate summary', + }), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + errors: [], + valid: true, + }) + }) + + it('should deny validation when access.validate returns false', async () => { + const collection = payload.collections[validationCollectionSlug]! + const validate = collection.config.access.validate + + collection.config.access.validate = () => false + + const response = await restClient.POST(`/${validationCollectionSlug}/validate?locale=en`, { + body: JSON.stringify({ + summary: 'candidate summary', + title: 'Candidate title', + }), + }) + + collection.config.access.validate = validate + + expect(response.status).toBe(403) + }) + + it('should ignore body attempts to alter access, context, or the validation operation', async () => { + const collection = payload.collections[validationCollectionSlug]! + const validate = collection.config.access.validate + + collection.config.access.validate = () => false + + const deniedResponse = await restClient.POST( + `/${validationCollectionSlug}/validate?locale=en`, + { + body: JSON.stringify({ + context: { allowValidation: true }, + operation: 'create', + overrideAccess: true, + req: { operation: 'create' }, + summary: 'candidate summary', + title: 'Candidate title', + user: { id: 'admin' }, + }), + }, + ) + + collection.config.access.validate = validate + + const allowedResponse = await restClient.POST( + `/${validationCollectionSlug}/validate?locale=en`, + { + body: JSON.stringify({ + operation: 'create', + summary: 'candidate summary', + title: 'Candidate title', + }), + }, + ) + + expect(deniedResponse.status).toBe(403) + expect(allowedResponse.status).toBe(200) + expect( + hookEvents.every( + ({ operation, requestOperation }) => + operation === 'validate' && requestOperation === 'validate', + ), + ).toBe(true) + }) + }) + describe('write safety', () => { it('should reject a create that reuses the validation request before a row is written', async () => { await expect(runWriteAttempt('create')).rejects.toThrow( From 5045bcf0ec4b40d39dc5a4b65665b151ba82a13a Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 19:48:17 +0100 Subject: [PATCH 07/47] test(payload): strengthen REST validation security coverage --- test/validate/config.ts | 20 ++++++++++ test/validate/int.spec.ts | 83 +++++++++++++++++++++++++++++---------- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/test/validate/config.ts b/test/validate/config.ts index 4e7d1165eb8..17b56d328a1 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -188,6 +188,26 @@ const validationCollection: CollectionConfig = { name: 'targetID', type: 'text', }, + { + name: 'user', + type: 'json', + }, + { + name: 'req', + type: 'json', + }, + { + name: 'context', + type: 'json', + }, + { + name: 'overrideAccess', + type: 'checkbox', + }, + { + name: 'operation', + type: 'text', + }, ], hooks: { beforeChange: [ diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index c01dbb6a1a9..ddac2b78f57 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -488,6 +488,27 @@ describe('validate Local API', () => { expect(malformedJSON.status).toBe(400) }) + it('should return 400 for repeated and all locale selectors', async () => { + const repeatedLocale = await restClient.POST( + `/${validationCollectionSlug}/validate?locale=en&locale=es`, + { + body: JSON.stringify({ + summary: 'candidate summary', + title: 'Candidate title', + }), + }, + ) + const allLocales = await restClient.POST(`/${validationCollectionSlug}/validate?locale=all`, { + body: JSON.stringify({ + summary: 'candidate summary', + title: 'Candidate title', + }), + }) + + expect(repeatedLocale.status).toBe(400) + expect(allLocales.status).toBe(400) + }) + it('should merge by-ID validation data without persisting it', async () => { const stored = await payload.create({ collection: validationCollectionSlug, @@ -586,48 +607,68 @@ describe('validate Local API', () => { expect(response.status).toBe(403) }) - it('should ignore body attempts to alter access, context, or the validation operation', async () => { + it('should keep body control-shaped fields as data without changing trusted access inputs', async () => { const collection = payload.collections[validationCollectionSlug]! const validate = collection.config.access.validate - - collection.config.access.validate = () => false + const accessRequests: unknown[] = [] + + collection.config.access.validate = ({ data, req }) => { + accessRequests.push({ + context: req.context, + data, + operation: req.operation, + user: req.user, + }) + + return ( + req.user?.email === 'trusted@example.com' && + req.context.allowValidation === true && + req.operation === 'validate' + ) + } const deniedResponse = await restClient.POST( `/${validationCollectionSlug}/validate?locale=en`, { body: JSON.stringify({ context: { allowValidation: true }, - operation: 'create', + operation: 'validate', overrideAccess: true, - req: { operation: 'create' }, + req: { + context: { allowValidation: true }, + operation: 'validate', + user: { email: 'trusted@example.com' }, + }, summary: 'candidate summary', title: 'Candidate title', - user: { id: 'admin' }, + user: { email: 'trusted@example.com' }, }), }, ) collection.config.access.validate = validate - const allowedResponse = await restClient.POST( - `/${validationCollectionSlug}/validate?locale=en`, + expect(deniedResponse.status).toBe(403) + expect(accessRequests).toEqual([ { - body: JSON.stringify({ - operation: 'create', + context: {}, + data: { + context: { allowValidation: true }, + operation: 'validate', + overrideAccess: true, + req: { + context: { allowValidation: true }, + operation: 'validate', + user: { email: 'trusted@example.com' }, + }, summary: 'candidate summary', title: 'Candidate title', - }), + user: { email: 'trusted@example.com' }, + }, + operation: 'validate', + user: null, }, - ) - - expect(deniedResponse.status).toBe(403) - expect(allowedResponse.status).toBe(200) - expect( - hookEvents.every( - ({ operation, requestOperation }) => - operation === 'validate' && requestOperation === 'validate', - ), - ).toBe(true) + ]) }) }) From 9b7a601579c199c073a3aff5d16e68c17f931870 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 20:22:51 +0100 Subject: [PATCH 08/47] feat(payload): add multi-locale publish validation --- .../src/collections/endpoints/validate.ts | 6 +- .../src/collections/operations/create.ts | 34 ++ .../collections/operations/local/validate.ts | 89 +++- .../operations/utilities/update.ts | 36 ++ .../src/collections/operations/validate.ts | 15 +- packages/payload/src/config/sanitize.ts | 2 + packages/payload/src/config/types.ts | 14 +- .../payload/src/globals/endpoints/validate.ts | 4 +- .../src/globals/operations/local/validate.ts | 79 ++- .../payload/src/globals/operations/update.ts | 34 ++ .../src/globals/operations/validate.ts | 15 +- packages/payload/src/index.ts | 4 +- .../src/utilities/applyLocaleFiltering.ts | 5 +- .../src/utilities/flattenDataByLocale.ts | 29 +- .../src/utilities/parseValidationLocale.ts | 16 + .../utilities/resolvePublishLocales.spec.ts | 46 ++ .../src/utilities/resolvePublishLocales.ts | 31 ++ .../src/utilities/resolveValidationLocales.ts | 136 ++++++ test/types/types.spec.ts | 32 ++ test/validate/config.ts | 123 ++++- test/validate/int.spec.ts | 454 +++++++++++++++++- 21 files changed, 1151 insertions(+), 53 deletions(-) create mode 100644 packages/payload/src/utilities/resolvePublishLocales.spec.ts create mode 100644 packages/payload/src/utilities/resolvePublishLocales.ts create mode 100644 packages/payload/src/utilities/resolveValidationLocales.ts diff --git a/packages/payload/src/collections/endpoints/validate.ts b/packages/payload/src/collections/endpoints/validate.ts index 6ad5ef25a3b..34620c737a7 100644 --- a/packages/payload/src/collections/endpoints/validate.ts +++ b/packages/payload/src/collections/endpoints/validate.ts @@ -9,13 +9,13 @@ import { import { headersWithCors } from '../../utilities/headersWithCors.js' import { assertValidationData, - parseSingleValidationLocale, + parseValidationLocaleSelector, } from '../../utilities/parseValidationLocale.js' import { validateLocal } from '../operations/local/validate.js' export const validateHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const locale = parseSingleValidationLocale(req.query.locale) + const locale = parseValidationLocaleSelector(req.query.locale) assertValidationData(req.data) @@ -38,7 +38,7 @@ export const validateHandler: PayloadHandler = async (req) => { export const validateByIDHandler: PayloadHandler = async (req) => { const { id, collection } = getRequestCollectionWithID(req) - const locale = parseSingleValidationLocale(req.query.locale) + const locale = parseValidationLocaleSelector(req.query.locale) if (req.data !== undefined) { assertValidationData(req.data) diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index bcd56a2d228..ea16f019e26 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -20,6 +20,7 @@ import { executeAccess } from '../../auth/executeAccess.js' import { sendVerificationEmail } from '../../auth/sendVerificationEmail.js' import { registerLocalStrategy } from '../../auth/strategies/local/register.js' import { getDuplicateDocumentData } from '../../duplicateDocument/index.js' +import { ValidationError } 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' @@ -37,9 +38,11 @@ import { } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' +import { resolvePublishLocales } from '../../utilities/resolvePublishLocales.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' +import { validateLocalWithDataLocale } from './local/validate.js' import { buildAfterOperation } from './utilities/buildAfterOperation.js' import { buildBeforeOperation } from './utilities/buildBeforeOperation.js' @@ -174,6 +177,37 @@ export const createOperation = async < data = newFileData + if (!isSavingDraft && hasDraftsEnabled(collectionConfig)) { + const validationResult = await validateLocalWithDataLocale(payload, { + collection: collectionConfig.slug, + data, + locale: resolvePublishLocales({ + locale: locale ?? null, + localization: config.localization, + publishAllLocales, + }), + overrideAccess, + req, + validationDataLocale: + locale && locale !== 'all' + ? locale + : config.localization + ? config.localization.defaultLocale + : undefined, + }) + + if (!validationResult.valid) { + throw new ValidationError( + { + collection: collectionConfig.slug, + errors: validationResult.errors, + req, + }, + req.t, + ) + } + } + // ///////////////////////////////////// // beforeValidate - Fields // ///////////////////////////////////// diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index b13f5e5b051..6f50db30e13 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -6,12 +6,11 @@ import type { CollectionSlug, Payload, RequestContext, - TypedLocale, User, ValidationFieldError, } from '../../../index.js' -import type { PayloadRequest } from '../../../types/index.js' -import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' +import type { JsonObject, PayloadRequest } from '../../../types/index.js' +import type { ValidationLocaleSelector } from '../../../utilities/resolveValidationLocales.js' import type { DataFromCollectionSlug, DraftFlagFromCollectionSlug, @@ -20,6 +19,12 @@ import type { import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' +import { filterDataToSelectedLocales } from '../../../utilities/filterDataToSelectedLocales.js' +import { + cloneValidationRequest, + resolveValidationLocales, + runValidationLocalePasses, +} from '../../../utilities/resolveValidationLocales.js' import { validateOperation } from '../validate.js' /** @@ -39,10 +44,8 @@ type BaseOptions = { * Hook context merged into `req.context` for the validation lifecycle. */ context?: RequestContext - /** - * The single locale to validate. Arrays and `'all'` are not accepted by this operation. - */ - locale: TypedLocale + /** One locale, a non-empty locale array, or every available locale. */ + locale: ValidationLocaleSelector /** * Skip collection and field access control. * @default true @@ -78,13 +81,31 @@ export type ValidateCollectionOptions = id: DataFromCollectionSlug['id'] } & BaseOptions) +type InternalValidateCollectionOptions = { + validationDataLocale?: string +} & ValidateCollectionOptions + export async function validateLocal( payload: Payload, options: ValidateCollectionOptions, ): Promise { - const { id, collection: collectionSlug, data, locale, overrideAccess = true } = options + return validateLocalWithDataLocale(payload, options) +} - if (locale === undefined || locale === 'all' || Array.isArray(locale)) { +export async function validateLocalWithDataLocale( + payload: Payload, + options: InternalValidateCollectionOptions, +): Promise { + const { + id, + collection: collectionSlug, + data, + locale, + overrideAccess = true, + validationDataLocale, + } = options + + if (locale === undefined) { throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) } @@ -100,19 +121,53 @@ export async function validateLocal( ) } - const req = await createLocalReq( + const baseReq = await createLocalReq( { - ...(options as CreateLocalReqOptions), + context: options.context, fallbackLocale: false, + req: cloneValidationRequest(options.req), + user: options.user ?? undefined, }, payload, ) + const locales = await resolveValidationLocales({ + locale, + req: baseReq, + }) + const results = await runValidationLocalePasses({ + locales, + validate: async (validationLocale) => { + const req = await createLocalReq( + { + fallbackLocale: false, + locale: validationLocale ?? undefined, + req: cloneValidationRequest(baseReq), + }, + payload, + ) + const validationData = + validationDataLocale && validationLocale !== validationDataLocale && data + ? filterDataToSelectedLocales({ + configBlockReferences: payload.config.blocks, + docWithLocales: data as JsonObject, + fields: collection.config.fields, + selectedLocales: [], + }) + : data - return validateOperation({ - id, - collection, - data, - overrideAccess, - req, + return validateOperation({ + id, + collection, + data: validationData, + overrideAccess, + req, + }) + }, }) + const errors = results.flatMap((result) => result.errors) + + return { + errors, + valid: errors.length === 0, + } } diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index f604129ecf1..177ef92ac87 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -24,6 +24,7 @@ import type { import { ensureUsernameOrEmail } from '../../../auth/ensureUsernameOrEmail.js' import { generatePasswordSaltHash } from '../../../auth/strategies/local/generatePasswordSaltHash.js' +import { ValidationError } 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' @@ -37,7 +38,9 @@ import { hasDraftValidationEnabled, hasLocalizeStatusEnabled, } from '../../../utilities/getVersionsConfig.js' +import { resolvePublishLocales } from '../../../utilities/resolvePublishLocales.js' import { buildLocalizedPublishData } from '../../../versions/buildSingleLocalePublishData.js' +import { validateLocalWithDataLocale } from '../local/validate.js' export type SharedUpdateDocumentArgs = { autosave: boolean collectionConfig: SanitizedCollectionConfig @@ -151,6 +154,39 @@ export const updateDocument = async < showHiddenFields: true, }) + if (hasDraftsEnabled(collectionConfig) && !isSavingDraft && !unpublishAllLocales) { + const validationResult = await validateLocalWithDataLocale(payload, { + id, + collection: collectionConfig.slug, + data, + locale: resolvePublishLocales({ + locale, + localization: config.localization, + publishAllLocales, + }), + overrideAccess, + req, + validationDataLocale: + locale !== 'all' + ? locale + : config.localization + ? config.localization.defaultLocale + : undefined, + }) + + if (!validationResult.valid) { + throw new ValidationError( + { + id, + collection: collectionConfig.slug, + errors: validationResult.errors, + req, + }, + req.t, + ) + } + } + const isRestoringDraftFromTrash = Boolean(originalDoc?.deletedAt) && data?._status !== 'published' if (collectionConfig.auth) { diff --git a/packages/payload/src/collections/operations/validate.ts b/packages/payload/src/collections/operations/validate.ts index 81ca97e9fd3..21821f19e8b 100644 --- a/packages/payload/src/collections/operations/validate.ts +++ b/packages/payload/src/collections/operations/validate.ts @@ -94,7 +94,15 @@ async function validateOperationWithScopedRequest( locale: req.locale!, }) - let data = deepCopyObjectSimple(incomingData ?? {}) + let data = flattenDataByLocale({ + configBlockReferences: req.payload.config.blocks, + docWithLocales: deepCopyObjectSimple(incomingData ?? {}) as JsonObject, + fields: collectionConfig.fields, + locale: req.locale!, + localeCodes: req.payload.config.localization + ? req.payload.config.localization.localeCodes + : undefined, + }) data = await beforeValidate({ id, @@ -152,7 +160,10 @@ async function validateOperationWithScopedRequest( } catch (error) { if (error instanceof ValidationError) { return { - errors: error.data.errors, + errors: error.data.errors.map((validationError) => ({ + ...validationError, + locale: req.locale ?? undefined, + })), valid: false, } } diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index 4cf29da7800..34a5f8af1f4 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -274,6 +274,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise ({ code: locale, label: locale, + required: false, rtl: false, toString: () => locale, })) @@ -285,6 +286,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise ({ ...locale, + required: locale.required ?? false, toString: () => locale.code, })) } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 51650cf47b1..402dcb7b323 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -582,6 +582,11 @@ export type Locale = { * @example "English" */ label: Record | string + /** + * Whether this locale must pass validation before publishing another locale. + * @default false + */ + required?: boolean /** * if true, defaults textAligmnent on text fields to RTL */ @@ -642,6 +647,12 @@ export type LocalizationConfigWithLabels = Prettify< } & BaseLocalizationConfig > +export type SanitizedLocale = Prettify< + { + required: boolean + } & Locale +> + export type SanitizedLocalizationConfig = Prettify< { /** @@ -649,7 +660,8 @@ export type SanitizedLocalizationConfig = Prettify< * @example `["en", "es", "fr", "nl", "de", "jp"]` */ localeCodes: string[] - } & LocalizationConfigWithLabels + locales: SanitizedLocale[] + } & Omit > /** diff --git a/packages/payload/src/globals/endpoints/validate.ts b/packages/payload/src/globals/endpoints/validate.ts index fe36d35b7dd..d31b771c737 100644 --- a/packages/payload/src/globals/endpoints/validate.ts +++ b/packages/payload/src/globals/endpoints/validate.ts @@ -6,13 +6,13 @@ import { getRequestGlobal } from '../../utilities/getRequestEntity.js' import { headersWithCors } from '../../utilities/headersWithCors.js' import { assertValidationData, - parseSingleValidationLocale, + parseValidationLocaleSelector, } from '../../utilities/parseValidationLocale.js' import { validateGlobalLocal } from '../operations/local/validate.js' export const validateHandler: PayloadHandler = async (req) => { const globalConfig = getRequestGlobal(req) - const locale = parseSingleValidationLocale(req.query.locale) + const locale = parseValidationLocaleSelector(req.query.locale) if (req.data !== undefined) { assertValidationData(req.data) diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index fd8670d9bdc..097d231afdf 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -3,13 +3,19 @@ import type { DeepPartial } from 'ts-essentials' import { status as httpStatus } from 'http-status' import type { ValidationResult } from '../../../collections/operations/local/validate.js' -import type { GlobalSlug, Payload, RequestContext, TypedLocale, User } from '../../../index.js' +import type { GlobalSlug, JsonObject, Payload, RequestContext, User } from '../../../index.js' import type { PayloadRequest } from '../../../types/index.js' -import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js' +import type { ValidationLocaleSelector } from '../../../utilities/resolveValidationLocales.js' import type { DataFromGlobalSlug, DraftFlagFromGlobalSlug } from '../../config/types.js' import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' +import { filterDataToSelectedLocales } from '../../../utilities/filterDataToSelectedLocales.js' +import { + cloneValidationRequest, + resolveValidationLocales, + runValidationLocalePasses, +} from '../../../utilities/resolveValidationLocales.js' import { validateOperation } from '../validate.js' /** @@ -23,8 +29,8 @@ export type ValidateGlobalOptions = { context?: RequestContext /** Optional partial data to merge over the stored global. */ data?: DeepPartial, 'id'>> - /** The single locale to validate. Arrays and `'all'` are not accepted. */ - locale: TypedLocale + /** One locale, a non-empty locale array, or every available locale. */ + locale: ValidationLocaleSelector /** * Skip global and field access control. * @default true @@ -38,13 +44,24 @@ export type ValidateGlobalOptions = { user?: null | User } & DraftFlagFromGlobalSlug +type InternalValidateGlobalOptions = { + validationDataLocale?: string +} & ValidateGlobalOptions + export async function validateGlobalLocal( payload: Payload, options: ValidateGlobalOptions, ): Promise { - const { slug, data, locale, overrideAccess = true } = options + return validateGlobalLocalWithDataLocale(payload, options) +} + +export async function validateGlobalLocalWithDataLocale( + payload: Payload, + options: InternalValidateGlobalOptions, +): Promise { + const { slug, data, locale, overrideAccess = true, validationDataLocale } = options - if (locale === undefined || locale === 'all' || Array.isArray(locale)) { + if (locale === undefined) { throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) } @@ -54,19 +71,53 @@ export async function validateGlobalLocal( throw new APIError(`The global with slug ${String(slug)} can't be found. Validate Operation.`) } - const req = await createLocalReq( + const baseReq = await createLocalReq( { - ...(options as CreateLocalReqOptions), + context: options.context, fallbackLocale: false, + req: cloneValidationRequest(options.req), + user: options.user ?? undefined, }, payload, ) + const locales = await resolveValidationLocales({ + locale, + req: baseReq, + }) + const results = await runValidationLocalePasses({ + locales, + validate: async (validationLocale) => { + const req = await createLocalReq( + { + fallbackLocale: false, + locale: validationLocale ?? undefined, + req: cloneValidationRequest(baseReq), + }, + payload, + ) + const validationData = + validationDataLocale && validationLocale !== validationDataLocale && data + ? filterDataToSelectedLocales({ + configBlockReferences: payload.config.blocks, + docWithLocales: data as JsonObject, + fields: globalConfig.fields, + selectedLocales: [], + }) + : data - return validateOperation({ - slug, - data, - globalConfig, - overrideAccess, - req, + return validateOperation({ + slug, + data: validationData, + globalConfig, + overrideAccess, + req, + }) + }, }) + const errors = results.flatMap((result) => result.errors) + + return { + errors, + valid: errors.length === 0, + } } diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 8ce6104ee57..dc3db95d54d 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -17,6 +17,7 @@ import type { } from '../config/types.js' import { executeAccess } from '../../auth/executeAccess.js' +import { ValidationError } 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' @@ -33,11 +34,13 @@ import { } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' +import { resolvePublishLocales } from '../../utilities/resolvePublishLocales.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' import { buildLocalizedPublishData } from '../../versions/buildSingleLocalePublishData.js' import { getLatestGlobalVersion } from '../../versions/getLatestGlobalVersion.js' import { saveVersion } from '../../versions/saveVersion.js' +import { validateGlobalLocalWithDataLocale } from './local/validate.js' type Args = { autosave?: boolean data: DeepPartial, 'id'>> @@ -189,6 +192,37 @@ export const updateOperation = async < req, }) + if (hasDraftsEnabled(globalConfig) && !isSavingDraft && !unpublishAllLocales) { + const validationResult = await validateGlobalLocalWithDataLocale(payload, { + slug: globalConfig.slug, + data, + locale: resolvePublishLocales({ + locale: locale ?? null, + localization: payload.config.localization, + publishAllLocales, + }), + overrideAccess, + req, + validationDataLocale: + locale && locale !== 'all' + ? locale + : payload.config.localization + ? payload.config.localization.defaultLocale + : undefined, + }) + + if (!validationResult.valid) { + throw new ValidationError( + { + errors: validationResult.errors, + global: globalConfig.slug, + req, + }, + req.t, + ) + } + } + // ///////////////////////////////////// // beforeValidate - Fields // ///////////////////////////////////// diff --git a/packages/payload/src/globals/operations/validate.ts b/packages/payload/src/globals/operations/validate.ts index c6586854152..d4326432022 100644 --- a/packages/payload/src/globals/operations/validate.ts +++ b/packages/payload/src/globals/operations/validate.ts @@ -71,7 +71,15 @@ async function validateOperationWithScopedRequest({ locale: req.locale!, }) - let data = deepCopyObjectSimple(incomingData ?? {}) + let data = flattenDataByLocale({ + configBlockReferences: req.payload.config.blocks, + docWithLocales: deepCopyObjectSimple(incomingData ?? {}) as JsonObject, + fields: globalConfig.fields, + locale: req.locale!, + localeCodes: req.payload.config.localization + ? req.payload.config.localization.localeCodes + : undefined, + }) data = await beforeValidate({ collection: null, @@ -129,7 +137,10 @@ async function validateOperationWithScopedRequest({ } catch (error) { if (error instanceof ValidationError) { return { - errors: error.data.errors, + errors: error.data.errors.map((validationError) => ({ + ...validationError, + locale: req.locale ?? undefined, + })), valid: false, } } diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 3659d0643e4..28d321f8289 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -755,7 +755,7 @@ export class BasePayload { } /** - * Validates a collection document for one locale without persisting data, versions, or files. + * Validates a collection document for selected locales without persisting data, versions, or files. * * Omit `id` to simulate create, or provide `id` to load a stored document and simulate update. */ @@ -766,7 +766,7 @@ export class BasePayload { } /** - * Validates a global document update for one locale without persisting data or versions. + * Validates a global document update for selected locales without persisting data or versions. */ validateGlobal = async ( options: ValidateGlobalOptions, diff --git a/packages/payload/src/utilities/applyLocaleFiltering.ts b/packages/payload/src/utilities/applyLocaleFiltering.ts index 74c59eb4627..7b1c4e3fbf6 100644 --- a/packages/payload/src/utilities/applyLocaleFiltering.ts +++ b/packages/payload/src/utilities/applyLocaleFiltering.ts @@ -24,7 +24,10 @@ export async function applyLocaleFiltering({ locales: config.localization.locales, req, }) - ).map(({ toString, ...rest }) => rest) + ).map(({ toString, ...rest }) => ({ + ...rest, + required: rest.required ?? false, + })) clientConfig.localization.localeCodes = filteredLocales.map(({ code }) => code) clientConfig.localization.locales = filteredLocales diff --git a/packages/payload/src/utilities/flattenDataByLocale.ts b/packages/payload/src/utilities/flattenDataByLocale.ts index aae9e0b8716..a5732ea9e5a 100644 --- a/packages/payload/src/utilities/flattenDataByLocale.ts +++ b/packages/payload/src/utilities/flattenDataByLocale.ts @@ -10,6 +10,7 @@ type Args = { docWithLocales: JsonObject fields: Field[] locale: string + localeCodes?: string[] parentIsLocalized?: boolean } @@ -23,6 +24,7 @@ export function flattenDataByLocale({ docWithLocales, fields, locale, + localeCodes, parentIsLocalized = false, }: Args): JsonObject { const result = deepCopyObjectSimple(docWithLocales) @@ -32,6 +34,7 @@ export function flattenDataByLocale({ data: result, fields, locale, + localeCodes, parentIsLocalized, }) @@ -43,6 +46,7 @@ type FlattenFieldsArgs = { data: JsonObject fields: Field[] locale: string + localeCodes?: string[] parentIsLocalized: boolean } @@ -51,6 +55,7 @@ function flattenFields({ data, fields, locale, + localeCodes, parentIsLocalized, }: FlattenFieldsArgs): void { for (const field of fields) { @@ -60,6 +65,7 @@ function flattenFields({ if (isLocalized) { data[field.name] = getLocaleValue({ locale, + localeCodes, value: data[field.name], }) } @@ -82,6 +88,7 @@ function flattenFields({ data: row, fields: field.fields, locale, + localeCodes, parentIsLocalized: nestedParentIsLocalized, }) } @@ -112,6 +119,7 @@ function flattenFields({ data: row, fields: block.fields, locale, + localeCodes, parentIsLocalized: nestedParentIsLocalized, }) } @@ -127,6 +135,7 @@ function flattenFields({ data: fieldValue, fields: field.fields, locale, + localeCodes, parentIsLocalized: nestedParentIsLocalized, }) } @@ -146,6 +155,7 @@ function flattenFields({ data, fields: field.fields, locale, + localeCodes, parentIsLocalized, }) break @@ -158,6 +168,7 @@ function flattenFields({ if (isLocalized) { data[tab.name] = getLocaleValue({ locale, + localeCodes, value: data[tab.name], }) } @@ -170,6 +181,7 @@ function flattenFields({ data: tabData, fields: tab.fields, locale, + localeCodes, parentIsLocalized: parentIsLocalized || Boolean(tab.localized), }) } @@ -179,6 +191,7 @@ function flattenFields({ data, fields: tab.fields, locale, + localeCodes, parentIsLocalized, }) } @@ -211,9 +224,21 @@ function transformStoredFieldValue({ field, value }: { field: Field; value: unkn } } -function getLocaleValue({ locale, value }: { locale: string; value: unknown }): unknown { +function getLocaleValue({ + locale, + localeCodes, + value, +}: { + locale: string + localeCodes?: string[] + value: unknown +}): unknown { if (value && typeof value === 'object' && !Array.isArray(value)) { - return (value as Record)[locale] + const objectValue = value as Record + const isLocaleMap = + !localeCodes || Object.keys(objectValue).some((key) => localeCodes.includes(key)) + + return isLocaleMap ? objectValue[locale] : value } return value diff --git a/packages/payload/src/utilities/parseValidationLocale.ts b/packages/payload/src/utilities/parseValidationLocale.ts index 894f5030cda..d25da27fc0d 100644 --- a/packages/payload/src/utilities/parseValidationLocale.ts +++ b/packages/payload/src/utilities/parseValidationLocale.ts @@ -1,6 +1,7 @@ import { status as httpStatus } from 'http-status' import type { TypedLocale } from '../index.js' +import type { ValidationLocaleSelector } from './resolveValidationLocales.js' import { APIError } from '../errors/index.js' @@ -61,6 +62,21 @@ export function parseSingleValidationLocale(locale: unknown): TypedLocale { return parsedLocale.locale } +export function parseValidationLocaleSelector(locale: unknown): ValidationLocaleSelector { + const parsedLocale = parseValidationLocale(locale) + + switch (parsedLocale.type) { + case 'all': + return 'all' + + case 'multiple': + return parsedLocale.locales + + case 'single': + return parsedLocale.locale + } +} + export function assertValidationData(data: unknown): asserts data is Record { if (!data || Array.isArray(data) || typeof data !== 'object') { throw new APIError('Validation data must be an object.', httpStatus.BAD_REQUEST) diff --git a/packages/payload/src/utilities/resolvePublishLocales.spec.ts b/packages/payload/src/utilities/resolvePublishLocales.spec.ts new file mode 100644 index 00000000000..47300b9c081 --- /dev/null +++ b/packages/payload/src/utilities/resolvePublishLocales.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import type { SanitizedLocalizationConfig } from '../config/types.js' + +import { resolvePublishLocales } from './resolvePublishLocales.js' + +const localization = { + locales: [ + { code: 'en', label: 'English', required: false }, + { code: 'es', label: 'Spanish', required: true }, + { code: 'de', label: 'German', required: false }, + { code: 'fr', label: 'French', required: true }, + ], +} as SanitizedLocalizationConfig + +describe('resolvePublishLocales', () => { + it('should return the current locale followed by configured required locales', () => { + expect( + resolvePublishLocales({ + locale: 'de', + localization, + publishAllLocales: false, + }), + ).toEqual(['de', 'es', 'fr']) + }) + + it('should exclude optional non-current locales and deduplicate required current locale', () => { + expect( + resolvePublishLocales({ + locale: 'es', + localization, + publishAllLocales: false, + }), + ).toEqual(['es', 'fr']) + }) + + it('should return all for explicit publish-all', () => { + expect( + resolvePublishLocales({ + locale: 'en', + localization, + publishAllLocales: true, + }), + ).toBe('all') + }) +}) diff --git a/packages/payload/src/utilities/resolvePublishLocales.ts b/packages/payload/src/utilities/resolvePublishLocales.ts new file mode 100644 index 00000000000..42ce92e5dcc --- /dev/null +++ b/packages/payload/src/utilities/resolvePublishLocales.ts @@ -0,0 +1,31 @@ +import type { SanitizedLocalizationConfig } from '../config/types.js' + +type Args = { + locale: null | string | undefined + localization: false | SanitizedLocalizationConfig + publishAllLocales: boolean +} + +export type PublishLocaleSelector = 'all' | (null | string)[] + +export function resolvePublishLocales({ + locale, + localization, + publishAllLocales, +}: Args): PublishLocaleSelector { + if (publishAllLocales || locale === 'all') { + return 'all' + } + + const locales = [locale ?? (localization ? localization.defaultLocale : null)] + + if (localization) { + for (const configuredLocale of localization.locales) { + if (configuredLocale.required) { + locales.push(configuredLocale.code) + } + } + } + + return [...new Set(locales)] +} diff --git a/packages/payload/src/utilities/resolveValidationLocales.ts b/packages/payload/src/utilities/resolveValidationLocales.ts new file mode 100644 index 00000000000..87b469d29a1 --- /dev/null +++ b/packages/payload/src/utilities/resolveValidationLocales.ts @@ -0,0 +1,136 @@ +import { status as httpStatus } from 'http-status' + +import type { TypedLocale } from '../index.js' +import type { PayloadRequest } from '../types/index.js' + +import { APIError } from '../errors/index.js' + +// TypedLocale is narrowed by generated types, while its untyped fallback intentionally includes string. +// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents +export type ValidationLocaleSelector = 'all' | TypedLocale | TypedLocale[] + +const validationLocaleConcurrency = 3 + +export async function resolveValidationLocales({ + locale, + req, +}: { + locale: ValidationLocaleSelector + req: PayloadRequest +}): Promise { + const localization = req.payload.config.localization + + if (!localization) { + if (locale === 'all') { + return [null] as TypedLocale[] + } + + const locales = Array.isArray(locale) ? locale : [locale] + + if (locales.length === 0 || locales.some((value) => value !== null)) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + return [...new Set(locales)] + } + + let availableLocaleCodes = localization.localeCodes + + if (localization.filterAvailableLocales) { + const availableLocales = await localization.filterAvailableLocales({ + locales: localization.locales, + req, + }) + availableLocaleCodes = availableLocales.map((availableLocale) => + typeof availableLocale === 'string' ? availableLocale : availableLocale.code, + ) + } + + if (locale === 'all') { + return [...new Set(availableLocaleCodes)] as TypedLocale[] + } + + const requestedLocales = Array.isArray(locale) ? locale : [locale] + + if ( + requestedLocales.length === 0 || + requestedLocales.some( + (requestedLocale) => typeof requestedLocale !== 'string' || requestedLocale.length === 0, + ) + ) { + throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) + } + + const locales = [...new Set(requestedLocales)] + + for (const requestedLocale of locales) { + if (!localization.localeCodes.includes(requestedLocale as string)) { + throw new APIError( + `Validation locale "${String(requestedLocale)}" is not configured.`, + httpStatus.BAD_REQUEST, + ) + } + + if (!availableLocaleCodes.includes(requestedLocale as string)) { + throw new APIError( + `Validation locale "${String(requestedLocale)}" is not available.`, + httpStatus.BAD_REQUEST, + ) + } + } + + return locales +} + +export async function runValidationLocalePasses({ + locales, + validate, +}: { + locales: TypedLocale[] + validate: (locale: TypedLocale) => Promise +}): Promise { + const results = new Array(locales.length) + let nextLocaleIndex = 0 + + const workers = Array.from( + { length: Math.min(validationLocaleConcurrency, locales.length) }, + async () => { + while (nextLocaleIndex < locales.length) { + const localeIndex = nextLocaleIndex + nextLocaleIndex += 1 + results[localeIndex] = await validate(locales[localeIndex]!) + } + }, + ) + + await Promise.all(workers) + + return results +} + +export function cloneValidationRequest( + request: Partial | undefined, +): Partial { + if (!request) { + return {} + } + + const { payloadDataLoader: _payloadDataLoader, ...requestProperties } = request + const clonedRequest = { + ...requestProperties, + context: { ...(request.context ?? {}) }, + headers: request.headers ? new Headers(request.headers) : undefined, + host: request.host, + method: request.method, + origin: request.origin, + pathname: request.pathname, + protocol: request.protocol, + query: { ...(request.query ?? {}) }, + routeParams: { ...(request.routeParams ?? {}) }, + searchParams: request.searchParams ? new URLSearchParams(request.searchParams) : undefined, + signal: request.signal, + url: request.url, + } as Partial + + return clonedRequest +} diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index 46aed3ee84e..7709adcc361 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -205,6 +205,38 @@ describe('Types testing', () => { }), ).type.toBe>() }) + + test('should accept multi-locale selectors without exposing concurrency controls', () => { + expect(payload.validate).type.toBeCallableWith({ + collection: 'pages', + data: {}, + locale: [null], + }) + expect(payload.validate).type.toBeCallableWith({ + collection: 'pages', + data: {}, + locale: 'all', + }) + expect(payload.validateGlobal).type.toBeCallableWith({ + slug: 'menu', + locale: [null], + }) + expect(payload.validateGlobal).type.toBeCallableWith({ + slug: 'menu', + locale: 'all', + }) + expect(payload.validate).type.not.toBeCallableWith({ + collection: 'pages', + concurrency: 4, + data: {}, + locale: 'all', + }) + expect(payload.validateGlobal).type.not.toBeCallableWith({ + slug: 'menu', + concurrency: 4, + locale: 'all', + }) + }) }) describe('authenticated user', () => { diff --git a/test/validate/config.ts b/test/validate/config.ts index 17b56d328a1..00f2055ccbf 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -1,4 +1,9 @@ -import type { CollectionBeforeChangeHook, CollectionConfig, GlobalConfig } from 'payload' +import type { + CollectionBeforeChangeHook, + CollectionConfig, + GlobalConfig, + PayloadRequest, +} from 'payload' import path from 'path' import { saveVersion } from 'payload' @@ -14,6 +19,8 @@ const dirname = path.dirname(filename) export const validationCollectionSlug = 'validation-items' export const validationGlobalSlug = 'validation-settings' +export const publishCollectionSlug = 'validation-publish-items' +export const publishGlobalSlug = 'validation-publish-settings' export const writeTargetsSlug = 'validation-write-targets' export const validationUploadsSlug = 'validation-uploads' export const validationUploadsDir = path.resolve(dirname, 'validation-uploads') @@ -27,10 +34,31 @@ type HookEvent = { export const hookEvents: HookEvent[] = [] export const accessEvents: string[] = [] +const localePassRequests = new Set() +export const localePassEvents: { + localeAtEnd?: string + localeAtStart: string | undefined + operationAtEnd?: string + operationAtStart: string | undefined +}[] = [] +let activeLocalePasses = 0 +let maximumActiveLocalePasses = 0 export function clearValidationEvents(): void { accessEvents.length = 0 hookEvents.length = 0 + localePassEvents.length = 0 + localePassRequests.clear() + activeLocalePasses = 0 + maximumActiveLocalePasses = 0 +} + +export function getLocalePassRequestCount(): number { + return localePassRequests.size +} + +export function getMaximumActiveLocalePasses(): number { + return maximumActiveLocalePasses } function recordHook({ context, hook, operation, requestOperation }: HookEvent): void { @@ -228,13 +256,40 @@ const validationCollection: CollectionConfig = { runWriteAttempt, ], beforeValidate: [ - ({ context, data, operation, req }) => { + async ({ context, data, operation, req }) => { recordHook({ context, hook: 'collectionBeforeValidate', operation, requestOperation: req.operation, }) + + if (req.context.trackLocalePasses === true) { + const localeAtStart = req.locale + const operationAtStart = req.operation + + localePassRequests.add(req) + activeLocalePasses += 1 + maximumActiveLocalePasses = Math.max(maximumActiveLocalePasses, activeLocalePasses) + const localePassEvent: (typeof localePassEvents)[number] = { + localeAtStart, + operationAtStart, + } + localePassEvents.push(localePassEvent) + + req.locale = `mutated-${localeAtStart}` + req.operation = 'update' + + await new Promise((resolve) => setTimeout(resolve, 25)) + + localePassEvent.localeAtEnd = req.locale + localePassEvent.operationAtEnd = req.operation + + req.locale = localeAtStart + req.operation = operationAtStart + activeLocalePasses -= 1 + } + return data }, ], @@ -303,6 +358,40 @@ const validationGlobal: GlobalConfig = { versions: false, } +const publishCollection: CollectionConfig = { + slug: publishCollectionSlug, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + required: true, + }, + ], + versions: { + drafts: { + validate: false, + }, + }, +} + +const publishGlobal: GlobalConfig = { + slug: publishGlobalSlug, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + required: true, + }, + ], + versions: { + drafts: { + validate: false, + }, + }, +} + export default buildConfigWithDefaults({ admin: { importMap: { @@ -311,6 +400,7 @@ export default buildConfigWithDefaults({ }, collections: [ validationCollection, + publishCollection, { slug: writeTargetsSlug, fields: [ @@ -331,9 +421,34 @@ export default buildConfigWithDefaults({ versions: false, }, ], - globals: [validationGlobal], + globals: [validationGlobal, publishGlobal], localization: { defaultLocale: 'en', - locales: ['en', 'es'], + filterAvailableLocales: ({ locales, req }) => { + const availableLocaleCodes = req.context.availableLocaleCodes as string[] | undefined + + return availableLocaleCodes + ? locales.filter(({ code }) => availableLocaleCodes.includes(code)) + : locales + }, + locales: [ + { + code: 'en', + label: 'English', + }, + { + code: 'es', + label: 'Spanish', + required: true, + }, + { + code: 'de', + label: 'German', + }, + { + code: 'fr', + label: 'French', + }, + ], }, }) diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index ddac2b78f57..d34dc475c0f 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -11,7 +11,12 @@ import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js' import { accessEvents, clearValidationEvents, + getLocalePassRequestCount, + getMaximumActiveLocalePasses, hookEvents, + localePassEvents, + publishCollectionSlug, + publishGlobalSlug, validationCollectionSlug, validationGlobalSlug, validationUploadsDir, @@ -46,6 +51,11 @@ describe('validate Local API', () => { afterEach(async () => { await Promise.all([ + payload.delete({ + collection: publishCollectionSlug, + disableTransaction: true, + where: { id: { exists: true } }, + }), payload.delete({ collection: validationCollectionSlug, disableTransaction: true, @@ -70,6 +80,144 @@ describe('validate Local API', () => { }) describe('collections', () => { + it('should expose required localization metadata after config sanitization', () => { + expect(payload.config.localization && payload.config.localization.locales).toMatchObject([ + { code: 'en', required: false }, + { code: 'es', required: true }, + { code: 'de', required: false }, + { code: 'fr', required: false }, + ]) + }) + + it('should validate explicit locales and tag only the invalid locale', async () => { + const stored = await payload.create({ + collection: validationCollectionSlug, + data: { + summary: 'stored summary', + title: 'English title', + }, + locale: 'en', + }) + const result = await payload.validate({ + id: stored.id, + collection: validationCollectionSlug, + locale: ['en', 'es'], + }) + + expect(result).toMatchObject({ + errors: [ + { + locale: 'es', + path: 'title', + }, + ], + valid: false, + }) + }) + + it('should resolve all to every available locale through locale filtering', async () => { + const result = await payload.validate({ + collection: validationCollectionSlug, + context: { + availableLocaleCodes: ['en', 'de'], + trackLocalePasses: true, + }, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: 'all', + }) + + expect(result).toEqual({ + errors: [], + valid: true, + }) + expect(localePassEvents.map(({ localeAtStart }) => localeAtStart)).toEqual(['en', 'de']) + }) + + it('should deduplicate explicit locales in deterministic order', async () => { + const result = await payload.validate({ + collection: validationCollectionSlug, + context: { + trackLocalePasses: true, + }, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: ['es', 'en', 'es'], + }) + + expect(result.valid).toBe(true) + expect(localePassEvents.map(({ localeAtStart }) => localeAtStart)).toEqual(['es', 'en']) + }) + + it('should reject empty, unknown, and unavailable locale selectors', async () => { + await expect( + payload.validate({ + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: [], + }), + ).rejects.toThrow('Validation requires a locale') + + await expect( + payload.validate({ + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: ['en', 'unknown'], + } as never), + ).rejects.toThrow('unknown') + + await expect( + payload.validate({ + collection: validationCollectionSlug, + context: { + availableLocaleCodes: ['en'], + }, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: ['en', 'es'], + }), + ).rejects.toThrow('es') + }) + + it('should cap concurrent locale passes at three with isolated request state', async () => { + const result = await payload.validate({ + collection: validationCollectionSlug, + context: { + trackLocalePasses: true, + }, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: 'all', + }) + + expect(result.valid).toBe(true) + expect(getMaximumActiveLocalePasses()).toBe(3) + expect(getLocalePassRequestCount()).toBe(4) + expect(localePassEvents).toHaveLength(4) + expect( + localePassEvents.every( + ({ localeAtEnd, localeAtStart, operationAtEnd, operationAtStart }) => + localeAtEnd === `mutated-${localeAtStart}` && + operationAtEnd === 'update' && + operationAtStart === 'validate', + ), + ).toBe(true) + }) + it('should return field errors for invalid create data without creating a document', async () => { const req = { operation: 'update', @@ -488,7 +636,7 @@ describe('validate Local API', () => { expect(malformedJSON.status).toBe(400) }) - it('should return 400 for repeated and all locale selectors', async () => { + it('should accept repeated and all locale selectors', async () => { const repeatedLocale = await restClient.POST( `/${validationCollectionSlug}/validate?locale=en&locale=es`, { @@ -505,8 +653,16 @@ describe('validate Local API', () => { }), }) - expect(repeatedLocale.status).toBe(400) - expect(allLocales.status).toBe(400) + expect(repeatedLocale.status).toBe(200) + await expect(repeatedLocale.json()).resolves.toEqual({ + errors: [], + valid: true, + }) + expect(allLocales.status).toBe(200) + await expect(allLocales.json()).resolves.toEqual({ + errors: [], + valid: true, + }) }) it('should merge by-ID validation data without persisting it', async () => { @@ -752,6 +908,234 @@ describe('validate Local API', () => { expect(versionsAfter).toEqual(versionsBefore) }) }) + + describe('publish enforcement', () => { + it('should block collection publish when a required locale is invalid without changing status', async () => { + const draft = await seedPublishCollection({ + de: '', + en: 'English draft', + es: '', + }) + const versionsBefore = await payload.countVersions({ + collection: publishCollectionSlug, + where: { + parent: { + equals: draft.id, + }, + }, + }) + + await expect( + payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + _status: 'published', + title: 'English published', + }, + locale: 'en', + }), + ).rejects.toMatchObject({ + data: { + errors: [ + { + locale: 'es', + path: 'title', + }, + ], + }, + }) + + const latestDraft = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'all', + }) + const versionsAfter = await payload.countVersions({ + collection: publishCollectionSlug, + where: { + parent: { + equals: draft.id, + }, + }, + }) + + expect(latestDraft._status.en).toBe('draft') + expect(versionsAfter).toEqual(versionsBefore) + }) + + it('should allow collection publish when only an optional non-current locale is invalid', async () => { + const draft = await seedPublishCollection({ + de: '', + en: 'English draft', + es: 'Spanish required', + }) + + await payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + _status: 'published', + title: 'English published', + }, + locale: 'en', + }) + + const published = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'all', + }) + + expect(published._status.en).toBe('published') + }) + + it('should block collection publish-all when an optional locale is invalid', async () => { + const draft = await seedPublishCollection({ + de: '', + en: 'English draft', + es: 'Spanish required', + }) + + await expect( + payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + _status: 'published', + title: 'English published', + }, + locale: 'en', + publishAllLocales: true, + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'de', + path: 'title', + }), + ]), + }, + }) + + const latestDraft = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'all', + }) + + expect(latestDraft._status.en).toBe('draft') + }) + + it('should block global publish when a required locale is invalid without changing status', async () => { + await seedPublishGlobal({ + de: '', + en: 'English draft', + es: '', + }) + const versionsBefore = await payload.countGlobalVersions({ + global: publishGlobalSlug, + }) + + await expect( + payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + _status: 'published', + title: 'English published', + }, + locale: 'en', + }), + ).rejects.toMatchObject({ + data: { + errors: [ + { + locale: 'es', + path: 'title', + }, + ], + }, + }) + + const latestDraft = await payload.findGlobal({ + slug: publishGlobalSlug, + draft: true, + locale: 'all', + }) + const versionsAfter = await payload.countGlobalVersions({ + global: publishGlobalSlug, + }) + + expect(latestDraft._status.en).toBe('draft') + expect(versionsAfter).toEqual(versionsBefore) + }) + + it('should allow global publish when only an optional non-current locale is invalid', async () => { + await seedPublishGlobal({ + de: '', + en: 'English draft', + es: 'Spanish required', + }) + + await payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + _status: 'published', + title: 'English published', + }, + locale: 'en', + }) + + const published = await payload.findGlobal({ + slug: publishGlobalSlug, + draft: true, + locale: 'all', + }) + + expect(published._status.en).toBe('published') + }) + + it('should block global publish-all when an optional locale is invalid', async () => { + await seedPublishGlobal({ + de: '', + en: 'English draft', + es: 'Spanish required', + }) + + await expect( + payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + _status: 'published', + title: 'English published', + }, + locale: 'en', + publishAllLocales: true, + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'de', + path: 'title', + }), + ]), + }, + }) + + const latestDraft = await payload.findGlobal({ + slug: publishGlobalSlug, + draft: true, + locale: 'all', + }) + + expect(latestDraft._status.en).toBe('draft') + }) + }) }) async function createWriteTarget() { @@ -779,3 +1163,67 @@ async function runWriteAttempt( locale: 'en', }) } + +async function seedPublishCollection({ de, en, es }: { de: string; en: string; es: string }) { + const draft = await payload.create({ + collection: publishCollectionSlug, + data: { + title: en, + }, + draft: true, + locale: 'en', + }) + + await payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + title: es, + }, + draft: true, + locale: 'es', + }) + await payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + title: de, + }, + draft: true, + locale: 'de', + }) + + return draft +} + +async function seedPublishGlobal({ de, en, es }: { de: string; en: string; es: string }) { + await payload.updateGlobal({ + slug: publishGlobalSlug, + data: {}, + unpublishAllLocales: true, + }) + await payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + title: en, + }, + draft: true, + locale: 'en', + }) + await payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + title: es, + }, + draft: true, + locale: 'es', + }) + await payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + title: de, + }, + draft: true, + locale: 'de', + }) +} From f5bd7a27960ac1367765331a545b7c9f2a4d5572 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 21:03:20 +0100 Subject: [PATCH 09/47] fix(payload): harden multi-locale validation --- .../src/collections/operations/create.ts | 12 +- .../collections/operations/local/validate.ts | 20 +- .../operations/utilities/update.ts | 17 +- .../src/collections/operations/validate.ts | 4 +- packages/payload/src/config/client.spec.ts | 40 ++ packages/payload/src/config/client.ts | 2 + .../src/globals/operations/local/validate.ts | 17 +- .../payload/src/globals/operations/update.ts | 4 +- .../utilities/projectNonLocalizedData.spec.ts | 128 +++++ .../src/utilities/projectNonLocalizedData.ts | 153 ++++++ .../src/utilities/resolveValidationLocales.ts | 137 +++++- test/validate/config.ts | 170 ++++++- test/validate/int.spec.ts | 438 +++++++++++++++++- 13 files changed, 1095 insertions(+), 47 deletions(-) create mode 100644 packages/payload/src/config/client.spec.ts create mode 100644 packages/payload/src/utilities/projectNonLocalizedData.spec.ts create mode 100644 packages/payload/src/utilities/projectNonLocalizedData.ts diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index ea16f019e26..c66beb15cb3 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -177,7 +177,17 @@ export const createOperation = async < data = newFileData - if (!isSavingDraft && hasDraftsEnabled(collectionConfig)) { + const isCreatingTrashedDocument = Boolean( + collectionConfig.trash && data._status !== 'published' && data.deletedAt, + ) + const isUnpublishMetadataOperation = data._status === 'draft' + + if ( + !isSavingDraft && + !isCreatingTrashedDocument && + !isUnpublishMetadataOperation && + hasDraftsEnabled(collectionConfig) + ) { const validationResult = await validateLocalWithDataLocale(payload, { collection: collectionConfig.slug, data, diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index 6f50db30e13..fa02822b077 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -19,9 +19,10 @@ import type { import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' -import { filterDataToSelectedLocales } from '../../../utilities/filterDataToSelectedLocales.js' +import { projectNonLocalizedData } from '../../../utilities/projectNonLocalizedData.js' import { cloneValidationRequest, + cloneValidationValue, resolveValidationLocales, runValidationLocalePasses, } from '../../../utilities/resolveValidationLocales.js' @@ -83,6 +84,7 @@ export type ValidateCollectionOptions = type InternalValidateCollectionOptions = { validationDataLocale?: string + validationTrash?: boolean } & ValidateCollectionOptions export async function validateLocal( @@ -103,6 +105,7 @@ export async function validateLocalWithDataLocale( locale, overrideAccess = true, validationDataLocale, + validationTrash, } = options if (locale === undefined) { @@ -123,10 +126,10 @@ export async function validateLocalWithDataLocale( const baseReq = await createLocalReq( { - context: options.context, + context: cloneValidationValue(options.context), fallbackLocale: false, req: cloneValidationRequest(options.req), - user: options.user ?? undefined, + user: cloneValidationValue(options.user) ?? undefined, }, payload, ) @@ -145,15 +148,15 @@ export async function validateLocalWithDataLocale( }, payload, ) + const validationCandidateData = cloneValidationValue(data) const validationData = - validationDataLocale && validationLocale !== validationDataLocale && data - ? filterDataToSelectedLocales({ + validationDataLocale && validationLocale !== validationDataLocale && validationCandidateData + ? projectNonLocalizedData({ configBlockReferences: payload.config.blocks, - docWithLocales: data as JsonObject, + data: validationCandidateData as JsonObject, fields: collection.config.fields, - selectedLocales: [], }) - : data + : validationCandidateData return validateOperation({ id, @@ -161,6 +164,7 @@ export async function validateLocalWithDataLocale( data: validationData, overrideAccess, req, + trash: validationTrash, }) }, }) diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 177ef92ac87..1712ef07eab 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -153,8 +153,20 @@ export const updateDocument = async < req, showHiddenFields: true, }) + const isRestoringDraftFromTrash = Boolean(originalDoc?.deletedAt) && data?._status !== 'published' + const isTrashMetadataOperation = Boolean( + collectionConfig.trash && + data?._status !== 'published' && + (data?.deletedAt || isRestoringDraftFromTrash), + ) + const isUnpublishMetadataOperation = unpublishAllLocales || data?._status === 'draft' - if (hasDraftsEnabled(collectionConfig) && !isSavingDraft && !unpublishAllLocales) { + if ( + hasDraftsEnabled(collectionConfig) && + !isSavingDraft && + !isTrashMetadataOperation && + !isUnpublishMetadataOperation + ) { const validationResult = await validateLocalWithDataLocale(payload, { id, collection: collectionConfig.slug, @@ -172,6 +184,7 @@ export const updateDocument = async < : config.localization ? config.localization.defaultLocale : undefined, + validationTrash: Boolean(originalDoc?.deletedAt), }) if (!validationResult.valid) { @@ -187,8 +200,6 @@ export const updateDocument = async < } } - const isRestoringDraftFromTrash = Boolean(originalDoc?.deletedAt) && data?._status !== 'published' - if (collectionConfig.auth) { ensureUsernameOrEmail({ authOptions: collectionConfig.auth, diff --git a/packages/payload/src/collections/operations/validate.ts b/packages/payload/src/collections/operations/validate.ts index 21821f19e8b..ae2228cb4b5 100644 --- a/packages/payload/src/collections/operations/validate.ts +++ b/packages/payload/src/collections/operations/validate.ts @@ -23,6 +23,7 @@ export type Arguments = { id?: number | string overrideAccess: boolean req: PayloadRequest + trash?: boolean } export async function validateOperation( @@ -44,6 +45,7 @@ async function validateOperationWithScopedRequest( data: incomingData, overrideAccess, req, + trash, }: Arguments): Promise { const collectionConfig = collection.config @@ -57,7 +59,7 @@ async function validateOperationWithScopedRequest( if (id !== undefined) { const where = appendNonTrashedFilter({ enableTrash: collectionConfig.trash, - trash: false, + trash: Boolean(trash), where: combineQueries({ id: { equals: id } }, accessResult), }) const query: FindOneArgs = { diff --git a/packages/payload/src/config/client.spec.ts b/packages/payload/src/config/client.spec.ts new file mode 100644 index 00000000000..a21be3781c7 --- /dev/null +++ b/packages/payload/src/config/client.spec.ts @@ -0,0 +1,40 @@ +import type { SanitizedConfig } from './types.js' + +import { describe, expect, it } from 'vitest' + +import { createClientConfig } from './client.js' + +describe('createClientConfig', () => { + it('should retain sanitized required locale metadata without locale filtering', () => { + const config = { + localization: { + defaultLocale: 'en', + localeCodes: ['en', 'es'], + locales: [ + { + code: 'en', + label: 'English', + required: false, + }, + { + code: 'es', + label: 'Spanish', + required: true, + }, + ], + }, + } as SanitizedConfig + + const clientConfig = createClientConfig({ + config, + i18n: {} as never, + importMap: {}, + user: true, + }) + + expect(clientConfig.localization && clientConfig.localization.locales).toMatchObject([ + { code: 'en', required: false }, + { code: 'es', required: true }, + ]) + }) +}) diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 2324e21a9e1..2d7fe086380 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -306,6 +306,8 @@ export const createClientConfig = ({ clientLocale.label = locale.label } + clientLocale.required = locale.required + if (locale.rtl) { clientLocale.rtl = locale.rtl } diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index 097d231afdf..b0dc9337548 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -10,9 +10,10 @@ import type { DataFromGlobalSlug, DraftFlagFromGlobalSlug } from '../../config/t import { APIError } from '../../../errors/index.js' import { createLocalReq } from '../../../utilities/createLocalReq.js' -import { filterDataToSelectedLocales } from '../../../utilities/filterDataToSelectedLocales.js' +import { projectNonLocalizedData } from '../../../utilities/projectNonLocalizedData.js' import { cloneValidationRequest, + cloneValidationValue, resolveValidationLocales, runValidationLocalePasses, } from '../../../utilities/resolveValidationLocales.js' @@ -73,10 +74,10 @@ export async function validateGlobalLocalWithDataLocale { + it('should omit every localized schema branch while preserving nested non-localized data', () => { + const fields: Field[] = [ + { + name: 'localizedGroup', + type: 'group', + fields: [{ name: 'value', type: 'text' }], + localized: true, + }, + { name: 'localizedJSON', type: 'json', localized: true }, + { name: 'localizedRichText', type: 'richText', localized: true }, + { + type: 'tabs', + tabs: [ + { + name: 'localizedTab', + fields: [{ name: 'value', type: 'text' }], + localized: true, + }, + ], + }, + { + name: 'localizedArray', + type: 'array', + fields: [{ name: 'value', type: 'text' }], + localized: true, + }, + { + name: 'localizedBlocks', + type: 'blocks', + blocks: [ + { + slug: 'example', + fields: [{ name: 'value', type: 'text' }], + }, + ], + localized: true, + }, + { + name: 'nested', + type: 'group', + fields: [ + { name: 'localizedJSON', type: 'json', localized: true }, + { name: 'shared', type: 'text' }, + ], + }, + { + name: 'nestedArray', + type: 'array', + fields: [ + { name: 'localizedJSON', type: 'json', localized: true }, + { name: 'shared', type: 'text' }, + ], + }, + { + name: 'nestedBlocks', + type: 'blocks', + blocks: [ + { + slug: 'nested', + fields: [ + { name: 'localizedJSON', type: 'json', localized: true }, + { name: 'shared', type: 'text' }, + ], + }, + ], + }, + ] + const data = { + _status: 'published', + localizedArray: [{ value: 'active array' }], + localizedBlocks: [{ blockType: 'example', value: 'active block' }], + localizedGroup: { value: 'active group' }, + localizedJSON: { value: 'active JSON' }, + localizedRichText: { root: { children: [] } }, + localizedTab: { value: 'active tab' }, + nested: { + localizedJSON: { value: 'active nested JSON' }, + shared: 'shared value', + }, + nestedArray: [ + { + localizedJSON: { value: 'active array JSON' }, + shared: 'shared array value', + }, + ], + nestedBlocks: [ + { + blockType: 'nested', + localizedJSON: { value: 'active block JSON' }, + shared: 'shared block value', + }, + ], + } + + expect( + projectNonLocalizedData({ + configBlockReferences: [], + data, + fields, + }), + ).toEqual({ + _status: 'published', + nested: { + shared: 'shared value', + }, + nestedArray: [ + { + shared: 'shared array value', + }, + ], + nestedBlocks: [ + { + blockType: 'nested', + shared: 'shared block value', + }, + ], + }) + expect(data.localizedJSON).toEqual({ value: 'active JSON' }) + expect(data.nested.localizedJSON).toEqual({ value: 'active nested JSON' }) + }) +}) diff --git a/packages/payload/src/utilities/projectNonLocalizedData.ts b/packages/payload/src/utilities/projectNonLocalizedData.ts new file mode 100644 index 00000000000..3ecffb9760d --- /dev/null +++ b/packages/payload/src/utilities/projectNonLocalizedData.ts @@ -0,0 +1,153 @@ +import type { Block, Field, FlattenedBlock } from '../fields/config/types.js' +import type { SanitizedConfig } from '../index.js' +import type { JsonObject } from '../types/index.js' + +import { fieldAffectsData, fieldIsLocalized, tabHasName } from '../fields/config/types.js' +import { deepCopyObjectSimple } from './deepCopyObject.js' + +type ProjectNonLocalizedDataArgs = { + configBlockReferences: SanitizedConfig['blocks'] + data: JsonObject + fields: Field[] +} + +export function projectNonLocalizedData({ + configBlockReferences, + data, + fields, +}: ProjectNonLocalizedDataArgs): JsonObject { + const projectedData = deepCopyObjectSimple(data) + + removeLocalizedData({ + configBlockReferences, + data: projectedData, + fields, + parentIsLocalized: false, + }) + + return projectedData +} + +type RemoveLocalizedDataArgs = { + parentIsLocalized: boolean +} & ProjectNonLocalizedDataArgs + +function removeLocalizedData({ + configBlockReferences, + data, + fields, + parentIsLocalized, +}: RemoveLocalizedDataArgs): void { + for (const field of fields) { + if (fieldAffectsData(field)) { + if (parentIsLocalized || fieldIsLocalized(field)) { + delete data[field.name] + continue + } + + const fieldValue = data[field.name] + + switch (field.type) { + case 'array': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (isObject(row)) { + removeLocalizedData({ + configBlockReferences, + data: row, + fields: field.fields, + parentIsLocalized: false, + }) + } + } + } + break + } + + case 'blocks': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (!isObject(row)) { + continue + } + + const blockOrSlug = field.blocks.find((block) => { + const slug = typeof block === 'string' ? block : block.slug + return slug === row.blockType + }) + const block: Block | FlattenedBlock | undefined = + typeof blockOrSlug === 'string' + ? configBlockReferences?.find(({ slug }) => slug === blockOrSlug) + : blockOrSlug + + if (block) { + removeLocalizedData({ + configBlockReferences, + data: row, + fields: block.fields, + parentIsLocalized: false, + }) + } + } + } + break + } + + case 'group': { + if (isObject(fieldValue)) { + removeLocalizedData({ + configBlockReferences, + data: fieldValue, + fields: field.fields, + parentIsLocalized: false, + }) + } + break + } + } + } else { + switch (field.type) { + case 'collapsible': + case 'group': + case 'row': { + removeLocalizedData({ + configBlockReferences, + data, + fields: field.fields, + parentIsLocalized, + }) + break + } + + case 'tabs': { + for (const tab of field.tabs) { + if (tabHasName(tab)) { + if (parentIsLocalized || fieldIsLocalized(tab)) { + delete data[tab.name] + } else if (isObject(data[tab.name])) { + removeLocalizedData({ + configBlockReferences, + data: data[tab.name], + fields: tab.fields, + parentIsLocalized: false, + }) + } + } else { + removeLocalizedData({ + configBlockReferences, + data, + fields: tab.fields, + parentIsLocalized, + }) + } + } + break + } + } + } + } +} + +function isObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} diff --git a/packages/payload/src/utilities/resolveValidationLocales.ts b/packages/payload/src/utilities/resolveValidationLocales.ts index 87b469d29a1..3495c3b38d7 100644 --- a/packages/payload/src/utilities/resolveValidationLocales.ts +++ b/packages/payload/src/utilities/resolveValidationLocales.ts @@ -10,6 +10,14 @@ import { APIError } from '../errors/index.js' export type ValidationLocaleSelector = 'all' | TypedLocale | TypedLocale[] const validationLocaleConcurrency = 3 +const sharedValidationRequestProperties = new Set([ + 'i18n', + 'payload', + 'server', + 'signal', + 't', + 'transactionID', +]) export async function resolveValidationLocales({ locale, @@ -115,22 +123,135 @@ export function cloneValidationRequest( return {} } - const { payloadDataLoader: _payloadDataLoader, ...requestProperties } = request - const clonedRequest = { - ...requestProperties, - context: { ...(request.context ?? {}) }, + const clonedRequest: Record = {} + + for (const [key, value] of Object.entries(request)) { + if (key === 'payloadDataLoader') { + continue + } + + clonedRequest[key] = sharedValidationRequestProperties.has(key) + ? value + : cloneValidationValue(value) + } + + Object.assign(clonedRequest, { + context: cloneValidationValue(request.context ?? {}), + data: cloneValidationValue(request.data), + file: cloneValidationValue(request.file), + files: cloneValidationValue(request.files), + hash: request.hash, headers: request.headers ? new Headers(request.headers) : undefined, host: request.host, + href: request.href, method: request.method, origin: request.origin, pathname: request.pathname, + payloadUploadSizes: cloneValidationValue(request.payloadUploadSizes), + port: request.port, protocol: request.protocol, - query: { ...(request.query ?? {}) }, - routeParams: { ...(request.routeParams ?? {}) }, + query: cloneValidationValue(request.query ?? {}), + responseHeaders: request.responseHeaders ? new Headers(request.responseHeaders) : undefined, + routeParams: cloneValidationValue(request.routeParams ?? {}), + search: request.search, searchParams: request.searchParams ? new URLSearchParams(request.searchParams) : undefined, signal: request.signal, url: request.url, - } as Partial + user: cloneValidationValue(request.user), + }) + + return clonedRequest as Partial +} + +export function cloneValidationValue(value: T, cache = new WeakMap()): T { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return value + } + + if (typeof value === 'function' || value instanceof Promise) { + return value + } + + const objectValue = value as object + const cachedValue = cache.get(objectValue) + + if (cachedValue) { + return cachedValue as T + } + + if (value instanceof Headers) { + return new Headers(value) as T + } + + if (value instanceof URLSearchParams) { + return new URLSearchParams(value) as T + } + + if (value instanceof URL) { + return new URL(value) as T + } + + if (value instanceof Date) { + return new Date(value) as T + } + + if (value instanceof RegExp) { + return new RegExp(value.source, value.flags) as T + } + + if (value instanceof ArrayBuffer) { + return value.slice(0) as T + } + + if (ArrayBuffer.isView(value)) { + if (Buffer.isBuffer(value)) { + return Buffer.from(value) as T + } + + if (value instanceof DataView) { + return new DataView(value.buffer.slice(0), value.byteOffset, value.byteLength) as T + } + + return new (value.constructor as new (input: typeof value) => typeof value)(value) + } + + if (value instanceof Map) { + const clonedMap = new Map() + cache.set(objectValue, clonedMap) + for (const [key, mapValue] of value) { + clonedMap.set(cloneValidationValue(key, cache), cloneValidationValue(mapValue, cache)) + } + return clonedMap as T + } + + if (value instanceof Set) { + const clonedSet = new Set() + cache.set(objectValue, clonedSet) + for (const setValue of value) { + clonedSet.add(cloneValidationValue(setValue, cache)) + } + return clonedSet as T + } + + if (typeof Blob !== 'undefined' && value instanceof Blob) { + return value + } + + const clonedValue: Record | unknown[] = Array.isArray(value) + ? [] + : Object.create(Object.getPrototypeOf(value)) + cache.set(objectValue, clonedValue) + + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + + if (descriptor?.enumerable) { + ;(clonedValue as Record)[key] = cloneValidationValue( + (value as Record)[key], + cache, + ) + } + } - return clonedRequest + return clonedValue as T } diff --git a/test/validate/config.ts b/test/validate/config.ts index 00f2055ccbf..29931109d8d 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -34,6 +34,22 @@ type HookEvent = { export const hookEvents: HookEvent[] = [] export const accessEvents: string[] = [] +export const isolationEvents: { + candidateMarker: unknown + contextMarker: unknown + headerMarker: null | string + locale: string | undefined + queryMarker: unknown + requestDataMarker: unknown + responseHeaderMarker: null | string + routeMarker: unknown + source: 'collection' | 'global' + userMarker: unknown +}[] = [] +export const validationRuntimeIdentityEvents: { + payload: PayloadRequest['payload'] + transactionID: PayloadRequest['transactionID'] +}[] = [] const localePassRequests = new Set() export const localePassEvents: { localeAtEnd?: string @@ -47,10 +63,12 @@ let maximumActiveLocalePasses = 0 export function clearValidationEvents(): void { accessEvents.length = 0 hookEvents.length = 0 + isolationEvents.length = 0 localePassEvents.length = 0 localePassRequests.clear() activeLocalePasses = 0 maximumActiveLocalePasses = 0 + validationRuntimeIdentityEvents.length = 0 } export function getLocalePassRequestCount(): number { @@ -70,6 +88,54 @@ function recordHook({ context, hook, operation, requestOperation }: HookEvent): }) } +function getIsolationMarker(value: unknown): unknown { + return (value as { isolation?: { marker?: unknown } } | null | undefined)?.isolation?.marker +} + +async function recordAndMutateIsolationState({ + data, + req, + source, +}: { + data: unknown + req: PayloadRequest + source: 'collection' | 'global' +}): Promise { + if (req.context.trackMutableIsolation !== true) { + return + } + + isolationEvents.push({ + candidateMarker: getIsolationMarker(data), + contextMarker: getIsolationMarker(req.context), + headerMarker: req.headers.get('x-validation-isolation'), + locale: req.locale, + queryMarker: getIsolationMarker(req.query), + requestDataMarker: getIsolationMarker(req.data), + responseHeaderMarker: req.responseHeaders?.get('x-validation-isolation') ?? null, + routeMarker: getIsolationMarker(req.routeParams), + source, + userMarker: getIsolationMarker(req.user), + }) + validationRuntimeIdentityEvents.push({ + payload: req.payload, + transactionID: req.transactionID, + }) + + if (req.locale === 'en') { + ;(data as { isolation: { marker: string } }).isolation.marker = 'mutated' + ;(req.context.isolation as { marker: string }).marker = 'mutated' + ;(req.data!.isolation as { marker: string }).marker = 'mutated' + req.headers.set('x-validation-isolation', 'mutated') + ;(req.query.isolation as { marker: string }).marker = 'mutated' + req.responseHeaders!.set('x-validation-isolation', 'mutated') + ;(req.routeParams!.isolation as { marker: string }).marker = 'mutated' + ;(req.user as unknown as { isolation: { marker: string } }).isolation.marker = 'mutated' + + await new Promise((resolve) => setTimeout(resolve, 25)) + } +} + const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, req }) => { if (operation !== 'validate') { return data @@ -143,8 +209,9 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re const validationCollection: CollectionConfig = { slug: validationCollectionSlug, access: { - validate: ({ req }) => { + validate: async ({ data, req }) => { accessEvents.push('collection') + await recordAndMutateIsolationState({ data, req, source: 'collection' }) return req.payloadAPI === 'REST' || req.context.allowValidation === true }, }, @@ -300,8 +367,9 @@ const validationCollection: CollectionConfig = { const validationGlobal: GlobalConfig = { slug: validationGlobalSlug, access: { - validate: ({ req }) => { + validate: async ({ data, req }) => { accessEvents.push('global') + await recordAndMutateIsolationState({ data, req, source: 'global' }) return req.payloadAPI === 'REST' || req.context.allowValidation === true }, }, @@ -367,7 +435,99 @@ const publishCollection: CollectionConfig = { localized: true, required: true, }, + { + name: 'localizedGroup', + type: 'group', + fields: [ + { + name: 'value', + type: 'text', + required: true, + }, + ], + localized: true, + }, + { + name: 'localizedJSON', + type: 'json', + localized: true, + required: true, + }, + { + name: 'localizedRichText', + type: 'richText', + localized: true, + required: true, + }, + { + type: 'tabs', + tabs: [ + { + name: 'localizedTab', + fields: [ + { + name: 'value', + type: 'text', + required: true, + }, + ], + label: 'Localized tab', + localized: true, + }, + ], + }, + { + name: 'localizedArray', + type: 'array', + fields: [ + { + name: 'value', + type: 'text', + required: true, + }, + ], + localized: true, + minRows: 1, + required: true, + }, + { + name: 'localizedBlocks', + type: 'blocks', + blocks: [ + { + slug: 'validationBlock', + fields: [ + { + name: 'value', + type: 'text', + required: true, + }, + ], + }, + ], + localized: true, + minRows: 1, + required: true, + }, + { + name: 'nested', + type: 'group', + fields: [ + { + name: 'localizedJSON', + type: 'json', + localized: true, + required: true, + }, + { + name: 'shared', + type: 'text', + required: true, + }, + ], + }, ], + trash: true, versions: { drafts: { validate: false, @@ -384,6 +544,12 @@ const publishGlobal: GlobalConfig = { localized: true, required: true, }, + { + name: 'localizedJSON', + type: 'json', + localized: true, + required: true, + }, ], versions: { drafts: { diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index d34dc475c0f..36e5e5a3b72 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -1,5 +1,6 @@ import type { Payload, PayloadRequest } from 'payload' +import { buildEditorState } from '@payloadcms/richtext-lexical' import fs from 'fs/promises' import path from 'path' import { fileURLToPath } from 'url' @@ -14,11 +15,13 @@ import { getLocalePassRequestCount, getMaximumActiveLocalePasses, hookEvents, + isolationEvents, localePassEvents, publishCollectionSlug, publishGlobalSlug, validationCollectionSlug, validationGlobalSlug, + validationRuntimeIdentityEvents, validationUploadsDir, validationUploadsSlug, writeTargetsSlug, @@ -54,6 +57,7 @@ describe('validate Local API', () => { payload.delete({ collection: publishCollectionSlug, disableTransaction: true, + trash: true, where: { id: { exists: true } }, }), payload.delete({ @@ -218,6 +222,88 @@ describe('validate Local API', () => { ).toBe(true) }) + it('should isolate mutable access data and request state between collection locale passes', async () => { + const candidateData = { + isolation: { marker: 'caller' }, + summary: 'candidate summary', + title: 'Candidate title', + } + const context = { + allowValidation: true, + isolation: { marker: 'caller' }, + trackMutableIsolation: true, + } + const requestData = { isolation: { marker: 'caller' } } + const query = { isolation: { marker: 'caller' } } + const routeParams = { isolation: { marker: 'caller' } } + const user = { + id: 'validation-user', + collection: validationCollectionSlug, + isolation: { marker: 'caller' }, + } + const transactionID = Promise.resolve('validation-transaction') + const req = { + data: requestData, + headers: new Headers({ 'x-validation-isolation': 'caller' }), + query, + responseHeaders: new Headers({ 'x-validation-isolation': 'caller' }), + routeParams, + transactionID, + } satisfies Partial + + const result = await payload.validate({ + collection: validationCollectionSlug, + context, + data: candidateData, + locale: ['en', 'es'], + overrideAccess: false, + req, + user: user as never, + }) + + expect(result.valid).toBe(true) + expect(isolationEvents).toEqual([ + { + candidateMarker: 'caller', + contextMarker: 'caller', + headerMarker: 'caller', + locale: 'en', + queryMarker: 'caller', + requestDataMarker: 'caller', + responseHeaderMarker: 'caller', + routeMarker: 'caller', + source: 'collection', + userMarker: 'caller', + }, + { + candidateMarker: 'caller', + contextMarker: 'caller', + headerMarker: 'caller', + locale: 'es', + queryMarker: 'caller', + requestDataMarker: 'caller', + responseHeaderMarker: 'caller', + routeMarker: 'caller', + source: 'collection', + userMarker: 'caller', + }, + ]) + expect(candidateData.isolation.marker).toBe('caller') + expect(context.isolation.marker).toBe('caller') + expect(requestData.isolation.marker).toBe('caller') + expect(query.isolation.marker).toBe('caller') + expect(req.headers.get('x-validation-isolation')).toBe('caller') + expect(req.responseHeaders.get('x-validation-isolation')).toBe('caller') + expect(routeParams.isolation.marker).toBe('caller') + expect(user.isolation.marker).toBe('caller') + expect( + validationRuntimeIdentityEvents.every( + (event) => event.payload === payload && event.transactionID === transactionID, + ), + ).toBe(true) + expect(req.transactionID).toBe(transactionID) + }) + it('should return field errors for invalid create data without creating a document', async () => { const req = { operation: 'update', @@ -450,6 +536,80 @@ describe('validate Local API', () => { }) describe('globals', () => { + it('should isolate mutable access data and request state between global locale passes', async () => { + const candidateData = { + isolation: { marker: 'caller' }, + summary: 'candidate summary', + title: 'Candidate title', + } + const context = { + allowValidation: true, + isolation: { marker: 'caller' }, + trackMutableIsolation: true, + } + const requestData = { isolation: { marker: 'caller' } } + const query = { isolation: { marker: 'caller' } } + const routeParams = { isolation: { marker: 'caller' } } + const user = { + id: 'validation-user', + collection: validationCollectionSlug, + isolation: { marker: 'caller' }, + } + const req = { + data: requestData, + headers: new Headers({ 'x-validation-isolation': 'caller' }), + query, + responseHeaders: new Headers({ 'x-validation-isolation': 'caller' }), + routeParams, + } satisfies Partial + + const result = await payload.validateGlobal({ + slug: validationGlobalSlug, + context, + data: candidateData, + locale: ['en', 'es'], + overrideAccess: false, + req, + user: user as never, + }) + + expect(result.valid).toBe(true) + expect(isolationEvents.map(({ locale, source }) => ({ locale, source }))).toEqual([ + { locale: 'en', source: 'global' }, + { locale: 'es', source: 'global' }, + ]) + expect( + isolationEvents.every( + ({ + candidateMarker, + contextMarker, + headerMarker, + queryMarker, + requestDataMarker, + responseHeaderMarker, + routeMarker, + userMarker, + }) => + candidateMarker === 'caller' && + contextMarker === 'caller' && + headerMarker === 'caller' && + queryMarker === 'caller' && + requestDataMarker === 'caller' && + responseHeaderMarker === 'caller' && + routeMarker === 'caller' && + userMarker === 'caller', + ), + ).toBe(true) + expect(candidateData.isolation.marker).toBe('caller') + expect(context.isolation.marker).toBe('caller') + expect(requestData.isolation.marker).toBe('caller') + expect(query.isolation.marker).toBe('caller') + expect(req.headers.get('x-validation-isolation')).toBe('caller') + expect(req.responseHeaders.get('x-validation-isolation')).toBe('caller') + expect(routeParams.isolation.marker).toBe('caller') + expect(user.isolation.marker).toBe('caller') + }) + it('should validate valid partial global data without persisting it', async () => { const req = { operation: 'read', @@ -910,6 +1070,174 @@ describe('validate Local API', () => { }) describe('publish enforcement', () => { + it('should not apply flat localized object candidates to a required sibling locale', async () => { + const omittedFields: PublishCollectionLocalizedField[] = [ + 'localizedArray', + 'localizedBlocks', + 'localizedGroup', + 'localizedJSON', + 'localizedRichText', + 'localizedTab', + 'nested.localizedJSON', + ] + const draft = await seedPublishCollection({ + de: 'German optional', + en: 'English draft', + es: 'Spanish required', + omit: { + es: omittedFields, + }, + }) + + await expect( + payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + ...getPublishCollectionLocaleData({ title: 'English published' }), + _status: 'published', + }, + locale: 'en', + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining( + [ + 'localizedArray', + 'localizedBlocks', + 'localizedGroup.value', + 'localizedJSON', + 'localizedRichText', + 'localizedTab.value', + 'nested.localizedJSON', + ].map((path) => + expect.objectContaining({ + locale: 'es', + path, + }), + ), + ), + }, + }) + }) + + it('should not apply a flat localized JSON candidate to a required global sibling locale', async () => { + await seedPublishGlobal({ + de: 'German optional', + en: 'English draft', + es: 'Spanish required', + omitLocalizedJSON: { + es: true, + }, + }) + + await expect( + payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + ...getPublishGlobalLocaleData({ title: 'English published' }), + _status: 'published', + }, + locale: 'en', + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'es', + path: 'localizedJSON', + }), + ]), + }, + }) + }) + + it('should bypass required-locale publish validation when trashing a draft', async () => { + const draft = await seedPublishCollection({ + de: 'German optional', + en: 'English draft', + es: '', + }) + const deletedAt = new Date().toISOString() + + await expect( + payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + deletedAt, + }, + locale: 'en', + }), + ).resolves.toMatchObject({ + deletedAt, + }) + }) + + it('should bypass required-locale publish validation when restoring a trashed draft', async () => { + const draft = await seedPublishCollection({ + de: 'German optional', + deletedAt: new Date().toISOString(), + en: 'English draft', + es: '', + }) + + await expect( + payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + deletedAt: null, + }, + locale: 'en', + trash: true, + }), + ).resolves.toMatchObject({ + deletedAt: null, + }) + }) + + it('should bypass required-locale publish validation for collection unpublish metadata', async () => { + const draft = await seedPublishCollection({ + de: 'German optional', + en: 'English draft', + es: '', + }) + + await expect( + payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + _status: 'draft', + }, + locale: 'en', + }), + ).resolves.toMatchObject({ + _status: 'draft', + }) + }) + + it('should bypass required-locale publish validation for global unpublish metadata', async () => { + await seedPublishGlobal({ + de: 'German optional', + en: 'English draft', + es: '', + }) + + await expect( + payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + _status: 'draft', + }, + locale: 'en', + }), + ).resolves.toMatchObject({ + _status: 'draft', + }) + }) + it('should block collection publish when a required locale is invalid without changing status', async () => { const draft = await seedPublishCollection({ de: '', @@ -1164,11 +1492,67 @@ async function runWriteAttempt( }) } -async function seedPublishCollection({ de, en, es }: { de: string; en: string; es: string }) { +type PublishCollectionLocalizedField = + | 'localizedArray' + | 'localizedBlocks' + | 'localizedGroup' + | 'localizedJSON' + | 'localizedRichText' + | 'localizedTab' + | 'nested.localizedJSON' + +function getPublishCollectionLocaleData({ + omit = [], + title, +}: { + omit?: PublishCollectionLocalizedField[] + title: string +}): Record { + const data: Record = { + localizedArray: [{ value: `${title} array` }], + localizedBlocks: [{ blockType: 'validationBlock', value: `${title} block` }], + localizedGroup: { value: `${title} group` }, + localizedJSON: { value: `${title} JSON` }, + localizedRichText: buildEditorState({ text: `${title} rich text` }), + localizedTab: { value: `${title} tab` }, + nested: { + localizedJSON: { value: `${title} nested JSON` }, + shared: 'shared value', + }, + title, + } + + for (const field of omit) { + if (field === 'nested.localizedJSON') { + delete (data.nested as Record).localizedJSON + } else if (field === 'localizedArray' || field === 'localizedBlocks') { + data[field] = [] + } else { + delete data[field] + } + } + + return data +} + +async function seedPublishCollection({ + de, + deletedAt, + en, + es, + omit, +}: { + de: string + deletedAt?: string + en: string + es: string + omit?: Partial> +}) { const draft = await payload.create({ collection: publishCollectionSlug, data: { - title: en, + ...getPublishCollectionLocaleData({ omit: omit?.en, title: en }), + ...(deletedAt ? { deletedAt } : {}), }, draft: true, locale: 'en', @@ -1177,26 +1561,47 @@ async function seedPublishCollection({ de, en, es }: { de: string; en: string; e await payload.update({ id: draft.id, collection: publishCollectionSlug, - data: { - title: es, - }, + data: getPublishCollectionLocaleData({ omit: omit?.es, title: es }), draft: true, locale: 'es', + trash: Boolean(deletedAt), }) await payload.update({ id: draft.id, collection: publishCollectionSlug, - data: { - title: de, - }, + data: getPublishCollectionLocaleData({ omit: omit?.de, title: de }), draft: true, locale: 'de', + trash: Boolean(deletedAt), }) return draft } -async function seedPublishGlobal({ de, en, es }: { de: string; en: string; es: string }) { +function getPublishGlobalLocaleData({ + includeLocalizedJSON = true, + title, +}: { + includeLocalizedJSON?: boolean + title: string +}): Record { + return { + localizedJSON: includeLocalizedJSON ? { value: `${title} JSON` } : null, + title, + } +} + +async function seedPublishGlobal({ + de, + en, + es, + omitLocalizedJSON, +}: { + de: string + en: string + es: string + omitLocalizedJSON?: Partial> +}) { await payload.updateGlobal({ slug: publishGlobalSlug, data: {}, @@ -1204,25 +1609,28 @@ async function seedPublishGlobal({ de, en, es }: { de: string; en: string; es: s }) await payload.updateGlobal({ slug: publishGlobalSlug, - data: { + data: getPublishGlobalLocaleData({ + includeLocalizedJSON: !omitLocalizedJSON?.en, title: en, - }, + }), draft: true, locale: 'en', }) await payload.updateGlobal({ slug: publishGlobalSlug, - data: { + data: getPublishGlobalLocaleData({ + includeLocalizedJSON: !omitLocalizedJSON?.es, title: es, - }, + }), draft: true, locale: 'es', }) await payload.updateGlobal({ slug: publishGlobalSlug, - data: { + data: getPublishGlobalLocaleData({ + includeLocalizedJSON: !omitLocalizedJSON?.de, title: de, - }, + }), draft: true, locale: 'de', }) From 278a49646edff22faaf276acf68325d9b0ec5f81 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 21:22:32 +0100 Subject: [PATCH 10/47] fix(payload): seal publish validation intent --- .../src/collections/operations/create.ts | 14 +- .../collections/operations/local/validate.ts | 23 +- .../operations/utilities/update.ts | 13 +- .../src/globals/operations/local/validate.ts | 11 +- .../payload/src/globals/operations/update.ts | 10 +- test/validate/int.spec.ts | 338 ++++++++++++++++++ 6 files changed, 393 insertions(+), 16 deletions(-) diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index c66beb15cb3..516a2f0c13c 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -122,10 +122,11 @@ export const createOperation = async < let { data } = args - // For creates there is no existing doc — always publish all locales when not a draft. + // Explicit publish-all intent wins; otherwise new non-drafts publish all applicable locales. const publishAllLocales = - !draft && - (publishAllLocalesArg ?? (hasLocalizeStatusEnabled(collectionConfig) ? false : true)) + publishAllLocalesArg === true || + (!draft && + (publishAllLocalesArg ?? (hasLocalizeStatusEnabled(collectionConfig) ? false : true))) const isSavingDraft = Boolean(draft && hasDraftsEnabled(collectionConfig) && !publishAllLocales) if (isSavingDraft) { @@ -178,9 +179,12 @@ export const createOperation = async < data = newFileData const isCreatingTrashedDocument = Boolean( - collectionConfig.trash && data._status !== 'published' && data.deletedAt, + !publishAllLocales && + collectionConfig.trash && + data._status !== 'published' && + data.deletedAt, ) - const isUnpublishMetadataOperation = data._status === 'draft' + const isUnpublishMetadataOperation = !publishAllLocales && data._status === 'draft' if ( !isSavingDraft && diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index fa02822b077..f73ccf48e52 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -91,7 +91,28 @@ export async function validateLocal( payload: Payload, options: ValidateCollectionOptions, ): Promise { - return validateLocalWithDataLocale(payload, options) + const publicOptions = { + collection: options.collection, + context: options.context, + draft: options.draft, + locale: options.locale, + overrideAccess: options.overrideAccess, + req: options.req, + user: options.user, + } + + if (options.id === undefined) { + return validateLocalWithDataLocale(payload, { + ...publicOptions, + data: options.data, + }) + } + + return validateLocalWithDataLocale(payload, { + ...publicOptions, + id: options.id, + data: options.data, + }) } export async function validateLocalWithDataLocale( diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 1712ef07eab..4b72d856763 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -104,9 +104,10 @@ export const updateDocument = async < }: SharedUpdateDocumentArgs): Promise> => { const password = data?.password const publishAllLocales = - !draftArg && - (publishAllLocalesArg ?? - (hasLocalizeStatusEnabled(collectionConfig) && locale !== 'all' ? false : true)) + publishAllLocalesArg === true || + (!draftArg && + (publishAllLocalesArg ?? + (hasLocalizeStatusEnabled(collectionConfig) && locale !== 'all' ? false : true))) const unpublishAllLocales = typeof unpublishAllLocalesArg === 'string' ? unpublishAllLocalesArg === 'true' @@ -155,11 +156,13 @@ export const updateDocument = async < }) const isRestoringDraftFromTrash = Boolean(originalDoc?.deletedAt) && data?._status !== 'published' const isTrashMetadataOperation = Boolean( - collectionConfig.trash && + !publishAllLocales && + collectionConfig.trash && data?._status !== 'published' && (data?.deletedAt || isRestoringDraftFromTrash), ) - const isUnpublishMetadataOperation = unpublishAllLocales || data?._status === 'draft' + const isUnpublishMetadataOperation = + unpublishAllLocales || (!publishAllLocales && data?._status === 'draft') if ( hasDraftsEnabled(collectionConfig) && diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index b0dc9337548..29106261b0d 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -53,7 +53,16 @@ export async function validateGlobalLocal( payload: Payload, options: ValidateGlobalOptions, ): Promise { - return validateGlobalLocalWithDataLocale(payload, options) + return validateGlobalLocalWithDataLocale(payload, { + slug: options.slug, + context: options.context, + data: options.data, + draft: options.draft, + locale: options.locale, + overrideAccess: options.overrideAccess, + req: options.req, + user: options.user, + }) } export async function validateGlobalLocalWithDataLocale( diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 2df683d4424..2fb3e3edc6a 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -108,9 +108,10 @@ export const updateOperation = async < let { data } = args const publishAllLocales = - !draftArg && - (publishAllLocalesArg ?? - (hasLocalizeStatusEnabled(globalConfig) && locale !== 'all' ? false : true)) + publishAllLocalesArg === true || + (!draftArg && + (publishAllLocalesArg ?? + (hasLocalizeStatusEnabled(globalConfig) && locale !== 'all' ? false : true))) const unpublishAllLocales = typeof unpublishAllLocalesArg === 'string' ? unpublishAllLocalesArg === 'true' @@ -192,7 +193,8 @@ export const updateOperation = async < req, }) - const isUnpublishMetadataOperation = unpublishAllLocales || data._status === 'draft' + const isUnpublishMetadataOperation = + unpublishAllLocales || (!publishAllLocales && data._status === 'draft') if (hasDraftsEnabled(globalConfig) && !isSavingDraft && !isUnpublishMetadataOperation) { const validationResult = await validateGlobalLocalWithDataLocale(payload, { diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 36e5e5a3b72..19f52afa936 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -119,6 +119,51 @@ describe('validate Local API', () => { }) }) + it('should ignore internal projection flags passed to the public collection validate API', async () => { + const stored = await payload.create({ + collection: validationCollectionSlug, + data: { + summary: 'stored summary', + title: 'Stored title', + }, + locale: 'en', + }) + + const result = await payload.validate({ + id: stored.id, + collection: validationCollectionSlug, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: ['en', 'es'], + validationDataLocale: 'en', + } as never) + + expect(result).toEqual({ + errors: [], + valid: true, + }) + }) + + it('should ignore internal trash-source flags passed to the public collection validate API', async () => { + const stored = await seedPublishCollection({ + de: 'German optional', + deletedAt: new Date().toISOString(), + en: 'English draft', + es: 'Spanish required', + }) + + await expect( + payload.validate({ + id: stored.id, + collection: publishCollectionSlug, + locale: 'en', + validationTrash: true, + } as never), + ).rejects.toThrow(/not found/i) + }) + it('should resolve all to every available locale through locale filtering', async () => { const result = await payload.validate({ collection: validationCollectionSlug, @@ -536,6 +581,23 @@ describe('validate Local API', () => { }) describe('globals', () => { + it('should ignore internal projection flags passed to the public global validate API', async () => { + const result = await payload.validateGlobal({ + slug: validationGlobalSlug, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: ['en', 'es'], + validationDataLocale: 'en', + } as never) + + expect(result).toEqual({ + errors: [], + valid: true, + }) + }) + it('should isolate mutable access data and request state between global locale passes', async () => { const candidateData = { isolation: { marker: 'caller' }, @@ -1070,6 +1132,282 @@ describe('validate Local API', () => { }) describe('publish enforcement', () => { + it('should block collection create publish-all when status is draft without persisting', async () => { + await expect( + payload.create({ + collection: publishCollectionSlug, + data: { + ...getPublishCollectionLocaleData({ title: 'English candidate' }), + _status: 'draft', + }, + locale: 'en', + publishAllLocales: true, + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'de', + path: 'title', + }), + ]), + }, + }) + + const documents = await payload.find({ + collection: publishCollectionSlug, + draft: true, + limit: 1, + locale: 'all', + }) + + expect(documents.totalDocs).toBe(0) + }) + + it('should block collection create publish-all when draft saving is requested', async () => { + await expect( + payload.create({ + collection: publishCollectionSlug, + data: getPublishCollectionLocaleData({ title: 'English candidate' }), + draft: true, + locale: 'en', + publishAllLocales: true, + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'de', + path: 'title', + }), + ]), + }, + }) + + const documents = await payload.find({ + collection: publishCollectionSlug, + draft: true, + limit: 1, + locale: 'all', + }) + + expect(documents.totalDocs).toBe(0) + }) + + it('should block collection publish-all when status is draft without changing status', async () => { + const draft = await seedPublishCollection({ + de: '', + en: 'English draft', + es: 'Spanish required', + }) + + await expect( + payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + _status: 'draft', + title: 'English candidate', + }, + locale: 'en', + publishAllLocales: true, + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'de', + path: 'title', + }), + ]), + }, + }) + + const latestDraft = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'all', + }) + + expect(latestDraft._status).toMatchObject({ + de: 'draft', + en: 'draft', + es: 'draft', + }) + }) + + it('should block collection publish-all when draft saving is requested', async () => { + const draft = await seedPublishCollection({ + de: '', + en: 'English draft', + es: 'Spanish required', + }) + + await expect( + payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + title: 'English candidate', + }, + draft: true, + locale: 'en', + publishAllLocales: true, + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'de', + path: 'title', + }), + ]), + }, + }) + + const latestDraft = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'all', + }) + + expect(latestDraft._status).toMatchObject({ + de: 'draft', + en: 'draft', + es: 'draft', + }) + }) + + it('should block global publish-all when status is draft without changing status', async () => { + await seedPublishGlobal({ + de: '', + en: 'English draft', + es: 'Spanish required', + }) + + await expect( + payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + _status: 'draft', + title: 'English candidate', + }, + locale: 'en', + publishAllLocales: true, + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'de', + path: 'title', + }), + ]), + }, + }) + + const latestDraft = await payload.findGlobal({ + slug: publishGlobalSlug, + draft: true, + locale: 'all', + }) + + expect(latestDraft._status).toMatchObject({ + de: 'draft', + en: 'draft', + es: 'draft', + }) + }) + + it('should block global publish-all when draft saving is requested', async () => { + await seedPublishGlobal({ + de: '', + en: 'English draft', + es: 'Spanish required', + }) + + await expect( + payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + title: 'English candidate', + }, + draft: true, + locale: 'en', + publishAllLocales: true, + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'de', + path: 'title', + }), + ]), + }, + }) + + const latestDraft = await payload.findGlobal({ + slug: publishGlobalSlug, + draft: true, + locale: 'all', + }) + + expect(latestDraft._status).toMatchObject({ + de: 'draft', + en: 'draft', + es: 'draft', + }) + }) + + it('should block publish-all from a trashed source when status is omitted', async () => { + const draft = await seedPublishCollection({ + de: '', + deletedAt: new Date().toISOString(), + en: 'English draft', + es: 'Spanish required', + }) + + await expect( + payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + title: 'English candidate', + }, + locale: 'en', + publishAllLocales: true, + trash: true, + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'de', + path: 'title', + }), + ]), + }, + }) + + const latestDraft = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'all', + trash: true, + }) + + expect(latestDraft.deletedAt).toBeTruthy() + expect(latestDraft._status).toMatchObject({ + de: 'draft', + en: 'draft', + es: 'draft', + }) + }) + it('should not apply flat localized object candidates to a required sibling locale', async () => { const omittedFields: PublishCollectionLocalizedField[] = [ 'localizedArray', From a4de29dced35c73e009d657b7400b831fceb1261 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 21:42:13 +0100 Subject: [PATCH 11/47] fix(payload): validate post-hook publish intent --- .../src/collections/operations/create.ts | 83 ++++++------- .../operations/utilities/update.ts | 90 +++++++------- .../payload/src/globals/operations/update.ts | 76 ++++++------ .../utilities/isPostHookPublishIntent.spec.ts | 74 ++++++++++++ .../src/utilities/isPostHookPublishIntent.ts | 37 ++++++ test/validate/config.ts | 28 +++++ test/validate/int.spec.ts | 114 ++++++++++++++++++ 7 files changed, 377 insertions(+), 125 deletions(-) create mode 100644 packages/payload/src/utilities/isPostHookPublishIntent.spec.ts create mode 100644 packages/payload/src/utilities/isPostHookPublishIntent.ts diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 516a2f0c13c..f31e5ca49b1 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -37,6 +37,7 @@ import { hasLocalizeStatusEnabled, } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' +import { isPostHookPublishIntent } from '../../utilities/isPostHookPublishIntent.js' import { killTransaction } from '../../utilities/killTransaction.js' import { resolvePublishLocales } from '../../utilities/resolvePublishLocales.js' import { resolveSelect } from '../../utilities/resolveSelect.js' @@ -178,50 +179,6 @@ export const createOperation = async < data = newFileData - const isCreatingTrashedDocument = Boolean( - !publishAllLocales && - collectionConfig.trash && - data._status !== 'published' && - data.deletedAt, - ) - const isUnpublishMetadataOperation = !publishAllLocales && data._status === 'draft' - - if ( - !isSavingDraft && - !isCreatingTrashedDocument && - !isUnpublishMetadataOperation && - hasDraftsEnabled(collectionConfig) - ) { - const validationResult = await validateLocalWithDataLocale(payload, { - collection: collectionConfig.slug, - data, - locale: resolvePublishLocales({ - locale: locale ?? null, - localization: config.localization, - publishAllLocales, - }), - overrideAccess, - req, - validationDataLocale: - locale && locale !== 'all' - ? locale - : config.localization - ? config.localization.defaultLocale - : undefined, - }) - - if (!validationResult.valid) { - throw new ValidationError( - { - collection: collectionConfig.slug, - errors: validationResult.errors, - req, - }, - req.t, - ) - } - } - // ///////////////////////////////////// // beforeValidate - Fields // ///////////////////////////////////// @@ -290,6 +247,44 @@ export const createOperation = async < skipValidation: isSavingDraft && !hasDraftValidationEnabled(collectionConfig), }) + if ( + hasDraftsEnabled(collectionConfig) && + isPostHookPublishIntent({ + locale: locale ?? (config.localization ? config.localization.defaultLocale : undefined), + publishAllLocales, + status: dataWithLocales._status, + }) + ) { + const validationResult = await validateLocalWithDataLocale(payload, { + collection: collectionConfig.slug, + data: dataWithLocales, + locale: resolvePublishLocales({ + locale: locale ?? null, + localization: config.localization, + publishAllLocales, + }), + overrideAccess, + req, + validationDataLocale: + locale && locale !== 'all' + ? locale + : config.localization + ? config.localization.defaultLocale + : undefined, + }) + + if (!validationResult.valid) { + throw new ValidationError( + { + collection: collectionConfig.slug, + errors: validationResult.errors, + req, + }, + req.t, + ) + } + } + // When locale='all' or when beforeChange doesn't convert the string (e.g. no locale hook ran), // the localized _status remains a plain string. Expand it to a per-locale object so MongoDB // doesn't reject the write. This covers both draft and non-draft operations. diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 4b72d856763..3efa440fb9e 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -38,6 +38,7 @@ import { hasDraftValidationEnabled, hasLocalizeStatusEnabled, } from '../../../utilities/getVersionsConfig.js' +import { isPostHookPublishIntent } from '../../../utilities/isPostHookPublishIntent.js' import { resolvePublishLocales } from '../../../utilities/resolvePublishLocales.js' import { buildLocalizedPublishData } from '../../../versions/buildSingleLocalePublishData.js' import { validateLocalWithDataLocale } from '../local/validate.js' @@ -155,53 +156,6 @@ export const updateDocument = async < showHiddenFields: true, }) const isRestoringDraftFromTrash = Boolean(originalDoc?.deletedAt) && data?._status !== 'published' - const isTrashMetadataOperation = Boolean( - !publishAllLocales && - collectionConfig.trash && - data?._status !== 'published' && - (data?.deletedAt || isRestoringDraftFromTrash), - ) - const isUnpublishMetadataOperation = - unpublishAllLocales || (!publishAllLocales && data?._status === 'draft') - - if ( - hasDraftsEnabled(collectionConfig) && - !isSavingDraft && - !isTrashMetadataOperation && - !isUnpublishMetadataOperation - ) { - const validationResult = await validateLocalWithDataLocale(payload, { - id, - collection: collectionConfig.slug, - data, - locale: resolvePublishLocales({ - locale, - localization: config.localization, - publishAllLocales, - }), - overrideAccess, - req, - validationDataLocale: - locale !== 'all' - ? locale - : config.localization - ? config.localization.defaultLocale - : undefined, - validationTrash: Boolean(originalDoc?.deletedAt), - }) - - if (!validationResult.valid) { - throw new ValidationError( - { - id, - collection: collectionConfig.slug, - errors: validationResult.errors, - req, - }, - req.t, - ) - } - } if (collectionConfig.auth) { ensureUsernameOrEmail({ @@ -326,6 +280,48 @@ export const updateDocument = async < let result: JsonObject = await beforeChange(beforeChangeArgs) + if ( + hasDraftsEnabled(collectionConfig) && + isPostHookPublishIntent({ + locale, + publishAllLocales, + status: result._status, + unpublishAllLocales, + }) + ) { + const validationResult = await validateLocalWithDataLocale(payload, { + id, + collection: collectionConfig.slug, + data: result, + locale: resolvePublishLocales({ + locale, + localization: config.localization, + publishAllLocales, + }), + overrideAccess, + req, + validationDataLocale: + locale !== 'all' + ? locale + : config.localization + ? config.localization.defaultLocale + : undefined, + validationTrash: Boolean(originalDoc?.deletedAt), + }) + + if (!validationResult.valid) { + throw new ValidationError( + { + id, + collection: collectionConfig.slug, + errors: validationResult.errors, + req, + }, + req.t, + ) + } + } + if ( config.localization && hasLocalizeStatusEnabled(collectionConfig) && diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 2fb3e3edc6a..644decb8e10 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -33,6 +33,7 @@ import { hasLocalizeStatusEnabled, } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' +import { isPostHookPublishIntent } from '../../utilities/isPostHookPublishIntent.js' import { killTransaction } from '../../utilities/killTransaction.js' import { resolvePublishLocales } from '../../utilities/resolvePublishLocales.js' import { resolveSelect } from '../../utilities/resolveSelect.js' @@ -193,40 +194,6 @@ export const updateOperation = async < req, }) - const isUnpublishMetadataOperation = - unpublishAllLocales || (!publishAllLocales && data._status === 'draft') - - if (hasDraftsEnabled(globalConfig) && !isSavingDraft && !isUnpublishMetadataOperation) { - const validationResult = await validateGlobalLocalWithDataLocale(payload, { - slug: globalConfig.slug, - data, - locale: resolvePublishLocales({ - locale: locale ?? null, - localization: payload.config.localization, - publishAllLocales, - }), - overrideAccess, - req, - validationDataLocale: - locale && locale !== 'all' - ? locale - : payload.config.localization - ? payload.config.localization.defaultLocale - : undefined, - }) - - if (!validationResult.valid) { - throw new ValidationError( - { - errors: validationResult.errors, - global: globalConfig.slug, - req, - }, - req.t, - ) - } - } - // ///////////////////////////////////// // beforeValidate - Fields // ///////////////////////////////////// @@ -301,6 +268,47 @@ export const updateOperation = async < let result: JsonObject = await beforeChange(beforeChangeArgs) + if ( + hasDraftsEnabled(globalConfig) && + isPostHookPublishIntent({ + locale: + locale ?? + (payload.config.localization ? payload.config.localization.defaultLocale : undefined), + publishAllLocales, + status: result._status, + unpublishAllLocales, + }) + ) { + const validationResult = await validateGlobalLocalWithDataLocale(payload, { + slug: globalConfig.slug, + data: result, + locale: resolvePublishLocales({ + locale: locale ?? null, + localization: payload.config.localization, + publishAllLocales, + }), + overrideAccess, + req, + validationDataLocale: + locale && locale !== 'all' + ? locale + : payload.config.localization + ? payload.config.localization.defaultLocale + : undefined, + }) + + if (!validationResult.valid) { + throw new ValidationError( + { + errors: validationResult.errors, + global: globalConfig.slug, + req, + }, + req.t, + ) + } + } + if ( config?.localization && hasLocalizeStatusEnabled(globalConfig) && diff --git a/packages/payload/src/utilities/isPostHookPublishIntent.spec.ts b/packages/payload/src/utilities/isPostHookPublishIntent.spec.ts new file mode 100644 index 00000000000..fc02d126083 --- /dev/null +++ b/packages/payload/src/utilities/isPostHookPublishIntent.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import { isPostHookPublishIntent } from './isPostHookPublishIntent.js' + +describe('isPostHookPublishIntent', () => { + it.each([ + { + args: { + locale: 'en', + publishAllLocales: true, + status: 'draft', + }, + expected: true, + name: 'explicit publish-all over draft status', + }, + { + args: { + locale: 'en', + publishAllLocales: true, + status: 'published', + unpublishAllLocales: true, + }, + expected: false, + name: 'explicit unpublish-all over publish-all', + }, + { + args: { + locale: 'en', + publishAllLocales: false, + status: 'published', + }, + expected: true, + name: 'flat published status', + }, + { + args: { + locale: 'en', + publishAllLocales: false, + status: { en: 'published', es: 'draft' }, + }, + expected: true, + name: 'published active localized status', + }, + { + args: { + locale: 'en', + publishAllLocales: false, + status: { en: 'draft', es: 'published' }, + }, + expected: false, + name: 'published inactive localized status', + }, + { + args: { + locale: 'all', + publishAllLocales: false, + status: { en: 'draft', es: 'published' }, + }, + expected: true, + name: 'published status in an all-locales candidate', + }, + { + args: { + locale: 'en', + publishAllLocales: false, + status: 'draft', + }, + expected: false, + name: 'ordinary draft status', + }, + ])('should resolve $name', ({ args, expected }) => { + expect(isPostHookPublishIntent(args)).toBe(expected) + }) +}) diff --git a/packages/payload/src/utilities/isPostHookPublishIntent.ts b/packages/payload/src/utilities/isPostHookPublishIntent.ts new file mode 100644 index 00000000000..780ea5b0ba0 --- /dev/null +++ b/packages/payload/src/utilities/isPostHookPublishIntent.ts @@ -0,0 +1,37 @@ +type Args = { + locale?: null | string + publishAllLocales: boolean + status: unknown + unpublishAllLocales?: boolean +} + +export function isPostHookPublishIntent({ + locale, + publishAllLocales, + status, + unpublishAllLocales, +}: Args): boolean { + if (unpublishAllLocales) { + return false + } + + if (publishAllLocales || status === 'published') { + return true + } + + if (!status || typeof status !== 'object' || Array.isArray(status)) { + return false + } + + const localizedStatus = status as Record + + if (locale === 'all') { + return Object.values(localizedStatus).some((localeStatus) => localeStatus === 'published') + } + + if (!locale) { + return false + } + + return localizedStatus[locale] === 'published' +} diff --git a/test/validate/config.ts b/test/validate/config.ts index 29931109d8d..03263acb736 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -527,6 +527,20 @@ const publishCollection: CollectionConfig = { ], }, ], + hooks: { + beforeChange: [ + ({ data, operation, req }) => { + if (req.context.promoteDraftToPublished === true && operation !== 'validate') { + return { + ...data, + _status: 'published', + } + } + + return data + }, + ], + }, trash: true, versions: { drafts: { @@ -551,6 +565,20 @@ const publishGlobal: GlobalConfig = { required: true, }, ], + hooks: { + beforeChange: [ + ({ data, operation, req }) => { + if (req.context.promoteDraftToPublished === true && operation !== 'validate') { + return { + ...data, + _status: 'published', + } + } + + return data + }, + ], + }, versions: { drafts: { validate: false, diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 19f52afa936..8e2a8c8c291 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -1132,6 +1132,120 @@ describe('validate Local API', () => { }) describe('publish enforcement', () => { + it('should block collection create when a hook promotes a draft to published', async () => { + await expect( + payload.create({ + collection: publishCollectionSlug, + context: { + promoteDraftToPublished: true, + }, + data: { + ...getPublishCollectionLocaleData({ title: 'English candidate' }), + _status: 'draft', + }, + locale: 'en', + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'es', + path: 'title', + }), + ]), + }, + }) + + const documents = await payload.find({ + collection: publishCollectionSlug, + draft: true, + limit: 1, + locale: 'all', + }) + + expect(documents.totalDocs).toBe(0) + }) + + it('should block collection update when a hook promotes a draft to published', async () => { + const draft = await seedPublishCollection({ + de: 'German optional', + en: 'English draft', + es: '', + }) + + await expect( + payload.update({ + id: draft.id, + collection: publishCollectionSlug, + context: { + promoteDraftToPublished: true, + }, + data: { + _status: 'draft', + title: 'English candidate', + }, + locale: 'en', + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'es', + path: 'title', + }), + ]), + }, + }) + + const latestDraft = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'all', + }) + + expect(latestDraft._status.en).toBe('draft') + }) + + it('should block global update when a hook promotes a draft to published', async () => { + await seedPublishGlobal({ + de: 'German optional', + en: 'English draft', + es: '', + }) + + await expect( + payload.updateGlobal({ + slug: publishGlobalSlug, + context: { + promoteDraftToPublished: true, + }, + data: { + _status: 'draft', + title: 'English candidate', + }, + locale: 'en', + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'es', + path: 'title', + }), + ]), + }, + }) + + const latestDraft = await payload.findGlobal({ + slug: publishGlobalSlug, + draft: true, + locale: 'all', + }) + + expect(latestDraft._status.en).toBe('draft') + }) + it('should block collection create publish-all when status is draft without persisting', async () => { await expect( payload.create({ From e72d9023c9d9c1e05a0ef9d2e401e4a81685aab7 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 22:01:32 +0100 Subject: [PATCH 12/47] feat(payload): validate scheduled publications --- packages/payload/src/versions/schedule/job.ts | 121 +++++- test/validate/config.ts | 14 + test/validate/int.spec.ts | 366 ++++++++++++++++++ test/versions/int.spec.ts | 3 + 4 files changed, 483 insertions(+), 21 deletions(-) diff --git a/packages/payload/src/versions/schedule/job.ts b/packages/payload/src/versions/schedule/job.ts index 526f0d3db49..27ff0e4ea0e 100644 --- a/packages/payload/src/versions/schedule/job.ts +++ b/packages/payload/src/versions/schedule/job.ts @@ -3,12 +3,33 @@ import type { User } from '../../index.js' import type { TaskConfig } from '../../queues/config/types/taskTypes.js' import type { SchedulePublishTaskInput } from './types.js' +import { ValidationError } from '../../errors/index.js' +import { JobCancelledError } from '../../queues/errors/index.js' +import { resolvePublishLocales } from '../../utilities/resolvePublishLocales.js' + type Args = { adminUserSlug: string collections: string[] globals: string[] } +function throwScheduledPublishValidationError( + errors: { + locale?: string + message: string + path: string + }[], +): never { + const details = errors + .map( + ({ locale, message, path }) => + `[${locale ?? 'default'}] ${path}${message ? `: ${message}` : ''}`, + ) + .join('; ') + + throw new JobCancelledError(`Scheduled publish validation failed: ${details}`) +} + export const getSchedulePublishTask = ({ adminUserSlug, collections, @@ -33,6 +54,13 @@ export const getSchedulePublishTask = ({ user.collection = adminUserSlug } + const isPublishAllLocales = input.locale === undefined + const publishLocales = resolvePublishLocales({ + locale: input.locale, + localization: req.payload.config.localization, + publishAllLocales: isPublishAllLocales, + }) + if (input.doc) { // input.doc.value is always a string (#10481); coerce back to the real ID type. const idType = @@ -41,30 +69,81 @@ export const getSchedulePublishTask = ({ 'text' const id = idType === 'number' ? Number(input.doc.value) : input.doc.value - await req.payload.update({ - id, - collection: input.doc.relationTo, - data: { - _status, - }, - depth: 0, - locale: input.locale, - overrideAccess: user === null, - user, - }) + if (_status === 'published') { + const validationResult = await req.payload.validate({ + id, + collection: input.doc.relationTo, + draft: true, + locale: publishLocales, + overrideAccess: user === null, + req, + user, + }) + + if (!validationResult.valid) { + throwScheduledPublishValidationError(validationResult.errors) + } + } + + try { + await req.payload.update({ + id, + collection: input.doc.relationTo, + data: { + _status, + }, + depth: 0, + locale: input.locale, + overrideAccess: user === null, + publishAllLocales: _status === 'published' && isPublishAllLocales, + req, + user, + }) + } catch (error) { + if (error instanceof ValidationError) { + throwScheduledPublishValidationError(error.data.errors) + } + + throw error + } } if (input.global) { - await req.payload.updateGlobal({ - slug: input.global, - data: { - _status, - }, - depth: 0, - locale: input.locale, - overrideAccess: user === null, - user, - }) + if (_status === 'published') { + const validationResult = await req.payload.validateGlobal({ + slug: input.global, + draft: true, + locale: publishLocales, + overrideAccess: user === null, + req, + user, + }) + + if (!validationResult.valid) { + throwScheduledPublishValidationError(validationResult.errors) + } + } + + try { + await req.payload.updateGlobal({ + slug: input.global, + data: { + _status, + }, + depth: 0, + locale: input.locale, + overrideAccess: user === null, + publishAllLocales: _status === 'published' && isPublishAllLocales, + req, + user, + }) + } catch (error) { + if (error instanceof ValidationError) { + throwScheduledPublishValidationError(error.data.errors) + } + + throw error + } } return { diff --git a/test/validate/config.ts b/test/validate/config.ts index 03263acb736..60103b3424f 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -537,6 +537,15 @@ const publishCollection: CollectionConfig = { } } + return data + }, + ], + beforeValidate: [ + ({ data, operation }) => { + if (operation === 'validate' && data?.title === 'throw transient scheduled error') { + throw new Error('transient scheduled validation error') + } + return data }, ], @@ -544,6 +553,7 @@ const publishCollection: CollectionConfig = { trash: true, versions: { drafts: { + schedulePublish: true, validate: false, }, }, @@ -581,6 +591,7 @@ const publishGlobal: GlobalConfig = { }, versions: { drafts: { + schedulePublish: true, validate: false, }, }, @@ -616,6 +627,9 @@ export default buildConfigWithDefaults({ }, ], globals: [validationGlobal, publishGlobal], + jobs: { + deleteJobOnComplete: false, + }, localization: { defaultLocale: 'en', filterAvailableLocales: ({ locales, req }) => { diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 8e2a8c8c291..9972305cdbc 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -75,6 +75,11 @@ describe('validate Local API', () => { disableTransaction: true, where: { id: { exists: true } }, }), + payload.delete({ + collection: 'payload-jobs', + disableTransaction: true, + where: { id: { exists: true } }, + }), ]) await fs.rm(validationUploadsDir, { force: true, recursive: true }) }) @@ -1915,6 +1920,343 @@ describe('validate Local API', () => { expect(latestDraft._status.en).toBe('draft') }) + + describe('scheduled publish', () => { + it('should cancel invalid collection publish-all jobs with safe locale details and no retry', async () => { + const draft = await seedPublishCollection({ + de: 'German initially valid', + en: 'private English scheduled value', + es: 'Spanish required', + fr: 'French optional', + }) + const job = await payload.jobs.queue({ + input: { + doc: { + relationTo: publishCollectionSlug, + value: draft.id.toString(), + }, + }, + task: 'schedulePublish', + }) + + await payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: getPublishCollectionLocaleData({ title: '' }), + draft: true, + locale: 'de', + }) + + const firstRun = await payload.jobs.run({ silent: true }) + const failedJob = await payload.findByID({ + id: job.id, + collection: 'payload-jobs', + depth: 0, + }) + const error = failedJob.error as { cancelled?: boolean; message?: string } + + expect(firstRun.jobStatus?.[job.id]?.status).toBe('error-reached-max-retries') + expect(failedJob.hasError).toBe(true) + expect(error.cancelled).toBe(true) + expect(error.message).toContain('[de] title') + expect(error.message).not.toContain('private English scheduled value') + + const secondRun = await payload.jobs.run({ silent: true }) + const jobAfterSecondRun = await payload.findByID({ + id: job.id, + collection: 'payload-jobs', + depth: 0, + }) + const latestDraft = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'all', + }) + + expect(secondRun.jobStatus).toBeUndefined() + expect(jobAfterSecondRun.totalTried).toBe(failedJob.totalTried) + expect(latestDraft._status).toMatchObject({ + de: 'draft', + en: 'draft', + es: 'draft', + }) + }) + + it('should publish every collection locale when a publish-all job is valid', async () => { + const draft = await seedPublishCollection({ + de: 'German optional', + en: 'English scheduled', + es: 'Spanish required', + fr: 'French optional', + }) + const job = await payload.jobs.queue({ + input: { + doc: { + relationTo: publishCollectionSlug, + value: draft.id.toString(), + }, + }, + task: 'schedulePublish', + }) + + const result = await payload.jobs.run({ silent: true }) + const published = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'all', + }) + const completedJob = await payload.findByID({ + id: job.id, + collection: 'payload-jobs', + depth: 0, + }) + + expect(result.jobStatus?.[job.id]?.status).toBe('success') + expect(completedJob.hasError).toBe(false) + expect(published._status).toMatchObject({ + de: 'published', + en: 'published', + es: 'published', + fr: 'published', + }) + }) + + it('should validate the current and required collection locales but skip optional siblings', async () => { + const draft = await seedPublishCollection({ + de: '', + en: 'English scheduled', + es: 'Spanish required', + fr: '', + }) + const job = await payload.jobs.queue({ + input: { + doc: { + relationTo: publishCollectionSlug, + value: draft.id.toString(), + }, + locale: 'en', + }, + task: 'schedulePublish', + }) + + const result = await payload.jobs.run({ silent: true }) + const published = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'all', + }) + + expect(result.jobStatus?.[job.id]?.status).toBe('success') + expect(published._status).toMatchObject({ + de: 'draft', + en: 'published', + es: 'draft', + fr: 'draft', + }) + }) + + it('should cancel a current-locale collection job when a required locale is invalid', async () => { + const draft = await seedPublishCollection({ + de: 'German optional', + en: 'English scheduled', + es: '', + }) + const job = await payload.jobs.queue({ + input: { + doc: { + relationTo: publishCollectionSlug, + value: draft.id.toString(), + }, + locale: 'en', + }, + task: 'schedulePublish', + }) + + await payload.jobs.run({ silent: true }) + + const failedJob = await payload.findByID({ + id: job.id, + collection: 'payload-jobs', + depth: 0, + }) + const error = failedJob.error as { cancelled?: boolean; message?: string } + + expect(error.cancelled).toBe(true) + expect(error.message).toContain('[es] title') + }) + + it('should cancel invalid global publish-all jobs', async () => { + await seedPublishGlobal({ + de: 'German initially valid', + en: 'private English global value', + es: 'Spanish required', + fr: 'French optional', + }) + const job = await payload.jobs.queue({ + input: { + global: publishGlobalSlug, + }, + task: 'schedulePublish', + }) + + await payload.updateGlobal({ + slug: publishGlobalSlug, + data: getPublishGlobalLocaleData({ title: '' }), + draft: true, + locale: 'de', + }) + + await payload.jobs.run({ silent: true }) + + const failedJob = await payload.findByID({ + id: job.id, + collection: 'payload-jobs', + depth: 0, + }) + const error = failedJob.error as { cancelled?: boolean; message?: string } + const latestDraft = await payload.findGlobal({ + slug: publishGlobalSlug, + draft: true, + locale: 'all', + }) + + expect(error.cancelled).toBe(true) + expect(error.message).toContain('[de] title') + expect(error.message).not.toContain('private English global value') + expect(latestDraft._status).toMatchObject({ + de: 'draft', + en: 'draft', + es: 'draft', + }) + }) + + it('should publish every global locale when a publish-all job is valid', async () => { + await seedPublishGlobal({ + de: 'German optional', + en: 'English scheduled', + es: 'Spanish required', + fr: 'French optional', + }) + const job = await payload.jobs.queue({ + input: { + global: publishGlobalSlug, + }, + task: 'schedulePublish', + }) + + const result = await payload.jobs.run({ silent: true }) + const published = await payload.findGlobal({ + slug: publishGlobalSlug, + draft: true, + locale: 'all', + }) + const completedJob = await payload.findByID({ + id: job.id, + collection: 'payload-jobs', + depth: 0, + }) + + expect(result.jobStatus?.[job.id]?.status).toBe('success') + expect(completedJob.hasError).toBe(false) + expect(published._status).toMatchObject({ + de: 'published', + en: 'published', + es: 'published', + fr: 'published', + }) + }) + + it('should validate the current and required global locales but skip optional siblings', async () => { + await seedPublishGlobal({ + de: '', + en: 'English scheduled', + es: 'Spanish required', + fr: '', + }) + const job = await payload.jobs.queue({ + input: { + global: publishGlobalSlug, + locale: 'en', + }, + task: 'schedulePublish', + }) + + const result = await payload.jobs.run({ silent: true }) + const published = await payload.findGlobal({ + slug: publishGlobalSlug, + draft: true, + locale: 'all', + }) + + expect(result.jobStatus?.[job.id]?.status).toBe('success') + expect(published._status).toMatchObject({ + de: 'draft', + en: 'published', + es: 'draft', + fr: 'draft', + }) + }) + + it('should cancel a current-locale global job when a required locale is invalid', async () => { + await seedPublishGlobal({ + de: 'German optional', + en: 'English scheduled', + es: '', + }) + const job = await payload.jobs.queue({ + input: { + global: publishGlobalSlug, + locale: 'en', + }, + task: 'schedulePublish', + }) + + await payload.jobs.run({ silent: true }) + + const failedJob = await payload.findByID({ + id: job.id, + collection: 'payload-jobs', + depth: 0, + }) + const error = failedJob.error as { cancelled?: boolean; message?: string } + + expect(error.cancelled).toBe(true) + expect(error.message).toContain('[es] title') + }) + + it('should preserve normal queue error handling for transient validation failures', async () => { + const draft = await seedPublishCollection({ + de: 'German optional', + en: 'throw transient scheduled error', + es: 'Spanish required', + }) + const job = await payload.jobs.queue({ + input: { + doc: { + relationTo: publishCollectionSlug, + value: draft.id.toString(), + }, + locale: 'en', + }, + task: 'schedulePublish', + }) + + const result = await payload.jobs.run({ silent: true }) + const failedJob = await payload.findByID({ + id: job.id, + collection: 'payload-jobs', + depth: 0, + }) + const error = failedJob.error as { cancelled?: boolean; message?: string } + + expect(result.jobStatus?.[job.id]?.status).toBe('error-reached-max-retries') + expect(error.cancelled).toBe(false) + expect(error.message).toContain('transient scheduled validation error') + }) + }) }) }) @@ -1992,12 +2334,14 @@ async function seedPublishCollection({ deletedAt, en, es, + fr, omit, }: { de: string deletedAt?: string en: string es: string + fr?: string omit?: Partial> }) { const draft = await payload.create({ @@ -2026,6 +2370,16 @@ async function seedPublishCollection({ locale: 'de', trash: Boolean(deletedAt), }) + if (fr !== undefined) { + await payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: getPublishCollectionLocaleData({ title: fr }), + draft: true, + locale: 'fr', + trash: Boolean(deletedAt), + }) + } return draft } @@ -2047,11 +2401,13 @@ async function seedPublishGlobal({ de, en, es, + fr, omitLocalizedJSON, }: { de: string en: string es: string + fr?: string omitLocalizedJSON?: Partial> }) { await payload.updateGlobal({ @@ -2086,4 +2442,14 @@ async function seedPublishGlobal({ draft: true, locale: 'de', }) + if (fr !== undefined) { + await payload.updateGlobal({ + slug: publishGlobalSlug, + data: getPublishGlobalLocaleData({ + title: fr, + }), + draft: true, + locale: 'fr', + }) + } } diff --git a/test/versions/int.spec.ts b/test/versions/int.spec.ts index 43613174e55..1f62c0e7276 100644 --- a/test/versions/int.spec.ts +++ b/test/versions/int.spec.ts @@ -3608,6 +3608,7 @@ describe('Versions', () => { relationTo: draftCollectionSlug, value: draft.id, }, + locale: 'en', }, task: 'schedulePublish', waitUntil: new Date(currentDate.getTime() + 3000), @@ -3652,6 +3653,7 @@ describe('Versions', () => { relationTo: draftCollectionSlug, value: draft.id, }, + locale: 'en', user: user.id, }, task: 'schedulePublish', @@ -3835,6 +3837,7 @@ describe('Versions', () => { await payload.jobs.queue({ input: { global: draftGlobalSlug, + locale: 'en', }, task: 'schedulePublish', waitUntil: new Date(currentDate.getTime() + 3000), From 895407989dbb3cea25fed000aa2867c6fad56455 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 22:08:33 +0100 Subject: [PATCH 13/47] test(payload): align localized versions publication --- test/versions/int.spec.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/test/versions/int.spec.ts b/test/versions/int.spec.ts index 1f62c0e7276..91609fa1c52 100644 --- a/test/versions/int.spec.ts +++ b/test/versions/int.spec.ts @@ -31,8 +31,8 @@ import { draftWithUploadCloudStorageCollectionSlug, draftWithUploadCollectionSlug, localizedCollectionSlug, - nestedArraySelectCollectionSlug, localizedGlobalSlug, + nestedArraySelectCollectionSlug, versionCollectionSlug, } from './slugs.js' @@ -3131,16 +3131,22 @@ describe('Versions', () => { it('should have different createdAt in a new version while the same version.createdAt', async () => { const doc = await payload.updateGlobal({ slug: autoSaveGlobalSlug, - data: { title: 'asd' }, - publishAllLocales: true, + data: { + _status: 'published', + title: 'asd', + }, + locale: 'en', }) await wait(10) const upd = await payload.updateGlobal({ slug: autoSaveGlobalSlug, - data: { title: 'asd2' }, - publishAllLocales: true, + data: { + _status: 'published', + title: 'asd2', + }, + locale: 'en', }) expect(upd.createdAt).toBe(doc.createdAt) @@ -3344,9 +3350,10 @@ describe('Versions', () => { const updatedGlobal = await payload.updateGlobal({ slug: autoSaveGlobalSlug, data: { + _status: 'published', title: title2, }, - publishAllLocales: true, + locale: 'en', }) expect(updatedGlobal.title).toBe(title2) @@ -3389,7 +3396,7 @@ describe('Versions', () => { description: 'kjnjyhbbdsfseankuhsjsfghb', title: originalTitle, }, - publishAllLocales: true, + locale: 'en', }) const publishedGlobal = await payload.findGlobal({ From ccc621082d6e998d01bf12809cad1081eb942366 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 22:14:52 +0100 Subject: [PATCH 14/47] fix(payload): cancel scheduled validation errors --- packages/payload/src/versions/schedule/job.ts | 90 ++++++++++--------- test/validate/config.ts | 20 ++++- test/validate/int.spec.ts | 48 ++++++++++ 3 files changed, 116 insertions(+), 42 deletions(-) diff --git a/packages/payload/src/versions/schedule/job.ts b/packages/payload/src/versions/schedule/job.ts index 27ff0e4ea0e..4fd49397f8c 100644 --- a/packages/payload/src/versions/schedule/job.ts +++ b/packages/payload/src/versions/schedule/job.ts @@ -30,6 +30,20 @@ function throwScheduledPublishValidationError( throw new JobCancelledError(`Scheduled publish validation failed: ${details}`) } +async function runWithScheduledPublishValidationErrorHandling( + operation: () => Promise, +): Promise { + try { + return await operation() + } catch (error) { + if (error instanceof ValidationError) { + throwScheduledPublishValidationError(error.data.errors) + } + + throw error + } +} + export const getSchedulePublishTask = ({ adminUserSlug, collections, @@ -62,33 +76,37 @@ export const getSchedulePublishTask = ({ }) if (input.doc) { + const collectionSlug = input.doc.relationTo + // input.doc.value is always a string (#10481); coerce back to the real ID type. const idType = - req.payload.collections[input.doc.relationTo]?.customIDType ?? + req.payload.collections[collectionSlug]?.customIDType ?? req.payload.db?.defaultIDType ?? 'text' const id = idType === 'number' ? Number(input.doc.value) : input.doc.value if (_status === 'published') { - const validationResult = await req.payload.validate({ - id, - collection: input.doc.relationTo, - draft: true, - locale: publishLocales, - overrideAccess: user === null, - req, - user, - }) + const validationResult = await runWithScheduledPublishValidationErrorHandling(() => + req.payload.validate({ + id, + collection: collectionSlug, + draft: true, + locale: publishLocales, + overrideAccess: user === null, + req, + user, + }), + ) if (!validationResult.valid) { throwScheduledPublishValidationError(validationResult.errors) } } - try { - await req.payload.update({ + await runWithScheduledPublishValidationErrorHandling(() => + req.payload.update({ id, - collection: input.doc.relationTo, + collection: collectionSlug, data: { _status, }, @@ -98,35 +116,33 @@ export const getSchedulePublishTask = ({ publishAllLocales: _status === 'published' && isPublishAllLocales, req, user, - }) - } catch (error) { - if (error instanceof ValidationError) { - throwScheduledPublishValidationError(error.data.errors) - } - - throw error - } + }), + ) } if (input.global) { + const globalSlug = input.global + if (_status === 'published') { - const validationResult = await req.payload.validateGlobal({ - slug: input.global, - draft: true, - locale: publishLocales, - overrideAccess: user === null, - req, - user, - }) + const validationResult = await runWithScheduledPublishValidationErrorHandling(() => + req.payload.validateGlobal({ + slug: globalSlug, + draft: true, + locale: publishLocales, + overrideAccess: user === null, + req, + user, + }), + ) if (!validationResult.valid) { throwScheduledPublishValidationError(validationResult.errors) } } - try { - await req.payload.updateGlobal({ - slug: input.global, + await runWithScheduledPublishValidationErrorHandling(() => + req.payload.updateGlobal({ + slug: globalSlug, data: { _status, }, @@ -136,14 +152,8 @@ export const getSchedulePublishTask = ({ publishAllLocales: _status === 'published' && isPublishAllLocales, req, user, - }) - } catch (error) { - if (error instanceof ValidationError) { - throwScheduledPublishValidationError(error.data.errors) - } - - throw error - } + }), + ) } return { diff --git a/test/validate/config.ts b/test/validate/config.ts index 60103b3424f..d2c9db648cd 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -6,7 +6,7 @@ import type { } from 'payload' import path from 'path' -import { saveVersion } from 'payload' +import { saveVersion, ValidationError } from 'payload' import { fileURLToPath } from 'url' // Direct internal import intentionally exercises the upload write guard. @@ -541,7 +541,23 @@ const publishCollection: CollectionConfig = { }, ], beforeValidate: [ - ({ data, operation }) => { + ({ data, operation, req }) => { + if (operation === 'validate' && data?.title === 'throw scheduled validation error') { + throw new ValidationError( + { + errors: [ + { + locale: req.locale ?? undefined, + message: 'Scheduled validation hook rejected the title', + path: 'title', + }, + ], + req, + }, + req.t, + ) + } + if (operation === 'validate' && data?.title === 'throw transient scheduled error') { throw new Error('transient scheduled validation error') } diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 9972305cdbc..4e49b647163 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -2227,6 +2227,54 @@ describe('validate Local API', () => { expect(error.message).toContain('[es] title') }) + it('should cancel validation errors thrown by scheduled validation hooks without retrying', async () => { + const draft = await seedPublishCollection({ + de: 'German optional', + en: 'throw scheduled validation error', + es: 'Spanish required', + }) + const job = await payload.jobs.queue({ + input: { + doc: { + relationTo: publishCollectionSlug, + value: draft.id.toString(), + }, + locale: 'en', + }, + task: 'schedulePublish', + }) + + const firstRun = await payload.jobs.run({ silent: true }) + const failedJob = await payload.findByID({ + id: job.id, + collection: 'payload-jobs', + depth: 0, + }) + const error = failedJob.error as { cancelled?: boolean; message?: string } + const latestDraft = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'all', + }) + + expect(firstRun.jobStatus?.[job.id]?.status).toBe('error-reached-max-retries') + expect(error.cancelled).toBe(true) + expect(error.message).toContain('[en] title: Scheduled validation hook rejected the title') + expect(error.message).not.toContain('throw scheduled validation error') + expect(latestDraft._status.en).toBe('draft') + + const secondRun = await payload.jobs.run({ silent: true }) + const jobAfterSecondRun = await payload.findByID({ + id: job.id, + collection: 'payload-jobs', + depth: 0, + }) + + expect(secondRun.jobStatus).toBeUndefined() + expect(jobAfterSecondRun.totalTried).toBe(failedJob.totalTried) + }) + it('should preserve normal queue error handling for transient validation failures', async () => { const draft = await seedPublishCollection({ de: 'German optional', From 8f863e489b4545537429aec3c48655b91a3dc824 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 22:44:00 +0100 Subject: [PATCH 15/47] feat(ui): add localized document validation feedback --- packages/translations/src/clientKeys.ts | 5 + packages/translations/src/languages/ar.ts | 5 + packages/translations/src/languages/az.ts | 5 + packages/translations/src/languages/bg.ts | 5 + packages/translations/src/languages/bnBd.ts | 5 + packages/translations/src/languages/bnIn.ts | 5 + packages/translations/src/languages/ca.ts | 5 + packages/translations/src/languages/cs.ts | 5 + packages/translations/src/languages/da.ts | 5 + packages/translations/src/languages/de.ts | 5 + packages/translations/src/languages/en.ts | 5 + packages/translations/src/languages/es.ts | 5 + packages/translations/src/languages/et.ts | 5 + packages/translations/src/languages/fa.ts | 5 + packages/translations/src/languages/fr.ts | 5 + packages/translations/src/languages/he.ts | 5 + packages/translations/src/languages/hr.ts | 5 + packages/translations/src/languages/hu.ts | 5 + packages/translations/src/languages/hy.ts | 5 + packages/translations/src/languages/id.ts | 5 + packages/translations/src/languages/is.ts | 5 + packages/translations/src/languages/it.ts | 5 + packages/translations/src/languages/ja.ts | 5 + packages/translations/src/languages/ko.ts | 5 + packages/translations/src/languages/lt.ts | 5 + packages/translations/src/languages/lv.ts | 5 + packages/translations/src/languages/my.ts | 5 + packages/translations/src/languages/nb.ts | 5 + packages/translations/src/languages/nl.ts | 5 + packages/translations/src/languages/pl.ts | 5 + packages/translations/src/languages/pt.ts | 5 + packages/translations/src/languages/ro.ts | 5 + packages/translations/src/languages/rs.ts | 5 + .../translations/src/languages/rsLatin.ts | 5 + packages/translations/src/languages/ru.ts | 5 + packages/translations/src/languages/sk.ts | 5 + packages/translations/src/languages/sl.ts | 5 + packages/translations/src/languages/sv.ts | 5 + packages/translations/src/languages/ta.ts | 5 + packages/translations/src/languages/th.ts | 5 + packages/translations/src/languages/tr.ts | 5 + packages/translations/src/languages/uk.ts | 5 + packages/translations/src/languages/vi.ts | 5 + packages/translations/src/languages/zh.ts | 5 + packages/translations/src/languages/zhTw.ts | 5 + .../src/elements/DocumentControls/index.tsx | 25 +- .../ui/src/elements/PublishButton/index.tsx | 19 +- .../elements/ValidateDocumentButton/index.tsx | 28 ++ .../ValidationResultsDrawer/index.css | 40 ++ .../ValidationResultsDrawer/index.tsx | 77 ++++ .../providers/DocumentValidation/index.tsx | 257 ++++++++++++ .../src/utilities/documentValidation.spec.ts | 240 +++++++++++ .../ui/src/utilities/documentValidation.ts | 392 ++++++++++++++++++ .../ui/src/views/CreateFirstUser/index.tsx | 3 +- test/validate/config.ts | 70 ++++ test/validate/e2e.spec.ts | 182 ++++++++ 56 files changed, 1554 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/elements/ValidateDocumentButton/index.tsx create mode 100644 packages/ui/src/elements/ValidationResultsDrawer/index.css create mode 100644 packages/ui/src/elements/ValidationResultsDrawer/index.tsx create mode 100644 packages/ui/src/providers/DocumentValidation/index.tsx create mode 100644 packages/ui/src/utilities/documentValidation.spec.ts create mode 100644 packages/ui/src/utilities/documentValidation.ts create mode 100644 test/validate/e2e.spec.ts diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index 7365af192c5..e0eee5b2fa6 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -506,6 +506,8 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'upload:noFile', 'upload:download', + 'validation:documentInvalid', + 'validation:documentValid', 'validation:emailAddress', 'validation:enterNumber', 'validation:fieldHasNo', @@ -522,6 +524,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'validation:longerThanMin', 'validation:notValidDate', 'validation:required', + 'validation:requiredLocale', 'validation:requiresAtLeast', 'validation:requiresNoMoreThan', 'validation:requiresTwoNumbers', @@ -529,6 +532,8 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'validation:trueOrFalse', 'validation:timezoneRequired', 'validation:username', + 'validation:validateAllLocales', + 'validation:validationResults', 'validation:validUploadID', 'version:aboutToPublishSelection', diff --git a/packages/translations/src/languages/ar.ts b/packages/translations/src/languages/ar.ts index 42424f27015..24e00f36c31 100644 --- a/packages/translations/src/languages/ar.ts +++ b/packages/translations/src/languages/ar.ts @@ -589,6 +589,8 @@ export const arTranslations: DefaultTranslationsObject = { width: 'العرض', }, validation: { + documentInvalid: 'قم بإصلاح المشاكل التالية.', + documentValid: 'جميع اللغات المختارة صالحة.', emailAddress: 'يرجى إدخال عنوان بريد إلكتروني صحيح.', enterNumber: 'يرجى إدخال رقم صحيح.', fieldHasNo: 'هذا الحقل ليس لديه {{label}}', @@ -605,6 +607,7 @@ export const arTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'يجب أن تكون الخطوط الطولية بين -180 و 180.', notValidDate: '"{{value}}" ليس تاريخا صالحا.', required: 'هذا الحقل مطلوب.', + requiredLocale: 'مطلوب', requiresAtLeast: 'هذا الحقل يتطلب على الأقل {{count}} {{label}}.', requiresNoMoreThan: 'هذا الحقل يتطلب عدم تجاوز {{count}} {{label}}.', requiresTwoNumbers: 'هذا الحقل يتطلب رقمين.', @@ -613,6 +616,8 @@ export const arTranslations: DefaultTranslationsObject = { trueOrFalse: 'يمكن أن يكون هذا الحقل مساويًا فقط للقيمتين صحيح أو خطأ.', username: 'يرجى إدخال اسم مستخدم صالح. يمكن أن يحتوي على أحرف، أرقام، شرطات، فواصل وشرطات سفلية.', + validateAllLocales: 'تحقق من جميع اللغات', + validationResults: 'نتائج التحقق', validUploadID: 'هذا الحقل ليس معرّف تحميل صالح.', }, version: { diff --git a/packages/translations/src/languages/az.ts b/packages/translations/src/languages/az.ts index e017a1421d8..f2364c85b9d 100644 --- a/packages/translations/src/languages/az.ts +++ b/packages/translations/src/languages/az.ts @@ -607,6 +607,8 @@ export const azTranslations: DefaultTranslationsObject = { width: 'En', }, validation: { + documentInvalid: 'Aşağıdakı problemləri aradan qaldırın.', + documentValid: 'Seçilmiş bütün locale-lar etibarlıdır.', emailAddress: 'Xahiş edirik doğru elektron poçt ünvanını daxil edin.', enterNumber: 'Xahiş edirik doğru nömrəni daxil edin.', fieldHasNo: 'Bu sahədə heç bir {{label}} yoxdur', @@ -623,6 +625,7 @@ export const azTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Boylam -180 ilə 180 arasında olmalıdır.', notValidDate: '"{{value}}" doğru tarix deyil.', required: 'Bu sahə mütləq doldurulmalıdır.', + requiredLocale: 'Tələb olunur', requiresAtLeast: 'Bu sahə ən azı {{count}} {{label}} tələb edir.', requiresNoMoreThan: 'Bu sahə {{count}} {{label}}-dan çox olmamalıdır.', requiresTwoNumbers: 'Bu sahə iki nömrə tələb edir.', @@ -631,6 +634,8 @@ export const azTranslations: DefaultTranslationsObject = { trueOrFalse: 'Bu sahə yalnız doğru və ya yanlış ola bilər.', username: 'Zəhmət olmasa, etibarlı bir istifadəçi adı daxil edin. Hərflər, rəqəmlər, tire, nöqtə və alt xəttlər ola bilər.', + validateAllLocales: 'Bütün locale-ləri yoxlayın', + validationResults: 'Doğrulama nəticələri', validUploadID: 'Bu sahə doğru yükləmə ID-si deyil.', }, version: { diff --git a/packages/translations/src/languages/bg.ts b/packages/translations/src/languages/bg.ts index 48f8b9f7663..692d40efb35 100644 --- a/packages/translations/src/languages/bg.ts +++ b/packages/translations/src/languages/bg.ts @@ -601,6 +601,8 @@ export const bgTranslations: DefaultTranslationsObject = { width: 'Ширина', }, validation: { + documentInvalid: 'Отстранете следните проблеми.', + documentValid: 'Всички избрани езици са валидни.', emailAddress: 'Моля, въведи валиден имейл адрес.', enterNumber: 'Моля, въведи валиден номер.', fieldHasNo: 'Това поле няма {{label}}', @@ -618,6 +620,7 @@ export const bgTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Дължината трябва да бъде между -180 и 180.', notValidDate: '"{{value}}" не е валидна дата.', required: 'Това поле е задължително.', + requiredLocale: 'Задължително', requiresAtLeast: 'Това поле изисква поне {{count}} {{label}}.', requiresNoMoreThan: 'Това поле изисква не повече от {{count}} {{label}}.', requiresTwoNumbers: 'Това поле изисква 2 числа.', @@ -627,6 +630,8 @@ export const bgTranslations: DefaultTranslationsObject = { trueOrFalse: 'Това поле може да бъде само "true" или "false".', username: 'Моля, въведете валидно потребителско име. Може да съдържа букви, цифри, тирета, точки и долни черти.', + validateAllLocales: 'Валидирайте всички езици', + validationResults: 'Резултати от валидация', validUploadID: 'Това поле не е валиден идентификатор на качването.', }, version: { diff --git a/packages/translations/src/languages/bnBd.ts b/packages/translations/src/languages/bnBd.ts index cb835e40b55..98d0e613323 100644 --- a/packages/translations/src/languages/bnBd.ts +++ b/packages/translations/src/languages/bnBd.ts @@ -609,6 +609,8 @@ export const bnBdTranslations: DefaultTranslationsObject = { width: 'প্রস্থ', }, validation: { + documentInvalid: 'নিম্নলিখিত সমস্যাগুলো সমাধান করুন।', + documentValid: 'সমস্ত নির্বাচিত Locale বৈধ।', emailAddress: 'একটি বৈধ ইমেইল ঠিকানা লিখুন।', enterNumber: 'একটি বৈধ সংখ্যা লিখুন।', fieldHasNo: 'এই ক্ষেত্রে কোনো {{label}} নেই', @@ -625,6 +627,7 @@ export const bnBdTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'দ্রাগিমাংস অবশ্যই -১৮০ এবং ১৮০ এর মধ্যে হতে হবে।', notValidDate: '"{{value}}" একটি বৈধ তারিখ নয়।', required: 'এই ক্ষেত্রটি প্রয়োজনীয়।', + requiredLocale: 'প্রয়োজনীয়', requiresAtLeast: 'এই ক্ষেত্রটির জন্য কমপক্ষে {{count}} {{label}} প্রয়োজন।', requiresNoMoreThan: 'এই ক্ষেত্রটির জন্য {{count}} {{label}} এর বেশি প্রয়োজন নেই।', requiresTwoNumbers: 'এই ক্ষেত্রটির জন্য দুটি সংখ্যা প্রয়োজন।', @@ -633,6 +636,8 @@ export const bnBdTranslations: DefaultTranslationsObject = { trueOrFalse: 'এই ক্ষেত্রটি শুধুমাত্র সত্য বা মিথ্যা হতে পারে।', username: 'একটি বৈধ ব্যবহারকারীর নাম লিখুন। এতে অক্ষর, সংখ্যা, হাইফেন, পিরিয়ড এবং আন্ডারস্কোর থাকতে পারে।', + validateAllLocales: 'সমস্ত Locale যাচাই করুন', + validationResults: 'বৈধতা ফলাফল', validUploadID: 'এই ক্ষেত্রটি একটি বৈধ আপলোড আইডি নয়।', }, version: { diff --git a/packages/translations/src/languages/bnIn.ts b/packages/translations/src/languages/bnIn.ts index 69f1027db33..f02ca130ecd 100644 --- a/packages/translations/src/languages/bnIn.ts +++ b/packages/translations/src/languages/bnIn.ts @@ -608,6 +608,8 @@ export const bnInTranslations: DefaultTranslationsObject = { width: 'প্রস্থ', }, validation: { + documentInvalid: 'নিম্নলিখিত সমস্যাগুলি সমাধান করুন।', + documentValid: 'সকল নির্বাচিত Locale বৈধ।', emailAddress: 'একটি বৈধ ইমেইল ঠিকানা লিখুন।', enterNumber: 'একটি বৈধ সংখ্যা লিখুন।', fieldHasNo: 'এই ক্ষেত্রে কোনো {{label}} নেই', @@ -624,6 +626,7 @@ export const bnInTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'দীর্ঘবিন্ন অবশ্যই -180 এবং 180 এর মধ্যে থাকতে হবে।', notValidDate: '"{{value}}" একটি বৈধ তারিখ নয়।', required: 'এই ক্ষেত্রটি প্রয়োজনীয়।', + requiredLocale: 'আবশ্যক', requiresAtLeast: 'এই ক্ষেত্রটির জন্য কমপক্ষে {{count}} {{label}} প্রয়োজন।', requiresNoMoreThan: 'এই ক্ষেত্রটির জন্য {{count}} {{label}} এর বেশি প্রয়োজন নেই।', requiresTwoNumbers: 'এই ক্ষেত্রটির জন্য দুটি সংখ্যা প্রয়োজন।', @@ -632,6 +635,8 @@ export const bnInTranslations: DefaultTranslationsObject = { trueOrFalse: 'এই ক্ষেত্রটি শুধুমাত্র সত্য বা মিথ্যা হতে পারে।', username: 'একটি বৈধ ব্যবহারকারীর নাম লিখুন। এতে অক্ষর, সংখ্যা, হাইফেন, পিরিয়ড এবং আন্ডারস্কোর থাকতে পারে।', + validateAllLocales: 'সমস্ত Locale বৈধতা পরীক্ষা করুন', + validationResults: 'বৈধতা ফলাফল', validUploadID: 'এই ক্ষেত্রটি একটি বৈধ আপলোড আইডি নয়।', }, version: { diff --git a/packages/translations/src/languages/ca.ts b/packages/translations/src/languages/ca.ts index b11479c5a3f..a0789eb8022 100644 --- a/packages/translations/src/languages/ca.ts +++ b/packages/translations/src/languages/ca.ts @@ -605,6 +605,8 @@ export const caTranslations: DefaultTranslationsObject = { width: 'Amplada', }, validation: { + documentInvalid: 'Solucioni els problemes següents.', + documentValid: 'Tots els idiomes seleccionats són vàlids.', emailAddress: 'Si us plau, introdueix una adreça de correu electrònic vàlida.', enterNumber: 'Si us plau, introdueix un número vàlid.', fieldHasNo: 'Aquest camp no té {{label}}', @@ -622,6 +624,7 @@ export const caTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'La longitud ha de ser entre -180 i 180.', notValidDate: '"{{value}}" no és una data vàlida.', required: 'Aquest camp és obligatori.', + requiredLocale: 'Obligatori', requiresAtLeast: 'Aquest camp requereix almenys {{count}} {{label}}.', requiresNoMoreThan: 'Aquest camp requereix com a màxim {{count}} {{label}}.', requiresTwoNumbers: 'Aquest camp requereix dos números.', @@ -631,6 +634,8 @@ export const caTranslations: DefaultTranslationsObject = { trueOrFalse: 'Aquest camp només pot ser igual a true o false.', username: "Si us plau, introdueix un nom d'usuari vàlid. Pot contenir lletres, números, guions, punts i guions baixos.", + validateAllLocales: 'Valideu tots els idiomes', + validationResults: 'Resultats de la validació', validUploadID: 'Aquest camp no és un ID de càrrega vàlid.', }, version: { diff --git a/packages/translations/src/languages/cs.ts b/packages/translations/src/languages/cs.ts index 24e89c8ae89..f1751c7a299 100644 --- a/packages/translations/src/languages/cs.ts +++ b/packages/translations/src/languages/cs.ts @@ -597,6 +597,8 @@ export const csTranslations: DefaultTranslationsObject = { width: 'Šířka', }, validation: { + documentInvalid: 'Opravte následující problémy.', + documentValid: 'Všechny vybrané lokalizace jsou platné.', emailAddress: 'Zadejte prosím platnou e-mailovou adresu.', enterNumber: 'Zadejte prosím platné číslo.', fieldHasNo: 'Toto pole nemá {{label}}', @@ -613,6 +615,7 @@ export const csTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Zeměpisná délka musí být mezi -180 a 180.', notValidDate: '"{{value}}" není platné datum.', required: 'Toto pole je povinné.', + requiredLocale: 'Povinné', requiresAtLeast: 'Toto pole vyžaduje alespoň {{count}} {{label}}.', requiresNoMoreThan: 'Toto pole vyžaduje ne více než {{count}} {{label}}.', requiresTwoNumbers: 'Toto pole vyžaduje dvě čísla.', @@ -621,6 +624,8 @@ export const csTranslations: DefaultTranslationsObject = { trueOrFalse: 'Toto pole může být rovno pouze true nebo false.', username: 'Prosím, zadejte platné uživatelské jméno. Může obsahovat písmena, čísla, pomlčky, tečky a podtržítka.', + validateAllLocales: 'Ověřit všechny jazyky', + validationResults: 'Výsledky validace', validUploadID: 'Toto pole není platné ID pro odeslání.', }, version: { diff --git a/packages/translations/src/languages/da.ts b/packages/translations/src/languages/da.ts index d36d6d8273e..c3719589e05 100644 --- a/packages/translations/src/languages/da.ts +++ b/packages/translations/src/languages/da.ts @@ -601,6 +601,8 @@ export const daTranslations: DefaultTranslationsObject = { width: 'Bredde', }, validation: { + documentInvalid: 'Løs følgende problemer.', + documentValid: 'Alle valgte locales er gyldige.', emailAddress: 'Indtast venligst en gyldig e-mailadresse.', enterNumber: 'Indtast venligst et gyldigt nummer.', fieldHasNo: 'Dette felt har ingen {{label}}', @@ -617,6 +619,7 @@ export const daTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Længdegrad skal være mellem -180 og 180.', notValidDate: '"{{value}}" er ikke en gyldig dato.', required: 'Dette felt er påkrævet.', + requiredLocale: 'Påkrævet', requiresAtLeast: 'Dette felt kræver mindst {{count}} {{label}}.', requiresNoMoreThan: 'Dette felt kræver maks {{count}} {{label}}.', requiresTwoNumbers: 'Dette felt kræver to numre.', @@ -625,6 +628,8 @@ export const daTranslations: DefaultTranslationsObject = { trueOrFalse: 'Denne værdi kan kun være lig med sandt eller falsk.', username: 'Indtast et brugernavn. Kan indeholde bogstaver, tal, bindestreger, punktum og underscores.', + validateAllLocales: 'Validér alle locales', + validationResults: 'Valideringsresultater', validUploadID: 'Dette felt er ikke en gyldig upload-ID.', }, version: { diff --git a/packages/translations/src/languages/de.ts b/packages/translations/src/languages/de.ts index 6549f7110ed..941dc98b140 100644 --- a/packages/translations/src/languages/de.ts +++ b/packages/translations/src/languages/de.ts @@ -614,6 +614,8 @@ export const deTranslations: DefaultTranslationsObject = { width: 'Breite', }, validation: { + documentInvalid: 'Beheben Sie die folgenden Probleme.', + documentValid: 'Alle ausgewählten Locales sind gültig.', emailAddress: 'Bitte gib eine korrekte E-Mail-Adresse an.', enterNumber: 'Bitte gib eine gültige Nummer an.', fieldHasNo: 'Dieses Feld hat kein {{label}}', @@ -630,6 +632,7 @@ export const deTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Die Längengrad muss zwischen -180 und 180 liegen.', notValidDate: '"{{value}}" ist kein gültiges Datum.', required: 'Pflichtfeld', + requiredLocale: 'Erforderlich', requiresAtLeast: 'Dieses Feld muss mindestens {{count}} {{label}} enthalten.', requiresNoMoreThan: 'Dieses Feld darf nicht mehr als {{count}} {{label}} enthalten.', requiresTwoNumbers: 'Dieses Feld muss zwei Zahlen enthalten.', @@ -639,6 +642,8 @@ export const deTranslations: DefaultTranslationsObject = { trueOrFalse: 'Dieses Feld kann nur wahr oder falsch sein.', username: 'Bitte gib einen gültigen Benutzernamen ein. Dieser kann Buchstaben, Zahlen, Bindestriche, Punkte und Unterstriche enthalten.', + validateAllLocales: 'Alle Sprachen validieren', + validationResults: 'Validierungsergebnisse', validUploadID: 'Dieses Feld enthält keine gültige Upload-ID.', }, version: { diff --git a/packages/translations/src/languages/en.ts b/packages/translations/src/languages/en.ts index 5d344496a2c..4b2f18d5573 100644 --- a/packages/translations/src/languages/en.ts +++ b/packages/translations/src/languages/en.ts @@ -601,6 +601,8 @@ export const enTranslations = { width: 'Width', }, validation: { + documentInvalid: 'Fix the following issues.', + documentValid: 'All selected locales are valid.', emailAddress: 'Please enter a valid email address.', enterNumber: 'Please enter a valid number.', fieldHasNo: 'This field has no {{label}}', @@ -617,6 +619,7 @@ export const enTranslations = { longitudeOutOfBounds: 'Longitude must be between -180 and 180.', notValidDate: '"{{value}}" is not a valid date.', required: 'This field is required.', + requiredLocale: 'Required', requiresAtLeast: 'This field requires at least {{count}} {{label}}.', requiresNoMoreThan: 'This field requires no more than {{count}} {{label}}.', requiresTwoNumbers: 'This field requires two numbers.', @@ -625,6 +628,8 @@ export const enTranslations = { trueOrFalse: 'This field can only be equal to true or false.', username: 'Please enter a valid username. Can contain letters, numbers, hyphens, periods and underscores.', + validateAllLocales: 'Validate all locales', + validationResults: 'Validation results', validUploadID: 'This field is not a valid upload ID.', }, version: { diff --git a/packages/translations/src/languages/es.ts b/packages/translations/src/languages/es.ts index 20c6558d2be..6d6c58ad906 100644 --- a/packages/translations/src/languages/es.ts +++ b/packages/translations/src/languages/es.ts @@ -608,6 +608,8 @@ export const esTranslations: DefaultTranslationsObject = { width: 'Ancho', }, validation: { + documentInvalid: 'Corrija los siguientes problemas.', + documentValid: 'Todos los idiomas seleccionados son válidos.', emailAddress: 'Por favor, introduce un correo electrónico válido.', enterNumber: 'Por favor, introduce un número válido.', fieldHasNo: 'Este campo no tiene {{label}}', @@ -624,6 +626,7 @@ export const esTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'La longitud debe estar entre -180 y 180.', notValidDate: '"{{value}}" es una fecha inválida.', required: 'Este campo es obligatorio.', + requiredLocale: 'Obligatorio', requiresAtLeast: 'Este campo requiere al menos {{count}} {{label}}.', requiresNoMoreThan: 'Este campo require no más de {{count}} {{label}}', requiresTwoNumbers: 'Este campo requiere dos números.', @@ -632,6 +635,8 @@ export const esTranslations: DefaultTranslationsObject = { trueOrFalse: 'Este campo solo puede ser verdadero o falso.', username: 'Por favor, introduce un nombre de usuario válido. Puede contener letras, números, guiones, puntos y guiones bajos.', + validateAllLocales: 'Validar todos los idiomas', + validationResults: 'Resultados de validación', validUploadID: 'Este campo no es un ID de subida válido.', }, version: { diff --git a/packages/translations/src/languages/et.ts b/packages/translations/src/languages/et.ts index 3e47aba6e1a..f911b4d0f92 100644 --- a/packages/translations/src/languages/et.ts +++ b/packages/translations/src/languages/et.ts @@ -596,6 +596,8 @@ export const etTranslations: DefaultTranslationsObject = { width: 'Laius', }, validation: { + documentInvalid: 'Parandage järgmised probleemid.', + documentValid: 'Kõik valitud töökeeled on kehtivad.', emailAddress: 'Palun sisesta kehtiv e-posti aadress.', enterNumber: 'Palun sisesta kehtiv number.', fieldHasNo: 'Sellel väljal pole {{label}}', @@ -612,6 +614,7 @@ export const etTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Pikkuskraad peab olema vahemikus -180 kuni 180.', notValidDate: '"{{value}}" ei ole kehtiv kuupäev.', required: 'See väli on kohustuslik.', + requiredLocale: 'Nõutav', requiresAtLeast: 'See väli nõuab vähemalt {{count}} {{label}}.', requiresNoMoreThan: 'See väli ei tohi sisaldada rohkem kui {{count}} {{label}}.', requiresTwoNumbers: 'See väli nõuab kahte numbrit.', @@ -620,6 +623,8 @@ export const etTranslations: DefaultTranslationsObject = { trueOrFalse: 'See väli saab olla ainult tõene või väär.', username: 'Palun sisesta kehtiv kasutajanimi. Võib sisaldada tähti, numbreid, sidekriipse, punkte ja alakriipse.', + validateAllLocales: 'Kinnita kõik keeled', + validationResults: 'Valideerimistulemused', validUploadID: 'See väli ei ole kehtiv üleslaadimise ID.', }, version: { diff --git a/packages/translations/src/languages/fa.ts b/packages/translations/src/languages/fa.ts index 0e0377bd60a..6558299e0b4 100644 --- a/packages/translations/src/languages/fa.ts +++ b/packages/translations/src/languages/fa.ts @@ -592,6 +592,8 @@ export const faTranslations: DefaultTranslationsObject = { width: 'عرض', }, validation: { + documentInvalid: 'مشکلات زیر را برطرف کنید.', + documentValid: 'همه زبان‌های انتخاب‌شده معتبر هستند.', emailAddress: 'لطفاً یک آدرس ایمیل معتبر وارد کنید.', enterNumber: 'لطفاً یک عدد معتبر وارد کنید.', fieldHasNo: 'این فیلد نباید حاوی هیچ {{label}} باشد.', @@ -608,6 +610,7 @@ export const faTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'عرض جغرافیایی باید بین -180 و 180 باشد.', notValidDate: '"{{value}}" یک تاریخ معتبر نیست.', required: 'این فیلد الزامی است.', + requiredLocale: 'الزامی', requiresAtLeast: 'این فیلد به حداقل {{count}} {{label}} نیاز دارد.', requiresNoMoreThan: 'این فیلد نمی‌تواند بیشتر از {{count}} {{label}} داشته باشد.', requiresTwoNumbers: 'این فیلد به دو عدد نیاز دارد.', @@ -616,6 +619,8 @@ export const faTranslations: DefaultTranslationsObject = { trueOrFalse: 'مقدار این فیلد فقط می‌تواند "true" یا "false" باشد.', username: 'لطفاً یک نام کاربری معتبر وارد کنید. فقط حروف، اعداد، نقطه، خط تیره و زیرخط مجاز هستند.', + validateAllLocales: 'اعتبارسنجی تمامی Localeها', + validationResults: 'نتایج اعتبارسنجی', validUploadID: 'شناسه آپلود معتبر نیست.', }, version: { diff --git a/packages/translations/src/languages/fr.ts b/packages/translations/src/languages/fr.ts index e976a1f65aa..5f06abfdeb2 100644 --- a/packages/translations/src/languages/fr.ts +++ b/packages/translations/src/languages/fr.ts @@ -614,6 +614,8 @@ export const frTranslations: DefaultTranslationsObject = { width: 'Largeur', }, validation: { + documentInvalid: 'Corrigez les problèmes suivants.', + documentValid: 'Toutes les langues sélectionnées sont valides.', emailAddress: 'S’il vous plaît, veuillez entrer une adresse e-mail valide.', enterNumber: 'S’il vous plait, veuillez entrer un nombre valide.', fieldHasNo: 'Ce champ n’a pas de {{label}}', @@ -631,6 +633,7 @@ export const frTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'La longitude doit être comprise entre -180 et 180.', notValidDate: '"{{value}}" n’est pas une date valide.', required: 'Ce champ est requis.', + requiredLocale: 'Obligatoire', requiresAtLeast: 'Ce champ doit avoir au moins {{count}} {{label}}.', requiresNoMoreThan: 'Ce champ ne doit pas avoir plus de {{count}} {{label}}.', requiresTwoNumbers: 'Ce champ doit avoir deux chiffres.', @@ -640,6 +643,8 @@ export const frTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ce champ ne peut être égal qu’à vrai ou faux.', username: "Veuillez entrer un nom d'utilisateur valide. Il peut contenir des lettres, des chiffres, des tirets, des points et des tirets bas.", + validateAllLocales: 'Valider toutes les langues', + validationResults: 'Résultats de la validation', validUploadID: 'Ce champ n’est pas un valide identifiant de fichier.', }, version: { diff --git a/packages/translations/src/languages/he.ts b/packages/translations/src/languages/he.ts index cb7bec3a547..eda338f1d87 100644 --- a/packages/translations/src/languages/he.ts +++ b/packages/translations/src/languages/he.ts @@ -583,6 +583,8 @@ export const heTranslations: DefaultTranslationsObject = { width: 'רוחב', }, validation: { + documentInvalid: 'תקן את הבעיות הבאות.', + documentValid: 'כל ה-Locales שנבחרו תקינים.', emailAddress: 'נא להזין כתובת דוא"ל תקנית.', enterNumber: 'נא להזין מספר תקני.', fieldHasNo: 'שדה זה אינו מכיל {{label}}', @@ -599,6 +601,7 @@ export const heTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'אורך צריך להיות בין -180 ל-180.', notValidDate: '"{{value}}" אינו תאריך תקני.', required: 'שדה זה הוא שדה חובה.', + requiredLocale: 'נדרש', requiresAtLeast: 'שדה זה דורש לפחות {{count}} {{label}}.', requiresNoMoreThan: 'שדה זה דורש לא יותר מ-{{count}} {{label}}.', requiresTwoNumbers: 'שדה זה דורש שני מספרים.', @@ -606,6 +609,8 @@ export const heTranslations: DefaultTranslationsObject = { timezoneRequired: 'נדרשת אזור זמן.', trueOrFalse: 'שדה זה יכול להיות רק true או false.', username: 'אנא הזן שם משתמש חוקי. יכול להכיל אותיות, מספרים, מקפים, נקודות וקווים תחתונים.', + validateAllLocales: 'אמת את כל ה-Locales', + validationResults: 'תוצאות האימות', validUploadID: 'שדה זה אינו מזהה העלאה תקני.', }, version: { diff --git a/packages/translations/src/languages/hr.ts b/packages/translations/src/languages/hr.ts index 03cf324a148..6fb914ca291 100644 --- a/packages/translations/src/languages/hr.ts +++ b/packages/translations/src/languages/hr.ts @@ -600,6 +600,8 @@ export const hrTranslations: DefaultTranslationsObject = { width: 'Širina', }, validation: { + documentInvalid: 'Ispravite sljedeće probleme.', + documentValid: 'Svi odabrani jezici su valjani.', emailAddress: 'Molimo unesite valjanu e-mail adresu.', enterNumber: 'Molimo unesite valjani broj.', fieldHasNo: 'Ovo polje nema {{label}}', @@ -616,6 +618,7 @@ export const hrTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Geografska dužina mora biti između -180 i 180.', notValidDate: '"{{value}}" nije valjan datum.', required: 'Ovo polje je obvezno.', + requiredLocale: 'Obavezno', requiresAtLeast: 'Ovo polje zahtjeva minimalno {{count}} {{label}}.', requiresNoMoreThan: 'Ovo polje zahtjeva ne više od {{count}} {{label}}.', requiresTwoNumbers: 'Ovo polje zahtjeva dva broja.', @@ -624,6 +627,8 @@ export const hrTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ovo polje može biti samo točno ili netočno', username: 'Unesite važeće korisničko ime. Može sadržavati slova, brojeve, crtice, točke i donje crte.', + validateAllLocales: 'Potvrdite sve locale', + validationResults: 'Rezultati validacije', validUploadID: 'Ovo polje nije valjani ID prijenosa.', }, version: { diff --git a/packages/translations/src/languages/hu.ts b/packages/translations/src/languages/hu.ts index 3d1292107ea..ed14670a08e 100644 --- a/packages/translations/src/languages/hu.ts +++ b/packages/translations/src/languages/hu.ts @@ -607,6 +607,8 @@ export const huTranslations: DefaultTranslationsObject = { width: 'Szélesség', }, validation: { + documentInvalid: 'Javítsa ki a következő problémákat.', + documentValid: 'Az összes kiválasztott locale érvényes.', emailAddress: 'Kérjük, adjon meg egy érvényes e-mail címet.', enterNumber: 'Kérjük, adjon meg egy érvényes számot.', fieldHasNo: 'Ennek a mezőnek nincs {{label}}', @@ -625,6 +627,7 @@ export const huTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'A hosszúságnak -180 és 180 között kell lennie.', notValidDate: '" {{value}} " nem érvényes dátum.', required: 'Ez a mező kötelező.', + requiredLocale: 'Kötelező', requiresAtLeast: 'Ehhez a mezőhöz legalább {{count}} {{label}} szükséges.', requiresNoMoreThan: 'Ehhez a mezőhöz legfeljebb {{count}} {{label}} szükséges.', requiresTwoNumbers: 'Ehhez a mezőhöz két szám szükséges.', @@ -634,6 +637,8 @@ export const huTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ez a mező csak igaz vagy hamis lehet.', username: 'Adjon meg egy érvényes felhasználónevet. Tartalmazhat betűket, számokat, kötőjeleket, pontokat és aláhúzásokat.', + validateAllLocales: 'Az összes Locale érvényesítése', + validationResults: 'Érvényesítési eredmények', validUploadID: 'Ez a mező nem érvényes feltöltési azonosító.', }, version: { diff --git a/packages/translations/src/languages/hy.ts b/packages/translations/src/languages/hy.ts index 351011f0c56..ce598ad9f83 100644 --- a/packages/translations/src/languages/hy.ts +++ b/packages/translations/src/languages/hy.ts @@ -605,6 +605,8 @@ export const hyTranslations: DefaultTranslationsObject = { width: 'Լայնություն', }, validation: { + documentInvalid: 'Ուղղեք հետևյալ խնդիրները։', + documentValid: 'Բոլոր ընտրված locale-ները վավեր են:', emailAddress: 'Խնդրում ենք մուտքագրել վավեր էլ. փոստի հասցե։', enterNumber: 'Խնդրում ենք մուտքագրել վավեր հեռախոսահամար։', fieldHasNo: 'Այս դաշտում {{label}} չկա', @@ -623,6 +625,7 @@ export const hyTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Լոնգիտուդը պետք է լինի -180-ի և 180-ի միջակայքում:', notValidDate: '"{{value}}"-ը վավեր ամսաթիվ չէ։', required: 'Այս դաշտը պարտադիր է։', + requiredLocale: 'Պահանջված', requiresAtLeast: 'Այս դաշտը պահանջում է առնվազն {{count}} {{label}}։', requiresNoMoreThan: 'Այս դաշտը պահանջում է ոչ ավելի, քան {{count}} {{label}}։', requiresTwoNumbers: 'Այս դաշտը պահանջում է երկու թիվ։', @@ -632,6 +635,8 @@ export const hyTranslations: DefaultTranslationsObject = { trueOrFalse: 'Այս դաշտի արժեքը կարող է միայն «ճիշտ» կամ «սխալ»։', username: 'Խնդրում ենք մուտքագրել վավեր օգտանուն։ Կարող է պարունակել տառեր, թվեր, գծիկներ, կետեր և ընդգծում։', + validateAllLocales: 'Հաստատել բոլոր locale-ները', + validationResults: 'Վավերացման արդյունքներ', validUploadID: 'Այս դաշտը վավեր վերբեռնման ID չի պարունակում։', }, version: { diff --git a/packages/translations/src/languages/id.ts b/packages/translations/src/languages/id.ts index cbfbb1c0c9c..9008e39d52a 100644 --- a/packages/translations/src/languages/id.ts +++ b/packages/translations/src/languages/id.ts @@ -605,6 +605,8 @@ export const idTranslations: DefaultTranslationsObject = { width: 'Lebar', }, validation: { + documentInvalid: 'Perbaiki masalah-masalah berikut.', + documentValid: 'Semua Locale yang dipilih valid.', emailAddress: 'Harap masukkan alamat email yang valid.', enterNumber: 'Harap masukkan nomor yang valid.', fieldHasNo: 'Isian ini tidak memiliki {{label}}', @@ -621,6 +623,7 @@ export const idTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Garis bujur harus berada di antara -180 dan 180.', notValidDate: '"{{value}}" bukan tanggal yang valid.', required: 'Isian ini wajib diisi.', + requiredLocale: 'Wajib', requiresAtLeast: 'Isian ini membutuhkan setidaknya {{count}} {{label}}.', requiresNoMoreThan: 'Isian ini membutuhkan tidak lebih dari {{count}} {{label}}.', requiresTwoNumbers: 'Isian ini membutuhkan dua angka.', @@ -629,6 +632,8 @@ export const idTranslations: DefaultTranslationsObject = { trueOrFalse: 'Isian ini hanya bisa sama dengan benar atau salah.', username: 'Harap masukkan nama pengguna yang valid. Dapat berisi huruf, angka, tanda hubung, titik, dan garis bawah.', + validateAllLocales: 'Validasi semua Locale', + validationResults: 'Hasil validasi', validUploadID: 'Isian ini bukan ID unggahan yang valid.', }, version: { diff --git a/packages/translations/src/languages/is.ts b/packages/translations/src/languages/is.ts index cb0799ade9e..3bd57da7a6c 100644 --- a/packages/translations/src/languages/is.ts +++ b/packages/translations/src/languages/is.ts @@ -599,6 +599,8 @@ export const isTranslations: DefaultTranslationsObject = { width: 'Breidd', }, validation: { + documentInvalid: 'Lagaðu eftirfarandi vandamál.', + documentValid: 'Öll valin tungumál eru gild.', emailAddress: 'Vinsamlegast settu inn gilt netfang.', enterNumber: 'Vinsamlegast settu inn gilda tölu.', fieldHasNo: 'Þetta svæði hefur ekkert {{label}}', @@ -616,6 +618,7 @@ export const isTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Lengdargráða verður að vera á bilinu -180 og 180.', notValidDate: '"{{value}}" er ekki gild dagsetning.', required: 'Þetta svæði er nauðsynlegt.', + requiredLocale: 'Nauðsynlegt', requiresAtLeast: 'Þetta svæði krefst að minnsta kosti {{count}} {{label}}.', requiresNoMoreThan: 'Þetta svæði krefst ekki meira en {{count}} {{label}}.', requiresTwoNumbers: 'Þetta svæði krefst tveggja talna.', @@ -625,6 +628,8 @@ export const isTranslations: DefaultTranslationsObject = { trueOrFalse: 'Þetta svæði getur aðeins verið jafnt og satt eða ósatt.', username: 'Vinsamlegast settu inn gilt notandanafn. Getur innihaldið bókstafi, tölur, bandstrik, punkta og undirstrik.', + validateAllLocales: 'Staðfestið öll tungumál', + validationResults: 'Staðfestingarniðurstöður', validUploadID: 'Þetta svæði er ekki gilt skráarupphleðslauðkenni.', }, version: { diff --git a/packages/translations/src/languages/it.ts b/packages/translations/src/languages/it.ts index c48ff310c8c..4718f44e504 100644 --- a/packages/translations/src/languages/it.ts +++ b/packages/translations/src/languages/it.ts @@ -607,6 +607,8 @@ export const itTranslations: DefaultTranslationsObject = { width: 'Larghezza', }, validation: { + documentInvalid: 'Correggi i seguenti problemi.', + documentValid: 'Tutte le lingue selezionate sono valide.', emailAddress: 'Si prega di inserire un indirizzo email valido.', enterNumber: 'Si prega di inserire un numero valido.', fieldHasNo: 'Questo campo non ha {{label}}', @@ -624,6 +626,7 @@ export const itTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'La longitudine deve essere compresa tra -180 e 180.', notValidDate: '"{{value}}" non è una data valida.', required: 'Questo campo è obbligatorio.', + requiredLocale: 'Obbligatorio', requiresAtLeast: 'Questo campo richiede almeno {{count}} {{label}}.', requiresNoMoreThan: 'Questo campo richiede non più di {{count}} {{label}}.', requiresTwoNumbers: 'Questo campo richiede due numeri.', @@ -633,6 +636,8 @@ export const itTranslations: DefaultTranslationsObject = { trueOrFalse: "Questo campo può essere solo uguale a 'true' o 'false'.", username: 'Inserisci un nome utente valido. Può contenere lettere, numeri, trattini, punti e underscore.', + validateAllLocales: 'Convalida tutte le lingue', + validationResults: 'Risultati della convalida', validUploadID: "'Questo campo non è un ID di Upload valido.'", }, version: { diff --git a/packages/translations/src/languages/ja.ts b/packages/translations/src/languages/ja.ts index db205f19e08..108efc6e150 100644 --- a/packages/translations/src/languages/ja.ts +++ b/packages/translations/src/languages/ja.ts @@ -601,6 +601,8 @@ export const jaTranslations: DefaultTranslationsObject = { width: '横幅', }, validation: { + documentInvalid: '次の問題を修正してください。', + documentValid: 'すべての選択されたLocaleは有効です。', emailAddress: '有効なメールアドレスを入力してください。', enterNumber: '有効な数値を入力してください。', fieldHasNo: '{{label}} が必要です。', @@ -617,6 +619,7 @@ export const jaTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: '経度は-180から180の間でなければなりません。', notValidDate: '"{{value}}" は有効な日付ではありません。', required: '必須フィールドです。', + requiredLocale: '必須', requiresAtLeast: '少なくとも {{count}} {{label}} 以上が必要です。', requiresNoMoreThan: '最大で {{count}} {{label}} 以下にする必要があります。', requiresTwoNumbers: '2つの数値が必要です。', @@ -625,6 +628,8 @@ export const jaTranslations: DefaultTranslationsObject = { trueOrFalse: '"true" または "false" の値にする必要があります。', username: '有効なユーザー名を入力してください。文字、数字、ハイフン、ピリオド、アンダースコアを使用できます。', + validateAllLocales: 'すべてのLocaleを検証してください', + validationResults: '検証結果', validUploadID: '有効なアップロードIDではありません。', }, version: { diff --git a/packages/translations/src/languages/ko.ts b/packages/translations/src/languages/ko.ts index 3ef7ec0247b..f6c32c9c383 100644 --- a/packages/translations/src/languages/ko.ts +++ b/packages/translations/src/languages/ko.ts @@ -598,6 +598,8 @@ export const koTranslations: DefaultTranslationsObject = { width: '너비', }, validation: { + documentInvalid: '다음 문제를 수정하십시오.', + documentValid: '선택된 모든 Locale은 유효합니다.', emailAddress: '유효한 이메일 주소를 입력하세요.', enterNumber: '유효한 숫자를 입력하세요.', fieldHasNo: '이 입력란에는 {{label}}이(가) 없습니다.', @@ -614,6 +616,7 @@ export const koTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: '경도는 -180에서 180 사이여야 합니다.', notValidDate: '"{{value}}"은(는) 유효한 날짜가 아닙니다.', required: '이 입력란은 필수입니다.', + requiredLocale: '필수', requiresAtLeast: '이 입력란운 최소한 {{count}} {{label}}이 필요합니다.', requiresNoMoreThan: '이 입력란은 최대 {{count}} {{label}} 이하이어야 합니다.', requiresTwoNumbers: '이 입력란은 두 개의 숫자가 필요합니다.', @@ -622,6 +625,8 @@ export const koTranslations: DefaultTranslationsObject = { trueOrFalse: '이 입력란은 true 또는 false만 가능합니다.', username: '유효한 사용자 이름을 입력해 주세요. 글자, 숫자, 하이픈, 마침표, 및 밑줄을 사용할 수 있습니다.', + validateAllLocales: '모든 Locale을 검증하십시오.', + validationResults: '검증 결과', validUploadID: '이 입력란은 유효한 업로드 ID가 아닙니다.', }, version: { diff --git a/packages/translations/src/languages/lt.ts b/packages/translations/src/languages/lt.ts index 2b13ba2e560..827bdd8f933 100644 --- a/packages/translations/src/languages/lt.ts +++ b/packages/translations/src/languages/lt.ts @@ -603,6 +603,8 @@ export const ltTranslations: DefaultTranslationsObject = { width: 'Plotis', }, validation: { + documentInvalid: 'Išspręskite šias problemas.', + documentValid: 'Visi pasirinkti Locale yra galiojantys.', emailAddress: 'Įveskite galiojantį el. pašto adresą.', enterNumber: 'Įveskite galiojantį skaičių.', fieldHasNo: 'Šiame lauke nėra {{label}}', @@ -622,6 +624,7 @@ export const ltTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Ilguma turi būti tarp -180 ir 180.', notValidDate: '"{{value}}" nėra galiojanti data.', required: 'Šis laukas yra privalomas.', + requiredLocale: 'Privalomas', requiresAtLeast: 'Šis laukas reikalauja bent {{count}} {{label}}.', requiresNoMoreThan: 'Šiame laukelyje gali būti ne daugiau kaip {{count}} {{label}}.', requiresTwoNumbers: 'Šiame lauke reikia įvesti du skaičius.', @@ -630,6 +633,8 @@ export const ltTranslations: DefaultTranslationsObject = { trueOrFalse: 'Šis laukas gali būti lygus tik „true“ ar „false“.', username: 'Įveskite galiojantį vartotojo vardą. Galima naudoti raides, skaičius, brūkšnelius, taškus ir pabraukimus.', + validateAllLocales: 'Patvirtinti visas kalbas', + validationResults: 'Patikros rezultatai', validUploadID: 'Šis laukas nėra tinkamas įkėlimo ID.', }, version: { diff --git a/packages/translations/src/languages/lv.ts b/packages/translations/src/languages/lv.ts index 4f3ab4397c1..f55901811c2 100644 --- a/packages/translations/src/languages/lv.ts +++ b/packages/translations/src/languages/lv.ts @@ -600,6 +600,8 @@ export const lvTranslations: DefaultTranslationsObject = { width: 'Platums', }, validation: { + documentInvalid: 'Izlabojiet šādas problēmas.', + documentValid: 'Visas atlasītās lokalizācijas ir derīgas.', emailAddress: 'Lūdzu, ievadiet derīgu e-pasta adresi.', enterNumber: 'Lūdzu, ievadiet derīgu numuru.', fieldHasNo: 'Šim laukam nav {{label}}', @@ -616,6 +618,7 @@ export const lvTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Garuma līnijai jābūt starp -180 un 180.', notValidDate: '"{{value}}" nav derīgs datums.', required: 'Šis lauks ir obligāts.', + requiredLocale: 'Obligāts', requiresAtLeast: 'Šim laukam nepieciešami vismaz {{count}} {{label}}.', requiresNoMoreThan: 'Šim laukam nepieciešams ne vairāk kā {{count}} {{label}}.', requiresTwoNumbers: 'Šim laukam nepieciešami divi skaitļi.', @@ -624,6 +627,8 @@ export const lvTranslations: DefaultTranslationsObject = { trueOrFalse: 'Šis lauks var būt tikai "true" vai "false".', username: 'Lūdzu, ievadiet derīgu lietotājvārdu. Drīkst saturēt burtus, ciparus, defises, punktus un pasvītras.', + validateAllLocales: 'Validēt visus lokālus', + validationResults: 'Validācijas rezultāti', validUploadID: 'Šis lauks nav derīgs augšupielādes ID.', }, version: { diff --git a/packages/translations/src/languages/my.ts b/packages/translations/src/languages/my.ts index b8ad0cd6115..fb71ee9cf91 100644 --- a/packages/translations/src/languages/my.ts +++ b/packages/translations/src/languages/my.ts @@ -609,6 +609,8 @@ export const myTranslations: DefaultTranslationsObject = { width: 'အကျယ်', }, validation: { + documentInvalid: 'အောက်ပါပြဿနာများကို ပြုပြင်ပါ။', + documentValid: 'ရွေးချယ်ထားသည့် Locale အားလုံးမှန်ကန်ပါသည်။', emailAddress: 'မှန်ကန်သော အီးမေးလ်လိပ်စာကို ထည့်သွင်းပါ။', enterNumber: 'မှန်ကန်သောနံပါတ်တစ်ခုထည့်ပါ။', fieldHasNo: 'ဤအကွက်တွင် {{label}} မရှိပါ။', @@ -628,6 +630,7 @@ export const myTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'လောင်ဂျီကျူဒ်သည် -180 နှင့် 180 ကြားတွင် ရှိရမည်။', notValidDate: '"{{value}}" သည် တရားဝင်ရက်စွဲမဟုတ်ပါ။', required: 'ဤအကွက်ကို လိုအပ်သည်။', + requiredLocale: 'လိုအပ်သည်', requiresAtLeast: 'ဤအကွက်သည် အနည်းဆုံး {{count}} {{label}} လိုအပ်သည်', requiresNoMoreThan: 'ဤအကွက်တွင် {{count}} {{label}} ထက် မပိုရပါ။', requiresTwoNumbers: 'ဤအကွက်သည် နံပါတ်နှစ်ခု လိုအပ်ပါသည်။', @@ -636,6 +639,8 @@ export const myTranslations: DefaultTranslationsObject = { trueOrFalse: 'ဤအကွက်သည် တစ်ခုခုဖြစ်ရပါမည်။', username: 'မှန်ကန်သော အသုံးပြုသူအမည် ထည့်သွင်းပါ။ အက္ခရာ၊ နံပါတ်၊ ဟိုင်ဖန်၊ အစက် နှင့် အောက်မျဉ်းတို့ ပါဝင်နိုင်ပါသည်။', + validateAllLocales: 'လူအပေါင်းတို့၏ဘာသာစကားများကိုအတည်ပြုပါ', + validationResults: 'အတည်ပြုမှုရလဒ်များ', validUploadID: "'ဤအကွက်သည် မှန်ကန်သော အပ်လုဒ် ID မဟုတ်ပါ။'", }, version: { diff --git a/packages/translations/src/languages/nb.ts b/packages/translations/src/languages/nb.ts index fd4602affa9..4af34726ab2 100644 --- a/packages/translations/src/languages/nb.ts +++ b/packages/translations/src/languages/nb.ts @@ -604,6 +604,8 @@ export const nbTranslations: DefaultTranslationsObject = { width: 'Bredde', }, validation: { + documentInvalid: 'Løs følgende problemer.', + documentValid: 'Alle valgte språk er gyldige.', emailAddress: 'Vennligst skriv inn en gyldig e-postadresse.', enterNumber: 'Vennligst skriv inn et gyldig tall.', fieldHasNo: 'Dette feltet har ingen {{label}}', @@ -620,6 +622,7 @@ export const nbTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Lengdegrad må være mellom -180 og 180.', notValidDate: '"{{value}}" er ikke en gyldig dato.', required: 'Dette feltet er påkrevd.', + requiredLocale: 'Obligatorisk', requiresAtLeast: 'Dette feltet krever minst {{count}} {{label}}.', requiresNoMoreThan: 'Dette feltet krever maksimalt {{count}} {{label}}.', requiresTwoNumbers: 'Dette feltet krever to tall.', @@ -628,6 +631,8 @@ export const nbTranslations: DefaultTranslationsObject = { trueOrFalse: 'Dette feltet kan bare være likt true eller false.', username: 'Vennligst oppgi et gyldig brukernavn. Kan inneholde bokstaver, nummer, bindestreker, punktum og understrek.', + validateAllLocales: 'Valider alle språk', + validationResults: 'Valideringsresultater', validUploadID: 'Dette feltet er ikke en gyldig opplastings-ID.', }, version: { diff --git a/packages/translations/src/languages/nl.ts b/packages/translations/src/languages/nl.ts index 466d4adb35c..0ebbee59b52 100644 --- a/packages/translations/src/languages/nl.ts +++ b/packages/translations/src/languages/nl.ts @@ -609,6 +609,8 @@ export const nlTranslations: DefaultTranslationsObject = { width: 'Breedte', }, validation: { + documentInvalid: 'Los de volgende problemen op.', + documentValid: 'Alle geselecteerde talen zijn geldig.', emailAddress: 'Voer een geldig e-mailadres in.', enterNumber: 'Voer een geldig nummer in.', fieldHasNo: 'Dit veld heeft geen {{label}}', @@ -625,6 +627,7 @@ export const nlTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Lengtegraad moet tussen -180 en 180 liggen.', notValidDate: '"{{value}}" is geen geldige datum.', required: 'Dit veld is verplicht.', + requiredLocale: 'Vereist', requiresAtLeast: 'Dit veld vereist minstens {{count}} {{label}}.', requiresNoMoreThan: 'Dit veld vereist niet meer dan {{count}} {{label}}.', requiresTwoNumbers: 'Dit veld vereist twee nummers.', @@ -633,6 +636,8 @@ export const nlTranslations: DefaultTranslationsObject = { trueOrFalse: 'Dit veld kan alleen waar of onwaar zijn.', username: 'Voer een geldige gebruikersnaam in. Kan letters, cijfers, koppeltekens, punten en underscores bevatten.', + validateAllLocales: 'Valideer alle talen', + validationResults: 'Validatieresultaten', validUploadID: 'Dit veld is geen geldige upload-ID.', }, version: { diff --git a/packages/translations/src/languages/pl.ts b/packages/translations/src/languages/pl.ts index 1e58b4ecbf1..2cadbfef56a 100644 --- a/packages/translations/src/languages/pl.ts +++ b/packages/translations/src/languages/pl.ts @@ -600,6 +600,8 @@ export const plTranslations: DefaultTranslationsObject = { width: 'Szerokość', }, validation: { + documentInvalid: 'Proszę naprawić następujące problemy.', + documentValid: 'Wszystkie wybrane locale są prawidłowe.', emailAddress: 'Wprowadź poprawny adres email.', enterNumber: 'Wprowadź poprawny numer telefonu.', fieldHasNo: 'To pole nie posiada {{label}}', @@ -616,6 +618,7 @@ export const plTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Długość geograficzna musi być pomiędzy -180 a 180.', notValidDate: '"{{value}}" nie jest prawidłową datą.', required: 'To pole jest wymagane.', + requiredLocale: 'Wymagane', requiresAtLeast: 'To pole wymaga co najmniej {{count}} {{label}}.', requiresNoMoreThan: 'To pole może posiadać co najmniej {{count}} {{label}}.', requiresTwoNumbers: 'To pole wymaga dwóch liczb.', @@ -624,6 +627,8 @@ export const plTranslations: DefaultTranslationsObject = { trueOrFalse: "To pole może mieć wartość tylko 'true' lub 'false'.", username: 'Proszę wprowadzić prawidłową nazwę użytkownika. Może zawierać litery, cyfry, myślniki, kropki i podkreślniki.', + validateAllLocales: 'Zatwierdź wszystkie locale', + validationResults: 'Wyniki walidacji', validUploadID: 'To pole nie jest prawidłowym identyfikatorem przesyłania.', }, version: { diff --git a/packages/translations/src/languages/pt.ts b/packages/translations/src/languages/pt.ts index 7858d1440ac..1ace33fb928 100644 --- a/packages/translations/src/languages/pt.ts +++ b/packages/translations/src/languages/pt.ts @@ -604,6 +604,8 @@ export const ptTranslations: DefaultTranslationsObject = { width: 'Largura', }, validation: { + documentInvalid: 'Corrija os seguintes problemas.', + documentValid: 'Todos os idiomas selecionados são válidos.', emailAddress: 'Por favor, insira um endereço de email válido.', enterNumber: 'Por favor, insira um número válido.', fieldHasNo: 'Esse campo não contém {{label}}', @@ -620,6 +622,7 @@ export const ptTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'A longitude deve estar entre -180 e 180.', notValidDate: '"{{value}}" não é uma data válida.', required: 'Esse campo é obrigatório.', + requiredLocale: 'Obrigatório', requiresAtLeast: 'Esse campo requer no máximo {{count}} {{label}}.', requiresNoMoreThan: 'Esse campo requer pelo menos {{count}} {{label}}.', requiresTwoNumbers: 'Esse campo requer dois números.', @@ -628,6 +631,8 @@ export const ptTranslations: DefaultTranslationsObject = { trueOrFalse: 'Esse campo pode ser apenas verdadeiro (true) ou falso (false)', username: 'Por favor, insira um nome de usuário válido. Pode conter letras, números, hifens, pontos e sublinhados.', + validateAllLocales: 'Validar todos os idiomas', + validationResults: 'Resultados da validação', validUploadID: "'Esse campo não é um ID de upload válido.'", }, version: { diff --git a/packages/translations/src/languages/ro.ts b/packages/translations/src/languages/ro.ts index 2e1fb2658eb..cd91b35217b 100644 --- a/packages/translations/src/languages/ro.ts +++ b/packages/translations/src/languages/ro.ts @@ -607,6 +607,8 @@ export const roTranslations: DefaultTranslationsObject = { width: 'Lățime', }, validation: { + documentInvalid: 'Remediați următoarele probleme.', + documentValid: 'Toate localele selectate sunt valide.', emailAddress: 'Vă rugăm să introduceți o adresă de email validă.', enterNumber: 'Vă rugăm să introduceți un număr valid.', fieldHasNo: 'Acest câmp nu are un {{label}}', @@ -626,6 +628,7 @@ export const roTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Longitudinea trebuie să fie între -180 și 180.', notValidDate: '"{{value}}" nu este o dată valabilă.', required: 'Acest câmp este obligatoriu.', + requiredLocale: 'Obligatoriu', requiresAtLeast: 'Acest domeniu necesită cel puțin {{count}} {{label}}.', requiresNoMoreThan: 'Acest câmp nu necesită mai mult de {{count}} {{label}}.', requiresTwoNumbers: 'Acest câmp necesită două numere.', @@ -635,6 +638,8 @@ export const roTranslations: DefaultTranslationsObject = { trueOrFalse: 'Acest câmp poate fi doar egal cu true sau false.', username: 'Vă rugăm să introduceți un nume de utilizator valid. Poate conține litere, numere, cratime, puncte și sublinieri.', + validateAllLocales: 'Validați toate limbile', + validationResults: 'Rezultatele validării', validUploadID: 'Acest câmp nu este un ID de încărcare valid.', }, version: { diff --git a/packages/translations/src/languages/rs.ts b/packages/translations/src/languages/rs.ts index cab55936763..6b9bcb368fc 100644 --- a/packages/translations/src/languages/rs.ts +++ b/packages/translations/src/languages/rs.ts @@ -600,6 +600,8 @@ export const rsTranslations: DefaultTranslationsObject = { width: 'Ширина', }, validation: { + documentInvalid: 'Ispravite sledeće probleme.', + documentValid: 'Svi izabrani jezici su validni.', emailAddress: 'Молимо Вас унесите валидну емаил адресу.', enterNumber: 'Молимо Вас унесите валидан број.', fieldHasNo: 'Ово поље нема {{label}}', @@ -616,6 +618,7 @@ export const rsTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Дужина мора бити између -180 и 180.', notValidDate: '"{{value}}" није валидан датум.', required: 'Ово поље је обавезно.', + requiredLocale: 'Obavezno', requiresAtLeast: 'Ово поље захтева минимално {{count}} {{label}}.', requiresNoMoreThan: 'Ово поље захтева не више од {{count}} {{label}}.', requiresTwoNumbers: 'Ово поље захтева два броја.', @@ -624,6 +627,8 @@ export const rsTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ово поље може бити само тачно или нетачно', username: 'Molimo unesite važeće korisničko ime. Može sadržati slova, brojeve, crtice, tačke i donje crte.', + validateAllLocales: 'Validirajte sve jezike', + validationResults: 'Rezultati validacije', validUploadID: 'Ово поље не садржи валидан ИД преноса.', }, version: { diff --git a/packages/translations/src/languages/rsLatin.ts b/packages/translations/src/languages/rsLatin.ts index 04989d6eb92..c34388f5e1e 100644 --- a/packages/translations/src/languages/rsLatin.ts +++ b/packages/translations/src/languages/rsLatin.ts @@ -601,6 +601,8 @@ export const rsLatinTranslations: DefaultTranslationsObject = { width: 'Širina', }, validation: { + documentInvalid: 'Ispravite sledeće probleme.', + documentValid: 'Svi izabrani jezici su validni.', emailAddress: 'Molimo Vas unesite validnu email adresu.', enterNumber: 'Molimo Vas unesite validan broj.', fieldHasNo: 'Ovo polje nema {{label}}', @@ -617,6 +619,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Geografska dužina mora biti između -180 i 180.', notValidDate: '"{{value}}" nije validan datum.', required: 'Ovo polje je obavezno.', + requiredLocale: 'Obavezno', requiresAtLeast: 'Ovo polje zahteva minimalno {{count}} {{label}}.', requiresNoMoreThan: 'Ovo polje zahteva ne više od {{count}} {{label}}.', requiresTwoNumbers: 'Ovo polje zahteva dva broja.', @@ -625,6 +628,8 @@ export const rsLatinTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ovo polje može biti samo tačno ili netačno', username: 'Molimo unesite važeće korisničko ime. Može sadržavati slova, brojeve, crtice, tačke i donje crte.', + validateAllLocales: 'Potvrdite sve jezike', + validationResults: 'Rezultati validacije', validUploadID: 'Ovo polje ne sadrži validan ID prenosa.', }, version: { diff --git a/packages/translations/src/languages/ru.ts b/packages/translations/src/languages/ru.ts index 3a662201e0b..acf9cbd4d22 100644 --- a/packages/translations/src/languages/ru.ts +++ b/packages/translations/src/languages/ru.ts @@ -606,6 +606,8 @@ export const ruTranslations: DefaultTranslationsObject = { width: 'Ширина', }, validation: { + documentInvalid: 'Исправьте следующие проблемы.', + documentValid: 'Все выбранные языки являются допустимыми.', emailAddress: 'Пожалуйста, введите корректный адрес email.', enterNumber: 'Пожалуйста, введите корректный номер.', fieldHasNo: 'У этого поля нет {{label}}', @@ -622,6 +624,7 @@ export const ruTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Долгота должна быть между -180 и 180.', notValidDate: '"{{value}}" это не действительная дата.', required: 'Это обязательное поле.', + requiredLocale: 'Обязательный', requiresAtLeast: 'Это поле требует не менее {{count}} {{label}}', requiresNoMoreThan: 'Это поле требует не более {{count}} {{label}}', requiresTwoNumbers: 'В этом поле требуется два числа.', @@ -630,6 +633,8 @@ export const ruTranslations: DefaultTranslationsObject = { trueOrFalse: 'Это поле может быть равно только true или false.', username: 'Пожалуйста, введите действительное имя пользователя. Может содержать буквы, цифры, дефисы, точки и подчёркивания.', + validateAllLocales: 'Проверить все языковые настройки', + validationResults: 'Результаты проверки', validUploadID: "'Это поле не является действительным ID загрузки.'", }, version: { diff --git a/packages/translations/src/languages/sk.ts b/packages/translations/src/languages/sk.ts index 0686af6500f..243bea4aff1 100644 --- a/packages/translations/src/languages/sk.ts +++ b/packages/translations/src/languages/sk.ts @@ -598,6 +598,8 @@ export const skTranslations: DefaultTranslationsObject = { width: 'Šírka', }, validation: { + documentInvalid: 'Opravte nasledujúce problémy.', + documentValid: 'Všetky vybrané jazyky sú platné.', emailAddress: 'Zadajte prosím platnú e-mailovú adresu.', enterNumber: 'Zadajte prosím platné číslo.', fieldHasNo: 'Toto pole nemá {{label}}', @@ -614,6 +616,7 @@ export const skTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Zemepisná dĺžka musí byť medzi -180 a 180.', notValidDate: '"{{value}}" nie je platný dátum.', required: 'Toto pole je povinné.', + requiredLocale: 'Povinné', requiresAtLeast: 'Toto pole vyžaduje aspoň {{count}} {{label}}.', requiresNoMoreThan: 'Toto pole vyžaduje nie viac ako {{count}} {{label}}.', requiresTwoNumbers: 'Toto pole vyžaduje dve čísla.', @@ -622,6 +625,8 @@ export const skTranslations: DefaultTranslationsObject = { trueOrFalse: 'Toto pole môže byť rovné iba true alebo false.', username: 'Prosím, zadajte platné používateľské meno. Môže obsahovať písmená, čísla, pomlčky, bodky a podčiarknutia.', + validateAllLocales: 'Overte všetky jazyky', + validationResults: 'Výsledky validácie', validUploadID: 'Toto pole nie je platné ID pre odoslanie.', }, version: { diff --git a/packages/translations/src/languages/sl.ts b/packages/translations/src/languages/sl.ts index 91d995134e6..2ebb3de1c86 100644 --- a/packages/translations/src/languages/sl.ts +++ b/packages/translations/src/languages/sl.ts @@ -599,6 +599,8 @@ export const slTranslations: DefaultTranslationsObject = { width: 'Širina', }, validation: { + documentInvalid: 'Odpravite naslednje težave.', + documentValid: 'Vsi izbrani jeziki so veljavni.', emailAddress: 'Vnesite veljaven e-poštni naslov.', enterNumber: 'Vnesite veljavno številko.', fieldHasNo: 'To polje nima {{label}}', @@ -615,6 +617,7 @@ export const slTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Zemljepisna dolžina mora biti med -180 in 180.', notValidDate: '"{{value}}" ni veljaven datum.', required: 'To polje je obvezno.', + requiredLocale: 'Obvezno', requiresAtLeast: 'To polje zahteva vsaj {{count}} {{label}}.', requiresNoMoreThan: 'To polje zahteva največ {{count}} {{label}}.', requiresTwoNumbers: 'To polje zahteva dve številki.', @@ -623,6 +626,8 @@ export const slTranslations: DefaultTranslationsObject = { trueOrFalse: 'To polje je lahko samo enako true ali false.', username: 'Vnesite veljavno uporabniško ime. Lahko vsebuje črke, številke, vezaje, pike in podčrtaje.', + validateAllLocales: 'Potrdite vse jezike', + validationResults: 'Rezultati validacije', validUploadID: 'To polje ni veljaven ID nalaganja.', }, version: { diff --git a/packages/translations/src/languages/sv.ts b/packages/translations/src/languages/sv.ts index d21655a598b..435471d71b9 100644 --- a/packages/translations/src/languages/sv.ts +++ b/packages/translations/src/languages/sv.ts @@ -603,6 +603,8 @@ export const svTranslations: DefaultTranslationsObject = { width: 'Bredd', }, validation: { + documentInvalid: 'Åtgärda följande problem.', + documentValid: 'Alla valda språk är giltiga.', emailAddress: 'Vänligen ange en giltig e-postadress.', enterNumber: 'Vänligen skriv in ett giltigt nummer.', fieldHasNo: 'Detta fält har ingen {{label}}', @@ -619,6 +621,7 @@ export const svTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Longituden måste vara mellan -180 och 180.', notValidDate: '"{{value}}" är inte ett giltigt datum.', required: 'Detta fält är obligatoriskt.', + requiredLocale: 'Obligatorisk', requiresAtLeast: 'Detta fält kräver minst {{count}} {{label}}.', requiresNoMoreThan: 'Detta fält kräver inte mer än {{count}} {{label}}.', requiresTwoNumbers: 'Detta fält kräver två nummer.', @@ -627,6 +630,8 @@ export const svTranslations: DefaultTranslationsObject = { trueOrFalse: 'Detta fält kan bara vara lika med sant eller falskt.', username: 'Var god ange ett giltigt användarnamn. Kan innehålla bokstäver, siffror, bindestreck, punkter och understreck.', + validateAllLocales: 'Validera alla språk', + validationResults: 'Valideringsresultat', validUploadID: 'Det här fältet är inte ett giltigt uppladdnings-ID', }, version: { diff --git a/packages/translations/src/languages/ta.ts b/packages/translations/src/languages/ta.ts index 0d41dbd5295..0f4ad84fb29 100644 --- a/packages/translations/src/languages/ta.ts +++ b/packages/translations/src/languages/ta.ts @@ -603,6 +603,8 @@ export const taTranslations: DefaultTranslationsObject = { width: 'அகலம்', }, validation: { + documentInvalid: 'பின்வரும் சிக்கல்களை சரிசெய்க.', + documentValid: 'தேர்ந்தெடுக்கப்பட்ட அனைத்து Locale-களும் செல்லுபடியாக உள்ளன.', emailAddress: 'சரியான மின்னஞ்சல் முகவரியை உள்ளிடவும்.', enterNumber: 'சரியான எண் ஒன்றை உள்ளிடவும்.', fieldHasNo: 'இந்த புலத்தில் {{label}} இல்லை', @@ -620,6 +622,7 @@ export const taTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'தீர்க்கக் கூறு -180 மற்றும் 180 இடையே இருக்க வேண்டும்.', notValidDate: '"{{value}}" ஒரு சரியான தேதி அல்ல.', required: 'இந்த புலம் அவசியம்.', + requiredLocale: 'தேவையானது', requiresAtLeast: 'இந்த புலம் குறைந்தது {{count}} {{label}} தேவைப்படுகிறது.', requiresNoMoreThan: 'இந்த புலம் அதிகபட்சம் {{count}} {{label}} மட்டுமே அனுமதிக்கிறது.', requiresTwoNumbers: 'இந்த புலத்தில் இரண்டு எண்கள் தேவை.', @@ -629,6 +632,8 @@ export const taTranslations: DefaultTranslationsObject = { trueOrFalse: 'இந்த புலம் true அல்லது false ஆக மட்டுமே இருக்க வேண்டும்.', username: 'சரியான பயனர் பெயரை உள்ளிடவும். எழுத்துக்கள், எண்கள், ஹைஃபன்கள், புள்ளிகள் மற்றும் அடிக்கோடுகள் இருக்கலாம்.', + validateAllLocales: 'எல்லா Locale-களையும் சரிபார்க்கவும்', + validationResults: 'சரிபார்ப்பு முடிவுகள்', validUploadID: 'இந்த புலம் சரியான பதிவேற்ற ID அல்ல.', }, version: { diff --git a/packages/translations/src/languages/th.ts b/packages/translations/src/languages/th.ts index 02bd187e625..74b1b9465ee 100644 --- a/packages/translations/src/languages/th.ts +++ b/packages/translations/src/languages/th.ts @@ -591,6 +591,8 @@ export const thTranslations: DefaultTranslationsObject = { width: 'ความกว้าง', }, validation: { + documentInvalid: 'โปรดแก้ไขปัญหาต่อไปนี้', + documentValid: 'ภาษาทั้งหมดที่เลือกมีความถูกต้อง', emailAddress: 'กรุณาระบุอีเมลที่ถูกต้อง', enterNumber: 'กรุณาระบุตัวเลขที่ถูกต้อง', fieldHasNo: 'ช่องนี้ไม่มี {{label}}', @@ -607,6 +609,7 @@ export const thTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'ลองจิจูดต้องอยู่ระหว่าง -180 และ 180.', notValidDate: 'วันที่ "{{value}}" ไม่ถูกต้อง', required: 'จำเป็นต้องระบุค่า', + requiredLocale: 'จำเป็น', requiresAtLeast: 'ต้องมีอย่างน้อย {{count}} {{label}}', requiresNoMoreThan: 'ห้ามมีเกิน {{count}} {{label}}', requiresTwoNumbers: 'ต้องมีตัวเลข 2 ค่า', @@ -614,6 +617,8 @@ export const thTranslations: DefaultTranslationsObject = { timezoneRequired: 'ต้องการเขตเวลา', trueOrFalse: 'เป็นได้แค่ "ใช่" หรือ "ไม่ใช่"', username: 'กรุณาใส่ชื่อผู้ใช้ที่ถูกต้อง สามารถมีตัวอักษร ตัวเลข ขีดกลาง จุด และขีดล่าง', + validateAllLocales: 'ตรวจสอบความถูกต้องของทุก Locale', + validationResults: 'ผลการตรวจสอบความถูกต้อง', validUploadID: 'ไม่ใช่ ID ของการอัปโหลดที่ถูกต้อง', }, version: { diff --git a/packages/translations/src/languages/tr.ts b/packages/translations/src/languages/tr.ts index cff390aa1fe..722e6ed799a 100644 --- a/packages/translations/src/languages/tr.ts +++ b/packages/translations/src/languages/tr.ts @@ -608,6 +608,8 @@ export const trTranslations: DefaultTranslationsObject = { width: 'Genişlik', }, validation: { + documentInvalid: 'Aşağıdaki sorunları düzeltin.', + documentValid: 'Tüm seçili Locale’ler geçerlidir.', emailAddress: 'Lütfen geçerli bir e-posta adresi girin.', enterNumber: 'Lütfen geçerli bir sayı girin.', fieldHasNo: 'Bu alanda {{label}} girili değil.', @@ -624,6 +626,7 @@ export const trTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Boylam -180 ile 180 arasında olmalıdır.', notValidDate: '"{{value}}" geçerli bir tarih değil.', required: 'Bu alan gereklidir.', + requiredLocale: 'Zorunlu', requiresAtLeast: 'Bu alan en az {{count}} adet {{label}} gerektirmektedir.', requiresNoMoreThan: 'Bu alana {{count}} adetten fazla {{label}} girilemez.', requiresTwoNumbers: 'Bu alana en az iki rakam girilmesi zorunludur.', @@ -632,6 +635,8 @@ export const trTranslations: DefaultTranslationsObject = { trueOrFalse: 'Bu alan yalnızca doğru ve yanlış olabilir.', username: 'Lütfen geçerli bir kullanıcı adı girin. Harfler, numaralar, kısa çizgiler, noktalar ve alt çizgiler içerebilir.', + validateAllLocales: "Tüm Locale'leri doğrulayın", + validationResults: 'Doğrulama sonuçları', validUploadID: "'Bu alan geçerli bir karşıya yükleme ID'sine sahip değil.'", }, version: { diff --git a/packages/translations/src/languages/uk.ts b/packages/translations/src/languages/uk.ts index 2c3ea5cdbdd..2f4ed60042f 100644 --- a/packages/translations/src/languages/uk.ts +++ b/packages/translations/src/languages/uk.ts @@ -597,6 +597,8 @@ export const ukTranslations: DefaultTranslationsObject = { width: 'Ширина', }, validation: { + documentInvalid: 'Виправте наведені нижче проблеми.', + documentValid: 'Усі вибрані локалі є дійсними.', emailAddress: 'Будь ласка, введіть коректну адресу електронної пошти.', enterNumber: 'Будь ласка, введіть коректне число.', fieldHasNo: 'У цьому полі немає {{label}}', @@ -613,6 +615,7 @@ export const ukTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Довгота повинна бути в межах від -180 до 180.', notValidDate: '"{{value}}" - некоректна дата.', required: "Це поле є обов'язковим.", + requiredLocale: 'Обов’язково', requiresAtLeast: 'Це поле потребує не менше {{count}} {{label}}.', requiresNoMoreThan: 'Це поле потребує не більше {{count}} {{label}}.', requiresTwoNumbers: 'У цьому полі потрібно ввести два числа.', @@ -621,6 +624,8 @@ export const ukTranslations: DefaultTranslationsObject = { trueOrFalse: 'Це поле може мати значення тільки true або false.', username: "Будь ласка, введіть коректне ім'я користувача. Може містити літери, цифри, дефіси, крапки та підкреслення.", + validateAllLocales: 'Перевірити всі Locale', + validationResults: 'Результати валідації', validUploadID: 'Це поле не є коректним ID завантаження.', }, version: { diff --git a/packages/translations/src/languages/vi.ts b/packages/translations/src/languages/vi.ts index 97e7c6b1fd9..20550269dbe 100644 --- a/packages/translations/src/languages/vi.ts +++ b/packages/translations/src/languages/vi.ts @@ -602,6 +602,8 @@ export const viTranslations: DefaultTranslationsObject = { width: 'Chiều rộng', }, validation: { + documentInvalid: 'Khắc phục các vấn đề sau.', + documentValid: 'Tất cả các locale được chọn đều hợp lệ.', emailAddress: 'Địa chỉ email không hợp lệ.', enterNumber: 'Vui lòng nhập số.', fieldHasNo: 'Field này không có: {{label}}', @@ -618,6 +620,7 @@ export const viTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Kinh độ phải nằm giữa -180 và 180.', notValidDate: '"{{value}}" không phải là một ngày (date) hợp lệ.', required: 'Trường này là bắt buộc.', + requiredLocale: 'Bắt buộc', requiresAtLeast: 'Trường này cần tối thiểu {{count}} {{label}}.', requiresNoMoreThan: 'Trường này không thể vượt quá {{count}} {{label}}.', requiresTwoNumbers: 'Trường này cần tối thiểu 2 chữ số.', @@ -626,6 +629,8 @@ export const viTranslations: DefaultTranslationsObject = { trueOrFalse: 'Trường này chỉ có thể chứa giá trị đúng hoặc sai.', username: 'Vui lòng nhập một tên người dùng hợp lệ. Có thể chứa các chữ cái, số, dấu gạch ngang, dấu chấm và dấu gạch dưới.', + validateAllLocales: 'Xác thực tất cả các Locale', + validationResults: 'Kết quả xác thực', validUploadID: "'Field này không chứa ID tải lên hợp lệ.'", }, version: { diff --git a/packages/translations/src/languages/zh.ts b/packages/translations/src/languages/zh.ts index eda7fc530ae..22c6808612d 100644 --- a/packages/translations/src/languages/zh.ts +++ b/packages/translations/src/languages/zh.ts @@ -575,6 +575,8 @@ export const zhTranslations: DefaultTranslationsObject = { width: '宽度', }, validation: { + documentInvalid: '请修复以下问题。', + documentValid: '所有选定的Locale均有效。', emailAddress: '请输入一个有效的电子邮件地址。', enterNumber: '请输入一个有效的数字。', fieldHasNo: '这个字段没有 {{label}}', @@ -591,6 +593,7 @@ export const zhTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: '经度必须在-180和180之间。', notValidDate: '「{{value}}」不是一个有效的日期。', required: '该字段为必填项目。', + requiredLocale: '必填', requiresAtLeast: '该字段至少需要 {{count}} 个 {{label}}。', requiresNoMoreThan: '该字段要求不超过 {{count}} 个 {{label}}。', requiresTwoNumbers: '该字段需要两个数字。', @@ -598,6 +601,8 @@ export const zhTranslations: DefaultTranslationsObject = { timezoneRequired: '需要选择一个时区。', trueOrFalse: '此项仅可选择"是"或"否"。', username: '请输入一个有效的用户名。可包含字母,数字,连字符,句点和下划线。', + validateAllLocales: '验证所有Locale', + validationResults: '验证结果', validUploadID: '该字段不是有效的上传 ID。', }, version: { diff --git a/packages/translations/src/languages/zhTw.ts b/packages/translations/src/languages/zhTw.ts index 61fc7b035be..9e0838a3681 100644 --- a/packages/translations/src/languages/zhTw.ts +++ b/packages/translations/src/languages/zhTw.ts @@ -573,6 +573,8 @@ export const zhTwTranslations: DefaultTranslationsObject = { width: '寬度', }, validation: { + documentInvalid: '請修正以下問題。', + documentValid: '所有已選擇的 Locale 均有效。', emailAddress: '請輸入有效的電子郵件地址。', enterNumber: '請輸入有效的數字。', fieldHasNo: '此欄位沒有 {{label}}', @@ -589,6 +591,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: '經度必須介於 -180 和 180 之間。', notValidDate: '「{{value}}」不是有效的日期格式。', required: '此欄位為必填項目。', + requiredLocale: '必填', requiresAtLeast: '此欄位至少需要 {{count}} 個 {{label}}。', requiresNoMoreThan: '此欄位最多只能有 {{count}} 個 {{label}}。', requiresTwoNumbers: '此欄位需包含兩個數字。', @@ -596,6 +599,8 @@ export const zhTwTranslations: DefaultTranslationsObject = { timezoneRequired: '請選取一個時區。', trueOrFalse: '此欄位只能為 true 或 false。', username: '請輸入有效的使用者名稱。可包含英文字母、數字、連字號、句點與底線。', + validateAllLocales: '驗證所有語系', + validationResults: '驗證結果', validUploadID: '此欄位不是有效的上傳 ID。', }, version: { diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index 7adc3f4fb5e..35a1a1fd5af 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -23,10 +23,13 @@ import { useElementHeightVariable } from '../../hooks/useElementHeightVariable.j import { MoreIcon } from '../../icons/More/index.js' import { useConfig } from '../../providers/Config/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' +import { DocumentValidationProvider } from '../../providers/DocumentValidation/index.js' import { useEditDepth } from '../../providers/EditDepth/index.js' import { useLivePreviewContext } from '../../providers/LivePreview/context.js' import { useTranslation } from '../../providers/Translation/index.js' +import { shouldShowValidateAllLocales } from '../../utilities/documentValidation.js' import { formatDate, formatTimeToNow } from '../../utilities/formatDocTitle/formatDateTitle.js' +import { traverseForLocalizedFields } from '../../utilities/traverseForLocalizedFields.js' import { Autosave } from '../Autosave/index.js' import { Button } from '../Button/index.js' import { CopyLocaleData } from '../CopyLocaleData/index.js' @@ -45,11 +48,12 @@ import { SaveDraftButton } from '../SaveDraftButton/index.js' import { SchedulePublishButton } from '../SchedulePublishButton/index.js' import { Status } from '../Status/index.js' import { UnpublishButton } from '../UnpublishButton/index.js' +import { ValidateDocumentButton } from '../ValidateDocumentButton/index.js' import './index.css' const baseClass = 'doc-controls' -export const DocumentControls: React.FC<{ +type DocumentControlsProps = { readonly apiURL: string readonly BeforeDocumentControls?: React.ReactNode readonly BeforeDocumentMeta?: React.ReactNode @@ -92,7 +96,9 @@ export const DocumentControls: React.FC<{ * - `drawerHeaderActions`: only the action buttons, rendered in the document drawer header. */ readonly variant?: 'default' | 'drawerHeaderActions' -}> = (props) => { +} + +const DocumentControlsContent: React.FC = (props) => { const { id, slug, @@ -138,6 +144,7 @@ export const DocumentControls: React.FC<{ const collectionConfig = getEntityConfig({ collectionSlug: slug }) const globalConfig = getEntityConfig({ globalSlug: slug }) + const entityConfig = collectionConfig || globalConfig const { isLivePreviewEnabled } = useLivePreviewContext() @@ -226,6 +233,11 @@ export const DocumentControls: React.FC<{ const showCopyToLocale = localization && !collectionConfig?.admin?.disableCopyToLocale const showLockedMetaIcon = user && readOnlyForIncomingUser + const showValidateAllLocales = shouldShowValidateAllLocales({ + hasLocalization: Boolean(localization), + hasLocalizedFields: traverseForLocalizedFields(entityConfig?.fields ?? []), + hasValidatePermission: Boolean(permissions?.validate), + }) return (
)}
+ {showValidateAllLocales && !disableActions && !isTrashed && !readOnlyForIncomingUser && ( + + )} {hasSavePermission && !isTrashed && !readOnlyForIncomingUser && ( {collectionHasDraftsEnabled || globalHasDraftsEnabled ? ( @@ -459,3 +474,9 @@ export const DocumentControls: React.FC<{ ) } + +export const DocumentControls: React.FC = (props) => ( + + + +) diff --git a/packages/ui/src/elements/PublishButton/index.tsx b/packages/ui/src/elements/PublishButton/index.tsx index 95e86c5052e..d21aa677df5 100644 --- a/packages/ui/src/elements/PublishButton/index.tsx +++ b/packages/ui/src/elements/PublishButton/index.tsx @@ -12,6 +12,7 @@ import { FormSubmit } from '../../forms/Submit/index.js' import { useHotkey } from '../../hooks/useHotkey.js' import { useConfig } from '../../providers/Config/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' +import { useDocumentValidation } from '../../providers/DocumentValidation/index.js' import { useEditDepth } from '../../providers/EditDepth/index.js' import { useLocale } from '../../providers/Locale/index.js' import { useOperation } from '../../providers/Operation/index.js' @@ -37,6 +38,7 @@ export function PublishButton({ } = useDocumentInfo() const { config, getEntityConfig } = useConfig() + const { isValidating, validateBeforePublish } = useDocumentValidation() const { submit } = useForm() const modified = useFormModified() const editDepth = useEditDepth() @@ -65,7 +67,8 @@ export function PublishButton({ const canPublish = hasPublishPermission && (modified || hasNewerVersions || !hasPublishedDoc) && - uploadStatus !== 'uploading' + uploadStatus !== 'uploading' && + !isValidating const [hasLocalizedFields, setHasLocalizedFields] = useState(false) @@ -144,6 +147,12 @@ export function PublishButton({ return } + const isValid = await validateBeforePublish({ isPublishAll: true }) + + if (!isValid) { + return + } + const params = qs.stringify( { depth: 0, @@ -184,6 +193,7 @@ export function PublishButton({ setUnpublishedVersionCount, uploadStatus, setMostRecentVersionIsAutosaved, + validateBeforePublish, ]) const publishLocale = useCallback( @@ -192,6 +202,12 @@ export function PublishButton({ return } + const isValid = await validateBeforePublish({ isPublishAll: false }) + + if (!isValid) { + return + } + const params = qs.stringify( { depth: 0, @@ -231,6 +247,7 @@ export function PublishButton({ setUnpublishedVersionCount, submit, uploadStatus, + validateBeforePublish, ], ) diff --git a/packages/ui/src/elements/ValidateDocumentButton/index.tsx b/packages/ui/src/elements/ValidateDocumentButton/index.tsx new file mode 100644 index 00000000000..40052955bd3 --- /dev/null +++ b/packages/ui/src/elements/ValidateDocumentButton/index.tsx @@ -0,0 +1,28 @@ +'use client' + +import React from 'react' + +import { useFormInitializing, useFormProcessing } from '../../forms/Form/context.js' +import { useDocumentValidation } from '../../providers/DocumentValidation/index.js' +import { useTranslation } from '../../providers/Translation/index.js' +import { Button } from '../Button/index.js' + +export const ValidateDocumentButton: React.FC = () => { + const initializing = useFormInitializing() + const processing = useFormProcessing() + const { isValidating, validateAllLocales } = useDocumentValidation() + const { t } = useTranslation() + + return ( + + ) +} diff --git a/packages/ui/src/elements/ValidationResultsDrawer/index.css b/packages/ui/src/elements/ValidationResultsDrawer/index.css new file mode 100644 index 00000000000..c2c1a848d4d --- /dev/null +++ b/packages/ui/src/elements/ValidationResultsDrawer/index.css @@ -0,0 +1,40 @@ +@layer payload-default { + .validation-results__content { + padding-block: var(--gutter-h); + } + + .validation-results__valid { + color: var(--theme-success-500); + } + + .validation-results__locale { + margin-block-start: var(--base); + + h3 { + align-items: center; + display: flex; + gap: calc(var(--base) / 2); + margin: 0 0 calc(var(--base) / 2); + } + + ul { + display: grid; + gap: calc(var(--base) / 2); + margin: 0; + padding-inline-start: var(--base); + } + + li { + display: grid; + gap: calc(var(--base) / 4); + } + } + + .validation-results__required { + background: var(--theme-elevation-100); + border-radius: var(--style-radius-s); + font-size: 0.75em; + font-weight: normal; + padding: calc(var(--base) / 8) calc(var(--base) / 4); + } +} diff --git a/packages/ui/src/elements/ValidationResultsDrawer/index.tsx b/packages/ui/src/elements/ValidationResultsDrawer/index.tsx new file mode 100644 index 00000000000..f19d810c074 --- /dev/null +++ b/packages/ui/src/elements/ValidationResultsDrawer/index.tsx @@ -0,0 +1,77 @@ +'use client' + +import type { SanitizedLocale, ValidationFieldError, ValidationResult } from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import React, { useMemo } from 'react' + +import { useTranslation } from '../../providers/Translation/index.js' +import { Drawer } from '../Drawer/index.js' +import { Gutter } from '../Gutter/index.js' +import './index.css' + +const baseClass = 'validation-results' + +export const ValidationResultsDrawer: React.FC<{ + activeLocale: string + locales: SanitizedLocale[] + result: null | ValidationResult + slug: string +}> = ({ slug, activeLocale, locales, result }) => { + const { i18n, t } = useTranslation() + const errorsByLocale = useMemo(() => { + return (result?.errors ?? []).reduce>( + (groups, error) => { + const locale = error.locale ?? activeLocale + groups[locale] = [...(groups[locale] ?? []), error] + return groups + }, + {}, + ) + }, [activeLocale, result?.errors]) + + if (!result) { + return null + } + + return ( + + + {result.valid ? ( +

+ {t('validation:documentValid')} +

+ ) : ( +
+

{t('validation:documentInvalid')}

+ {Object.entries(errorsByLocale).map(([localeCode, errors]) => { + const locale = locales.find(({ code }) => code === localeCode) + const localeLabel = locale ? getTranslation(locale.label, i18n) : localeCode + + return ( +
+

+ {localeLabel} + {locale?.required && ( + + {t('validation:requiredLocale')} + + )} +

+
    + {errors.map((error, index) => ( +
  • + {error.path && {error.path}} + {error.message} +
  • + ))} +
+
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/packages/ui/src/providers/DocumentValidation/index.tsx b/packages/ui/src/providers/DocumentValidation/index.tsx new file mode 100644 index 00000000000..a049b5ee0e0 --- /dev/null +++ b/packages/ui/src/providers/DocumentValidation/index.tsx @@ -0,0 +1,257 @@ +'use client' + +import type { ValidationFieldError, ValidationResult } from 'payload' + +import { useModal } from '@faceless-ui/modal' +import React, { + createContext, + use, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react' + +import { ValidationResultsDrawer } from '../../elements/ValidationResultsDrawer/index.js' +import { useForm, useFormFields } from '../../forms/Form/context.js' +import { + getPublishValidationLocales, + getValidationEndpoint, + validateDocumentLocales, +} from '../../utilities/documentValidation.js' +import { traverseForLocalizedFields } from '../../utilities/traverseForLocalizedFields.js' +import { useConfig } from '../Config/index.js' +import { useDocumentInfo } from '../DocumentInfo/index.js' +import { useLocale } from '../Locale/index.js' + +type DocumentValidationContext = { + isValidating: boolean + validateAllLocales: () => Promise + validateBeforePublish: (args: { isPublishAll: boolean }) => Promise +} + +const Context = createContext({ + isValidating: false, + validateAllLocales: () => Promise.resolve(true), + validateBeforePublish: () => Promise.resolve(true), +}) + +export const useDocumentValidation = (): DocumentValidationContext => use(Context) + +export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { + config: { + blocksMap, + localization, + routes: { api }, + }, + } = useConfig() + const { id, collectionSlug, docConfig, globalSlug } = useDocumentInfo() + const { code: activeLocale } = useLocale() + const { getData } = useForm() + const formState = useFormFields(([fields]) => fields) + const { closeModal, openModal } = useModal() + + const [isValidating, setIsValidating] = useState(false) + const [result, setResult] = useState(null) + const activeRequestRef = useRef(null) + const validResultTimeoutRef = useRef>(null) + const drawerID = useId() + const drawerSlug = useMemo( + () => `document-validation-results-${drawerID.replaceAll(':', '')}`, + [drawerID], + ) + + const fields = useMemo(() => docConfig?.fields ?? [], [docConfig?.fields]) + const hasLocalizedFields = useMemo(() => traverseForLocalizedFields(fields), [fields]) + const targetKey = `${collectionSlug ?? ''}:${globalSlug ?? ''}:${id ?? 'create'}` + + const clearResult = useCallback(() => { + activeRequestRef.current?.abort() + activeRequestRef.current = null + + if (validResultTimeoutRef.current) { + clearTimeout(validResultTimeoutRef.current) + validResultTimeoutRef.current = null + } + + setIsValidating(false) + setResult(null) + closeModal(drawerSlug) + }, [closeModal, drawerSlug]) + + useEffect(() => { + clearResult() + }, [activeLocale, clearResult, formState, targetKey]) + + useEffect(() => { + return () => { + activeRequestRef.current?.abort() + + if (validResultTimeoutRef.current) { + clearTimeout(validResultTimeoutRef.current) + } + } + }, []) + + const runValidation = useCallback( + async ({ + isPublishing, + locales, + showValidResult, + }: { + isPublishing: boolean + locales: string[] + showValidResult: boolean + }): Promise => { + if (!localization || !hasLocalizedFields) { + return true + } + + activeRequestRef.current?.abort() + + if (validResultTimeoutRef.current) { + clearTimeout(validResultTimeoutRef.current) + validResultTimeoutRef.current = null + } + + const abortController = new AbortController() + activeRequestRef.current = abortController + setIsValidating(true) + + try { + const data = { + ...getData(), + ...(isPublishing ? { _status: 'published' } : {}), + } + const validationResult = await validateDocumentLocales({ + activeLocale, + blocksMap, + data, + endpoint: getValidationEndpoint({ + id, + apiRoute: api, + collectionSlug, + globalSlug, + }), + fields, + locales, + signal: abortController.signal, + }) + + if (abortController.signal.aborted) { + return false + } + + if (!validationResult.valid || showValidResult) { + setResult(validationResult) + openModal(drawerSlug) + } + + if (validationResult.valid && showValidResult) { + validResultTimeoutRef.current = setTimeout(() => { + setResult(null) + closeModal(drawerSlug) + }, 4000) + } + + return validationResult.valid + } catch (error) { + if (abortController.signal.aborted) { + return false + } + + const validationError: ValidationFieldError = { + locale: activeLocale, + message: error instanceof Error ? error.message : String(error), + path: '', + } + const validationResult = { + errors: [validationError], + valid: false, + } + + setResult(validationResult) + openModal(drawerSlug) + + return false + } finally { + if (activeRequestRef.current === abortController) { + activeRequestRef.current = null + setIsValidating(false) + } + } + }, + [ + activeLocale, + api, + blocksMap, + collectionSlug, + fields, + getData, + globalSlug, + hasLocalizedFields, + id, + localization, + openModal, + closeModal, + drawerSlug, + ], + ) + + const validateAllLocales = useCallback(() => { + if (!localization) { + return Promise.resolve(true) + } + + return runValidation({ + isPublishing: false, + locales: localization.locales.map(({ code }) => code), + showValidResult: true, + }) + }, [localization, runValidation]) + + const validateBeforePublish = useCallback( + ({ isPublishAll }: { isPublishAll: boolean }) => { + if (!localization) { + return Promise.resolve(true) + } + + return runValidation({ + isPublishing: true, + locales: getPublishValidationLocales({ + activeLocale, + isPublishAll, + locales: localization.locales, + }), + showValidResult: false, + }) + }, + [activeLocale, localization, runValidation], + ) + + const value = useMemo( + () => ({ + isValidating, + validateAllLocales, + validateBeforePublish, + }), + [isValidating, validateAllLocales, validateBeforePublish], + ) + + return ( + + {children} + + + ) +} diff --git a/packages/ui/src/utilities/documentValidation.spec.ts b/packages/ui/src/utilities/documentValidation.spec.ts new file mode 100644 index 00000000000..72b07acfda5 --- /dev/null +++ b/packages/ui/src/utilities/documentValidation.spec.ts @@ -0,0 +1,240 @@ +import type { ClientField, SanitizedLocale } from 'payload' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + getPublishValidationLocales, + getValidationEndpoint, + projectValidationDataForSiblingLocales, + requestDocumentValidation, + shouldShowValidateAllLocales, + validateDocumentLocales, +} from './documentValidation.js' + +const locales = [ + { code: 'en', label: 'English', required: true }, + { code: 'de', label: 'German', required: false }, + { code: 'fr', label: 'French', required: true }, +] as SanitizedLocale[] + +describe('document validation', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should construct create, update, and global validation endpoints', () => { + expect( + getValidationEndpoint({ + apiRoute: '/api', + collectionSlug: 'posts', + }), + ).toBe('/api/posts/validate') + expect( + getValidationEndpoint({ + apiRoute: '/api', + collectionSlug: 'posts', + id: 'post/id', + }), + ).toBe('/api/posts/post%2Fid/validate') + expect( + getValidationEndpoint({ + apiRoute: '/api', + globalSlug: 'settings', + }), + ).toBe('/api/globals/settings/validate') + }) + + it('should only show the action for localized entities with validation access', () => { + expect( + shouldShowValidateAllLocales({ + hasLocalizedFields: true, + hasLocalization: true, + hasValidatePermission: true, + }), + ).toBe(true) + expect( + shouldShowValidateAllLocales({ + hasLocalizedFields: false, + hasLocalization: true, + hasValidatePermission: true, + }), + ).toBe(false) + expect( + shouldShowValidateAllLocales({ + hasLocalizedFields: true, + hasLocalization: true, + hasValidatePermission: false, + }), + ).toBe(false) + }) + + it('should select all locales for publish-all and active plus required locales otherwise', () => { + expect( + getPublishValidationLocales({ + activeLocale: 'de', + isPublishAll: false, + locales, + }), + ).toEqual(['de', 'en', 'fr']) + expect( + getPublishValidationLocales({ + activeLocale: 'de', + isPublishAll: true, + locales, + }), + ).toEqual(['en', 'de', 'fr']) + }) + + it('should remove localized values from nested sibling-locale candidate data', () => { + const fields = [ + { localized: true, name: 'title', type: 'text' }, + { + fields: [ + { localized: true, name: 'strapline', type: 'text' }, + { name: 'theme', type: 'text' }, + ], + name: 'settings', + type: 'group', + }, + { + fields: [ + { localized: true, name: 'caption', type: 'text' }, + { name: 'kind', type: 'text' }, + ], + name: 'items', + type: 'array', + }, + { + blocks: [ + { + fields: [ + { localized: true, name: 'copy', type: 'text' }, + { name: 'style', type: 'text' }, + ], + slug: 'hero', + }, + ], + name: 'layout', + type: 'blocks', + }, + { + tabs: [ + { + fields: [{ name: 'body', type: 'richText' }], + localized: true, + name: 'seo', + }, + ], + type: 'tabs', + }, + { localized: true, name: 'metadata', type: 'json' }, + ] as ClientField[] + + expect( + projectValidationDataForSiblingLocales({ + blocksMap: {}, + data: { + layout: [{ blockType: 'hero', copy: 'Active copy', style: 'dark' }], + items: [{ caption: 'Active caption', kind: 'card' }], + metadata: { title: 'Active metadata' }, + settings: { strapline: 'Active strapline', theme: 'dark' }, + seo: { body: { root: {} } }, + title: 'Active title', + }, + fields, + }), + ).toEqual({ + layout: [{ blockType: 'hero', style: 'dark' }], + items: [{ kind: 'card' }], + settings: { theme: 'dark' }, + }) + }) + + it('should not let valid unsaved active-locale values satisfy invalid stored sibling locales', async () => { + const request = vi.fn(async ({ body, locales: requestedLocales }) => { + if (requestedLocales.includes('de') && 'title' in body) { + return { + errors: [], + valid: true, + } + } + + return { + errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], + valid: false, + } + }) + + const result = await validateDocumentLocales({ + activeLocale: 'de', + blocksMap: {}, + data: { summary: 'Shared summary', title: 'Unsaved German title' }, + endpoint: '/api/posts/123/validate', + fields: [ + { localized: true, name: 'title', type: 'text' }, + { name: 'summary', type: 'text' }, + ], + locales: ['en', 'de', 'fr'], + request, + }) + + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + body: { summary: 'Shared summary', title: 'Unsaved German title' }, + locales: ['de'], + }), + ) + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + body: { summary: 'Shared summary' }, + locales: ['en', 'fr'], + }), + ) + expect(result).toEqual({ + errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], + valid: false, + }) + }) + + it('should render validation errors returned in a non-2xx Payload error response', async () => { + const fetchMock = vi.fn(async () => { + return Response.json( + { + errors: [ + { + data: { + errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], + }, + message: 'The following field is invalid: title', + name: 'ValidationError', + }, + ], + }, + { status: 400 }, + ) + }) + + vi.stubGlobal('fetch', fetchMock) + + await expect( + requestDocumentValidation({ + body: { title: 'Titre' }, + endpoint: '/api/posts/123/validate', + locales: ['en', 'fr'], + }), + ).resolves.toEqual({ + errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], + valid: false, + }) + expect(fetchMock).toHaveBeenCalledWith( + '/api/posts/123/validate?locale=en&locale=fr', + expect.objectContaining({ + body: JSON.stringify({ title: 'Titre' }), + credentials: 'include', + method: 'POST', + }), + ) + }) +}) diff --git a/packages/ui/src/utilities/documentValidation.ts b/packages/ui/src/utilities/documentValidation.ts new file mode 100644 index 00000000000..b1b7db8784e --- /dev/null +++ b/packages/ui/src/utilities/documentValidation.ts @@ -0,0 +1,392 @@ +import type { + ClientBlock, + ClientField, + Data, + SanitizedLocale, + ValidationFieldError, + ValidationResult, +} from 'payload' + +import { + fieldAffectsData, + fieldShouldBeLocalized, + formatAdminURL, + tabHasName, +} from 'payload/shared' + +export type DocumentValidationRequest = (args: { + body: Data + endpoint: string + locales: string[] + signal?: AbortSignal +}) => Promise + +type ValidationTarget = { + apiRoute: string + collectionSlug?: string + globalSlug?: string + id?: number | string +} + +export function getValidationEndpoint({ + id, + apiRoute, + collectionSlug, + globalSlug, +}: ValidationTarget): string { + if (!collectionSlug && !globalSlug) { + throw new Error('Document validation requires a collection or global slug.') + } + + const encodedID = id === undefined ? '' : `/${encodeURIComponent(String(id))}` + const path: `/${string}` = globalSlug + ? `/globals/${encodeURIComponent(globalSlug)}/validate` + : `/${encodeURIComponent(collectionSlug)}${encodedID}/validate` + + return formatAdminURL({ + apiRoute, + path, + }) +} + +export function shouldShowValidateAllLocales({ + hasLocalization, + hasLocalizedFields, + hasValidatePermission, +}: { + hasLocalization: boolean + hasLocalizedFields: boolean + hasValidatePermission: boolean +}): boolean { + return hasLocalization && hasLocalizedFields && hasValidatePermission +} + +export function getPublishValidationLocales({ + activeLocale, + isPublishAll, + locales, +}: { + activeLocale: string + isPublishAll: boolean + locales: SanitizedLocale[] +}): string[] { + if (isPublishAll) { + return locales.map(({ code }) => code) + } + + return [ + activeLocale, + ...locales.filter(({ required }) => required).map(({ code }) => code), + ].filter((locale, index, selectedLocales) => selectedLocales.indexOf(locale) === index) +} + +export function projectValidationDataForSiblingLocales({ + blocksMap, + data, + fields, +}: { + blocksMap: Record + data: Data + fields: ClientField[] +}): Data { + const projectedData = cloneValidationData(data) + + removeLocalizedData({ + blocksMap, + data: projectedData, + fields, + parentIsLocalized: false, + }) + + return projectedData +} + +export async function validateDocumentLocales({ + activeLocale, + blocksMap, + data, + endpoint, + fields, + locales, + request = requestDocumentValidation, + signal, +}: { + activeLocale: string + blocksMap: Record + data: Data + endpoint: string + fields: ClientField[] + locales: string[] + request?: DocumentValidationRequest + signal?: AbortSignal +}): Promise { + const selectedLocales = locales.filter((locale, index) => locales.indexOf(locale) === index) + const validationResults: ValidationResult[] = [] + + if (selectedLocales.includes(activeLocale)) { + validationResults.push( + await request({ + body: data, + endpoint, + locales: [activeLocale], + signal, + }), + ) + } + + const siblingLocales = selectedLocales.filter((locale) => locale !== activeLocale) + + if (siblingLocales.length > 0) { + validationResults.push( + await request({ + body: projectValidationDataForSiblingLocales({ + blocksMap, + data, + fields, + }), + endpoint, + locales: siblingLocales, + signal, + }), + ) + } + + const errors = validationResults.flatMap(({ errors }) => errors) + + return { + errors, + valid: validationResults.every(({ valid }) => valid), + } +} + +export async function requestDocumentValidation({ + body, + endpoint, + locales, + signal, +}: Parameters[0]): Promise { + const search = new URLSearchParams() + + for (const locale of locales) { + search.append('locale', locale) + } + + const response = await fetch(`${endpoint}?${search.toString()}`, { + body: JSON.stringify(body), + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + signal, + }) + const responseData = (await response.json()) as unknown + const result = parseValidationResult(responseData) + + if (result) { + return result + } + + throw new Error(getResponseErrorMessage(responseData) || response.statusText) +} + +function parseValidationResult(responseData: unknown): null | ValidationResult { + if (!isObject(responseData)) { + return null + } + + if (typeof responseData.valid === 'boolean' && Array.isArray(responseData.errors)) { + return { + errors: responseData.errors.filter(isValidationFieldError), + valid: responseData.valid, + } + } + + if (Array.isArray(responseData.errors)) { + const errors = responseData.errors.flatMap((error) => { + if (!isObject(error) || !isObject(error.data) || !Array.isArray(error.data.errors)) { + return [] + } + + return error.data.errors.filter(isValidationFieldError) + }) + + if (errors.length > 0) { + return { + errors, + valid: false, + } + } + } + + return null +} + +function getResponseErrorMessage(responseData: unknown): null | string { + if (!isObject(responseData)) { + return null + } + + if (typeof responseData.message === 'string') { + return responseData.message + } + + if (Array.isArray(responseData.errors)) { + const errorWithMessage = responseData.errors.find( + (error) => isObject(error) && typeof error.message === 'string', + ) + + if (isObject(errorWithMessage) && typeof errorWithMessage.message === 'string') { + return errorWithMessage.message + } + } + + return null +} + +function isValidationFieldError(value: unknown): value is ValidationFieldError { + return ( + isObject(value) && + typeof value.message === 'string' && + typeof value.path === 'string' && + (value.locale === undefined || typeof value.locale === 'string') + ) +} + +function cloneValidationData(value: T): T { + if (Array.isArray(value)) { + return value.map(cloneValidationData) as T + } + + if (isObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, childValue]) => [key, cloneValidationData(childValue)]), + ) as T + } + + return value +} + +function removeLocalizedData({ + blocksMap, + data, + fields, + parentIsLocalized, +}: { + blocksMap: Record + data: Data + fields: ClientField[] + parentIsLocalized: boolean +}): void { + for (const field of fields) { + if (fieldAffectsData(field)) { + if (parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized })) { + delete data[field.name] + continue + } + + const fieldValue = data[field.name] + + switch (field.type) { + case 'array': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (isObject(row)) { + removeLocalizedData({ + blocksMap, + data: row, + fields: field.fields, + parentIsLocalized: false, + }) + } + } + } + break + } + + case 'blocks': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (!isObject(row) || typeof row.blockType !== 'string') { + continue + } + + const blockOrSlug = field.blocks.find((block) => { + return (typeof block === 'string' ? block : block.slug) === row.blockType + }) + const block = typeof blockOrSlug === 'string' ? blocksMap[blockOrSlug] : blockOrSlug + + if (block) { + removeLocalizedData({ + blocksMap, + data: row, + fields: block.fields, + parentIsLocalized: false, + }) + } + } + } + break + } + + case 'group': { + if (isObject(fieldValue)) { + removeLocalizedData({ + blocksMap, + data: fieldValue, + fields: field.fields, + parentIsLocalized: false, + }) + } + break + } + } + } else { + switch (field.type) { + case 'collapsible': + case 'group': + case 'row': { + const isLocalized = + parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized }) + + removeLocalizedData({ + blocksMap, + data, + fields: field.fields, + parentIsLocalized: isLocalized, + }) + break + } + + case 'tabs': { + for (const tab of field.tabs) { + if (tabHasName(tab)) { + if (parentIsLocalized || fieldShouldBeLocalized({ field: tab, parentIsLocalized })) { + delete data[tab.name] + } else if (isObject(data[tab.name])) { + removeLocalizedData({ + blocksMap, + data: data[tab.name], + fields: tab.fields, + parentIsLocalized: false, + }) + } + } else { + removeLocalizedData({ + blocksMap, + data, + fields: tab.fields, + parentIsLocalized, + }) + } + } + break + } + } + } + } +} + +function isObject(value: unknown): value is Data { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} diff --git a/packages/ui/src/views/CreateFirstUser/index.tsx b/packages/ui/src/views/CreateFirstUser/index.tsx index 6bb056a7ad4..47aa22ffd86 100644 --- a/packages/ui/src/views/CreateFirstUser/index.tsx +++ b/packages/ui/src/views/CreateFirstUser/index.tsx @@ -52,7 +52,7 @@ export async function CreateFirstUserView({ initPageResult }: AdminViewServerPro const baseFields: SanitizedFieldsPermissions = Object.fromEntries( collectionConfig.fields .filter((f): f is { name: string } & typeof f => 'name' in f && typeof f.name === 'string') - .map((f) => [f.name, { create: true, read: true, update: true }]), + .map((f) => [f.name, { create: true, read: true, update: true, validate: true }]), ) // In create-first-user we should always allow all fields @@ -63,6 +63,7 @@ export async function CreateFirstUserView({ initPageResult }: AdminViewServerPro read: true, readVersions: true, update: true, + validate: true, } // Build initial form state from data diff --git a/test/validate/config.ts b/test/validate/config.ts index d2c9db648cd..7c0260db943 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from 'url' // eslint-disable-next-line payload/no-relative-monorepo-imports import { uploadFiles } from '../../packages/payload/src/uploads/uploadFiles.js' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { devUser } from '../credentials.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -23,6 +24,9 @@ export const publishCollectionSlug = 'validation-publish-items' export const publishGlobalSlug = 'validation-publish-settings' export const writeTargetsSlug = 'validation-write-targets' export const validationUploadsSlug = 'validation-uploads' +export const validationAdminCollectionSlug = 'validation-admin-items' +export const validationDeniedCollectionSlug = 'validation-denied-items' +export const validationNonLocalizedCollectionSlug = 'validation-non-localized-items' export const validationUploadsDir = path.resolve(dirname, 'validation-uploads') type HookEvent = { @@ -613,8 +617,58 @@ const publishGlobal: GlobalConfig = { }, } +const validationAdminCollection: CollectionConfig = { + slug: validationAdminCollectionSlug, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + required: true, + }, + { + name: 'summary', + type: 'text', + required: true, + }, + ], + versions: { + drafts: { + validate: false, + }, + }, +} + +const validationDeniedCollection: CollectionConfig = { + slug: validationDeniedCollectionSlug, + access: { + validate: () => false, + }, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + }, + ], +} + +const validationNonLocalizedCollection: CollectionConfig = { + slug: validationNonLocalizedCollectionSlug, + fields: [ + { + name: 'title', + type: 'text', + }, + ], +} + export default buildConfigWithDefaults({ admin: { + autoLogin: { + email: devUser.email, + password: devUser.password, + }, importMap: { baseDir: path.resolve(dirname), }, @@ -622,6 +676,9 @@ export default buildConfigWithDefaults({ collections: [ validationCollection, publishCollection, + validationAdminCollection, + validationDeniedCollection, + validationNonLocalizedCollection, { slug: writeTargetsSlug, fields: [ @@ -675,4 +732,17 @@ export default buildConfigWithDefaults({ }, ], }, + onInit: async (payload) => { + if (process.env.NODE_ENV === 'test') { + return + } + + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + }, }) diff --git a/test/validate/e2e.spec.ts b/test/validate/e2e.spec.ts new file mode 100644 index 00000000000..76faae8a77d --- /dev/null +++ b/test/validate/e2e.spec.ts @@ -0,0 +1,182 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import path from 'path' +import { fileURLToPath } from 'url' + +import { + changeLocale, + ensureCompilationIsDone, + initPageConsoleErrorCatch, + waitForFormReady, +} from '../__helpers/e2e/helpers.js' +import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js' +import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js' +import { RESTClient } from '../__helpers/shared/rest.js' +import { + publishGlobalSlug, + validationAdminCollectionSlug, + validationDeniedCollectionSlug, + validationNonLocalizedCollectionSlug, +} from './config.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +test.describe('Admin document validation', () => { + const createdIDs: string[] = [] + let client: RESTClient + let page: Page + let serverURL: string + let validationURL: AdminUrlUtil + + test.beforeAll(async ({ browser }) => { + ;({ serverURL } = await initPayloadE2ENoConfig({ dirname })) + validationURL = new AdminUrlUtil(serverURL, validationAdminCollectionSlug) + client = new RESTClient({ + defaultSlug: validationAdminCollectionSlug, + serverURL, + }) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + await ensureCompilationIsDone({ page, serverURL }) + await client.login() + }) + + test.afterEach(async () => { + await page.goto(validationURL.admin) + + for (const id of createdIDs) { + await client.delete(id, { + id, + slug: validationAdminCollectionSlug, + }) + } + createdIDs.length = 0 + }) + + test('should only show the action on localized documents with validation access', async () => { + await page.goto(validationURL.create) + await expect(page.locator('#action-validate-all-locales')).toBeVisible() + + const nonLocalizedURL = new AdminUrlUtil(serverURL, validationNonLocalizedCollectionSlug) + await page.goto(nonLocalizedURL.create) + await expect(page.locator('#action-validate-all-locales')).toHaveCount(0) + + const deniedURL = new AdminUrlUtil(serverURL, validationDeniedCollectionSlug) + await page.goto(deniedURL.create) + await expect(page.locator('#action-validate-all-locales')).toHaveCount(0) + + await page.goto(validationURL.global(publishGlobalSlug)) + await expect(page.locator('#action-validate-all-locales')).toBeVisible() + }) + + test('should show sibling-locale errors without discarding unsaved active-locale data', async () => { + const id = await createDraft({ spanishTitle: 'Título en español' }) + + await openDraft(id) + await page.locator('#field-title').fill('Unsaved English title') + await page.locator('#action-validate-all-locales').click() + + const result = page.locator('.validation-results') + await expect(result.getByRole('alert')).toBeVisible() + await expect(result).toContainText('German') + await expect(result).toContainText('title') + await expect(page.locator('#field-title')).toHaveValue('Unsaved English title') + }) + + test('should announce a valid result accessibly', async () => { + const id = await createDraft({ + frenchTitle: 'Titre français', + germanTitle: 'Deutscher Titel', + spanishTitle: 'Título en español', + }) + + await openDraft(id) + await page.locator('#action-validate-all-locales').click() + + await expect(page.locator('.validation-results').getByRole('status')).toHaveText( + 'All selected locales are valid.', + ) + }) + + test('should block publish-all for an invalid optional locale but allow normal publish', async () => { + const id = await createDraft({ spanishTitle: 'Título en español' }) + + await openDraft(id) + await page.locator('#action-save').click() + + await expect(page.locator('.validation-results').getByRole('alert')).toBeVisible() + await expect(page.locator('.validation-results')).toContainText('German') + + await page.locator('.validation-results .drawer__header__close').click() + await page.locator('#action-save-popup').click() + await page.locator('#publish-locale').click() + + await expect(page.locator('.payload-toast-container')).toContainText('successfully') + await expect(page.locator('.validation-results')).toHaveCount(0) + }) + + test('should block normal publish when a required locale is invalid', async () => { + const id = await createDraft({}) + + await openDraft(id) + await page.locator('#action-save-popup').click() + await page.locator('#publish-locale').click() + + const result = page.locator('.validation-results') + await expect(result.getByRole('alert')).toBeVisible() + await expect(result).toContainText('Spanish') + await expect(result).toContainText('Required') + }) + + async function createDraft({ + frenchTitle, + germanTitle, + spanishTitle, + }: { + frenchTitle?: string + germanTitle?: string + spanishTitle?: string + }): Promise { + const response = await client.endpointWithAuth<{ doc: { id: number | string } }>( + `/api/${validationAdminCollectionSlug}?draft=true&locale=en`, + 'POST', + { + _status: 'draft', + summary: 'Shared summary', + title: 'English title', + }, + ) + const id = String(response.data.doc.id) + + createdIDs.push(id) + + for (const [locale, title] of [ + ['es', spanishTitle], + ['de', germanTitle], + ['fr', frenchTitle], + ]) { + if (title) { + await client.endpointWithAuth( + `/api/${validationAdminCollectionSlug}/${id}?draft=true&locale=${locale}`, + 'PATCH', + { + _status: 'draft', + title, + }, + ) + } + } + + return id + } + + async function openDraft(id: string): Promise { + await page.goto(validationURL.edit(id)) + await changeLocale(page, 'en') + await waitForFormReady(page) + } +}) From 5329ee2eda004e12c5692465067b2e3e2fa5a815 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 22:54:10 +0100 Subject: [PATCH 16/47] fix(ui): detect localized fields in referenced blocks --- .../src/elements/DocumentControls/index.tsx | 4 +- .../providers/DocumentValidation/index.tsx | 5 +- .../src/utilities/documentValidation.spec.ts | 21 +++++- .../utilities/traverseForLocalizedFields.ts | 66 +++++++++++++++++-- 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index 35a1a1fd5af..d0337ab7e9d 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -235,7 +235,9 @@ const DocumentControlsContent: React.FC = (props) => { const showLockedMetaIcon = user && readOnlyForIncomingUser const showValidateAllLocales = shouldShowValidateAllLocales({ hasLocalization: Boolean(localization), - hasLocalizedFields: traverseForLocalizedFields(entityConfig?.fields ?? []), + hasLocalizedFields: traverseForLocalizedFields(entityConfig?.fields ?? [], { + blocksMap: config.blocksMap, + }), hasValidatePermission: Boolean(permissions?.validate), }) diff --git a/packages/ui/src/providers/DocumentValidation/index.tsx b/packages/ui/src/providers/DocumentValidation/index.tsx index a049b5ee0e0..32cb9e3f352 100644 --- a/packages/ui/src/providers/DocumentValidation/index.tsx +++ b/packages/ui/src/providers/DocumentValidation/index.tsx @@ -67,7 +67,10 @@ export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> ) const fields = useMemo(() => docConfig?.fields ?? [], [docConfig?.fields]) - const hasLocalizedFields = useMemo(() => traverseForLocalizedFields(fields), [fields]) + const hasLocalizedFields = useMemo( + () => traverseForLocalizedFields(fields, { blocksMap }), + [blocksMap, fields], + ) const targetKey = `${collectionSlug ?? ''}:${globalSlug ?? ''}:${id ?? 'create'}` const clearResult = useCallback(() => { diff --git a/packages/ui/src/utilities/documentValidation.spec.ts b/packages/ui/src/utilities/documentValidation.spec.ts index 72b07acfda5..d1539ce5f64 100644 --- a/packages/ui/src/utilities/documentValidation.spec.ts +++ b/packages/ui/src/utilities/documentValidation.spec.ts @@ -1,4 +1,4 @@ -import type { ClientField, SanitizedLocale } from 'payload' +import type { ClientBlock, ClientField, SanitizedLocale } from 'payload' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -10,6 +10,7 @@ import { shouldShowValidateAllLocales, validateDocumentLocales, } from './documentValidation.js' +import { traverseForLocalizedFields } from './traverseForLocalizedFields.js' const locales = [ { code: 'en', label: 'English', required: true }, @@ -68,6 +69,24 @@ describe('document validation', () => { ).toBe(false) }) + it('should detect localized fields inside referenced blocks', () => { + const fields = [ + { + blocks: ['hero'], + name: 'layout', + type: 'blocks', + }, + ] as ClientField[] + const blocksMap = { + hero: { + fields: [{ localized: true, name: 'heading', type: 'text' }], + slug: 'hero', + }, + } as Record + + expect(traverseForLocalizedFields(fields, { blocksMap })).toBe(true) + }) + it('should select all locales for publish-all and active plus required locales otherwise', () => { expect( getPublishValidationLocales({ diff --git a/packages/ui/src/utilities/traverseForLocalizedFields.ts b/packages/ui/src/utilities/traverseForLocalizedFields.ts index 49ec87dd69c..b0053b680cd 100644 --- a/packages/ui/src/utilities/traverseForLocalizedFields.ts +++ b/packages/ui/src/utilities/traverseForLocalizedFields.ts @@ -1,6 +1,29 @@ -import type { ClientField, Field } from 'payload' +import type { ClientBlock, ClientField, Field } from 'payload' -export const traverseForLocalizedFields = (fields: ClientField[] | Field[]): boolean => { +type TraverseForLocalizedFieldsOptions = { + blocksMap?: Record +} + +export const traverseForLocalizedFields = ( + fields: ClientField[] | Field[], + { blocksMap }: TraverseForLocalizedFieldsOptions = {}, +): boolean => { + return traverseFields({ + blocksMap, + fields, + visitedBlockSlugs: new Set(), + }) +} + +const traverseFields = ({ + blocksMap, + fields, + visitedBlockSlugs, +}: { + blocksMap?: Record + fields: ClientField[] | Field[] + visitedBlockSlugs: Set +}): boolean => { for (const field of fields) { if ('localized' in field && field.localized) { return true @@ -11,18 +34,39 @@ export const traverseForLocalizedFields = (fields: ClientField[] | Field[]): boo case 'collapsible': case 'group': case 'row': - if (field.fields && traverseForLocalizedFields(field.fields)) { + if ( + field.fields && + traverseFields({ + blocksMap, + fields: field.fields, + visitedBlockSlugs, + }) + ) { return true } break case 'blocks': if (field.blocks) { - for (const block of field.blocks) { - if (typeof block === 'string') { + for (const blockOrSlug of field.blocks) { + if (typeof blockOrSlug === 'string' && visitedBlockSlugs.has(blockOrSlug)) { continue } - if (block.fields && traverseForLocalizedFields(block.fields)) { + + const block = typeof blockOrSlug === 'string' ? blocksMap?.[blockOrSlug] : blockOrSlug + + if (typeof blockOrSlug === 'string') { + visitedBlockSlugs.add(blockOrSlug) + } + + if ( + block?.fields && + traverseFields({ + blocksMap, + fields: block.fields, + visitedBlockSlugs, + }) + ) { return true } } @@ -35,7 +79,15 @@ export const traverseForLocalizedFields = (fields: ClientField[] | Field[]): boo if ('localized' in tab && tab.localized) { return true } - if ('fields' in tab && tab.fields && traverseForLocalizedFields(tab.fields)) { + if ( + 'fields' in tab && + tab.fields && + traverseFields({ + blocksMap, + fields: tab.fields, + visitedBlockSlugs, + }) + ) { return true } } From 0b79df97c639d4e7904faadfd8ce26623a94a503 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 23:10:55 +0100 Subject: [PATCH 17/47] docs(payload): document on-demand validation --- docs/access-control/collections.mdx | 14 +- docs/access-control/fields.mdx | 12 +- docs/access-control/globals.mdx | 10 +- docs/access-control/overview.mdx | 3 + docs/configuration/localization.mdx | 16 +- docs/local-api/overview.mdx | 1 + docs/rest-api/overview.mdx | 3 + docs/validation/overview.mdx | 301 ++++++++++++++++++ docs/versions/drafts.mdx | 4 + packages/payload/src/auth/types.ts | 4 + .../payload/src/collections/config/types.ts | 5 + .../src/collections/endpoints/validate.ts | 13 + .../collections/operations/local/validate.ts | 36 ++- packages/payload/src/config/types.ts | 4 +- .../payload/src/errors/ValidationError.ts | 6 +- packages/payload/src/fields/config/types.ts | 5 + packages/payload/src/globals/config/types.ts | 5 + .../payload/src/globals/endpoints/validate.ts | 7 + .../src/globals/operations/local/validate.ts | 15 +- packages/payload/src/index.ts | 17 +- packages/payload/src/types/index.ts | 6 + .../src/utilities/parseValidationLocale.ts | 5 + .../src/utilities/resolveValidationLocales.ts | 6 + 23 files changed, 458 insertions(+), 40 deletions(-) create mode 100644 docs/validation/overview.mdx diff --git a/docs/access-control/collections.mdx b/docs/access-control/collections.mdx index 6ee7b02f1c9..19870d719cc 100644 --- a/docs/access-control/collections.mdx +++ b/docs/access-control/collections.mdx @@ -39,6 +39,7 @@ export const CollectionWithAccessControl: CollectionConfig = { read: () => {...}, update: () => {...}, delete: () => {...}, + validate: () => {...}, // Auth-enabled Collections only admin: () => {...}, @@ -53,12 +54,13 @@ export const CollectionWithAccessControl: CollectionConfig = { The following options are available: -| Function | Allows/Denies Access | -| ------------ | -------------------------------------------------------------------- | -| **`create`** | Used in the `create` operation. [More details](#create). | -| **`read`** | Used in the `find` and `findByID` operations. [More details](#read). | -| **`update`** | Used in the `update` operation. [More details](#update). | -| **`delete`** | Used in the `delete` operation. [More details](#delete). | +| Function | Allows/Denies Access | +| -------------- | -------------------------------------------------------------------------------- | +| **`create`** | Used in the `create` operation. [More details](#create). | +| **`read`** | Used in the `find` and `findByID` operations. [More details](#read). | +| **`update`** | Used in the `update` operation. [More details](#update). | +| **`delete`** | Used in the `delete` operation. [More details](#delete). | +| **`validate`** | Used by [on-demand validation](../validation/overview#access-control-and-hooks). | If a Collection supports [`Authentication`](../authentication/overview), the following additional options are available: diff --git a/docs/access-control/fields.mdx b/docs/access-control/fields.mdx index 649910da33d..62e0f860ecb 100644 --- a/docs/access-control/fields.mdx +++ b/docs/access-control/fields.mdx @@ -48,6 +48,7 @@ export const Posts: CollectionConfig = { create: ({ req: { user } }) => { ... }, read: ({ req: { user } }) => { ... }, update: ({ req: { user } }) => { ... }, + validate: ({ req: { user } }) => { ... }, }, // highlight-end }; @@ -57,11 +58,12 @@ export const Posts: CollectionConfig = { The following options are available: -| Function | Purpose | -| ------------ | ---------------------------------------------------------------------------------------------------------- | -| **`create`** | Allows or denies the ability to set a field's value when creating a new document. [More details](#create). | -| **`read`** | Allows or denies the ability to read a field's value. [More details](#read). | -| **`update`** | Allows or denies the ability to update a field's value [More details](#update). | +| Function | Purpose | +| -------------- | --------------------------------------------------------------------------------------------------------------------- | +| **`create`** | Allows or denies the ability to set a field's value when creating a new document. [More details](#create). | +| **`read`** | Allows or denies the ability to read a field's value. [More details](#read). | +| **`update`** | Allows or denies the ability to update a field's value. [More details](#update). | +| **`validate`** | Allows or denies candidate field data during [on-demand validation](../validation/overview#access-control-and-hooks). | ### Create diff --git a/docs/access-control/globals.mdx b/docs/access-control/globals.mdx index 44cfe7e480d..836a73c1116 100644 --- a/docs/access-control/globals.mdx +++ b/docs/access-control/globals.mdx @@ -37,6 +37,7 @@ const GlobalWithAccessControl: GlobalConfig = { access: { read: ({ req: { user } }) => {...}, update: ({ req: { user } }) => {...}, + validate: ({ req: { user } }) => {...}, // Version-enabled Globals only readVersions: () => {...}, @@ -49,10 +50,11 @@ export default Header The following options are available: -| Function | Allows/Denies Access | -| ------------ | --------------------------------------------------------------- | -| **`read`** | Used in the `findOne` Global operation. [More details](#read). | -| **`update`** | Used in the `update` Global operation. [More details](#update). | +| Function | Allows/Denies Access | +| -------------- | -------------------------------------------------------------------------------- | +| **`read`** | Used in the `findOne` Global operation. [More details](#read). | +| **`update`** | Used in the `update` Global operation. [More details](#update). | +| **`validate`** | Used by [on-demand validation](../validation/overview#access-control-and-hooks). | If a Global supports [Versions](../versions/overview), the following additional options are available: diff --git a/docs/access-control/overview.mdx b/docs/access-control/overview.mdx index 910d3b7b4bd..af77a054810 100644 --- a/docs/access-control/overview.mdx +++ b/docs/access-control/overview.mdx @@ -12,6 +12,9 @@ Access Control determines what a user can and cannot do with any given Document, Access Control functions are scoped to the _operation_, meaning you can have different rules for `create`, `read`, `update`, `delete`, etc. Access Control functions are executed _before_ any changes are made and _before_ any operations are completed. This allows you to determine if the user has the necessary permissions before fulfilling the request. +[On-demand validation](../validation/overview#access-control-and-hooks) uses its own first-class +`validate` operation for collection, global, and field access control. + There are many use cases for Access Control, including: - Allowing anyone `read` access to all posts diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index e76d4bc6e50..4eed2845796 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -10,6 +10,9 @@ Localization is one of the most important features of a modern CMS. It allows yo With Localization, you can begin to serve personalized content to your users based on their specific language preferences, such as a multilingual website or multi-site application. There are no limits to the number of locales you can add to your Payload project. +You can also [validate one or more locales without saving](../validation/overview) and require +specific locales before publishing. + To configure Localization, use the `localization` key in your [Payload Config](./overview): ```ts @@ -93,12 +96,13 @@ The locale codes do not need to be in any specific format. It's up to you to def #### Locale Object -| Option | Description | -| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| **`code`** \* | Unique code to identify the language throughout the APIs for `locale` and `fallbackLocale` | -| **`label`** | A string to use for the selector when choosing a language, or an object keyed on the i18n keys for different languages in use. | -| **`rtl`** | A boolean that when true will make the admin UI display in Right-To-Left. | -| **`fallbackLocale`** | The code for this language to fallback to when properties of a document are not present. This can be a single locale or array of locales. | +| Option | Description | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`code`** \* | Unique code to identify the language throughout the APIs for `locale` and `fallbackLocale`. | +| **`label`** | A string to use for the selector when choosing a language, or an object keyed on the i18n keys for different languages in use. | +| **`rtl`** | A boolean that when true will make the admin UI display in Right-To-Left. | +| **`fallbackLocale`** | The code for this language to fallback to when properties of a document are not present. This can be a single locale or array of locales. | +| **`required`** | Require this locale to pass validation whenever any locale is published. Defaults to `false`. [More details](../validation/overview#publishing-and-required-locales). | _\* An asterisk denotes that a property is required._ diff --git a/docs/local-api/overview.mdx b/docs/local-api/overview.mdx index 98a00ef0ae1..cfd46a98d55 100644 --- a/docs/local-api/overview.mdx +++ b/docs/local-api/overview.mdx @@ -21,6 +21,7 @@ Here are some common examples of how you can use the Local API: - Seeding data via Node seed scripts that you write and maintain - Opening custom Next.js route handlers which feature additional functionality but still rely on Payload - Within [Access Control](../access-control/overview) and [Hooks](../hooks/overview) +- [Validating document candidates without saving them](../validation/overview#local-api) ## Accessing Payload diff --git a/docs/rest-api/overview.mdx b/docs/rest-api/overview.mdx index 4b93b7d3b64..0a6c17992f4 100644 --- a/docs/rest-api/overview.mdx +++ b/docs/rest-api/overview.mdx @@ -14,6 +14,9 @@ keywords: rest, api, documentation, Content Management System, cms, headless, ja The REST API is a fully functional HTTP client that allows you to interact with your Documents in a RESTful manner. It supports all CRUD operations and is equipped with automatic pagination, depth, and sorting. All Payload API routes are mounted and prefixed to your config's `routes.api` URL segment (default: `/api`). +To check collection or global candidates without saving them, use the +[on-demand validation endpoints](../validation/overview#rest-api). + For example, if you have a Collection called `pages`, you can fetch its documents directly from the browser or any HTTP client: ```ts diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx new file mode 100644 index 00000000000..938a7927270 --- /dev/null +++ b/docs/validation/overview.mdx @@ -0,0 +1,301 @@ +--- +title: On-demand Validation +label: Overview +order: 10 +desc: Validate collection and global document candidates without saving them. +keywords: validation, local api, rest api, localization, publishing, access control, hooks, drafts +--- + +On-demand validation checks a collection or global document candidate without saving the candidate, +creating a version, or writing files. Use it for workflow readiness checks, multi-locale review, and +validation before a custom publish flow. + +Field validation failures are returned as a result: + +```ts +import type { ValidationFieldError } from 'payload' + +type ValidationResult = { + valid: boolean + errors: ValidationFieldError[] +} +``` + +Every error has a `path` and `message`. On-demand validation also sets `locale`; optional field +label and table metadata may be present. + +Access denials, missing documents, invalid arguments, and non-validation errors thrown by hooks still +throw normally. + +## Local API + +Use `payload.validate()` for collections and `payload.validateGlobal()` for globals. A collection +call without `id` validates a create candidate and requires `data`. A call with `id` loads the latest +stored document and merges optional, partial `data` over it. Global validation always loads the +latest global and merges optional, partial `data`. + +```ts +// Validate collection create data in one locale +const createResult = await payload.validate({ + collection: 'posts', + data: { + title: 'Launch announcement', + }, + locale: 'en', +}) +``` + +```ts +// Merge the same partial candidate into each selected locale +const updateResult = await payload.validate({ + collection: 'posts', + id: postID, + data: { + title: 'Ready to publish', + }, + locale: ['en', 'de'], +}) +``` + +```ts +// Validate every locale available to this request +const allLocalesResult = await payload.validate({ + collection: 'posts', + id: postID, + locale: 'all', +}) +``` + +```ts +// Validate a partial global candidate +const globalResult = await payload.validateGlobal({ + slug: 'site-settings', + data: { + announcement: 'Maintenance starts at 22:00', + }, + locale: ['en', 'es'], +}) +``` + +For projects without localization, pass `locale: null` to the Local API. + +Use `context` to add values to `req.context` for the validation lifecycle. You can also reuse a +partial request with `req`. + +### Workflow readiness + +Validation failures do not throw a `ValidationError`. Check `valid` and use the locale, path, and +message on each error: + +```ts +const result = await payload.validate({ + collection: 'posts', + id: postID, + locale: 'all', + overrideAccess: false, + user, +}) + +if (!result.valid) { + return { + ready: false, + issues: result.errors.map(({ locale, message, path }) => ({ + field: path, + locale, + message, + })), + } +} + +return { ready: true } +``` + +The Local API skips access control by default. Set `overrideAccess: false` and pass `user` or `req` +when validation should enforce access. + +## REST API + +Validation endpoints use `POST`. Send candidate data as a JSON object and select locales with the +required `locale` query parameter. These examples use the default `/api` base path; custom +`routes.api` configuration changes that prefix. + +```ts +// Collection create candidate +const result = await fetch('/api/posts/validate?locale=en', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Launch announcement', + }), +}).then((response) => response.json()) +``` + +```ts +// Collection update candidate; repeated locale parameters select multiple locales +const result = await fetch( + `/api/posts/${postID}/validate?locale=en&locale=de`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Ready to publish', + }), + }, +).then((response) => response.json()) +``` + +```ts +// Global candidate +const result = await fetch('/api/globals/site-settings/validate?locale=en', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + announcement: 'Maintenance starts at 22:00', + }), +}).then((response) => response.json()) +``` + +Use `?locale=all` to validate every available locale: + +```ts +const result = await fetch(`/api/posts/${postID}/validate?locale=all`, { + method: 'POST', + credentials: 'include', +}).then((response) => response.json()) +``` + +The collection create endpoint requires an object body. The collection by-ID and global endpoints +allow the body to be omitted, which validates the stored data unchanged. + +The body is always candidate document data. Authentication, access context, and other operation +controls come from the HTTP request, not body properties. + +| Outcome | Status | +| ------------------------------------------ | ------ | +| Valid or invalid field data | `200` | +| Missing, unknown, or unavailable locale | `400` | +| Missing or malformed required request data | `400` | +| Validation access denied | `403` | +| Collection document not found | `404` | + +Other errors use Payload's normal REST error handling. REST validation always enforces access +control. + +## Locale semantics + +`locale` accepts one locale, a non-empty array, or `'all'`. Duplicate locales are removed. `'all'` +uses the locales returned by `localization.filterAvailableLocales`, when configured. Each error is +tagged with the locale that failed. + +A public multi-locale Local or REST call has one `data` payload. Payload independently merges that +same candidate into every selected locale, then aggregates the results. If a custom interface has +unsaved localized values for only its active locale, do not send those values as a flat +multi-locale candidate. + +The built-in Admin UI handles this distinction for you: it validates the active locale with the +current unsaved form data, then validates sibling locales with only non-localized candidate fields. +Sibling localized values therefore come from their stored document data, or remain missing for a +new document. + +Fallback locale values are disabled during on-demand validation. Missing data must pass validation +in the locale being checked. + +## Publishing and required locales + +Use locale configuration objects to require a locale whenever any locale is published: + +```ts +import { buildConfig } from 'payload' + +export default buildConfig({ + localization: { + defaultLocale: 'en', + locales: [ + { code: 'en', label: 'English' }, + { code: 'es', label: 'Spanish', required: true }, + { code: 'de', label: 'German' }, + ], + }, +}) +``` + +Publishing one locale validates that locale and every locale with `required: true`. Publishing all +locales validates every available locale. If any locale is invalid, Payload throws a +`ValidationError` before writing the document, version, or files. + +The default Admin Publish controls run a preflight so editors can see locale-grouped errors before +submission. The **Validate all locales** action uses current unsaved form data and is shown only for +localized documents when the user has `validate` permission. If you replace the default Publish +button, your custom UI owns any preflight feedback; the server-side publish validation remains +authoritative. + +Scheduled publishes revalidate the latest stored target when the job runs. An invalid target +cancels the publish job without retrying or changing publication state. Unpublishing does not +require publish validation. + +## Access control and hooks + +Configure `validate` access independently for collections, globals, and fields: + +```ts +import type { CollectionConfig, GlobalConfig } from 'payload' + +export const Posts: CollectionConfig = { + slug: 'posts', + access: { + validate: ({ req }) => Boolean(req.user), + }, + fields: [ + { + name: 'internalNotes', + type: 'textarea', + access: { + validate: ({ req }) => Boolean(req.user), + }, + }, + ], +} + +export const SiteSettings: GlobalConfig = { + slug: 'site-settings', + access: { + validate: ({ req }) => Boolean(req.user), + }, + fields: [], +} +``` + +Collection, global, and field access functions all receive the first-class +`req.operation === 'validate'`. Field hooks, field validators, and collection/global +`beforeValidate` and `beforeChange` hooks also receive `operation: 'validate'`. Payload never +simulates `'create'` or `'update'` as the access operation. + +The validation lifecycle itself does not persist the candidate. Payload also rejects managed write +operations that reuse the active validation request. Hooks still run, however, and can cause side +effects through an external service, a raw database client, or a separate Payload request. Branch +side-effecting hooks when needed: + +```ts +import type { CollectionBeforeChangeHook } from 'payload' + +const beforeChange: CollectionBeforeChangeHook = async ({ + data, + operation, +}) => { + if (operation === 'validate') { + return data + } + + await notifySearchIndexer(data) + return data +} +``` + +## Current scope + +On-demand validation is available through the Local and REST APIs and as a localized document action +in the Admin UI. It does not add a GraphQL validation operation, bulk Admin validation, or +fallback-locale validation. diff --git a/docs/versions/drafts.mdx b/docs/versions/drafts.mdx index f16ba79d98d..be79e8cc6d8 100644 --- a/docs/versions/drafts.mdx +++ b/docs/versions/drafts.mdx @@ -292,6 +292,10 @@ Payload provides for an ability to schedule publishing / unpublishing events in You can enable this functionality on both collections and globals via the `versions.drafts.schedulePublish: true` property. +At execution time, a scheduled publish [revalidates the latest stored +document](../validation/overview#publishing-and-required-locales). If validation fails, Payload +cancels the job without changing publication state. + **Important:** if you are going to enable scheduled publish / unpublish, you need to make sure your Payload app is set up to process diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index 928d18524e0..24fd2549423 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -20,6 +20,7 @@ export type BlockPermissions = { fields: FieldsPermissions read: Permission update: Permission + /** Permission to validate candidate block data without saving. */ validate: Permission } @@ -45,6 +46,7 @@ export type FieldPermissions = { fields?: FieldsPermissions read?: Permission update?: Permission + /** Permission to validate candidate field data without saving. */ validate?: Permission } @@ -74,6 +76,7 @@ export type CollectionPermission = { // Auth-enabled Collections only unlock?: Permission update?: Permission + /** Permission to validate collection candidate data without saving. */ validate?: Permission } @@ -94,6 +97,7 @@ export type GlobalPermission = { read?: Permission readVersions?: Permission update?: Permission + /** Permission to validate global candidate data without saving. */ validate?: Permission } diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 10b26e51b1d..5b1aad62342 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -550,6 +550,11 @@ export type CollectionConfig = { readVersions?: Access unlock?: Access update?: Access + /** + * Controls on-demand validation for this collection. + * The access function receives `req.operation === 'validate'`. + * @see https://payloadcms.com/docs/validation/overview#access-control-and-hooks + */ validate?: Access } /** diff --git a/packages/payload/src/collections/endpoints/validate.ts b/packages/payload/src/collections/endpoints/validate.ts index 34620c737a7..00b278ba975 100644 --- a/packages/payload/src/collections/endpoints/validate.ts +++ b/packages/payload/src/collections/endpoints/validate.ts @@ -13,6 +13,12 @@ import { } from '../../utilities/parseValidationLocale.js' import { validateLocal } from '../operations/local/validate.js' +/** + * Validates collection create candidate data. + * + * `POST {routes.api}/{collection}/validate` requires an object body and one or more `locale` query + * parameters, or `locale=all`. Field validation failures return a `200` ValidationResult. + */ export const validateHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) const locale = parseValidationLocaleSelector(req.query.locale) @@ -36,6 +42,13 @@ export const validateHandler: PayloadHandler = async (req) => { }) } +/** + * Validates a stored collection document with optional partial candidate data. + * + * `POST {routes.api}/{collection}/{id}/validate` accepts an optional object body and requires one + * or more `locale` query parameters, or `locale=all`. Field validation failures return a `200` + * ValidationResult. + */ export const validateByIDHandler: PayloadHandler = async (req) => { const { id, collection } = getRequestCollectionWithID(req) const locale = parseValidationLocaleSelector(req.query.locale) diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index f73ccf48e52..0611606fa74 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -29,12 +29,18 @@ import { import { validateOperation } from '../validate.js' /** - * The result of validating a collection or global document without persisting it. + * The result of validating a collection or global document candidate without persisting it. + * + * Field validation failures are returned in this result. Access denials, invalid arguments, + * missing documents, and other lifecycle errors throw instead. */ export type ValidationResult = { - /** Field validation errors. Empty when {@link valid} is `true`. */ + /** + * Field validation errors, tagged with the locale that failed. + * Empty when {@link valid} is `true`. + */ errors: ValidationFieldError[] - /** Whether the simulated document passed field validation. */ + /** Whether the candidate passed field validation in every selected locale. */ valid: boolean } @@ -45,7 +51,13 @@ type BaseOptions = { * Hook context merged into `req.context` for the validation lifecycle. */ context?: RequestContext - /** One locale, a non-empty locale array, or every available locale. */ + /** + * A locale, a non-empty locale array, or `'all'`. + * + * Each selected locale receives an independent copy of the same candidate `data`. + * `'all'` resolves through `localization.filterAvailableLocales` when configured. Use `null` + * for projects without localization. + */ locale: ValidationLocaleSelector /** * Skip collection and field access control. @@ -65,20 +77,24 @@ type BaseOptions = { /** * Options for validating a collection document without persisting it. * - * Omitting `id` simulates create and requires `data`. Supplying `id` simulates update and allows - * `data` to be omitted or partial because stored document data is loaded first. + * Omitting `id` validates create candidate data. Supplying `id` loads the latest stored document + * and merges optional partial data over it. Access control, hooks, field access, and validators + * receive the first-class `validate` operation in both cases. */ export type ValidateCollectionOptions = | ({ - /** Candidate create data. The property is required, but fields may be incomplete or invalid. */ + /** + * Candidate create data. This property is required, but its fields may be incomplete or + * invalid so callers can inspect the returned errors. + */ data: DeepPartial> - /** Create simulation does not accept a document ID. */ + /** Create candidate validation does not accept a document ID. */ id?: never } & BaseOptions) | ({ - /** Optional partial data to merge over the stored document. */ + /** Optional partial candidate data to merge over the latest stored document. */ data?: DeepPartial> - /** The stored document ID used for update simulation. */ + /** ID of the stored document used as the candidate's base. */ id: DataFromCollectionSlug['id'] } & BaseOptions) diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 402dcb7b323..e6d86454873 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -583,8 +583,10 @@ export type Locale = { */ label: Record | string /** - * Whether this locale must pass validation before publishing another locale. + * Require this locale to pass validation whenever any one locale is published. + * Publish-all operations validate every available locale regardless of this setting. * @default false + * @see https://payloadcms.com/docs/validation/overview#publishing-and-required-locales */ required?: boolean /** diff --git a/packages/payload/src/errors/ValidationError.ts b/packages/payload/src/errors/ValidationError.ts index 13143c6d064..7da3c248fa7 100644 --- a/packages/payload/src/errors/ValidationError.ts +++ b/packages/payload/src/errors/ValidationError.ts @@ -12,11 +12,15 @@ import { APIError } from './APIError.js' export const ValidationErrorName = 'ValidationError' export type ValidationFieldError = { + /** Configured field label, when available. */ label?: LabelFunction | StaticLabel + /** Locale in which this field failed validation. */ locale?: string - // The error message to display for this field + /** Error message to display for this field. */ message: string + /** Dot-separated path to the invalid field. */ path: string + /** Database table associated with the invalid field, when applicable. */ tableName?: string } diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 15dcb1e73dc..ac3dd1f0cdc 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -504,6 +504,11 @@ export interface FieldBase { create?: FieldAccess read?: FieldAccess update?: FieldAccess + /** + * Controls whether candidate field data participates in on-demand validation. + * The access function receives `req.operation === 'validate'`. + * @see https://payloadcms.com/docs/validation/overview#access-control-and-hooks + */ validate?: FieldAccess } admin?: FieldAdmin diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index eb1e394a89a..c85cdc6d436 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -201,6 +201,11 @@ export type GlobalConfig = { read?: Access readVersions?: Access update?: Access + /** + * Controls on-demand validation for this global. + * The access function receives `req.operation === 'validate'`. + * @see https://payloadcms.com/docs/validation/overview#access-control-and-hooks + */ validate?: Access } admin?: GlobalAdminOptions diff --git a/packages/payload/src/globals/endpoints/validate.ts b/packages/payload/src/globals/endpoints/validate.ts index d31b771c737..eaa58f1e723 100644 --- a/packages/payload/src/globals/endpoints/validate.ts +++ b/packages/payload/src/globals/endpoints/validate.ts @@ -10,6 +10,13 @@ import { } from '../../utilities/parseValidationLocale.js' import { validateGlobalLocal } from '../operations/local/validate.js' +/** + * Validates a global with optional partial candidate data. + * + * `POST {routes.api}/globals/{global}/validate` accepts an optional object body and requires one or + * more `locale` query parameters, or `locale=all`. Field validation failures return a `200` + * ValidationResult. + */ export const validateHandler: PayloadHandler = async (req) => { const globalConfig = getRequestGlobal(req) const locale = parseValidationLocaleSelector(req.query.locale) diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index 29106261b0d..a3aaab4f6f9 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -22,15 +22,22 @@ import { validateOperation } from '../validate.js' /** * Options for validating a global document without persisting it. * - * Global validation always simulates update. Candidate data is optional and, when provided, is - * merged over the stored global before validation. + * The latest stored global is loaded and optional partial candidate data is merged over it. + * Access control, hooks, field access, and validators receive the first-class `validate` + * operation. */ export type ValidateGlobalOptions = { /** Hook context merged into `req.context` for the validation lifecycle. */ context?: RequestContext - /** Optional partial data to merge over the stored global. */ + /** Optional partial candidate data to merge over the latest stored global. */ data?: DeepPartial, 'id'>> - /** One locale, a non-empty locale array, or every available locale. */ + /** + * A locale, a non-empty locale array, or `'all'`. + * + * Each selected locale receives an independent copy of the same candidate `data`. + * `'all'` resolves through `localization.filterAvailableLocales` when configured. Use `null` + * for projects without localization. + */ locale: ValidationLocaleSelector /** * Skip global and field access control. diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 28d321f8289..43f02062197 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -755,9 +755,14 @@ export class BasePayload { } /** - * Validates a collection document for selected locales without persisting data, versions, or files. + * Validates a collection document candidate for selected locales without persisting data, + * versions, or files. * - * Omit `id` to simulate create, or provide `id` to load a stored document and simulate update. + * Omit `id` to validate create data, or provide `id` to merge partial candidate data over the + * latest stored document. Field validation failures resolve to `{ valid: false, errors }`; + * access, argument, lookup, and other lifecycle errors throw. + * + * @see https://payloadcms.com/docs/validation/overview#local-api */ validate = async ( options: ValidateCollectionOptions, @@ -766,7 +771,13 @@ export class BasePayload { } /** - * Validates a global document update for selected locales without persisting data or versions. + * Validates a global document candidate for selected locales without persisting data or + * versions. + * + * Partial candidate data is merged over the latest stored global. Field validation failures + * resolve to `{ valid: false, errors }`; access, argument, and other lifecycle errors throw. + * + * @see https://payloadcms.com/docs/validation/overview#local-api */ validateGlobal = async ( options: ValidateGlobalOptions, diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index e7cd4da9002..f9ba94a50e0 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -192,6 +192,12 @@ export type JoinQuery = // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Document = any +/** + * Operations exposed to access control and request lifecycle functions. + * + * On-demand validation uses the first-class `validate` value rather than substituting `create` + * or `update`. + */ export type Operation = 'create' | 'delete' | 'read' | 'update' | 'validate' export type FieldOperation = Operation export type VersionOperations = 'readVersions' diff --git a/packages/payload/src/utilities/parseValidationLocale.ts b/packages/payload/src/utilities/parseValidationLocale.ts index d25da27fc0d..938eea92da3 100644 --- a/packages/payload/src/utilities/parseValidationLocale.ts +++ b/packages/payload/src/utilities/parseValidationLocale.ts @@ -62,6 +62,10 @@ export function parseSingleValidationLocale(locale: unknown): TypedLocale { return parsedLocale.locale } +/** + * Parses a REST `locale` query value. Repeated query parameters are represented as an array and + * `locale=all` selects all locales. + */ export function parseValidationLocaleSelector(locale: unknown): ValidationLocaleSelector { const parsedLocale = parseValidationLocale(locale) @@ -77,6 +81,7 @@ export function parseValidationLocaleSelector(locale: unknown): ValidationLocale } } +/** Ensures a REST validation request body is a non-null JSON object. */ export function assertValidationData(data: unknown): asserts data is Record { if (!data || Array.isArray(data) || typeof data !== 'object') { throw new APIError('Validation data must be an object.', httpStatus.BAD_REQUEST) diff --git a/packages/payload/src/utilities/resolveValidationLocales.ts b/packages/payload/src/utilities/resolveValidationLocales.ts index 3495c3b38d7..e391cae13e5 100644 --- a/packages/payload/src/utilities/resolveValidationLocales.ts +++ b/packages/payload/src/utilities/resolveValidationLocales.ts @@ -6,6 +6,12 @@ import type { PayloadRequest } from '../types/index.js' import { APIError } from '../errors/index.js' // TypedLocale is narrowed by generated types, while its untyped fallback intentionally includes string. +/** + * Locales accepted by collection and global on-demand validation. + * + * A non-empty array validates its unique locale codes in the order provided. `'all'` validates + * every locale available to the request. Projects without localization use `null`. + */ // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents export type ValidationLocaleSelector = 'all' | TypedLocale | TypedLocale[] From b0edbbeb5b2e5f8c0748d0016a84f975bda7dbe2 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 23 Jul 2026 23:28:23 +0100 Subject: [PATCH 18/47] fix(payload): honor validation draft option --- docs/validation/overview.mdx | 29 +- .../src/collections/endpoints/validate.ts | 4 +- .../collections/operations/local/validate.ts | 14 +- .../operations/utilities/update.ts | 1 + .../src/collections/operations/validate.ts | 25 +- .../payload/src/errors/ValidationError.ts | 2 +- .../payload/src/globals/endpoints/validate.ts | 5 +- .../src/globals/operations/local/validate.ts | 9 +- .../payload/src/globals/operations/update.ts | 1 + .../src/globals/operations/validate.ts | 23 +- packages/payload/src/index.ts | 13 +- .../src/utilities/resolveValidationLocales.ts | 3 +- test/validate/config.ts | 6 + test/validate/int.spec.ts | 247 ++++++++++++++++++ 14 files changed, 337 insertions(+), 45 deletions(-) diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index 938a7927270..bb7f5b76f8d 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -21,8 +21,8 @@ type ValidationResult = { } ``` -Every error has a `path` and `message`. On-demand validation also sets `locale`; optional field -label and table metadata may be present. +Every error has a `path` and `message`. Errors from localized validation passes also set `locale`; +non-localized validation may omit it. Optional field label and table metadata may be present. Access denials, missing documents, invalid arguments, and non-validation errors thrown by hooks still throw normally. @@ -30,9 +30,10 @@ throw normally. ## Local API Use `payload.validate()` for collections and `payload.validateGlobal()` for globals. A collection -call without `id` validates a create candidate and requires `data`. A call with `id` loads the latest -stored document and merges optional, partial `data` over it. Global validation always loads the -latest global and merges optional, partial `data`. +call without `id` validates a create candidate and requires `data`. For collection by-ID and global +validation, the stored main or published document is the base by default. Set `draft: true` to use +the newest available draft version instead, falling back to the main document when no draft exists. +Optional, partial `data` is merged over that base. ```ts // Validate collection create data in one locale @@ -62,6 +63,7 @@ const updateResult = await payload.validate({ const allLocalesResult = await payload.validate({ collection: 'posts', id: postID, + draft: true, locale: 'all', }) ``` @@ -77,7 +79,8 @@ const globalResult = await payload.validateGlobal({ }) ``` -For projects without localization, pass `locale: null` to the Local API. +For projects without localization, pass `locale: null` to the Local API or use `?locale=all` with +the REST API. Use `context` to add values to `req.context` for the validation lifecycle. You can also reuse a partial request with `req`. @@ -168,7 +171,8 @@ const result = await fetch(`/api/posts/${postID}/validate?locale=all`, { ``` The collection create endpoint requires an object body. The collection by-ID and global endpoints -allow the body to be omitted, which validates the stored data unchanged. +allow the body to be omitted. Those endpoints use the newest available draft as their base, +falling back to the main document when no draft exists. The body is always candidate document data. Authentication, access context, and other operation controls come from the HTTP request, not body properties. @@ -187,8 +191,9 @@ control. ## Locale semantics `locale` accepts one locale, a non-empty array, or `'all'`. Duplicate locales are removed. `'all'` -uses the locales returned by `localization.filterAvailableLocales`, when configured. Each error is -tagged with the locale that failed. +uses the locales returned by `localization.filterAvailableLocales`, when configured. Errors from +localized validation passes are tagged with the locale that failed; non-localized validation may +omit `locale`. A public multi-locale Local or REST call has one `data` payload. Payload independently merges that same candidate into every selected locale, then aggregates the results. If a custom interface has @@ -269,9 +274,9 @@ export const SiteSettings: GlobalConfig = { ``` Collection, global, and field access functions all receive the first-class -`req.operation === 'validate'`. Field hooks, field validators, and collection/global -`beforeValidate` and `beforeChange` hooks also receive `operation: 'validate'`. Payload never -simulates `'create'` or `'update'` as the access operation. +`req.operation === 'validate'`. Field `beforeValidate` and `beforeChange` hooks, field validators, +and collection/global `beforeValidate` and `beforeChange` hooks also receive +`operation: 'validate'`. Payload never simulates `'create'` or `'update'` as the access operation. The validation lifecycle itself does not persist the candidate. Payload also rejects managed write operations that reuse the active validation request. Hooks still run, however, and can cause side diff --git a/packages/payload/src/collections/endpoints/validate.ts b/packages/payload/src/collections/endpoints/validate.ts index 00b278ba975..4a0d577750e 100644 --- a/packages/payload/src/collections/endpoints/validate.ts +++ b/packages/payload/src/collections/endpoints/validate.ts @@ -46,7 +46,8 @@ export const validateHandler: PayloadHandler = async (req) => { * Validates a stored collection document with optional partial candidate data. * * `POST {routes.api}/{collection}/{id}/validate` accepts an optional object body and requires one - * or more `locale` query parameters, or `locale=all`. Field validation failures return a `200` + * or more `locale` query parameters, or `locale=all`. The newest available draft is used as the + * base, falling back to the main document. Field validation failures return a `200` * ValidationResult. */ export const validateByIDHandler: PayloadHandler = async (req) => { @@ -61,6 +62,7 @@ export const validateByIDHandler: PayloadHandler = async (req) => { id, collection: collection.config.slug, data: req.data, + draft: true, locale, overrideAccess: false, req, diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index 0611606fa74..3dd6e5693ea 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -36,7 +36,8 @@ import { validateOperation } from '../validate.js' */ export type ValidationResult = { /** - * Field validation errors, tagged with the locale that failed. + * Field validation errors. Errors from localized passes are tagged with the locale that failed; + * non-localized validation may omit the locale. * Empty when {@link valid} is `true`. */ errors: ValidationFieldError[] @@ -77,9 +78,10 @@ type BaseOptions = { /** * Options for validating a collection document without persisting it. * - * Omitting `id` validates create candidate data. Supplying `id` loads the latest stored document - * and merges optional partial data over it. Access control, hooks, field access, and validators - * receive the first-class `validate` operation in both cases. + * Omitting `id` validates create candidate data. Supplying `id` loads the stored main document by + * default. Set `draft: true` to use the newest available draft version, falling back to the main + * document. Optional partial data is merged over that base. Access control, hooks, field access, + * and validators receive the first-class `validate` operation in both cases. */ export type ValidateCollectionOptions = | ({ @@ -92,7 +94,7 @@ export type ValidateCollectionOptions = id?: never } & BaseOptions) | ({ - /** Optional partial candidate data to merge over the latest stored document. */ + /** Optional partial candidate data to merge over the selected stored document. */ data?: DeepPartial> /** ID of the stored document used as the candidate's base. */ id: DataFromCollectionSlug['id'] @@ -139,6 +141,7 @@ export async function validateLocalWithDataLocale( id, collection: collectionSlug, data, + draft = false, locale, overrideAccess = true, validationDataLocale, @@ -199,6 +202,7 @@ export async function validateLocalWithDataLocale( id, collection, data: validationData, + draft, overrideAccess, req, trash: validationTrash, diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 3efa440fb9e..7ecf8a91ff1 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -293,6 +293,7 @@ export const updateDocument = async < id, collection: collectionConfig.slug, data: result, + draft: true, locale: resolvePublishLocales({ locale, localization: config.localization, diff --git a/packages/payload/src/collections/operations/validate.ts b/packages/payload/src/collections/operations/validate.ts index ae2228cb4b5..9eed99eaa90 100644 --- a/packages/payload/src/collections/operations/validate.ts +++ b/packages/payload/src/collections/operations/validate.ts @@ -15,11 +15,12 @@ import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' import { flattenDataByLocale } from '../../utilities/flattenDataByLocale.js' -import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion.js' +import { replaceWithDraftIfAvailable } from '../../versions/drafts/replaceWithDraftIfAvailable.js' export type Arguments = { collection: Collection data?: DeepPartial> + draft: boolean id?: number | string overrideAccess: boolean req: PayloadRequest @@ -43,6 +44,7 @@ async function validateOperationWithScopedRequest( id, collection, data: incomingData, + draft, overrideAccess, req, trash, @@ -69,15 +71,9 @@ async function validateOperationWithScopedRequest( where, } - const storedDocument = await getLatestCollectionVersion< + let storedDocument = await req.payload.db.findOne< RequiredDataFromCollectionSlug & TypeWithID - >({ - id, - config: collectionConfig, - payload: req.payload, - query, - req, - }) + >({ ...query, req }) if (!storedDocument && hasWherePolicy) { throw new Forbidden(req.t) @@ -86,6 +82,17 @@ async function validateOperationWithScopedRequest( throw new NotFound(req.t) } + if (draft && collectionConfig.versions?.drafts) { + storedDocument = await replaceWithDraftIfAvailable({ + accessResult, + doc: storedDocument, + entity: collectionConfig, + entityType: 'collection', + overrideAccess, + req, + }) + } + docWithLocales = deepCopyObjectSimple(storedDocument) } diff --git a/packages/payload/src/errors/ValidationError.ts b/packages/payload/src/errors/ValidationError.ts index 7da3c248fa7..2035071254c 100644 --- a/packages/payload/src/errors/ValidationError.ts +++ b/packages/payload/src/errors/ValidationError.ts @@ -14,7 +14,7 @@ export const ValidationErrorName = 'ValidationError' export type ValidationFieldError = { /** Configured field label, when available. */ label?: LabelFunction | StaticLabel - /** Locale in which this field failed validation. */ + /** Locale for a localized validation pass. Omitted for non-localized validation. */ locale?: string /** Error message to display for this field. */ message: string diff --git a/packages/payload/src/globals/endpoints/validate.ts b/packages/payload/src/globals/endpoints/validate.ts index eaa58f1e723..f8119043ae6 100644 --- a/packages/payload/src/globals/endpoints/validate.ts +++ b/packages/payload/src/globals/endpoints/validate.ts @@ -14,8 +14,8 @@ import { validateGlobalLocal } from '../operations/local/validate.js' * Validates a global with optional partial candidate data. * * `POST {routes.api}/globals/{global}/validate` accepts an optional object body and requires one or - * more `locale` query parameters, or `locale=all`. Field validation failures return a `200` - * ValidationResult. + * more `locale` query parameters, or `locale=all`. The newest available draft is used as the base, + * falling back to the main global. Field validation failures return a `200` ValidationResult. */ export const validateHandler: PayloadHandler = async (req) => { const globalConfig = getRequestGlobal(req) @@ -28,6 +28,7 @@ export const validateHandler: PayloadHandler = async (req) => { const result = await validateGlobalLocal(req.payload, { slug: globalConfig.slug, data: req.data, + draft: true, locale, overrideAccess: false, req, diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index a3aaab4f6f9..7f05ebf1a57 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -22,14 +22,15 @@ import { validateOperation } from '../validate.js' /** * Options for validating a global document without persisting it. * - * The latest stored global is loaded and optional partial candidate data is merged over it. - * Access control, hooks, field access, and validators receive the first-class `validate` + * The stored main global is loaded by default. Set `draft: true` to use the newest available draft + * version, falling back to the main global. Optional partial candidate data is merged over that + * base. Access control, hooks, field access, and validators receive the first-class `validate` * operation. */ export type ValidateGlobalOptions = { /** Hook context merged into `req.context` for the validation lifecycle. */ context?: RequestContext - /** Optional partial candidate data to merge over the latest stored global. */ + /** Optional partial candidate data to merge over the selected stored global. */ data?: DeepPartial, 'id'>> /** * A locale, a non-empty locale array, or `'all'`. @@ -77,6 +78,7 @@ export async function validateGlobalLocalWithDataLocale, ): Promise { const { slug, data, locale, overrideAccess = true, validationDataLocale } = options + const { draft = false } = options if (locale === undefined) { throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) @@ -125,6 +127,7 @@ export async function validateGlobalLocalWithDataLocale = { data?: DeepPartial, 'id'>> + draft: boolean globalConfig: SanitizedGlobalConfig overrideAccess: boolean req: PayloadRequest @@ -37,6 +38,7 @@ export async function validateOperation( async function validateOperationWithScopedRequest({ slug, data: incomingData, + draft, globalConfig, overrideAccess, req, @@ -45,19 +47,28 @@ async function validateOperationWithScopedRequest({ ? await executeAccess({ data: incomingData, req }, globalConfig.access.validate) : true const where: Where = overrideAccess ? undefined! : (accessResult as Where) - const globalVersionResult = await getLatestGlobalVersion({ + let storedGlobal = await req.payload.db.findGlobal({ slug, - config: globalConfig, locale: req.locale!, - payload: req.payload, req, where, }) let docWithLocales: JsonObject = {} - if (globalVersionResult?.global) { - docWithLocales = deepCopyObjectSimple(globalVersionResult.global) + if (storedGlobal) { + if (draft && globalConfig.versions?.drafts) { + storedGlobal = await replaceWithDraftIfAvailable({ + accessResult, + doc: storedGlobal, + entity: globalConfig, + entityType: 'global', + overrideAccess, + req, + }) + } + + docWithLocales = deepCopyObjectSimple(storedGlobal) if (docWithLocales._id) { delete docWithLocales._id diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 43f02062197..daf5df065d9 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -758,9 +758,10 @@ export class BasePayload { * Validates a collection document candidate for selected locales without persisting data, * versions, or files. * - * Omit `id` to validate create data, or provide `id` to merge partial candidate data over the - * latest stored document. Field validation failures resolve to `{ valid: false, errors }`; - * access, argument, lookup, and other lifecycle errors throw. + * Omit `id` to validate create data. With `id`, the stored main document is the default base; + * `draft: true` selects the newest available draft version and falls back to the main document. + * Partial candidate data is merged over that base. Field validation failures resolve to + * `{ valid: false, errors }`; access, argument, lookup, and other lifecycle errors throw. * * @see https://payloadcms.com/docs/validation/overview#local-api */ @@ -774,8 +775,10 @@ export class BasePayload { * Validates a global document candidate for selected locales without persisting data or * versions. * - * Partial candidate data is merged over the latest stored global. Field validation failures - * resolve to `{ valid: false, errors }`; access, argument, and other lifecycle errors throw. + * The stored main global is the default base; `draft: true` selects the newest available draft + * version and falls back to the main global. Partial candidate data is merged over that base. + * Field validation failures resolve to `{ valid: false, errors }`; access, argument, and other + * lifecycle errors throw. * * @see https://payloadcms.com/docs/validation/overview#local-api */ diff --git a/packages/payload/src/utilities/resolveValidationLocales.ts b/packages/payload/src/utilities/resolveValidationLocales.ts index e391cae13e5..9420ddfcdae 100644 --- a/packages/payload/src/utilities/resolveValidationLocales.ts +++ b/packages/payload/src/utilities/resolveValidationLocales.ts @@ -10,7 +10,8 @@ import { APIError } from '../errors/index.js' * Locales accepted by collection and global on-demand validation. * * A non-empty array validates its unique locale codes in the order provided. `'all'` validates - * every locale available to the request. Projects without localization use `null`. + * every locale available to the request. Projects without localization use `null` in the Local + * API or `locale=all` in the REST API. */ // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents export type ValidationLocaleSelector = 'all' | TypedLocale | TypedLocale[] diff --git a/test/validate/config.ts b/test/validate/config.ts index 7c0260db943..ba199fcc1ff 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -432,6 +432,9 @@ const validationGlobal: GlobalConfig = { const publishCollection: CollectionConfig = { slug: publishCollectionSlug, + access: { + validate: () => true, + }, fields: [ { name: 'title', @@ -581,6 +584,9 @@ const publishCollection: CollectionConfig = { const publishGlobal: GlobalConfig = { slug: publishGlobalSlug, + access: { + validate: () => true, + }, fields: [ { name: 'title', diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 4e49b647163..2cac27e8261 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -511,6 +511,99 @@ describe('validate Local API', () => { }) }) + it('should use the published collection as the validation base unless draft is true', async () => { + const draft = await seedPublishCollection({ + de: 'German optional', + en: 'English draft', + es: 'Spanish required', + }) + + await payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + _status: 'published', + title: 'English published', + }, + locale: 'en', + }) + await payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + title: '', + }, + draft: true, + locale: 'en', + }) + + const versionsBefore = await payload.countVersions({ + collection: publishCollectionSlug, + where: { + parent: { + equals: draft.id, + }, + }, + }) + const defaultResult = await payload.validate({ + id: draft.id, + collection: publishCollectionSlug, + locale: 'en', + }) + const publishedResult = await payload.validate({ + id: draft.id, + collection: publishCollectionSlug, + draft: false, + locale: 'en', + }) + const draftResult = await payload.validate({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'en', + }) + const publishedAfter = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + locale: 'en', + }) + const draftAfter = await payload.findByID({ + id: draft.id, + collection: publishCollectionSlug, + draft: true, + locale: 'en', + }) + const versionsAfter = await payload.countVersions({ + collection: publishCollectionSlug, + where: { + parent: { + equals: draft.id, + }, + }, + }) + + expect(defaultResult).toEqual({ + errors: [], + valid: true, + }) + expect(publishedResult).toEqual({ + errors: [], + valid: true, + }) + expect(draftResult).toMatchObject({ + errors: [ + { + locale: 'en', + path: 'title', + }, + ], + valid: false, + }) + expect(publishedAfter.title).toBe('English published') + expect(draftAfter.title).toBe('') + expect(versionsAfter).toEqual(versionsBefore) + }) + it('should validate a partial update with an unchanged stored point representation', async () => { const stored = await payload.create({ collection: validationCollectionSlug, @@ -751,6 +844,81 @@ describe('validate Local API', () => { }) }) + it('should use the published global as the validation base unless draft is true', async () => { + await seedPublishGlobal({ + de: 'German optional', + en: 'English draft', + es: 'Spanish required', + }) + await payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + _status: 'published', + title: 'English published', + }, + locale: 'en', + }) + await payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + title: '', + }, + draft: true, + locale: 'en', + }) + + const versionsBefore = await payload.countGlobalVersions({ + global: publishGlobalSlug, + }) + const defaultResult = await payload.validateGlobal({ + slug: publishGlobalSlug, + locale: 'en', + }) + const publishedResult = await payload.validateGlobal({ + slug: publishGlobalSlug, + draft: false, + locale: 'en', + }) + const draftResult = await payload.validateGlobal({ + slug: publishGlobalSlug, + draft: true, + locale: 'en', + }) + const publishedAfter = await payload.findGlobal({ + slug: publishGlobalSlug, + locale: 'en', + }) + const draftAfter = await payload.findGlobal({ + slug: publishGlobalSlug, + draft: true, + locale: 'en', + }) + const versionsAfter = await payload.countGlobalVersions({ + global: publishGlobalSlug, + }) + + expect(defaultResult).toEqual({ + errors: [], + valid: true, + }) + expect(publishedResult).toEqual({ + errors: [], + valid: true, + }) + expect(draftResult).toMatchObject({ + errors: [ + { + locale: 'en', + path: 'title', + }, + ], + valid: false, + }) + expect(publishedAfter.title).toBe('English published') + expect(draftAfter.title).toBe('') + expect(versionsAfter).toEqual(versionsBefore) + }) + it('should execute first-class global validation access and throw on denial', async () => { const req = { operation: 'delete', @@ -892,6 +1060,85 @@ describe('validate Local API', () => { }) }) + it('should use the latest collection draft as the REST validation base', async () => { + const draft = await seedPublishCollection({ + de: 'German optional', + en: 'English draft', + es: 'Spanish required', + }) + + await payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + _status: 'published', + title: 'English published', + }, + locale: 'en', + }) + await payload.update({ + id: draft.id, + collection: publishCollectionSlug, + data: { + title: '', + }, + draft: true, + locale: 'en', + }) + + const response = await restClient.POST( + `/${publishCollectionSlug}/${draft.id}/validate?locale=en`, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + errors: [ + { + locale: 'en', + path: 'title', + }, + ], + valid: false, + }) + }) + + it('should use the latest global draft as the REST validation base', async () => { + await seedPublishGlobal({ + de: 'German optional', + en: 'English draft', + es: 'Spanish required', + }) + await payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + _status: 'published', + title: 'English published', + }, + locale: 'en', + }) + await payload.updateGlobal({ + slug: publishGlobalSlug, + data: { + title: '', + }, + draft: true, + locale: 'en', + }) + + const response = await restClient.POST(`/globals/${publishGlobalSlug}/validate?locale=en`) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + errors: [ + { + locale: 'en', + path: 'title', + }, + ], + valid: false, + }) + }) + it('should merge by-ID validation data without persisting it', async () => { const stored = await payload.create({ collection: validationCollectionSlug, From c5588190f9ec9fdd63df40b7c346b1443adb2aff Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 24 Jul 2026 00:02:24 +0100 Subject: [PATCH 19/47] fix(payload): harden validation safety --- .../operations/utilities/update.ts | 42 ++- .../utilities/getAccessOperationRequest.ts | 21 ++ .../getEntityPermissions.ts | 15 +- .../populateFieldPermissions.ts | 11 +- .../src/utilities/parseValidationLocale.ts | 2 +- .../src/utilities/resolvePublishLocales.ts | 4 +- .../src/utilities/resolveValidationLocales.ts | 8 +- test/types/types.spec.ts | 24 +- test/validate/config.ts | 227 +++++++++++++- test/validate/int.spec.ts | 293 ++++++++++++++++++ 10 files changed, 611 insertions(+), 36 deletions(-) create mode 100644 packages/payload/src/utilities/getAccessOperationRequest.ts diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 7ecf8a91ff1..d135aa4469c 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -168,28 +168,13 @@ export const updateDocument = async < }) } - // ///////////////////////////////////// - // Delete any associated files - // ///////////////////////////////////// - // When saving a draft on a document whose latest version is published, the file // referenced by docWithLocales is still actively used by the published main document. - // Deleting it here would break the published document's file even though no publish + // Deleting it during the update would break the published document's file even though no publish // is happening. Only skip deletion in this case; when the latest version is already a // draft, it is safe to delete the old draft file as it is being replaced. const isDraftOverPublished = isSavingDraft && docWithLocales._status === 'published' - if (!isDraftOverPublished) { - await deleteAssociatedFiles({ - collectionConfig, - config, - doc: docWithLocales, - files: filesToUpload, - overrideDelete: false, - req, - }) - } - // ///////////////////////////////////// // beforeValidate - Fields // ///////////////////////////////////// @@ -224,14 +209,6 @@ export const updateDocument = async < } } - // ///////////////////////////////////// - // Write files to local storage - // ///////////////////////////////////// - - if (!collectionConfig.upload.disableLocalStorage) { - await uploadFiles(payload, filesToUpload, req) - } - // ///////////////////////////////////// // beforeChange - Collection // ///////////////////////////////////// @@ -323,6 +300,23 @@ export const updateDocument = async < } } + // File deletion and writes must occur after the final post-hook publication validation barrier. + // Validation failures leave both the persisted upload and local files untouched. + if (!isDraftOverPublished) { + await deleteAssociatedFiles({ + collectionConfig, + config, + doc: docWithLocales, + files: filesToUpload, + overrideDelete: false, + req, + }) + } + + if (!collectionConfig.upload.disableLocalStorage) { + await uploadFiles(payload, filesToUpload, req) + } + if ( config.localization && hasLocalizeStatusEnabled(collectionConfig) && diff --git a/packages/payload/src/utilities/getAccessOperationRequest.ts b/packages/payload/src/utilities/getAccessOperationRequest.ts new file mode 100644 index 00000000000..325d94a6fb2 --- /dev/null +++ b/packages/payload/src/utilities/getAccessOperationRequest.ts @@ -0,0 +1,21 @@ +import type { FieldOperation, PayloadRequest } from '../types/index.js' + +import { isolateObjectProperty } from './isolateObjectProperty.js' + +/** + * Returns a request view whose active operation is isolated from the caller and sibling access + * policies. Runtime services and transaction identity remain shared with the original request. + */ +export function getAccessOperationRequest({ + operation, + req, +}: { + operation: FieldOperation + req: PayloadRequest +}): PayloadRequest { + const operationRequest = isolateObjectProperty(req, 'operation') + + operationRequest.operation = operation + + return operationRequest +} diff --git a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts index 4cde10bc0a8..12ed365896d 100644 --- a/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/getEntityPermissions.ts @@ -12,6 +12,7 @@ import type { SanitizedGlobalConfig } from '../../globals/config/types.js' import type { BlockSlug, DefaultDocumentIDType } from '../../index.js' import type { AllOperations, JsonObject, PayloadRequest, Where } from '../../types/index.js' +import { getAccessOperationRequest } from '../getAccessOperationRequest.js' import { entityDocExists } from './entityDocExists.js' import { populateFieldPermissions } from './populateFieldPermissions.js' @@ -163,7 +164,19 @@ export async function getEntityPermissions, + result: Promise.resolve( + accessFunction({ + id, + data, + req: + _operation === 'readVersions' || _operation === 'unlock' + ? req + : getAccessOperationRequest({ + operation: _operation, + req, + }), + }), + ) as Promise, }) } else { entityPermissions[operation] = { diff --git a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts index c72e37e2f71..c927ca5513d 100644 --- a/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts +++ b/packages/payload/src/utilities/getEntityPermissions/populateFieldPermissions.ts @@ -14,6 +14,7 @@ import type { AllOperations, JsonObject, PayloadRequest } from '../../types/inde import type { BlockReferencesPermissions } from './getEntityPermissions.js' import { type Field, tabHasName } from '../../fields/config/types.js' +import { getAccessOperationRequest } from '../getAccessOperationRequest.js' const isThenable = (value: unknown): value is Promise => value != null && typeof (value as { then?: unknown }).then === 'function' @@ -107,7 +108,10 @@ export const populateFieldPermissions = ({ collection, data, doc: data, - req, + req: getAccessOperationRequest({ + operation, + req, + }), // We cannot include siblingData or blockData here, as we do not have siblingData/blockData available once we reach block or array // rows, as we're calculating schema permissions, which do not include individual rows. // For consistency, it's thus better to never include the siblingData and blockData @@ -117,7 +121,10 @@ export const populateFieldPermissions = ({ data, doc: data, global: global!, - req, + req: getAccessOperationRequest({ + operation, + req, + }), // We cannot include siblingData or blockData here, as we do not have siblingData/blockData available once we reach block or array // rows, as we're calculating schema permissions, which do not include individual rows. // For consistency, it's thus better to never include the siblingData and blockData diff --git a/packages/payload/src/utilities/parseValidationLocale.ts b/packages/payload/src/utilities/parseValidationLocale.ts index 938eea92da3..87bdf42ab6b 100644 --- a/packages/payload/src/utilities/parseValidationLocale.ts +++ b/packages/payload/src/utilities/parseValidationLocale.ts @@ -74,7 +74,7 @@ export function parseValidationLocaleSelector(locale: unknown): ValidationLocale return 'all' case 'multiple': - return parsedLocale.locales + return parsedLocale.locales as [TypedLocale, ...TypedLocale[]] case 'single': return parsedLocale.locale diff --git a/packages/payload/src/utilities/resolvePublishLocales.ts b/packages/payload/src/utilities/resolvePublishLocales.ts index 42ce92e5dcc..d34d3cd38e8 100644 --- a/packages/payload/src/utilities/resolvePublishLocales.ts +++ b/packages/payload/src/utilities/resolvePublishLocales.ts @@ -6,7 +6,7 @@ type Args = { publishAllLocales: boolean } -export type PublishLocaleSelector = 'all' | (null | string)[] +export type PublishLocaleSelector = 'all' | [null | string, ...(null | string)[]] export function resolvePublishLocales({ locale, @@ -27,5 +27,5 @@ export function resolvePublishLocales({ } } - return [...new Set(locales)] + return [...new Set(locales)] as [null | string, ...(null | string)[]] } diff --git a/packages/payload/src/utilities/resolveValidationLocales.ts b/packages/payload/src/utilities/resolveValidationLocales.ts index 9420ddfcdae..781eab691b4 100644 --- a/packages/payload/src/utilities/resolveValidationLocales.ts +++ b/packages/payload/src/utilities/resolveValidationLocales.ts @@ -13,8 +13,12 @@ import { APIError } from '../errors/index.js' * every locale available to the request. Projects without localization use `null` in the Local * API or `locale=all` in the REST API. */ -// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -export type ValidationLocaleSelector = 'all' | TypedLocale | TypedLocale[] +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +export type ValidationLocaleSelector = + | 'all' + | readonly [TypedLocale, ...TypedLocale[]] + | TypedLocale +/* eslint-enable @typescript-eslint/no-redundant-type-constituents */ const validationLocaleConcurrency = 3 const sharedValidationRequestProperties = new Set([ diff --git a/test/types/types.spec.ts b/test/types/types.spec.ts index 7709adcc361..709bf16b4b8 100644 --- a/test/types/types.spec.ts +++ b/test/types/types.spec.ts @@ -207,10 +207,18 @@ describe('Types testing', () => { }) test('should accept multi-locale selectors without exposing concurrency controls', () => { + const mutableLocales: [null, ...null[]] = [null] + const readonlyLocales = [null] as const + + expect(payload.validate).type.toBeCallableWith({ + collection: 'pages', + data: {}, + locale: mutableLocales, + }) expect(payload.validate).type.toBeCallableWith({ collection: 'pages', data: {}, - locale: [null], + locale: readonlyLocales, }) expect(payload.validate).type.toBeCallableWith({ collection: 'pages', @@ -219,7 +227,11 @@ describe('Types testing', () => { }) expect(payload.validateGlobal).type.toBeCallableWith({ slug: 'menu', - locale: [null], + locale: mutableLocales, + }) + expect(payload.validateGlobal).type.toBeCallableWith({ + slug: 'menu', + locale: readonlyLocales, }) expect(payload.validateGlobal).type.toBeCallableWith({ slug: 'menu', @@ -236,6 +248,14 @@ describe('Types testing', () => { concurrency: 4, locale: 'all', }) + + const invalidOptions: ValidateCollectionOptions<'pages'> = { + collection: 'pages', + data: {}, + // @ts-expect-error Type '[]' is not assignable to type 'ValidationLocaleSelector'. + locale: [], + } + expect(invalidOptions).type.toBe>() }) }) diff --git a/test/validate/config.ts b/test/validate/config.ts index ba199fcc1ff..25aaee1a9b4 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -24,10 +24,12 @@ export const publishCollectionSlug = 'validation-publish-items' export const publishGlobalSlug = 'validation-publish-settings' export const writeTargetsSlug = 'validation-write-targets' export const validationUploadsSlug = 'validation-uploads' +export const validationPublishUploadsSlug = 'validation-publish-uploads' export const validationAdminCollectionSlug = 'validation-admin-items' export const validationDeniedCollectionSlug = 'validation-denied-items' export const validationNonLocalizedCollectionSlug = 'validation-non-localized-items' export const validationUploadsDir = path.resolve(dirname, 'validation-uploads') +export const validationPublishUploadsDir = path.resolve(dirname, 'validation-publish-uploads') type HookEvent = { context: Record @@ -38,6 +40,12 @@ type HookEvent = { export const hookEvents: HookEvent[] = [] export const accessEvents: string[] = [] +export const permissionOperationEvents: { + entity: 'collection' | 'global' + observedOperation: string | undefined + operation: string + source: 'entity' | 'field' +}[] = [] export const isolationEvents: { candidateMarker: unknown contextMarker: unknown @@ -72,6 +80,7 @@ export function clearValidationEvents(): void { localePassRequests.clear() activeLocalePasses = 0 maximumActiveLocalePasses = 0 + permissionOperationEvents.length = 0 validationRuntimeIdentityEvents.length = 0 } @@ -96,6 +105,27 @@ function getIsolationMarker(value: unknown): unknown { return (value as { isolation?: { marker?: unknown } } | null | undefined)?.isolation?.marker } +function recordPermissionOperation({ + entity, + operation, + req, + source, +}: { + entity: 'collection' | 'global' + operation: string + req: PayloadRequest + source: 'entity' | 'field' +}): boolean { + permissionOperationEvents.push({ + entity, + observedOperation: req.operation, + operation, + source, + }) + + return req.operation === operation +} + async function recordAndMutateIsolationState({ data, req, @@ -213,10 +243,50 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re const validationCollection: CollectionConfig = { slug: validationCollectionSlug, access: { + create: ({ req }) => { + recordPermissionOperation({ + entity: 'collection', + operation: 'create', + req, + source: 'entity', + }) + return true + }, + delete: ({ req }) => { + recordPermissionOperation({ + entity: 'collection', + operation: 'delete', + req, + source: 'entity', + }) + return true + }, + read: ({ req }) => { + recordPermissionOperation({ entity: 'collection', operation: 'read', req, source: 'entity' }) + return true + }, + update: ({ req }) => { + recordPermissionOperation({ + entity: 'collection', + operation: 'update', + req, + source: 'entity', + }) + return true + }, validate: async ({ data, req }) => { accessEvents.push('collection') + const hasValidationOperation = recordPermissionOperation({ + entity: 'collection', + operation: 'validate', + req, + source: 'entity', + }) await recordAndMutateIsolationState({ data, req, source: 'collection' }) - return req.payloadAPI === 'REST' || req.context.allowValidation === true + return ( + hasValidationOperation && + (req.payloadAPI === 'REST' || req.context.allowValidation === true) + ) }, }, fields: [ @@ -307,6 +377,76 @@ const validationCollection: CollectionConfig = { name: 'operation', type: 'text', }, + { + name: 'permissionProbe', + type: 'group', + fields: [ + { + name: 'nested', + type: 'text', + access: { + create: ({ req }) => { + recordPermissionOperation({ + entity: 'collection', + operation: 'create', + req, + source: 'field', + }) + return true + }, + read: ({ req }) => { + recordPermissionOperation({ + entity: 'collection', + operation: 'read', + req, + source: 'field', + }) + return true + }, + update: ({ req }) => { + recordPermissionOperation({ + entity: 'collection', + operation: 'update', + req, + source: 'field', + }) + return true + }, + validate: ({ req }) => + recordPermissionOperation({ + entity: 'collection', + operation: 'validate', + req, + source: 'field', + }), + }, + }, + { + name: 'content', + type: 'blocks', + blocks: [ + { + slug: 'permissionProbeBlock', + fields: [ + { + name: 'nested', + type: 'text', + access: { + validate: ({ req }) => + recordPermissionOperation({ + entity: 'collection', + operation: 'validate', + req, + source: 'field', + }), + }, + }, + ], + }, + ], + }, + ], + }, ], hooks: { beforeChange: [ @@ -371,10 +511,27 @@ const validationCollection: CollectionConfig = { const validationGlobal: GlobalConfig = { slug: validationGlobalSlug, access: { + read: ({ req }) => { + recordPermissionOperation({ entity: 'global', operation: 'read', req, source: 'entity' }) + return true + }, + update: ({ req }) => { + recordPermissionOperation({ entity: 'global', operation: 'update', req, source: 'entity' }) + return true + }, validate: async ({ data, req }) => { accessEvents.push('global') + const hasValidationOperation = recordPermissionOperation({ + entity: 'global', + operation: 'validate', + req, + source: 'entity', + }) await recordAndMutateIsolationState({ data, req, source: 'global' }) - return req.payloadAPI === 'REST' || req.context.allowValidation === true + return ( + hasValidationOperation && + (req.payloadAPI === 'REST' || req.context.allowValidation === true) + ) }, }, fields: [ @@ -397,6 +554,43 @@ const validationGlobal: GlobalConfig = { ? true : 'Location must use the public point tuple representation', }, + { + name: 'permissionProbe', + type: 'group', + fields: [ + { + name: 'nested', + type: 'text', + access: { + read: ({ req }) => { + recordPermissionOperation({ + entity: 'global', + operation: 'read', + req, + source: 'field', + }) + return true + }, + update: ({ req }) => { + recordPermissionOperation({ + entity: 'global', + operation: 'update', + req, + source: 'field', + }) + return true + }, + validate: ({ req }) => + recordPermissionOperation({ + entity: 'global', + operation: 'validate', + req, + source: 'field', + }), + }, + }, + ], + }, ], hooks: { beforeChange: [ @@ -704,6 +898,35 @@ export default buildConfigWithDefaults({ }, versions: false, }, + { + slug: validationPublishUploadsSlug, + access: { + validate: () => true, + }, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + required: true, + }, + ], + upload: { + imageSizes: [ + { + name: 'thumbnail', + height: 64, + width: 64, + }, + ], + staticDir: validationPublishUploadsDir, + }, + versions: { + drafts: { + validate: false, + }, + }, + }, ], globals: [validationGlobal, publishGlobal], jobs: { diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 2cac27e8261..94e50ecae00 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -3,6 +3,8 @@ import type { Payload, PayloadRequest } from 'payload' import { buildEditorState } from '@payloadcms/richtext-lexical' import fs from 'fs/promises' import path from 'path' +import { createLocalReq, getFileByPath } from 'payload' +import { getEntityPermissions } from 'payload/internal' import { fileURLToPath } from 'url' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' @@ -17,10 +19,13 @@ import { hookEvents, isolationEvents, localePassEvents, + permissionOperationEvents, publishCollectionSlug, publishGlobalSlug, validationCollectionSlug, validationGlobalSlug, + validationPublishUploadsDir, + validationPublishUploadsSlug, validationRuntimeIdentityEvents, validationUploadsDir, validationUploadsSlug, @@ -70,6 +75,11 @@ describe('validate Local API', () => { disableTransaction: true, where: { id: { exists: true } }, }), + payload.delete({ + collection: validationPublishUploadsSlug, + disableTransaction: true, + where: { id: { exists: true } }, + }), payload.delete({ collection: writeTargetsSlug, disableTransaction: true, @@ -82,6 +92,7 @@ describe('validate Local API', () => { }), ]) await fs.rm(validationUploadsDir, { force: true, recursive: true }) + await fs.rm(validationPublishUploadsDir, { force: true, recursive: true }) }) afterAll(async () => { @@ -89,6 +100,152 @@ describe('validate Local API', () => { }) describe('collections', () => { + it('should isolate operation-sensitive entity and nested field permission discovery', async () => { + const req = await createLocalReq( + { + context: { + allowValidation: true, + }, + }, + payload, + ) + req.operation = 'update' + + const authResult = await payload.auth({ + headers: new Headers(), + req, + }) + + expect(authResult.permissions.collections?.[validationCollectionSlug]).toMatchObject({ + fields: true, + validate: true, + }) + expect(authResult.permissions.globals?.[validationGlobalSlug]).toMatchObject({ + fields: true, + validate: true, + }) + + const collectionPermissions = await getEntityPermissions({ + blockReferencesPermissions: {}, + entity: payload.collections[validationCollectionSlug]!.config, + entityType: 'collection', + fetchData: false, + operations: ['validate'], + req, + }) + const globalPermissions = await getEntityPermissions({ + blockReferencesPermissions: {}, + entity: payload.globals.config.find(({ slug }) => slug === validationGlobalSlug)!, + entityType: 'global', + fetchData: false, + operations: ['validate'], + req, + }) + + expect(collectionPermissions).toMatchObject({ + fields: { + permissionProbe: { + fields: { + content: { + blocks: { + permissionProbeBlock: { + fields: { + nested: { + validate: { + permission: true, + }, + }, + }, + }, + }, + }, + nested: { + validate: { + permission: true, + }, + }, + }, + }, + }, + validate: { + permission: true, + }, + }) + expect(globalPermissions).toMatchObject({ + fields: { + permissionProbe: { + fields: { + nested: { + validate: { + permission: true, + }, + }, + }, + }, + }, + validate: { + permission: true, + }, + }) + expect(permissionOperationEvents).not.toHaveLength(0) + expect(permissionOperationEvents).toSatisfy((events: typeof permissionOperationEvents) => + events.every(({ observedOperation, operation }) => observedOperation === operation), + ) + expect(req.operation).toBe('update') + + clearValidationEvents() + + await expect( + payload.validate({ + collection: validationCollectionSlug, + data: { + permissionProbe: { + content: [ + { + blockType: 'permissionProbeBlock', + nested: 'collection block', + }, + ], + nested: 'collection nested', + }, + summary: 'collection summary', + title: 'Collection title', + }, + locale: 'en', + overrideAccess: false, + req, + }), + ).resolves.toEqual({ + errors: [], + valid: true, + }) + await expect( + payload.validateGlobal({ + slug: validationGlobalSlug, + data: { + permissionProbe: { + nested: 'global nested', + }, + }, + locale: 'en', + overrideAccess: false, + req, + }), + ).resolves.toEqual({ + errors: [], + valid: true, + }) + expect( + permissionOperationEvents.filter(({ operation }) => operation === 'validate'), + ).not.toHaveLength(0) + expect( + permissionOperationEvents + .filter(({ operation }) => operation === 'validate') + .every(({ observedOperation }) => observedOperation === 'validate'), + ).toBe(true) + expect(req.operation).toBe('update') + }) + it('should expose required localization metadata after config sanitization', () => { expect(payload.config.localization && payload.config.localization.locales).toMatchObject([ { code: 'en', required: false }, @@ -1384,6 +1541,142 @@ describe('validate Local API', () => { }) describe('publish enforcement', () => { + it('should preserve upload files and persisted state when replacement publish validation fails', async () => { + const originalFile = await getFileByPath(path.resolve(dirname, '../uploads/image.png')) + originalFile.name = 'validation-published-original.png' + + const draft = await payload.create({ + collection: validationPublishUploadsSlug, + data: { + _status: 'draft', + title: 'English title', + }, + draft: true, + file: originalFile, + locale: 'en', + }) + + await payload.update({ + id: draft.id, + collection: validationPublishUploadsSlug, + data: { + _status: 'draft', + title: 'Spanish title', + }, + draft: true, + locale: 'es', + }) + await payload.update({ + id: draft.id, + collection: validationPublishUploadsSlug, + data: { + _status: 'published', + title: 'English title', + }, + locale: 'en', + }) + await payload.update({ + id: draft.id, + collection: validationPublishUploadsSlug, + data: { + _status: 'draft', + title: '', + }, + draft: true, + locale: 'es', + }) + + const mainBefore = await payload.findByID({ + id: draft.id, + collection: validationPublishUploadsSlug, + locale: 'all', + }) + const draftBefore = await payload.findByID({ + id: draft.id, + collection: validationPublishUploadsSlug, + draft: true, + locale: 'all', + }) + const versionsBefore = await payload.countVersions({ + collection: validationPublishUploadsSlug, + where: { + parent: { + equals: draft.id, + }, + }, + }) + const filenamesBefore = (await fs.readdir(validationPublishUploadsDir)).sort() + const fileContentsBefore = new Map( + await Promise.all( + filenamesBefore.map(async (filename) => [ + filename, + await fs.readFile(path.join(validationPublishUploadsDir, filename)), + ]), + ), + ) + + expect(filenamesBefore).toEqual( + expect.arrayContaining([mainBefore.filename, mainBefore.sizes.thumbnail.filename]), + ) + + const replacementFile = await getFileByPath(path.resolve(dirname, '../uploads/small.png')) + replacementFile.name = 'validation-rejected-replacement.png' + + await expect( + payload.update({ + id: draft.id, + collection: validationPublishUploadsSlug, + data: { + _status: 'published', + title: 'Replacement title', + }, + file: replacementFile, + locale: 'en', + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'es', + path: 'title', + }), + ]), + }, + }) + + const mainAfter = await payload.findByID({ + id: draft.id, + collection: validationPublishUploadsSlug, + locale: 'all', + }) + const draftAfter = await payload.findByID({ + id: draft.id, + collection: validationPublishUploadsSlug, + draft: true, + locale: 'all', + }) + const versionsAfter = await payload.countVersions({ + collection: validationPublishUploadsSlug, + where: { + parent: { + equals: draft.id, + }, + }, + }) + const filenamesAfter = (await fs.readdir(validationPublishUploadsDir)).sort() + + expect(mainAfter).toEqual(mainBefore) + expect(draftAfter).toEqual(draftBefore) + expect(versionsAfter).toEqual(versionsBefore) + expect(filenamesAfter).toEqual(filenamesBefore) + + for (const [filename, contents] of fileContentsBefore) { + await expect( + fs.readFile(path.join(validationPublishUploadsDir, filename)), + ).resolves.toEqual(contents) + } + }) + it('should block collection create when a hook promotes a draft to published', async () => { await expect( payload.create({ From df19d62e040ebb7cdd742c7fb2d68cf56bce5b0d Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 24 Jul 2026 00:30:32 +0100 Subject: [PATCH 20/47] fix(payload): resolve global validation sources --- .../src/globals/operations/validate.ts | 108 ++++++++++++++---- test/validate/config.ts | 87 +++++++++++++- test/validate/int.spec.ts | 106 +++++++++++++++++ 3 files changed, 277 insertions(+), 24 deletions(-) diff --git a/packages/payload/src/globals/operations/validate.ts b/packages/payload/src/globals/operations/validate.ts index 29fb702afd0..212656d78ef 100644 --- a/packages/payload/src/globals/operations/validate.ts +++ b/packages/payload/src/globals/operations/validate.ts @@ -1,12 +1,15 @@ import type { DeepPartial } from 'ts-essentials' +import type { TypeWithID } from '../../collections/config/types.js' import type { ValidationResult } from '../../collections/operations/local/validate.js' +import type { AccessResult } from '../../config/types.js' import type { GlobalSlug, JsonObject } from '../../index.js' import type { PayloadRequest, Where } from '../../types/index.js' import type { DataFromGlobalSlug, SanitizedGlobalConfig } from '../config/types.js' import { executeAccess } from '../../auth/executeAccess.js' -import { ValidationError } from '../../errors/index.js' +import { hasWhereAccessResult } from '../../auth/types.js' +import { Forbidden, ValidationError } from '../../errors/index.js' import { beforeChange } from '../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' @@ -46,33 +49,19 @@ async function validateOperationWithScopedRequest({ const accessResult = !overrideAccess ? await executeAccess({ data: incomingData, req }, globalConfig.access.validate) : true - const where: Where = overrideAccess ? undefined! : (accessResult as Where) - let storedGlobal = await req.payload.db.findGlobal({ + const storedGlobal = await resolveValidationGlobalSource({ slug, - locale: req.locale!, + accessResult, + draft, + globalConfig, + overrideAccess, req, - where, }) - let docWithLocales: JsonObject = {} - - if (storedGlobal) { - if (draft && globalConfig.versions?.drafts) { - storedGlobal = await replaceWithDraftIfAvailable({ - accessResult, - doc: storedGlobal, - entity: globalConfig, - entityType: 'global', - overrideAccess, - req, - }) - } + const docWithLocales: JsonObject = deepCopyObjectSimple(storedGlobal) - docWithLocales = deepCopyObjectSimple(storedGlobal) - - if (docWithLocales._id) { - delete docWithLocales._id - } + if (docWithLocales._id) { + delete docWithLocales._id } const originalDoc = flattenDataByLocale({ @@ -164,3 +153,76 @@ async function validateOperationWithScopedRequest({ valid: true, } } + +async function resolveValidationGlobalSource({ + slug, + accessResult, + draft, + globalConfig, + overrideAccess, + req, +}: { + accessResult: AccessResult + draft: boolean + globalConfig: SanitizedGlobalConfig + overrideAccess: boolean + req: PayloadRequest + slug: string +}): Promise { + const accessWhere = overrideAccess ? undefined : (accessResult as Where) + const accessibleMain = await req.payload.db.findGlobal({ + slug, + locale: req.locale!, + req, + where: accessWhere, + }) + const hasAccessibleMain = hasGlobalSource(accessibleMain) + const accessibleBase = hasAccessibleMain ? accessibleMain : { globalType: slug } + // Global version lookups are slug-scoped; the shared helper's ID constraint applies to collections. + const accessibleSource = + draft && globalConfig.versions?.drafts + ? await replaceWithDraftIfAvailable({ + accessResult, + doc: accessibleBase as JsonObject & TypeWithID, + entity: globalConfig, + entityType: 'global', + overrideAccess, + req, + }) + : accessibleBase + + if (hasAccessibleMain || accessibleSource !== accessibleBase) { + return accessibleSource + } + + if (hasWhereAccessResult(accessResult)) { + const unrestrictedMain = await req.payload.db.findGlobal({ + slug, + locale: req.locale!, + req, + }) + const hasUnrestrictedMain = hasGlobalSource(unrestrictedMain) + const unrestrictedBase = hasUnrestrictedMain ? unrestrictedMain : { globalType: slug } + const unrestrictedSource = + draft && globalConfig.versions?.drafts + ? await replaceWithDraftIfAvailable({ + accessResult: true, + doc: unrestrictedBase as JsonObject & TypeWithID, + entity: globalConfig, + entityType: 'global', + overrideAccess: true, + req, + }) + : unrestrictedBase + + if (hasUnrestrictedMain || unrestrictedSource !== unrestrictedBase) { + throw new Forbidden(req.t) + } + } + + return {} +} + +function hasGlobalSource(source: JsonObject | null | undefined): source is JsonObject { + return Boolean(source && Object.keys(source).length > 0) +} diff --git a/test/validate/config.ts b/test/validate/config.ts index 25aaee1a9b4..fed32ed419b 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -20,6 +20,8 @@ const dirname = path.dirname(filename) export const validationCollectionSlug = 'validation-items' export const validationGlobalSlug = 'validation-settings' +export const validationDraftSourceGlobalSlug = 'validation-draft-source-settings' +export const validationAccessSourceGlobalSlug = 'validation-access-source-settings' export const publishCollectionSlug = 'validation-publish-items' export const publishGlobalSlug = 'validation-publish-settings' export const writeTargetsSlug = 'validation-write-targets' @@ -40,6 +42,7 @@ type HookEvent = { export const hookEvents: HookEvent[] = [] export const accessEvents: string[] = [] +export const globalValidationSourceEvents: string[] = [] export const permissionOperationEvents: { entity: 'collection' | 'global' observedOperation: string | undefined @@ -74,6 +77,7 @@ let maximumActiveLocalePasses = 0 export function clearValidationEvents(): void { accessEvents.length = 0 + globalValidationSourceEvents.length = 0 hookEvents.length = 0 isolationEvents.length = 0 localePassEvents.length = 0 @@ -624,6 +628,82 @@ const validationGlobal: GlobalConfig = { versions: false, } +const getValidationSourceAccess: NonNullable['validate'] = ({ req }) => { + const validationScope = req.context.validationScope + + return typeof validationScope === 'string' + ? { + scope: { + equals: validationScope, + }, + } + : false +} + +const validationDraftSourceGlobal: GlobalConfig = { + slug: validationDraftSourceGlobalSlug, + access: { + validate: getValidationSourceAccess, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'scope', + type: 'text', + required: true, + }, + ], + hooks: { + beforeValidate: [ + ({ data }) => { + globalValidationSourceEvents.push(validationDraftSourceGlobalSlug) + return data + }, + ], + }, + versions: { + drafts: { + validate: false, + }, + }, +} + +const validationAccessSourceGlobal: GlobalConfig = { + slug: validationAccessSourceGlobalSlug, + access: { + validate: getValidationSourceAccess, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'scope', + type: 'text', + required: true, + }, + ], + hooks: { + beforeValidate: [ + ({ data }) => { + globalValidationSourceEvents.push(validationAccessSourceGlobalSlug) + return data + }, + ], + }, + versions: { + drafts: { + validate: false, + }, + }, +} + const publishCollection: CollectionConfig = { slug: publishCollectionSlug, access: { @@ -928,7 +1008,12 @@ export default buildConfigWithDefaults({ }, }, ], - globals: [validationGlobal, publishGlobal], + globals: [ + validationGlobal, + validationDraftSourceGlobal, + validationAccessSourceGlobal, + publishGlobal, + ], jobs: { deleteJobOnComplete: false, }, diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 94e50ecae00..29fa0ad5171 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -16,13 +16,16 @@ import { clearValidationEvents, getLocalePassRequestCount, getMaximumActiveLocalePasses, + globalValidationSourceEvents, hookEvents, isolationEvents, localePassEvents, permissionOperationEvents, publishCollectionSlug, publishGlobalSlug, + validationAccessSourceGlobalSlug, validationCollectionSlug, + validationDraftSourceGlobalSlug, validationGlobalSlug, validationPublishUploadsDir, validationPublishUploadsSlug, @@ -51,6 +54,24 @@ describe('validate Local API', () => { }, locale: 'en', }) + await payload.updateGlobal({ + slug: validationDraftSourceGlobalSlug, + data: { + _status: 'draft', + scope: 'draft-visible', + title: 'Draft-only title', + }, + draft: true, + locale: 'en', + }) + await payload.updateGlobal({ + slug: validationAccessSourceGlobalSlug, + data: { + scope: 'stored-private', + title: 'Stored private title', + }, + locale: 'en', + }) }) beforeEach(() => { @@ -836,6 +857,91 @@ describe('validate Local API', () => { }) describe('globals', () => { + it('should load a draft-only global only when draft validation is requested', async () => { + const versionsBefore = await payload.countGlobalVersions({ + global: validationDraftSourceGlobalSlug, + }) + + const draftResult = await payload.validateGlobal({ + slug: validationDraftSourceGlobalSlug, + draft: true, + locale: 'en', + }) + const defaultResult = await payload.validateGlobal({ + slug: validationDraftSourceGlobalSlug, + locale: 'en', + }) + const mainResult = await payload.validateGlobal({ + slug: validationDraftSourceGlobalSlug, + draft: false, + locale: 'en', + }) + const versionsAfter = await payload.countGlobalVersions({ + global: validationDraftSourceGlobalSlug, + }) + + expect(draftResult).toEqual({ + errors: [], + valid: true, + }) + expect(defaultResult).toMatchObject({ + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'en', + path: 'title', + }), + ]), + valid: false, + }) + expect(mainResult).toEqual(defaultResult) + expect(versionsAfter).toEqual(versionsBefore) + }) + + it('should resolve an access-constrained draft when no matching main global exists', async () => { + const req = { + operation: 'read', + } satisfies Partial + + const result = await payload.validateGlobal({ + slug: validationDraftSourceGlobalSlug, + context: { + validationScope: 'draft-visible', + }, + draft: true, + locale: 'en', + overrideAccess: false, + req, + }) + + expect(result).toEqual({ + errors: [], + valid: true, + }) + expect(globalValidationSourceEvents).toEqual([validationDraftSourceGlobalSlug]) + expect(req.operation).toBe('read') + }) + + it('should reject a Where policy that filters out the persisted main global', async () => { + await expect( + payload.validateGlobal({ + slug: validationAccessSourceGlobalSlug, + context: { + validationScope: 'candidate-public', + }, + data: { + scope: 'candidate-public', + title: 'Valid candidate title', + }, + locale: 'en', + overrideAccess: false, + }), + ).rejects.toMatchObject({ + status: 403, + }) + + expect(globalValidationSourceEvents).toEqual([]) + }) + it('should ignore internal projection flags passed to the public global validate API', async () => { const result = await payload.validateGlobal({ slug: validationGlobalSlug, From 62f569b733517d9716ac4b370364bee0d8bf6656 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 29 Jul 2026 22:41:19 +0100 Subject: [PATCH 21/47] add tests --- test/validate/config.ts | 280 ++++++++++++- test/validate/int.spec.ts | 850 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 1088 insertions(+), 42 deletions(-) diff --git a/test/validate/config.ts b/test/validate/config.ts index fed32ed419b..4dd14d7200f 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -19,9 +19,14 @@ const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) export const validationCollectionSlug = 'validation-items' +export const validationFallbackCollectionSlug = 'validation-fallback-items' export const validationGlobalSlug = 'validation-settings' +export const validationFallbackGlobalSlug = 'validation-fallback-settings' +export const validationDeniedGlobalSlug = 'validation-denied-settings' export const validationDraftSourceGlobalSlug = 'validation-draft-source-settings' export const validationAccessSourceGlobalSlug = 'validation-access-source-settings' +export const validationWhereCollectionSlug = 'validation-where-items' +export const validationWriteTargetGlobalSlug = 'validation-write-target-settings' export const publishCollectionSlug = 'validation-publish-items' export const publishGlobalSlug = 'validation-publish-settings' export const writeTargetsSlug = 'validation-write-targets' @@ -42,13 +47,19 @@ type HookEvent = { export const hookEvents: HookEvent[] = [] export const accessEvents: string[] = [] +export const fallbackAccessEvents: { + operation: string | undefined + source: 'collection' | 'field' | 'global' +}[] = [] export const globalValidationSourceEvents: string[] = [] +export const localeFilterOperationEvents: (string | undefined)[] = [] export const permissionOperationEvents: { entity: 'collection' | 'global' observedOperation: string | undefined operation: string source: 'entity' | 'field' }[] = [] +export const scheduledValidationEvents: string[] = [] export const isolationEvents: { candidateMarker: unknown contextMarker: unknown @@ -77,14 +88,17 @@ let maximumActiveLocalePasses = 0 export function clearValidationEvents(): void { accessEvents.length = 0 + fallbackAccessEvents.length = 0 globalValidationSourceEvents.length = 0 hookEvents.length = 0 isolationEvents.length = 0 + localeFilterOperationEvents.length = 0 localePassEvents.length = 0 localePassRequests.clear() activeLocalePasses = 0 maximumActiveLocalePasses = 0 permissionOperationEvents.length = 0 + scheduledValidationEvents.length = 0 validationRuntimeIdentityEvents.length = 0 } @@ -200,6 +214,36 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re }) break + case 'deleteMany': + await req.payload.delete({ + collection: writeTargetsSlug, + disableTransaction: true, + req, + where: { + id: { + equals: targetID!, + }, + }, + }) + break + + case 'restoreGlobalVersion': + await req.payload.restoreGlobalVersion({ + id: targetID!, + req, + slug: validationWriteTargetGlobalSlug, + }) + break + + case 'restoreVersion': + await req.payload.restoreVersion({ + id: targetID!, + collection: writeTargetsSlug, + disableTransaction: true, + req, + }) + break + case 'update': await req.payload.update({ id: targetID!, @@ -210,6 +254,32 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re }) break + case 'updateGlobal': + await req.payload.updateGlobal({ + slug: validationWriteTargetGlobalSlug, + data: { + title: 'must not be updated', + }, + req, + }) + break + + case 'updateMany': + await req.payload.update({ + collection: writeTargetsSlug, + data: { + title: 'must not be updated', + }, + disableTransaction: true, + req, + where: { + id: { + equals: targetID!, + }, + }, + }) + break + case 'upload': { const fileData = Buffer.from('must not be uploaded') @@ -355,7 +425,18 @@ const validationCollection: CollectionConfig = { { name: 'writeAttempt', type: 'select', - options: ['create', 'delete', 'update', 'upload', 'version'], + options: [ + 'create', + 'delete', + 'deleteMany', + 'restoreGlobalVersion', + 'restoreVersion', + 'update', + 'updateGlobal', + 'updateMany', + 'upload', + 'version', + ], }, { name: 'targetID', @@ -628,6 +709,187 @@ const validationGlobal: GlobalConfig = { versions: false, } +const validationFallbackCollection: CollectionConfig = { + slug: validationFallbackCollectionSlug, + access: { + create: () => true, + read: () => true, + update: ({ req }) => { + fallbackAccessEvents.push({ + operation: req.operation, + source: 'collection', + }) + + if (req.operation === 'validate' && req.context.requireValidationUser === true) { + return Boolean(req.user) + } + + return req.operation !== 'validate' + ? true + : req.payloadAPI === 'REST' || req.context.allowUpdateFallback === true + }, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'updateProtected', + type: 'text', + access: { + update: ({ req }) => { + fallbackAccessEvents.push({ + operation: req.operation, + source: 'field', + }) + + return req.context.allowFieldUpdateFallback === true + }, + }, + validate: (value) => + value === undefined || value === 'valid' ? true : 'Update-protected field is invalid', + }, + { + name: 'explicitlyValidated', + type: 'text', + access: { + update: () => false, + validate: () => true, + }, + validate: (value) => + value === undefined || value === 'valid' ? true : 'Explicitly validated field is invalid', + }, + ], +} + +const validationWhereCollection: CollectionConfig = { + slug: validationWhereCollectionSlug, + access: { + create: () => true, + read: () => true, + update: ({ req }) => { + fallbackAccessEvents.push({ + operation: req.operation, + source: 'collection', + }) + + const validationScope = req.context.validationScope + + if (req.operation !== 'validate') { + return true + } + + return typeof validationScope === 'string' + ? { + scope: { + equals: validationScope, + }, + } + : false + }, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'scope', + type: 'text', + required: true, + }, + ], +} + +const validationFallbackGlobal: GlobalConfig = { + slug: validationFallbackGlobalSlug, + access: { + update: ({ req }) => { + fallbackAccessEvents.push({ + operation: req.operation, + source: 'global', + }) + + if (req.operation === 'validate' && req.context.requireValidationUser === true) { + return Boolean(req.user) + } + + return req.operation !== 'validate' + ? true + : req.payloadAPI === 'REST' || req.context.allowUpdateFallback === true + }, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'updateProtected', + type: 'text', + access: { + update: ({ req }) => { + fallbackAccessEvents.push({ + operation: req.operation, + source: 'field', + }) + + return req.context.allowFieldUpdateFallback === true + }, + }, + validate: (value) => + value === undefined || value === 'valid' + ? true + : 'Global update-protected field is invalid', + }, + ], +} + +const validationDeniedGlobal: GlobalConfig = { + slug: validationDeniedGlobalSlug, + access: { + update: ({ req }) => req.user?.email !== 'revoked@example.com', + validate: () => false, + }, + fields: [ + { + name: 'title', + type: 'text', + }, + ], + hooks: { + beforeValidate: [ + ({ operation }) => { + if (operation === 'validate') { + scheduledValidationEvents.push(operation) + } + }, + ], + }, + versions: { + drafts: { + schedulePublish: true, + validate: false, + }, + }, +} + +const validationWriteTargetGlobal: GlobalConfig = { + slug: validationWriteTargetGlobalSlug, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + ], + versions: true, +} + const getValidationSourceAccess: NonNullable['validate'] = ({ req }) => { const validationScope = req.context.validationScope @@ -707,12 +969,16 @@ const validationAccessSourceGlobal: GlobalConfig = { const publishCollection: CollectionConfig = { slug: publishCollectionSlug, access: { + update: () => true, validate: () => true, }, fields: [ { name: 'title', type: 'text', + access: { + validate: ({ req }) => req.context.denyPublishFieldValidation !== true, + }, localized: true, required: true, }, @@ -922,6 +1188,7 @@ const validationAdminCollection: CollectionConfig = { const validationDeniedCollection: CollectionConfig = { slug: validationDeniedCollectionSlug, access: { + create: () => true, validate: () => false, }, fields: [ @@ -931,6 +1198,11 @@ const validationDeniedCollection: CollectionConfig = { localized: true, }, ], + versions: { + drafts: { + validate: false, + }, + }, } const validationNonLocalizedCollection: CollectionConfig = { @@ -955,6 +1227,8 @@ export default buildConfigWithDefaults({ }, collections: [ validationCollection, + validationFallbackCollection, + validationWhereCollection, publishCollection, validationAdminCollection, validationDeniedCollection, @@ -1010,6 +1284,9 @@ export default buildConfigWithDefaults({ ], globals: [ validationGlobal, + validationFallbackGlobal, + validationDeniedGlobal, + validationWriteTargetGlobal, validationDraftSourceGlobal, validationAccessSourceGlobal, publishGlobal, @@ -1020,6 +1297,7 @@ export default buildConfigWithDefaults({ localization: { defaultLocale: 'en', filterAvailableLocales: ({ locales, req }) => { + localeFilterOperationEvents.push(req.operation) const availableLocaleCodes = req.context.availableLocaleCodes as string[] | undefined return availableLocaleCodes diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 29fa0ad5171..959983ceea3 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -11,27 +11,37 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import type { NextRESTClient } from '../__helpers/shared/NextRESTClient.js' import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js' +import { devUser } from '../credentials.js' import { accessEvents, clearValidationEvents, + fallbackAccessEvents, getLocalePassRequestCount, getMaximumActiveLocalePasses, globalValidationSourceEvents, hookEvents, isolationEvents, + localeFilterOperationEvents, localePassEvents, permissionOperationEvents, publishCollectionSlug, publishGlobalSlug, + scheduledValidationEvents, validationAccessSourceGlobalSlug, validationCollectionSlug, + validationDeniedCollectionSlug, + validationDeniedGlobalSlug, validationDraftSourceGlobalSlug, + validationFallbackCollectionSlug, + validationFallbackGlobalSlug, validationGlobalSlug, validationPublishUploadsDir, validationPublishUploadsSlug, validationRuntimeIdentityEvents, validationUploadsDir, validationUploadsSlug, + validationWhereCollectionSlug, + validationWriteTargetGlobalSlug, writeTargetsSlug, } from './config.js' @@ -91,6 +101,21 @@ describe('validate Local API', () => { disableTransaction: true, where: { id: { exists: true } }, }), + payload.delete({ + collection: validationDeniedCollectionSlug, + disableTransaction: true, + where: { id: { exists: true } }, + }), + payload.delete({ + collection: validationFallbackCollectionSlug, + disableTransaction: true, + where: { id: { exists: true } }, + }), + payload.delete({ + collection: validationWhereCollectionSlug, + disableTransaction: true, + where: { id: { exists: true } }, + }), payload.delete({ collection: validationUploadsSlug, disableTransaction: true, @@ -121,6 +146,248 @@ describe('validate Local API', () => { }) describe('collections', () => { + it('should fall back to collection update access with the validate operation', async () => { + await expect( + payload.validate({ + collection: validationFallbackCollectionSlug, + context: { + allowUpdateFallback: true, + }, + data: { + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + }), + ).resolves.toEqual({ + errors: [], + valid: true, + }) + + expect(fallbackAccessEvents).toContainEqual({ + operation: 'validate', + source: 'collection', + }) + expect(fallbackAccessEvents.every(({ operation }) => operation === 'validate')).toBe(true) + }) + + it('should deny collection validation when its update access fallback denies it', async () => { + await expect( + payload.validate({ + collection: validationFallbackCollectionSlug, + data: { + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + }), + ).rejects.toMatchObject({ + status: 403, + }) + }) + + it('should prefer explicit collection validate access over its update access fallback', async () => { + await expect( + payload.validate({ + collection: validationDeniedCollectionSlug, + data: { + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + }), + ).rejects.toMatchObject({ + status: 403, + }) + }) + + it('should let an explicit null user override an authenticated reused collection request', async () => { + const req = { + user: { + collection: validationCollectionSlug, + id: 'authenticated-user', + } as never, + } satisfies Partial + + await expect( + payload.validate({ + collection: validationFallbackCollectionSlug, + context: { + requireValidationUser: true, + }, + data: { + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + req, + user: null, + }), + ).rejects.toMatchObject({ + status: 403, + }) + expect(req.user).toMatchObject({ + id: 'authenticated-user', + }) + }) + + it('should fall back to field update access with the validate operation', async () => { + const excludedResult = await payload.validate({ + collection: validationFallbackCollectionSlug, + context: { + allowUpdateFallback: true, + }, + data: { + title: 'Candidate title', + updateProtected: 'invalid', + }, + locale: 'en', + overrideAccess: false, + }) + + expect(excludedResult).toEqual({ + errors: [], + valid: true, + }) + + clearValidationEvents() + + const includedResult = await payload.validate({ + collection: validationFallbackCollectionSlug, + context: { + allowFieldUpdateFallback: true, + allowUpdateFallback: true, + }, + data: { + title: 'Candidate title', + updateProtected: 'invalid', + }, + locale: 'en', + overrideAccess: false, + }) + + expect(includedResult).toMatchObject({ + errors: [ + { + locale: 'en', + path: 'updateProtected', + }, + ], + valid: false, + }) + expect(fallbackAccessEvents).toContainEqual({ + operation: 'validate', + source: 'field', + }) + }) + + it('should prefer explicit field validate access over its update access fallback', async () => { + const result = await payload.validate({ + collection: validationFallbackCollectionSlug, + context: { + allowUpdateFallback: true, + }, + data: { + explicitlyValidated: 'invalid', + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + }) + + expect(result).toMatchObject({ + errors: [ + { + locale: 'en', + path: 'explicitlyValidated', + }, + ], + valid: false, + }) + }) + + it('should expose fallback-derived entity and field validation permissions', async () => { + const req = await createLocalReq( + { + context: { + allowFieldUpdateFallback: true, + allowUpdateFallback: true, + }, + }, + payload, + ) + const permissions = await getEntityPermissions({ + blockReferencesPermissions: {}, + entity: payload.collections[validationFallbackCollectionSlug]!.config, + entityType: 'collection', + fetchData: false, + operations: ['validate'], + req, + }) + + expect(permissions).toMatchObject({ + fields: { + updateProtected: { + validate: { + permission: true, + }, + }, + }, + validate: { + permission: true, + }, + }) + expect(fallbackAccessEvents).toContainEqual({ + operation: 'validate', + source: 'collection', + }) + expect(fallbackAccessEvents).toContainEqual({ + operation: 'validate', + source: 'field', + }) + expect(req.operation).toBeUndefined() + }) + + it('should apply update access fallback constraints to stored collection validation', async () => { + const stored = await payload.create({ + collection: validationWhereCollectionSlug, + data: { + scope: 'allowed', + title: 'Stored title', + }, + }) + + await expect( + payload.validate({ + id: stored.id, + collection: validationWhereCollectionSlug, + context: { + validationScope: 'allowed', + }, + locale: 'en', + overrideAccess: false, + }), + ).resolves.toEqual({ + errors: [], + valid: true, + }) + + await expect( + payload.validate({ + id: stored.id, + collection: validationWhereCollectionSlug, + context: { + validationScope: 'denied', + }, + locale: 'en', + overrideAccess: false, + }), + ).rejects.toMatchObject({ + status: 403, + }) + expect(fallbackAccessEvents.every(({ operation }) => operation === 'validate')).toBe(true) + }) + it('should isolate operation-sensitive entity and nested field permission discovery', async () => { const req = await createLocalReq( { @@ -366,6 +633,7 @@ describe('validate Local API', () => { valid: true, }) expect(localePassEvents.map(({ localeAtStart }) => localeAtStart)).toEqual(['en', 'de']) + expect(localeFilterOperationEvents).toEqual(['validate']) }) it('should deduplicate explicit locales in deterministic order', async () => { @@ -857,6 +1125,183 @@ describe('validate Local API', () => { }) describe('globals', () => { + it('should fall back to global update access with the validate operation', async () => { + await expect( + payload.validateGlobal({ + slug: validationFallbackGlobalSlug, + context: { + allowUpdateFallback: true, + }, + data: { + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + }), + ).resolves.toEqual({ + errors: [], + valid: true, + }) + + expect(fallbackAccessEvents).toContainEqual({ + operation: 'validate', + source: 'global', + }) + expect(fallbackAccessEvents.every(({ operation }) => operation === 'validate')).toBe(true) + }) + + it('should deny global validation when its update access fallback denies it', async () => { + await expect( + payload.validateGlobal({ + slug: validationFallbackGlobalSlug, + data: { + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + }), + ).rejects.toMatchObject({ + status: 403, + }) + }) + + it('should let an explicit null user override an authenticated reused global request', async () => { + const req = { + user: { + collection: validationCollectionSlug, + id: 'authenticated-user', + } as never, + } satisfies Partial + + await expect( + payload.validateGlobal({ + slug: validationFallbackGlobalSlug, + context: { + requireValidationUser: true, + }, + data: { + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + req, + user: null, + }), + ).rejects.toMatchObject({ + status: 403, + }) + expect(req.user).toMatchObject({ + id: 'authenticated-user', + }) + }) + + it('should fall back to global field update access with the validate operation', async () => { + const excludedResult = await payload.validateGlobal({ + slug: validationFallbackGlobalSlug, + context: { + allowUpdateFallback: true, + }, + data: { + title: 'Candidate title', + updateProtected: 'invalid', + }, + locale: 'en', + overrideAccess: false, + }) + + expect(excludedResult).toEqual({ + errors: [], + valid: true, + }) + + clearValidationEvents() + + const includedResult = await payload.validateGlobal({ + slug: validationFallbackGlobalSlug, + context: { + allowFieldUpdateFallback: true, + allowUpdateFallback: true, + }, + data: { + title: 'Candidate title', + updateProtected: 'invalid', + }, + locale: 'en', + overrideAccess: false, + }) + + expect(includedResult).toMatchObject({ + errors: [ + { + locale: 'en', + path: 'updateProtected', + }, + ], + valid: false, + }) + expect(fallbackAccessEvents).toContainEqual({ + operation: 'validate', + source: 'field', + }) + }) + + it('should expose fallback-derived global and field validation permissions', async () => { + const req = await createLocalReq( + { + context: { + allowFieldUpdateFallback: true, + allowUpdateFallback: true, + }, + }, + payload, + ) + const permissions = await getEntityPermissions({ + blockReferencesPermissions: {}, + entity: payload.globals.config.find(({ slug }) => slug === validationFallbackGlobalSlug)!, + entityType: 'global', + fetchData: false, + operations: ['validate'], + req, + }) + + expect(permissions).toMatchObject({ + fields: { + updateProtected: { + validate: { + permission: true, + }, + }, + }, + validate: { + permission: true, + }, + }) + expect(fallbackAccessEvents).toContainEqual({ + operation: 'validate', + source: 'global', + }) + expect(fallbackAccessEvents).toContainEqual({ + operation: 'validate', + source: 'field', + }) + expect(req.operation).toBeUndefined() + }) + + it('should prefer explicit global validate access over its update access fallback', async () => { + await expect( + payload.validateGlobal({ + slug: validationDeniedGlobalSlug, + data: { + title: 'Candidate title', + }, + locale: 'en', + overrideAccess: false, + }), + ).rejects.toMatchObject({ + status: 403, + }) + }) + it('should load a draft-only global only when draft validation is requested', async () => { const versionsBefore = await payload.countGlobalVersions({ global: validationDraftSourceGlobalSlug, @@ -1059,6 +1504,7 @@ describe('validate Local API', () => { summary: 'stored global summary', title: 'Stored global title', }) + expect(localeFilterOperationEvents).toEqual(['validate']) expect(req.operation).toBe('read') }) @@ -1197,32 +1643,87 @@ describe('validate Local API', () => { ).rejects.toMatchObject({ status: 403, }) - - expect(accessEvents).toEqual(['global']) - expect(req.operation).toBe('delete') + + expect(accessEvents).toEqual(['global']) + expect(req.operation).toBe('delete') + }) + + it('should restore the caller request operation after a global hook throws', async () => { + const req = { + operation: 'create', + } satisfies Partial + + await expect( + payload.validateGlobal({ + slug: validationGlobalSlug, + context: { + throwValidationHook: true, + }, + locale: 'en', + req, + }), + ).rejects.toThrow('global validation hook failure') + + expect(req.operation).toBe('create') + }) + }) + + describe('REST API', () => { + it('should use collection update access when validate access is not configured', async () => { + const response = await restClient.POST( + `/${validationFallbackCollectionSlug}/validate?locale=en`, + { + body: JSON.stringify({ + title: 'Candidate title', + }), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + errors: [], + valid: true, + }) + expect(fallbackAccessEvents).toContainEqual({ + operation: 'validate', + source: 'collection', + }) + }) + + it('should use global update access when validate access is not configured', async () => { + const response = await restClient.POST( + `/globals/${validationFallbackGlobalSlug}/validate?locale=en`, + { + body: JSON.stringify({ + title: 'Candidate title', + }), + }, + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + errors: [], + valid: true, + }) + expect(fallbackAccessEvents).toContainEqual({ + operation: 'validate', + source: 'global', + }) }) - it('should restore the caller request operation after a global hook throws', async () => { - const req = { - operation: 'create', - } satisfies Partial - - await expect( - payload.validateGlobal({ - slug: validationGlobalSlug, - context: { - throwValidationHook: true, - }, - locale: 'en', - req, - }), - ).rejects.toThrow('global validation hook failure') + it('should deny global REST validation when explicit validate access denies it', async () => { + const response = await restClient.POST( + `/globals/${validationDeniedGlobalSlug}/validate?locale=en`, + { + body: JSON.stringify({ + title: 'Candidate title', + }), + }, + ) - expect(req.operation).toBe('create') + expect(response.status).toBe(403) }) - }) - describe('REST API', () => { it('should return invalid collection create validation without creating a document', async () => { const response = await restClient.POST(`/${validationCollectionSlug}/validate?locale=en`, { body: JSON.stringify({ @@ -1304,6 +1805,14 @@ describe('validate Local API', () => { }), }, ) + expect(repeatedLocale.status).toBe(200) + await expect(repeatedLocale.json()).resolves.toEqual({ + errors: [], + valid: true, + }) + + clearValidationEvents() + const allLocales = await restClient.POST(`/${validationCollectionSlug}/validate?locale=all`, { body: JSON.stringify({ summary: 'candidate summary', @@ -1311,16 +1820,12 @@ describe('validate Local API', () => { }), }) - expect(repeatedLocale.status).toBe(200) - await expect(repeatedLocale.json()).resolves.toEqual({ - errors: [], - valid: true, - }) expect(allLocales.status).toBe(200) await expect(allLocales.json()).resolves.toEqual({ errors: [], valid: true, }) + expect(localeFilterOperationEvents).toEqual(['validate']) }) it('should use the latest collection draft as the REST validation base', async () => { @@ -1482,20 +1987,15 @@ describe('validate Local API', () => { }) }) - it('should deny validation when access.validate returns false', async () => { - const collection = payload.collections[validationCollectionSlug]! - const validate = collection.config.access.validate - - collection.config.access.validate = () => false - - const response = await restClient.POST(`/${validationCollectionSlug}/validate?locale=en`, { - body: JSON.stringify({ - summary: 'candidate summary', - title: 'Candidate title', - }), - }) - - collection.config.access.validate = validate + it('should deny collection REST validation when explicit validate access denies it', async () => { + const response = await restClient.POST( + `/${validationDeniedCollectionSlug}/validate?locale=en`, + { + body: JSON.stringify({ + title: 'Candidate title', + }), + }, + ) expect(response.status).toBe(403) }) @@ -1591,6 +2091,23 @@ describe('validate Local API', () => { }) }) + it('should reject a bulk update that reuses the validation request before a row is written', async () => { + const target = await createWriteTarget() + + await expect(runWriteAttempt('updateMany', target.id)).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + await expect( + payload.findByID({ + id: target.id, + collection: writeTargetsSlug, + }), + ).resolves.toMatchObject({ + title: 'stored target', + }) + }) + it('should reject a delete that reuses the validation request before a row is removed', async () => { const target = await createWriteTarget() @@ -1608,6 +2125,118 @@ describe('validate Local API', () => { }) }) + it('should reject a bulk delete that reuses the validation request before a row is removed', async () => { + const target = await createWriteTarget() + + await expect(runWriteAttempt('deleteMany', target.id)).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + await expect( + payload.findByID({ + id: target.id, + collection: writeTargetsSlug, + }), + ).resolves.toMatchObject({ + title: 'stored target', + }) + }) + + it('should reject a global update that reuses the validation request before data is written', async () => { + await payload.updateGlobal({ + slug: validationWriteTargetGlobalSlug, + data: { + title: 'stored global target', + }, + }) + + await expect(runWriteAttempt('updateGlobal')).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + await expect( + payload.findGlobal({ + slug: validationWriteTargetGlobalSlug, + }), + ).resolves.toMatchObject({ + title: 'stored global target', + }) + }) + + it('should reject a collection version restore before the document is written', async () => { + const target = await createWriteTarget() + + await payload.update({ + id: target.id, + collection: writeTargetsSlug, + data: { + title: 'latest target', + }, + disableTransaction: true, + }) + + const versions = await payload.findVersions({ + collection: writeTargetsSlug, + where: { + parent: { + equals: target.id, + }, + }, + }) + const originalVersion = versions.docs.find(({ version }) => version.title === 'stored target') + + expect(originalVersion).toBeDefined() + + await expect(runWriteAttempt('restoreVersion', originalVersion!.id)).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + await expect( + payload.findByID({ + id: target.id, + collection: writeTargetsSlug, + }), + ).resolves.toMatchObject({ + title: 'latest target', + }) + }) + + it('should reject a global version restore before the global is written', async () => { + await payload.updateGlobal({ + slug: validationWriteTargetGlobalSlug, + data: { + title: 'stored global target', + }, + }) + await payload.updateGlobal({ + slug: validationWriteTargetGlobalSlug, + data: { + title: 'latest global target', + }, + }) + + const versions = await payload.findGlobalVersions({ + slug: validationWriteTargetGlobalSlug, + }) + const originalVersion = versions.docs.find( + ({ version }) => version.title === 'stored global target', + ) + + expect(originalVersion).toBeDefined() + + await expect(runWriteAttempt('restoreGlobalVersion', originalVersion!.id)).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + await expect( + payload.findGlobal({ + slug: validationWriteTargetGlobalSlug, + }), + ).resolves.toMatchObject({ + title: 'latest global target', + }) + }) + it('should reject an upload that reuses the validation request before a row or file is written', async () => { await fs.mkdir(validationUploadsDir, { recursive: true }) @@ -1647,6 +2276,72 @@ describe('validate Local API', () => { }) describe('publish enforcement', () => { + it('should not require validate access after collection create access succeeds', async () => { + await expect( + payload.create({ + collection: validationDeniedCollectionSlug, + data: { + _status: 'published', + title: 'Published with create access', + }, + locale: 'en', + overrideAccess: false, + }), + ).resolves.toMatchObject({ + _status: 'published', + title: 'Published with create access', + }) + }) + + it('should not require validate access after global update access succeeds', async () => { + await expect( + payload.updateGlobal({ + slug: validationDeniedGlobalSlug, + data: { + _status: 'published', + title: 'Published with update access', + }, + locale: 'en', + overrideAccess: false, + }), + ).resolves.toMatchObject({ + _status: 'published', + title: 'Published with update access', + }) + }) + + it('should validate the complete publish candidate despite restrictive validate field access', async () => { + const draft = await seedPublishCollection({ + de: 'German optional', + en: 'English title', + es: '', + }) + + await expect( + payload.update({ + id: draft.id, + collection: publishCollectionSlug, + context: { + denyPublishFieldValidation: true, + }, + data: { + _status: 'published', + }, + locale: 'en', + overrideAccess: false, + }), + ).rejects.toMatchObject({ + data: { + errors: expect.arrayContaining([ + expect.objectContaining({ + locale: 'es', + path: 'title', + }), + ]), + }, + }) + }) + it('should preserve upload files and persisted state when replacement publish validation fails', async () => { const originalFile = await getFileByPath(path.resolve(dirname, '../uploads/image.png')) originalFile.name = 'validation-published-original.png' @@ -2815,6 +3510,69 @@ describe('validate Local API', () => { }) }) + it('should not require validate access after scheduled global update access succeeds', async () => { + const adminUser = await payload.create({ + collection: 'users', + data: devUser, + }) + + await payload.updateGlobal({ + slug: validationDeniedGlobalSlug, + data: { + _status: 'draft', + title: 'Scheduled with update access', + }, + draft: true, + locale: 'en', + }) + + const job = await payload.jobs.queue({ + input: { + global: validationDeniedGlobalSlug, + user: adminUser.id, + }, + task: 'schedulePublish', + }) + + const result = await payload.jobs.run({ silent: true }) + + expect(result.jobStatus?.[job.id]?.status).toBe('success') + expect(scheduledValidationEvents).toEqual(['validate', 'validate', 'validate', 'validate']) + }) + + it('should check scheduled update access before running validation hooks', async () => { + const revokedUser = await payload.create({ + collection: 'users', + data: { + ...devUser, + email: 'revoked@example.com', + }, + }) + + await payload.updateGlobal({ + slug: validationDeniedGlobalSlug, + data: { + _status: 'draft', + title: 'Must not be validated', + }, + draft: true, + locale: 'en', + }) + + const job = await payload.jobs.queue({ + input: { + global: validationDeniedGlobalSlug, + user: revokedUser.id, + }, + task: 'schedulePublish', + }) + + const result = await payload.jobs.run({ silent: true }) + + expect(result.jobStatus?.[job.id]?.status).toBe('error-reached-max-retries') + expect(scheduledValidationEvents).toEqual([]) + }) + it('should validate the current and required global locales but skip optional siblings', async () => { await seedPublishGlobal({ de: '', @@ -2965,7 +3723,17 @@ async function createWriteTarget() { } async function runWriteAttempt( - writeAttempt: 'create' | 'delete' | 'update' | 'upload' | 'version', + writeAttempt: + | 'create' + | 'delete' + | 'deleteMany' + | 'restoreGlobalVersion' + | 'restoreVersion' + | 'update' + | 'updateGlobal' + | 'updateMany' + | 'upload' + | 'version', targetID?: number | string, ) { return payload.validate({ From 92222784d6cbb314a4a03c090b760befe968c2d9 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 29 Jul 2026 22:41:30 +0100 Subject: [PATCH 22/47] more docs --- docs/access-control/collections.mdx | 14 +++++++------- docs/access-control/fields.mdx | 12 ++++++------ docs/access-control/globals.mdx | 10 +++++----- docs/access-control/overview.mdx | 3 ++- docs/validation/overview.mdx | 24 ++++++++++++++++-------- 5 files changed, 36 insertions(+), 27 deletions(-) diff --git a/docs/access-control/collections.mdx b/docs/access-control/collections.mdx index 19870d719cc..e7bdb7dbe70 100644 --- a/docs/access-control/collections.mdx +++ b/docs/access-control/collections.mdx @@ -54,13 +54,13 @@ export const CollectionWithAccessControl: CollectionConfig = { The following options are available: -| Function | Allows/Denies Access | -| -------------- | -------------------------------------------------------------------------------- | -| **`create`** | Used in the `create` operation. [More details](#create). | -| **`read`** | Used in the `find` and `findByID` operations. [More details](#read). | -| **`update`** | Used in the `update` operation. [More details](#update). | -| **`delete`** | Used in the `delete` operation. [More details](#delete). | -| **`validate`** | Used by [on-demand validation](../validation/overview#access-control-and-hooks). | +| Function | Allows/Denies Access | +| -------------- | ----------------------------------------------------------------------------------------------------------------- | +| **`create`** | Used in the `create` operation. [More details](#create). | +| **`read`** | Used in the `find` and `findByID` operations. [More details](#read). | +| **`update`** | Used in the `update` operation. [More details](#update). | +| **`delete`** | Used in the `delete` operation. [More details](#delete). | +| **`validate`** | Optionally overrides `update` access for [on-demand validation](../validation/overview#access-control-and-hooks). | If a Collection supports [`Authentication`](../authentication/overview), the following additional options are available: diff --git a/docs/access-control/fields.mdx b/docs/access-control/fields.mdx index 62e0f860ecb..26b70abb4cc 100644 --- a/docs/access-control/fields.mdx +++ b/docs/access-control/fields.mdx @@ -58,12 +58,12 @@ export const Posts: CollectionConfig = { The following options are available: -| Function | Purpose | -| -------------- | --------------------------------------------------------------------------------------------------------------------- | -| **`create`** | Allows or denies the ability to set a field's value when creating a new document. [More details](#create). | -| **`read`** | Allows or denies the ability to read a field's value. [More details](#read). | -| **`update`** | Allows or denies the ability to update a field's value. [More details](#update). | -| **`validate`** | Allows or denies candidate field data during [on-demand validation](../validation/overview#access-control-and-hooks). | +| Function | Purpose | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| **`create`** | Allows or denies the ability to set a field's value when creating a new document. [More details](#create). | +| **`read`** | Allows or denies the ability to read a field's value. [More details](#read). | +| **`update`** | Allows or denies the ability to update a field's value. [More details](#update). | +| **`validate`** | Optionally overrides `update` access for candidate field data during [on-demand validation](../validation/overview#access-control-and-hooks). | ### Create diff --git a/docs/access-control/globals.mdx b/docs/access-control/globals.mdx index 836a73c1116..2a533f65414 100644 --- a/docs/access-control/globals.mdx +++ b/docs/access-control/globals.mdx @@ -50,11 +50,11 @@ export default Header The following options are available: -| Function | Allows/Denies Access | -| -------------- | -------------------------------------------------------------------------------- | -| **`read`** | Used in the `findOne` Global operation. [More details](#read). | -| **`update`** | Used in the `update` Global operation. [More details](#update). | -| **`validate`** | Used by [on-demand validation](../validation/overview#access-control-and-hooks). | +| Function | Allows/Denies Access | +| -------------- | ----------------------------------------------------------------------------------------------------------------- | +| **`read`** | Used in the `findOne` Global operation. [More details](#read). | +| **`update`** | Used in the `update` Global operation. [More details](#update). | +| **`validate`** | Optionally overrides `update` access for [on-demand validation](../validation/overview#access-control-and-hooks). | If a Global supports [Versions](../versions/overview), the following additional options are available: diff --git a/docs/access-control/overview.mdx b/docs/access-control/overview.mdx index af77a054810..b3dfb433a09 100644 --- a/docs/access-control/overview.mdx +++ b/docs/access-control/overview.mdx @@ -13,7 +13,8 @@ Access Control determines what a user can and cannot do with any given Document, Access Control functions are scoped to the _operation_, meaning you can have different rules for `create`, `read`, `update`, `delete`, etc. Access Control functions are executed _before_ any changes are made and _before_ any operations are completed. This allows you to determine if the user has the necessary permissions before fulfilling the request. [On-demand validation](../validation/overview#access-control-and-hooks) uses its own first-class -`validate` operation for collection, global, and field access control. +`validate` operation for collection, global, and field access control. Its access policy falls back +to the corresponding `update` function unless `validate` is configured explicitly. There are many use cases for Access Control, including: diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index bb7f5b76f8d..61335456afe 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -231,6 +231,9 @@ Publishing one locale validates that locale and every locale with `required: tru locales validates every available locale. If any locale is invalid, Payload throws a `ValidationError` before writing the document, version, or files. +Publishing uses its normal `create` or `update` access policy; the internal validity check does not +require separate `validate` access. + The default Admin Publish controls run a preflight so editors can see locale-grouped errors before submission. The **Validate all locales** action uses current unsaved form data and is shown only for localized documents when the user has `validate` permission. If you replace the default Publish @@ -243,7 +246,9 @@ require publish validation. ## Access control and hooks -Configure `validate` access independently for collections, globals, and fields: +Validation falls back to the corresponding `update` access function for collections, globals, and +fields. The fallback still receives `req.operation === 'validate'`. Configure `validate` only when +validation should use a different policy: ```ts import type { CollectionConfig, GlobalConfig } from 'payload' @@ -251,14 +256,14 @@ import type { CollectionConfig, GlobalConfig } from 'payload' export const Posts: CollectionConfig = { slug: 'posts', access: { - validate: ({ req }) => Boolean(req.user), + update: ({ req }) => Boolean(req.user), }, fields: [ { name: 'internalNotes', type: 'textarea', access: { - validate: ({ req }) => Boolean(req.user), + update: ({ req }) => Boolean(req.user), }, }, ], @@ -267,21 +272,24 @@ export const Posts: CollectionConfig = { export const SiteSettings: GlobalConfig = { slug: 'site-settings', access: { + update: ({ req }) => req.user?.collection === 'admins', + // Optional: let other authenticated users validate without granting updates. validate: ({ req }) => Boolean(req.user), }, fields: [], } ``` -Collection, global, and field access functions all receive the first-class +Whether configured directly or inherited from `update`, collection, global, and field access +functions all receive the first-class `req.operation === 'validate'`. Field `beforeValidate` and `beforeChange` hooks, field validators, and collection/global `beforeValidate` and `beforeChange` hooks also receive `operation: 'validate'`. Payload never simulates `'create'` or `'update'` as the access operation. -The validation lifecycle itself does not persist the candidate. Payload also rejects managed write -operations that reuse the active validation request. Hooks still run, however, and can cause side -effects through an external service, a raw database client, or a separate Payload request. Branch -side-effecting hooks when needed: +The validation lifecycle itself does not persist the candidate. Payload also rejects document, +global, upload, and version writes that reuse the active validation request. Hooks still run, +however, and can cause side effects through other Payload APIs, an external service, a raw database +client, or a separate Payload request. Branch side-effecting hooks when needed: ```ts import type { CollectionBeforeChangeHook } from 'payload' From 1e5e2280965418b68dbd27affac75c16d64df22d Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 29 Jul 2026 22:46:39 +0100 Subject: [PATCH 23/47] remove redundant calls from job.ts --- packages/payload/src/versions/schedule/job.ts | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/packages/payload/src/versions/schedule/job.ts b/packages/payload/src/versions/schedule/job.ts index 4fd49397f8c..fce9973306c 100644 --- a/packages/payload/src/versions/schedule/job.ts +++ b/packages/payload/src/versions/schedule/job.ts @@ -5,7 +5,6 @@ import type { SchedulePublishTaskInput } from './types.js' import { ValidationError } from '../../errors/index.js' import { JobCancelledError } from '../../queues/errors/index.js' -import { resolvePublishLocales } from '../../utilities/resolvePublishLocales.js' type Args = { adminUserSlug: string @@ -69,11 +68,6 @@ export const getSchedulePublishTask = ({ } const isPublishAllLocales = input.locale === undefined - const publishLocales = resolvePublishLocales({ - locale: input.locale, - localization: req.payload.config.localization, - publishAllLocales: isPublishAllLocales, - }) if (input.doc) { const collectionSlug = input.doc.relationTo @@ -85,24 +79,6 @@ export const getSchedulePublishTask = ({ 'text' const id = idType === 'number' ? Number(input.doc.value) : input.doc.value - if (_status === 'published') { - const validationResult = await runWithScheduledPublishValidationErrorHandling(() => - req.payload.validate({ - id, - collection: collectionSlug, - draft: true, - locale: publishLocales, - overrideAccess: user === null, - req, - user, - }), - ) - - if (!validationResult.valid) { - throwScheduledPublishValidationError(validationResult.errors) - } - } - await runWithScheduledPublishValidationErrorHandling(() => req.payload.update({ id, @@ -123,23 +99,6 @@ export const getSchedulePublishTask = ({ if (input.global) { const globalSlug = input.global - if (_status === 'published') { - const validationResult = await runWithScheduledPublishValidationErrorHandling(() => - req.payload.validateGlobal({ - slug: globalSlug, - draft: true, - locale: publishLocales, - overrideAccess: user === null, - req, - user, - }), - ) - - if (!validationResult.valid) { - throwScheduledPublishValidationError(validationResult.errors) - } - } - await runWithScheduledPublishValidationErrorHandling(() => req.payload.updateGlobal({ slug: globalSlug, From 0ca6ed2af69c4f772916854b9feb2b8f94d38bbe Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 29 Jul 2026 22:46:47 +0100 Subject: [PATCH 24/47] update --- .../src/collections/config/defaults.ts | 6 +- .../payload/src/collections/config/types.ts | 1 + .../src/collections/operations/create.ts | 2 +- .../collections/operations/local/validate.ts | 5 +- .../collections/operations/restoreVersion.ts | 3 + .../operations/utilities/update.ts | 2 +- .../payload/src/fields/config/sanitize.ts | 2 + packages/payload/src/fields/config/types.ts | 1 + .../payload/src/globals/config/sanitize.ts | 2 +- packages/payload/src/globals/config/types.ts | 1 + .../src/globals/operations/local/validate.ts | 5 +- .../src/globals/operations/restoreVersion.ts | 3 + .../payload/src/globals/operations/update.ts | 2 +- .../src/utilities/assertNoValidationWrite.ts | 5 +- .../src/utilities/createLocalReq.spec.ts | 28 ++++++ .../payload/src/utilities/createLocalReq.ts | 4 +- .../resolveValidationLocales.spec.ts | 92 +++++++++++++++++++ .../src/utilities/resolveValidationLocales.ts | 47 ++++++---- 18 files changed, 181 insertions(+), 30 deletions(-) create mode 100644 packages/payload/src/utilities/resolveValidationLocales.spec.ts diff --git a/packages/payload/src/collections/config/defaults.ts b/packages/payload/src/collections/config/defaults.ts index 6819d7039d9..7859ad3b961 100644 --- a/packages/payload/src/collections/config/defaults.ts +++ b/packages/payload/src/collections/config/defaults.ts @@ -56,14 +56,16 @@ export const defaults: Partial = { } export const addDefaultsToCollectionConfig = (collection: CollectionConfig): CollectionConfig => { + const incomingAccess = collection.access + collection.access = { create: defaultAccess, delete: defaultAccess, read: defaultAccess, unlock: defaultAccess, update: defaultAccess, - validate: defaultAccess, - ...(collection.access || {}), + ...incomingAccess, + validate: incomingAccess?.validate ?? incomingAccess?.update ?? defaultAccess, } collection.admin = { diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 5b1aad62342..603b8e5dedd 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -552,6 +552,7 @@ export type CollectionConfig = { update?: Access /** * Controls on-demand validation for this collection. + * Falls back to `update` access when omitted. * The access function receives `req.operation === 'validate'`. * @see https://payloadcms.com/docs/validation/overview#access-control-and-hooks */ diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index f31e5ca49b1..796212ff6da 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -263,7 +263,7 @@ export const createOperation = async < localization: config.localization, publishAllLocales, }), - overrideAccess, + overrideAccess: true, req, validationDataLocale: locale && locale !== 'all' diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index 3dd6e5693ea..af75de90d92 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -23,6 +23,7 @@ import { projectNonLocalizedData } from '../../../utilities/projectNonLocalizedD import { cloneValidationRequest, cloneValidationValue, + resolveValidationConcurrency, resolveValidationLocales, runValidationLocalePasses, } from '../../../utilities/resolveValidationLocales.js' @@ -169,15 +170,17 @@ export async function validateLocalWithDataLocale( context: cloneValidationValue(options.context), fallbackLocale: false, req: cloneValidationRequest(options.req), - user: cloneValidationValue(options.user) ?? undefined, + user: cloneValidationValue(options.user), }, payload, ) + baseReq.operation = 'validate' const locales = await resolveValidationLocales({ locale, req: baseReq, }) const results = await runValidationLocalePasses({ + concurrency: resolveValidationConcurrency(options.req), locales, validate: async (validationLocale) => { const req = await createLocalReq( diff --git a/packages/payload/src/collections/operations/restoreVersion.ts b/packages/payload/src/collections/operations/restoreVersion.ts index 2d2a6c4206f..66d9bc3074e 100644 --- a/packages/payload/src/collections/operations/restoreVersion.ts +++ b/packages/payload/src/collections/operations/restoreVersion.ts @@ -13,6 +13,7 @@ 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 { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' import { hasDraftValidationEnabled } from '../../utilities/getVersionsConfig.js' @@ -43,6 +44,8 @@ export const restoreVersionOperation = async < >( args: Arguments, ): Promise => { + assertNoValidationWrite(args.req) + const { id, collection: { config: collectionConfig }, diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index d135aa4469c..15cf20611c2 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -276,7 +276,7 @@ export const updateDocument = async < localization: config.localization, publishAllLocales, }), - overrideAccess, + overrideAccess: true, req, validationDataLocale: locale !== 'all' diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index 54058f972e0..965bef2f1bf 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -365,6 +365,8 @@ export const sanitizeField = async ({ field.access = {} } + field.access.validate = field.access.validate ?? field.access.update + setDefaultBeforeDuplicate(field, parentIsLocalized) } diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index ac3dd1f0cdc..4dc6e491899 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -506,6 +506,7 @@ export interface FieldBase { update?: FieldAccess /** * Controls whether candidate field data participates in on-demand validation. + * Falls back to `update` access when omitted. * The access function receives `req.operation === 'validate'`. * @see https://payloadcms.com/docs/validation/overview#access-control-and-hooks */ diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 542e935740a..ace4b950899 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -57,7 +57,7 @@ export const sanitizeGlobal = async ( } if (!global.access.validate) { - global.access.validate = defaultAccess + global.access.validate = global.access.update } if (!global.hooks.beforeValidate) { diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index c85cdc6d436..c06d2793b1a 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -203,6 +203,7 @@ export type GlobalConfig = { update?: Access /** * Controls on-demand validation for this global. + * Falls back to `update` access when omitted. * The access function receives `req.operation === 'validate'`. * @see https://payloadcms.com/docs/validation/overview#access-control-and-hooks */ diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index 7f05ebf1a57..21b2786352c 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -14,6 +14,7 @@ import { projectNonLocalizedData } from '../../../utilities/projectNonLocalizedD import { cloneValidationRequest, cloneValidationValue, + resolveValidationConcurrency, resolveValidationLocales, runValidationLocalePasses, } from '../../../utilities/resolveValidationLocales.js' @@ -95,15 +96,17 @@ export async function validateGlobalLocalWithDataLocale { const req = await createLocalReq( diff --git a/packages/payload/src/globals/operations/restoreVersion.ts b/packages/payload/src/globals/operations/restoreVersion.ts index 9f829ae6ba5..212ca754436 100644 --- a/packages/payload/src/globals/operations/restoreVersion.ts +++ b/packages/payload/src/globals/operations/restoreVersion.ts @@ -6,6 +6,7 @@ import { executeAccess } from '../../auth/executeAccess.js' import { NotFound } from '../../errors/index.js' import { afterChange } from '../../fields/hooks/afterChange/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -24,6 +25,8 @@ export type Arguments = { export const restoreVersionOperation = async = any>( args: Arguments, ): Promise => { + assertNoValidationWrite(args.req) + const { id, depth, draft, globalConfig, overrideAccess, populate, showHiddenFields } = args const req = args.req! const { fallbackLocale, locale, payload } = req diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 7ea24a0da58..839248c84e7 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -288,7 +288,7 @@ export const updateOperation = async < localization: payload.config.localization, publishAllLocales, }), - overrideAccess, + overrideAccess: true, req, validationDataLocale: locale && locale !== 'all' diff --git a/packages/payload/src/utilities/assertNoValidationWrite.ts b/packages/payload/src/utilities/assertNoValidationWrite.ts index e30511626a2..a041a2b6876 100644 --- a/packages/payload/src/utilities/assertNoValidationWrite.ts +++ b/packages/payload/src/utilities/assertNoValidationWrite.ts @@ -3,8 +3,9 @@ import type { PayloadRequest } from '../types/index.js' import { APIError } from '../errors/index.js' /** - * Prevents Payload-managed mutation entry points from writing through an active validation - * request. Reads and writes made with a separate request are intentionally unaffected. + * Prevents guarded document, global, upload, and version mutation entry points from writing + * through an active validation request. Reads and writes made with a separate request are + * intentionally unaffected. */ export function assertNoValidationWrite(req?: Partial): void { if (req?.operation === 'validate') { diff --git a/packages/payload/src/utilities/createLocalReq.spec.ts b/packages/payload/src/utilities/createLocalReq.spec.ts index 9ba39b015ce..4764cca561e 100644 --- a/packages/payload/src/utilities/createLocalReq.spec.ts +++ b/packages/payload/src/utilities/createLocalReq.spec.ts @@ -122,4 +122,32 @@ describe('createLocalReq - URL construction', () => { expect(result.url).toBe('http://localhost/api/test') expect(mockPayload.logger.error).not.toHaveBeenCalled() }) + + it('should preserve an explicit anonymous user override', async () => { + const authenticatedUser = { + collection: 'users', + id: 'authenticated-user', + } as never + + const inherited = await createLocalReq( + { + req: { + user: authenticatedUser, + }, + }, + mockPayload, + ) + const anonymous = await createLocalReq( + { + req: { + user: authenticatedUser, + }, + user: null, + }, + mockPayload, + ) + + expect(inherited.user).toBe(authenticatedUser) + expect(anonymous.user).toBeNull() + }) }) diff --git a/packages/payload/src/utilities/createLocalReq.ts b/packages/payload/src/utilities/createLocalReq.ts index 9f5c2b7925e..a3a74bc5e41 100644 --- a/packages/payload/src/utilities/createLocalReq.ts +++ b/packages/payload/src/utilities/createLocalReq.ts @@ -92,7 +92,7 @@ export type CreateLocalReqOptions = { locale?: string req?: Partial urlSuffix?: string - user?: User + user?: null | User } type CreateLocalReq = (options: CreateLocalReqOptions, payload: Payload) => Promise @@ -141,7 +141,7 @@ export const createLocalReq: CreateLocalReq = async ( req.payload = payload req.i18n = i18n req.t = i18n.t - req.user = user || req?.user || null + req.user = user === undefined ? (req?.user ?? null) : user // Ensure user.collection is set for auth-related access control // TODO (4.0): Instead of silently falling back, throw an error if user.collection is missing diff --git a/packages/payload/src/utilities/resolveValidationLocales.spec.ts b/packages/payload/src/utilities/resolveValidationLocales.spec.ts new file mode 100644 index 00000000000..90ad6dc1741 --- /dev/null +++ b/packages/payload/src/utilities/resolveValidationLocales.spec.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' + +import type { TypedLocale } from '../index.js' + +import { runValidationLocalePasses } from './resolveValidationLocales.js' + +describe('runValidationLocalePasses - concurrency', () => { + const tenLocales = Array.from({ length: 10 }, (_, index) => `locale-${index}`) as TypedLocale[] + + it('should run locale passes concurrently by default, allowing overlap', async () => { + let activeCount = 0 + let maxObservedActiveCount = 0 + + await runValidationLocalePasses({ + locales: tenLocales, + validate: async (locale) => { + activeCount += 1 + maxObservedActiveCount = Math.max(maxObservedActiveCount, activeCount) + + await new Promise((resolve) => setTimeout(resolve, 5)) + + activeCount -= 1 + return locale + }, + }) + + expect(maxObservedActiveCount).toBeGreaterThan(1) + }) + + it('should never run more than one locale pass at a time across 10 locales when concurrency is 1', async () => { + let activeCount = 0 + let maxObservedActiveCount = 0 + + const results = await runValidationLocalePasses({ + concurrency: 1, + locales: tenLocales, + validate: async (locale) => { + activeCount += 1 + maxObservedActiveCount = Math.max(maxObservedActiveCount, activeCount) + + if (activeCount > 1) { + throw new Error(`Locale pass for "${String(locale)}" overlapped with another pass`) + } + + await new Promise((resolve) => setTimeout(resolve, 5)) + + activeCount -= 1 + return locale + }, + }) + + expect(maxObservedActiveCount).toBe(1) + expect(results).toEqual(tenLocales) + }) + + it('should preserve input locale order in the results regardless of concurrency', async () => { + const results = await runValidationLocalePasses({ + concurrency: 4, + locales: tenLocales, + validate: async (locale) => { + const delayMs = tenLocales.indexOf(locale) % 2 === 0 ? 1 : 10 + + await new Promise((resolve) => setTimeout(resolve, delayMs)) + + return locale + }, + }) + + expect(results).toEqual(tenLocales) + }) + + it('should clamp concurrency to the number of locales', async () => { + let maxObservedActiveCount = 0 + let activeCount = 0 + + await runValidationLocalePasses({ + concurrency: 100, + locales: tenLocales, + validate: async (locale) => { + activeCount += 1 + maxObservedActiveCount = Math.max(maxObservedActiveCount, activeCount) + + await new Promise((resolve) => setTimeout(resolve, 5)) + + activeCount -= 1 + return locale + }, + }) + + expect(maxObservedActiveCount).toBe(tenLocales.length) + }) +}) diff --git a/packages/payload/src/utilities/resolveValidationLocales.ts b/packages/payload/src/utilities/resolveValidationLocales.ts index 781eab691b4..b8271079bca 100644 --- a/packages/payload/src/utilities/resolveValidationLocales.ts +++ b/packages/payload/src/utilities/resolveValidationLocales.ts @@ -101,28 +101,39 @@ export async function resolveValidationLocales({ return locales } +/** + * A request that already carries a transaction ID shares a database session with the transaction + * it was cloned from. Concurrent operations on one session are unsafe, so locale passes must run + * one at a time rather than with the default concurrency. + */ +export function resolveValidationConcurrency( + req: Partial | undefined, +): number | undefined { + return req?.transactionID ? 1 : undefined +} + export async function runValidationLocalePasses({ + concurrency = validationLocaleConcurrency, locales, validate, }: { + /** + * Maximum number of locale passes to run at once. Pass `1` when the request being validated + * shares a database session with an already-open transaction, since concurrent operations on + * one session are unsafe. + * @default 3 + */ + concurrency?: number locales: TypedLocale[] validate: (locale: TypedLocale) => Promise }): Promise { - const results = new Array(locales.length) - let nextLocaleIndex = 0 - - const workers = Array.from( - { length: Math.min(validationLocaleConcurrency, locales.length) }, - async () => { - while (nextLocaleIndex < locales.length) { - const localeIndex = nextLocaleIndex - nextLocaleIndex += 1 - results[localeIndex] = await validate(locales[localeIndex]!) - } - }, - ) - - await Promise.all(workers) + const batchSize = Math.max(1, Math.min(concurrency, locales.length)) + const results: TResult[] = [] + + for (let batchStart = 0; batchStart < locales.length; batchStart += batchSize) { + const batch = locales.slice(batchStart, batchStart + batchSize) + results.push(...(await Promise.all(batch.map((locale) => validate(locale))))) + } return results } @@ -152,7 +163,7 @@ export function cloneValidationRequest( file: cloneValidationValue(request.file), files: cloneValidationValue(request.files), hash: request.hash, - headers: request.headers ? new Headers(request.headers) : undefined, + headers: cloneValidationValue(request.headers), host: request.host, href: request.href, method: request.method, @@ -162,10 +173,10 @@ export function cloneValidationRequest( port: request.port, protocol: request.protocol, query: cloneValidationValue(request.query ?? {}), - responseHeaders: request.responseHeaders ? new Headers(request.responseHeaders) : undefined, + responseHeaders: cloneValidationValue(request.responseHeaders), routeParams: cloneValidationValue(request.routeParams ?? {}), search: request.search, - searchParams: request.searchParams ? new URLSearchParams(request.searchParams) : undefined, + searchParams: cloneValidationValue(request.searchParams), signal: request.signal, url: request.url, user: cloneValidationValue(request.user), From bea18e60e2e199549dca7af345b0591798551b7f Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 29 Jul 2026 23:04:55 +0100 Subject: [PATCH 25/47] fix build --- .../src/utils/schemaConversion/filterFieldsByAccess.ts | 2 ++ packages/plugin-multi-tenant/src/types.ts | 2 +- .../plugin-multi-tenant/src/utilities/addCollectionAccess.ts | 1 + packages/richtext-lexical/src/features/typesServer.ts | 4 ++-- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/plugin-mcp/src/utils/schemaConversion/filterFieldsByAccess.ts b/packages/plugin-mcp/src/utils/schemaConversion/filterFieldsByAccess.ts index 51c9f41e3f4..40bbc83e826 100644 --- a/packages/plugin-mcp/src/utils/schemaConversion/filterFieldsByAccess.ts +++ b/packages/plugin-mcp/src/utils/schemaConversion/filterFieldsByAccess.ts @@ -53,6 +53,7 @@ export const filterFieldsByAccess = ({ create: isOperationAllowed('create'), read: isOperationAllowed('read'), update: isOperationAllowed('update'), + validate: isOperationAllowed('validate'), } if (shouldExcludeField(allowedOperations)) { @@ -119,5 +120,6 @@ export const filterFieldsByAccess = ({ create: 'create' in permissions && permissions.create === true, read: permissions.read === true, update: permissions.update === true, + validate: permissions.validate === true, }) } diff --git a/packages/plugin-multi-tenant/src/types.ts b/packages/plugin-multi-tenant/src/types.ts index d62a66ac8ad..0f3dea909e2 100644 --- a/packages/plugin-multi-tenant/src/types.ts +++ b/packages/plugin-multi-tenant/src/types.ts @@ -270,7 +270,7 @@ type AllAccessKeysT = T[number] extends keyof Omit< : never export type AllAccessKeys = AllAccessKeysT< - ['create', 'read', 'update', 'delete', 'readVersions', 'unlock'] + ['create', 'read', 'update', 'delete', 'readVersions', 'unlock', 'validate'] > export type CollectionAccessResultOverride = ({ diff --git a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts index 9113ca291d7..a6b2d65eb48 100644 --- a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts +++ b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts @@ -11,6 +11,7 @@ export const collectionAccessKeys: AllAccessKeys = [ 'delete', 'readVersions', 'unlock', + 'validate', ] as const type Args = { diff --git a/packages/richtext-lexical/src/features/typesServer.ts b/packages/richtext-lexical/src/features/typesServer.ts index 2c3ccaa6bc9..ce963e69ed1 100644 --- a/packages/richtext-lexical/src/features/typesServer.ts +++ b/packages/richtext-lexical/src/features/typesServer.ts @@ -167,7 +167,7 @@ export type AfterChangeNodeHookArgs = { } export type BeforeValidateNodeHookArgs = { /** A string relating to which operation the field type is currently executing within. Useful within beforeValidate, beforeChange, and afterChange hooks to differentiate between create and update operations. */ - operation: 'create' | 'delete' | 'read' | 'update' + operation: 'create' | 'delete' | 'read' | 'update' | 'validate' /** The value of the node before any changes. Not available in afterRead hooks */ originalNode: T overrideAccess: boolean @@ -180,7 +180,7 @@ export type BeforeChangeNodeHookArgs = { errors: ValidationFieldError[] mergeLocaleActions: (() => Promise | void)[] /** A string relating to which operation the field type is currently executing within. Useful within beforeValidate, beforeChange, and afterChange hooks to differentiate between create and update operations. */ - operation: 'create' | 'delete' | 'read' | 'update' + operation: 'create' | 'delete' | 'read' | 'update' | 'validate' /** The value of the node before any changes. Not available in afterRead hooks */ originalNode: T /** From 6b0e723410d5dbbdae6b7db6c98a7e0f5498a1a3 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 30 Jul 2026 10:43:47 +0100 Subject: [PATCH 26/47] fix publish button --- packages/ui/src/elements/PublishButton/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/elements/PublishButton/index.tsx b/packages/ui/src/elements/PublishButton/index.tsx index d21aa677df5..21e8829496b 100644 --- a/packages/ui/src/elements/PublishButton/index.tsx +++ b/packages/ui/src/elements/PublishButton/index.tsx @@ -147,7 +147,7 @@ export function PublishButton({ return } - const isValid = await validateBeforePublish({ isPublishAll: true }) + const isValid = await validateBeforePublish({ isPublishAll: localizeStatusEnabled }) if (!isValid) { return From 071a8904f3fb07d0c2e3e7ff5311d1ce1661c255 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 30 Jul 2026 18:47:08 +0100 Subject: [PATCH 27/47] fix e2e --- test/versions/e2e.spec.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index d1f14b29e25..e5569dab453 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -290,7 +290,7 @@ describe('Versions', () => { await waitForFormReady(page) await page.locator('#field-title').fill('v1') await page.locator('#field-description').fill('hello') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') await page.locator('#field-title').fill('v2') await saveDocAndAssert(page, '#action-save-draft') const savedDocURL = page.url() @@ -311,10 +311,10 @@ describe('Versions', () => { await saveDocAndAssert(page, '#action-save-draft') await page.locator('#field-title').fill('v2') await page.locator('#field-description').fill('restore me as draft') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') await page.locator('#field-title').fill('v3') await page.locator('#field-description').fill('published') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') const savedDocURL = page.url() await page.goto(`${savedDocURL}/versions`) @@ -608,7 +608,7 @@ describe('Versions', () => { // fill out doc in english await page.locator('#field-title').fill('title') await page.locator('#field-description').fill('initial description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') const updatedAtWrapper = page.locator( '.doc-controls .doc-controls__content .doc-controls__value-wrap', @@ -619,7 +619,7 @@ describe('Versions', () => { await wait(1000) await page.locator('#field-description').fill('changed description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') await expect(async () => { const newTitle = updatedAtWrapper @@ -687,14 +687,14 @@ describe('Versions', () => { // fill out doc in english await page.locator('#field-title').fill(englishTitle) await page.locator('#field-description').fill('unchanged description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') // change locale to spanish await changeLocale(page, es) // fill out doc in spanish await page.locator('#field-title').fill(spanishTitle) - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') // wait for the page to load with the new version await expect @@ -723,14 +723,14 @@ describe('Versions', () => { await page.locator('#field-title').fill('first post title') await expect(page.locator('#field-description')).toBeEnabled() await page.locator('#field-description').fill('first post description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') await page.goto(autosaveURL.create) await wait(500) await expect(page.locator('#field-title')).toBeEnabled() await page.locator('#field-title').fill('second post title') await expect(page.locator('#field-description')).toBeEnabled() await page.locator('#field-description').fill('second post description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') await page.locator('#field-title').fill('updated second post title') await page.locator('#field-description').fill('updated second post description') await waitForAutoSaveToRunAndComplete(page) @@ -1525,7 +1525,7 @@ describe('Versions', () => { await expect(page.locator('#schedule-publish-button')).toBeHidden() // save draft then try to schedule publish - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#action-save-draft') await page.locator('#schedule-publish-button').click() // drawer should open @@ -1558,7 +1558,7 @@ describe('Versions', () => { await expect(page.locator('#schedule-publish-button')).toBeHidden() // save draft then try to schedule publish - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#action-save-draft') await page.locator('#schedule-publish-button').click() // drawer should open @@ -1586,7 +1586,7 @@ describe('Versions', () => { await page.locator('#field-title').fill('scheduled publish positioning') await page.locator('#field-description').fill('scheduled publish positioning description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#action-save-draft') await page.locator('#schedule-publish-button').click() await expect(page.locator('.drawer__header')).toBeVisible() @@ -1611,7 +1611,7 @@ describe('Versions', () => { await page.locator('#field-title').fill('scheduled publish') await page.locator('#field-description').fill('scheduled publish description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') await page.locator('#field-title').fill('scheduled publish updated') From de5ac02cfeb37810c3f1524dbbc3e51033bbdfc6 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 7 Aug 2026 17:56:39 +0100 Subject: [PATCH 28/47] updates --- docs/configuration/localization.mdx | 2 + docs/migration-guide/v4.mdx | 18 +++ packages/codemod/README.md | 1 + packages/codemod/src/registry.ts | 2 + .../basic.input.ts | 10 ++ .../basic.output.ts | 9 ++ .../index.spec.ts | 58 ++++++++ .../index.ts | 36 +++++ .../non-matching.input.ts | 9 ++ .../value-all.input.ts | 10 ++ packages/payload/src/config/client.ts | 5 - packages/payload/src/config/types.ts | 8 - .../ui/src/elements/PublishButton/index.tsx | 28 +--- test/__helpers/e2e/helpers.ts | 4 +- test/admin/config.ts | 1 - test/admin/e2e/document-view/e2e.spec.ts | 4 +- test/localization/e2e.spec.ts | 16 +- test/localization/payload-types.ts | 84 +++++++++++ test/validate/e2e.spec.ts | 9 +- test/versions/e2e.spec.ts | 60 ++++---- test/versions/payload-types.ts | 137 ++++++++++++++++-- 21 files changed, 413 insertions(+), 98 deletions(-) create mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/basic.input.ts create mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/basic.output.ts create mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/index.spec.ts create mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/index.ts create mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/non-matching.input.ts create mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/value-all.input.ts diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index 4eed2845796..abcb72ca931 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -231,6 +231,8 @@ Passing `locale: 'all'` together with `_status: 'published'` also publishes ever The same parameters are available on `payload.updateGlobal()` for globals, as well as on the REST and Local APIs. +In the admin UI, the document controls' main Publish button always publishes only the currently active locale. A secondary "Publish all locales" button, next to the main one, calls `publishAllLocales` for every locale you have access to. + ## Retrieving Localized Docs When retrieving documents, you can specify which locale you'd like to receive as well as which fallback locale should be diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index 5385231834a..4b3779cf578 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1556,3 +1556,21 @@ The Admin UI styles were migrated from SCSS to plain CSS. The `@payloadcms/ui/sc The `--theme-elevation-*` scale was removed. Use the semantic `--color-*` tokens (e.g. `--color-bg`, `--color-text`, `--color-border`, `--color-icon`) or the raw `--ramp-*` palette instead. See [Customizing CSS](../admin/customizing-css) for the full list of available tokens. Any custom component `.scss` files you authored should be renamed to `.css` and updated to plain CSS syntax — Payload no longer ships a SCSS toolchain. + +### Admin Publish button now defaults to the active locale + +`localization.defaultLocalePublishOption` was removed. For collections and globals with localized fields, the main Publish button now always publishes only the currently active locale. "Publish all locales" is available via the secondary button next to it. + +Previously, the button defaulted to publishing every locale unless `defaultLocalePublishOption: 'active'` was set, with "Publish in {locale}" as the secondary option. That split is gone — publishing a single locale is now the primary action for every project, and there's no config to change it. + +```diff + localization: { + defaultLocale: 'en', +- defaultLocalePublishOption: 'active', + locales: [...], + }, +``` + +Run `npx @payloadcms/codemod --transform remove-default-locale-publish-option` to migrate automatically. + +If you had `defaultLocalePublishOption: 'all'` (or left it unset) and relied on the Publish button publishing every locale by default, click the secondary "Publish all locales" button instead, or call `payload.update()`/`payload.updateGlobal()` with `publishAllLocales: true` directly. diff --git a/packages/codemod/README.md b/packages/codemod/README.md index d939c49f896..643595dfb12 100644 --- a/packages/codemod/README.md +++ b/packages/codemod/README.md @@ -45,6 +45,7 @@ The tool loads your project via [ts-morph](https://ts-morph.com/), using your `t - `migrate-versions-default` — adds `versions: false` to every `CollectionConfig` or `GlobalConfig` object that does not already have a `versions` property. Preserves the previous opt-in behaviour now that `versions` defaults to `true` for both collections and globals. Detects the three common annotation forms: `: CollectionConfig`, `satisfies GlobalConfig`, and `as CollectionConfig`. - `remove-versions-true` — removes the now-redundant `versions: true` property from `CollectionConfig` and `GlobalConfig` objects. Only removes the bare boolean `true`; object-form configs (e.g. `versions: { drafts: true }`) are left untouched. - `remove-group-by-true` — removes `admin.groupBy` from `CollectionConfig` objects. The experimental `groupBy` flag has been removed; groupBy is now an always-available per-user UI preference. +- `remove-default-locale-publish-option` — removes `defaultLocalePublishOption` from the root `localization` config. The admin Publish button now always defaults to the active locale, with "Publish all locales" available via the secondary button; the option no longer has any effect. - `rename-typescript-schema-to-json-schema` — renames the `typescriptSchema` field-config property to `jsonSchema` (it always accepted JSON Schema, not TypeScript). Skips any object that already defines a `jsonSchema` sibling and surfaces it as a note for manual review. - `migrate-build-script` — rewrites the `build` npm script in `package.json` from `next build` to `payload build`, so the Import Map (and types) are generated before the Next.js build. Matches the `next build` invocation only (leaves `next build-storybook` and the like untouched) and is a no-op when `build` is already `payload build`. - `migrate-slug-field` — converts the removed experimental `slugField()` helper (imported from `payload`) into the native `{ type: 'slug' }` field, mapping `useAsSlug`/`fieldToUse`, `slugify`, `required`, `localized`, `disableUnique` (→ `unique: false`), and `position` (→ `admin.position`), and dropping the obsolete `checkboxName`. Removes the now-unused `slugField` import. Calls using `overrides` (or other unrecognized options) are left in place with a note for manual migration. diff --git a/packages/codemod/src/registry.ts b/packages/codemod/src/registry.ts index 0ef104f5ff5..98844538c58 100644 --- a/packages/codemod/src/registry.ts +++ b/packages/codemod/src/registry.ts @@ -19,6 +19,7 @@ import { migrateNextSubpathExports } from './transforms/migrate-next-subpath-exp import { migrateSlugField } from './transforms/migrate-slug-field/index.js' import { migrateStorageAdaptersToConfig } from './transforms/migrate-storage-adapters-to-config/index.js' import { migrateVersionsDefault } from './transforms/migrate-versions-default/index.js' +import { removeDefaultLocalePublishOption } from './transforms/remove-default-locale-publish-option/index.js' import { removeGroupByTrue } from './transforms/remove-group-by-true/index.js' import { removeLocalizeStatusConfig } from './transforms/remove-localize-status-config/index.js' import { removePublishSpecificLocale } from './transforms/remove-publish-specific-locale/index.js' @@ -49,6 +50,7 @@ export const transforms: Transform[] = [ migrateVersionsDefault, removeGroupByTrue, removeLocalizeStatusConfig, + removeDefaultLocalePublishOption, removeVersionsTrue, removePublishSpecificLocale, renameTypescriptSchemaToJsonSchema, diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.input.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.input.ts new file mode 100644 index 00000000000..968bd8b4dc8 --- /dev/null +++ b/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.input.ts @@ -0,0 +1,10 @@ +import { buildConfig } from 'payload' + +export default buildConfig({ + localization: { + defaultLocale: 'en', + defaultLocalePublishOption: 'active', + locales: [{ code: 'en' }, { code: 'es' }], + }, + collections: [], +}) diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.output.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.output.ts new file mode 100644 index 00000000000..b5e73f60418 --- /dev/null +++ b/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.output.ts @@ -0,0 +1,9 @@ +import { buildConfig } from 'payload' + +export default buildConfig({ + localization: { + defaultLocale: 'en', + locales: [{ code: 'en' }, { code: 'es' }], + }, + collections: [], +}) diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/index.spec.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/index.spec.ts new file mode 100644 index 00000000000..fcb201ea8ae --- /dev/null +++ b/packages/codemod/src/transforms/remove-default-locale-publish-option/index.spec.ts @@ -0,0 +1,58 @@ +import { readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +import { runTransform } from '../../utils/test-helpers.js' +import { removeDefaultLocalePublishOption } from './index.js' + +const here = dirname(fileURLToPath(import.meta.url)) +const fixture = (name: string) => readFile(join(here, name), 'utf8') + +describe('remove-default-locale-publish-option', () => { + it("removes defaultLocalePublishOption: 'active'", async () => { + const input = await fixture('basic.input.ts') + const output = await fixture('basic.output.ts') + + const result = await runTransform({ + source: input, + transform: removeDefaultLocalePublishOption, + }) + + expect(result).toBe(output) + }) + + it("removes defaultLocalePublishOption: 'all'", async () => { + const input = await fixture('value-all.input.ts') + const output = await fixture('basic.output.ts') + + const result = await runTransform({ + source: input, + transform: removeDefaultLocalePublishOption, + }) + + expect(result).toBe(output) + }) + + it('is idempotent', async () => { + const output = await fixture('basic.output.ts') + + const result = await runTransform({ + source: output, + transform: removeDefaultLocalePublishOption, + }) + + expect(result).toBe(output) + }) + + it('is a no-op when the property is not present', async () => { + const input = await fixture('non-matching.input.ts') + + const result = await runTransform({ + source: input, + transform: removeDefaultLocalePublishOption, + }) + + expect(result).toBe(input) + }) +}) diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/index.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/index.ts new file mode 100644 index 00000000000..0f14a22d21f --- /dev/null +++ b/packages/codemod/src/transforms/remove-default-locale-publish-option/index.ts @@ -0,0 +1,36 @@ +import type { Transform } from '../../types.js' + +/** + * Removes `defaultLocalePublishOption` from the root `localization` config. + * + * The admin Publish button now always defaults to publishing the active locale; + * "Publish all locales" is available via the secondary button. The config + * property has no effect and is no longer typed. + */ +export const removeDefaultLocalePublishOption: Transform = { + name: 'remove-default-locale-publish-option', + apply: ({ project }) => { + const filesChanged = new Set() + + for (const sourceFile of project.getSourceFiles()) { + let text = sourceFile.getFullText() + const original = text + + // Remove `defaultLocalePublishOption: 'active',` / `defaultLocalePublishOption: 'all',` + // (single or double quotes, with or without a trailing comma). + text = text.replace( + /^[^\S\n]*defaultLocalePublishOption:\s*['"](?:active|all)['"],?\s*\n/gm, + '', + ) + + if (text !== original) { + sourceFile.replaceWithText(text) + filesChanged.add(sourceFile.getFilePath()) + } + } + + return { filesChanged: [...filesChanged] } + }, + description: + "Remove `defaultLocalePublishOption` from the root localization config. The admin Publish button now always defaults to the active locale, with 'Publish all locales' available via the secondary button; the option no longer has any effect.", +} diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/non-matching.input.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/non-matching.input.ts new file mode 100644 index 00000000000..b5e73f60418 --- /dev/null +++ b/packages/codemod/src/transforms/remove-default-locale-publish-option/non-matching.input.ts @@ -0,0 +1,9 @@ +import { buildConfig } from 'payload' + +export default buildConfig({ + localization: { + defaultLocale: 'en', + locales: [{ code: 'en' }, { code: 'es' }], + }, + collections: [], +}) diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/value-all.input.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/value-all.input.ts new file mode 100644 index 00000000000..01bf51bcf4d --- /dev/null +++ b/packages/codemod/src/transforms/remove-default-locale-publish-option/value-all.input.ts @@ -0,0 +1,10 @@ +import { buildConfig } from 'payload' + +export default buildConfig({ + localization: { + defaultLocale: 'en', + defaultLocalePublishOption: 'all', + locales: [{ code: 'en' }, { code: 'es' }], + }, + collections: [], +}) diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 2d7fe086380..5d4efbd89e6 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -274,11 +274,6 @@ export const createClientConfig = ({ clientConfig.localization.defaultLocale = config.localization.defaultLocale } - if (config.localization.defaultLocalePublishOption) { - clientConfig.localization.defaultLocalePublishOption = - config.localization.defaultLocalePublishOption - } - if (config.localization.fallback) { clientConfig.localization.fallback = config.localization.fallback } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 11458eb5040..d46c564dba2 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -601,14 +601,6 @@ export type BaseLocalizationConfig = { * @example `"en"` */ defaultLocale: string - /** - * Change the locale used by the default Publish button. - * If set to `all`, all locales will be published. - * If set to `active`, only the locale currently being edited will be published. - * The non-default option will be available via the secondary button. - * @default 'all' - */ - defaultLocalePublishOption?: 'active' | 'all' /** Set to `true` to let missing values in localised fields fall back to the values in `defaultLocale` * * If false, then no requests will fallback unless a fallbackLocale is specified in the request. diff --git a/packages/ui/src/elements/PublishButton/index.tsx b/packages/ui/src/elements/PublishButton/index.tsx index 21e8829496b..10ac030f789 100644 --- a/packages/ui/src/elements/PublishButton/index.tsx +++ b/packages/ui/src/elements/PublishButton/index.tsx @@ -251,11 +251,6 @@ export function PublishButton({ ], ) - // Publish to all locales unless there are localized fields AND defaultLocalePublishOption is 'active' - const isDefaultPublishAll = - !isSpecificLocalePublishEnabled || - (localization && localization?.defaultLocalePublishOption !== 'active') - const activeLocale = localization && localization?.locales.find((locale) => @@ -273,27 +268,18 @@ export function PublishButton({ publishLocale(activeLocale.code)} + onClick={isSpecificLocalePublishEnabled ? () => publishLocale(activeLocale.code) : publish} size="medium" SubMenuPopupContent={ isSpecificLocalePublishEnabled ? ({ close }) => { return ( - {isSpecificLocalePublishEnabled && ( - - publishLocale(activeLocale.code) : publish - } - > - {isDefaultPublishAll - ? t('version:publishIn', { locale: activeLocaleLabel }) - : t('version:publishAllLocales')} - - - )} + + + {t('version:publishAllLocales')} + + ) } @@ -301,7 +287,7 @@ export function PublishButton({ } type="button" > - {!isDefaultPublishAll ? ( + {isSpecificLocalePublishEnabled ? ( t('version:publishIn', { locale: activeLocaleLabel }) ) : ( diff --git a/test/__helpers/e2e/helpers.ts b/test/__helpers/e2e/helpers.ts index bc681061d31..7d5852473c3 100644 --- a/test/__helpers/e2e/helpers.ts +++ b/test/__helpers/e2e/helpers.ts @@ -225,7 +225,7 @@ export async function saveDocAndAssert( | '#action-publish' | '#action-save' | '#action-save-draft' - | '#publish-locale' + | '#publish-all-locales' | string = '#action-save', expectation: 'error' | 'success' = 'success', options?: { @@ -236,7 +236,7 @@ export async function saveDocAndAssert( }, ): Promise { await wait(500) // TODO: Fix this - if (selector === '#publish-locale') { + if (selector === '#publish-all-locales') { // open dropdown const chevronButton = page.locator('.form-submit .popup__trigger-wrap > .popup-button') await chevronButton.click() diff --git a/test/admin/config.ts b/test/admin/config.ts index 22f92a3cf55..7353630ec43 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -282,7 +282,6 @@ export default buildConfigWithDefaults({ }, localization: { defaultLocale: 'en', - defaultLocalePublishOption: 'active', locales: [ { code: 'es', diff --git a/test/admin/e2e/document-view/e2e.spec.ts b/test/admin/e2e/document-view/e2e.spec.ts index f0a8349f3a9..7bf4dbcfff7 100644 --- a/test/admin/e2e/document-view/e2e.spec.ts +++ b/test/admin/e2e/document-view/e2e.spec.ts @@ -754,14 +754,14 @@ describe('Document View', () => { }) describe('publish button', () => { - test('should show publish active locale button with defaultLocalePublishOption set to active', async () => { + test('should show publish active locale button by default when localized fields exist', async () => { await navigateToDoc(page, localizedURL) const publishButton = page.locator('#action-save') await expect(publishButton).toBeVisible() await expect(publishButton).toContainText('Publish in English') }) - test('should not show publish active locale button with defaultLocalePublishOption set to active but no localized fields', async () => { + test('should not show publish active locale button when no localized fields exist', async () => { await navigateToDoc(page, postsUrl) const publishButton = page.locator('#action-save') await expect(publishButton).toBeVisible() diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index 826183e90a1..14392f3b177 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -804,7 +804,7 @@ describe('Localization', () => { await page.goto(urlPostsWithDrafts.create) await changeLocale(page, 'es') await fillValues({ title: 'Created In Spanish' }) - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) await expect(page.locator('#field-title')).toHaveValue('Created In Spanish') await changeLocale(page, defaultLocale) @@ -846,7 +846,7 @@ describe('Localization', () => { test('should show unpublish in specific locale when localized fields exist', async () => { await page.goto(urlAllFieldsLocalized.create) await page.locator('#field-text').fill('EN Published') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) await openDocControls(page) await expect(page.locator('#action-unpublish')).toBeVisible() @@ -865,11 +865,11 @@ describe('Localization', () => { }) }) - test('should not show publish specific locale button when no localized fields exist', async () => { + test('should not show publish all locales button when no localized fields exist', async () => { await page.goto(urlPostsWithDrafts.create) - await expect(page.locator('#publish-locale')).toHaveCount(1) + await expect(page.locator('#publish-all-locales')).toHaveCount(1) await page.goto(noLocalizedFieldsURL.create) - await expect(page.locator('#publish-locale')).toHaveCount(0) + await expect(page.locator('#publish-all-locales')).toHaveCount(0) }) describe('duplicate selected locales', () => { @@ -945,7 +945,7 @@ describe('Localization', () => { // publish en await page.locator('#field-text').fill('EN Published') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) await page.goto(urlAllFieldsLocalized.versions(docID)) @@ -959,7 +959,7 @@ describe('Localization', () => { // publish en await page.locator('#field-text').fill('EN Published') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) const docID = (await page.locator('.render-title').getAttribute('data-doc-id')) as string @@ -971,7 +971,7 @@ describe('Localization', () => { // publish es await page.locator('#field-text').fill('ES Published') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) await page.goto(urlAllFieldsLocalized.versions(docID)) await changeLocale(page, defaultLocale) diff --git a/test/localization/payload-types.ts b/test/localization/payload-types.ts index 291f8470bd2..4e1b5726420 100644 --- a/test/localization/payload-types.ts +++ b/test/localization/payload-types.ts @@ -200,6 +200,8 @@ export interface Config { locale: 'xx' | 'en' | 'es' | 'pt' | 'ar' | 'hu'; widgets: { collections: CollectionsWidget; + 'collection-query': CollectionQueryWidget; + activity: ActivityWidget; }; user: User; jobs: { @@ -1905,6 +1907,88 @@ export interface CollectionsWidget { }; width: 'full'; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collection-query_widget". + */ +export interface CollectionQueryWidget { + data?: { + title?: string | null; + relatedCollection: + | 'richText' + | 'blocks-fields' + | 'nested-arrays' + | 'nested-field-tables' + | 'localized-drafts' + | 'localized-date-fields' + | 'all-fields-localized' + | 'users' + | 'localized-posts' + | 'no-localized-fields' + | 'array-fields' + | 'localized-required' + | 'with-localized-relationship' + | 'relationship-localized' + | 'cannot-create-default-locale' + | 'locale-restricted' + | 'nested' + | 'groups' + | 'tabs' + | 'localized-sort' + | 'blocks-same-name' + | 'localized-within-localized' + | 'array-with-fallback-fields'; + where?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + sortField?: string | null; + sortDirection?: ('asc' | 'desc') | null; + limit?: number | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "activity_widget". + */ +export interface ActivityWidget { + data?: { + excludedCollections?: + | ( + | 'richText' + | 'blocks-fields' + | 'nested-arrays' + | 'nested-field-tables' + | 'localized-drafts' + | 'localized-date-fields' + | 'all-fields-localized' + | 'users' + | 'localized-posts' + | 'no-localized-fields' + | 'array-fields' + | 'localized-required' + | 'with-localized-relationship' + | 'relationship-localized' + | 'cannot-create-default-locale' + | 'locale-restricted' + | 'nested' + | 'groups' + | 'tabs' + | 'localized-sort' + | 'blocks-same-name' + | 'localized-within-localized' + | 'array-with-fallback-fields' + )[] + | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". diff --git a/test/validate/e2e.spec.ts b/test/validate/e2e.spec.ts index 76faae8a77d..ea14be71354 100644 --- a/test/validate/e2e.spec.ts +++ b/test/validate/e2e.spec.ts @@ -106,14 +106,14 @@ test.describe('Admin document validation', () => { const id = await createDraft({ spanishTitle: 'Título en español' }) await openDraft(id) - await page.locator('#action-save').click() + await page.locator('#action-save-popup').click() + await page.locator('#publish-all-locales').click() await expect(page.locator('.validation-results').getByRole('alert')).toBeVisible() await expect(page.locator('.validation-results')).toContainText('German') await page.locator('.validation-results .drawer__header__close').click() - await page.locator('#action-save-popup').click() - await page.locator('#publish-locale').click() + await page.locator('#action-save').click() await expect(page.locator('.payload-toast-container')).toContainText('successfully') await expect(page.locator('.validation-results')).toHaveCount(0) @@ -123,8 +123,7 @@ test.describe('Admin document validation', () => { const id = await createDraft({}) await openDraft(id) - await page.locator('#action-save-popup').click() - await page.locator('#publish-locale').click() + await page.locator('#action-save').click() const result = page.locator('.validation-results') await expect(result.getByRole('alert')).toBeVisible() diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index e5569dab453..a7de7da1337 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -290,7 +290,7 @@ describe('Versions', () => { await waitForFormReady(page) await page.locator('#field-title').fill('v1') await page.locator('#field-description').fill('hello') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) await page.locator('#field-title').fill('v2') await saveDocAndAssert(page, '#action-save-draft') const savedDocURL = page.url() @@ -311,10 +311,10 @@ describe('Versions', () => { await saveDocAndAssert(page, '#action-save-draft') await page.locator('#field-title').fill('v2') await page.locator('#field-description').fill('restore me as draft') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) await page.locator('#field-title').fill('v3') await page.locator('#field-description').fill('published') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) const savedDocURL = page.url() await page.goto(`${savedDocURL}/versions`) @@ -608,7 +608,7 @@ describe('Versions', () => { // fill out doc in english await page.locator('#field-title').fill('title') await page.locator('#field-description').fill('initial description') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) const updatedAtWrapper = page.locator( '.doc-controls .doc-controls__content .doc-controls__value-wrap', @@ -619,7 +619,7 @@ describe('Versions', () => { await wait(1000) await page.locator('#field-description').fill('changed description') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) await expect(async () => { const newTitle = updatedAtWrapper @@ -687,14 +687,14 @@ describe('Versions', () => { // fill out doc in english await page.locator('#field-title').fill(englishTitle) await page.locator('#field-description').fill('unchanged description') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) // change locale to spanish await changeLocale(page, es) // fill out doc in spanish await page.locator('#field-title').fill(spanishTitle) - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) // wait for the page to load with the new version await expect @@ -723,14 +723,14 @@ describe('Versions', () => { await page.locator('#field-title').fill('first post title') await expect(page.locator('#field-description')).toBeEnabled() await page.locator('#field-description').fill('first post description') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) await page.goto(autosaveURL.create) await wait(500) await expect(page.locator('#field-title')).toBeEnabled() await page.locator('#field-title').fill('second post title') await expect(page.locator('#field-description')).toBeEnabled() await page.locator('#field-description').fill('second post description') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) await page.locator('#field-title').fill('updated second post title') await page.locator('#field-description').fill('updated second post description') await waitForAutoSaveToRunAndComplete(page) @@ -1611,7 +1611,7 @@ describe('Versions', () => { await page.locator('#field-title').fill('scheduled publish') await page.locator('#field-description').fill('scheduled publish description') - await saveDocAndAssert(page, '#publish-locale') + await saveDocAndAssert(page) await page.locator('#field-title').fill('scheduled publish updated') @@ -1699,13 +1699,20 @@ describe('Versions', () => { }) test('should show option to publish current locale', async () => { + await page.goto(url.create) + const publishButton = page.locator('#action-save') + + await expect(publishButton).toContainText('English') + }) + + test('should show option to publish all locales in dropdown', async () => { await page.goto(url.create) const publishOptions = page.locator('.doc-controls__controls .popup') await publishOptions.click() - const publishLocaleContent = page.locator('.popup__content') + const publishAllLocalesContent = page.locator('.popup__content') - await expect(publishLocaleContent).toContainText('English') + await expect(publishAllLocalesContent).toContainText('Publish all locales') }) test('should publish specific locale', async () => { @@ -1724,23 +1731,7 @@ describe('Versions', () => { await changeLocale(page, 'en') await textField.fill('english published') - - const publishOptions = page.locator('#action-save-popup') - await publishOptions.click() - - const publishLocaleButton = page.locator('#publish-locale') - await expect(publishLocaleButton).toContainText('English') - await publishLocaleButton.click() - - await wait(500) - - await expect(async () => { - await expect( - page.locator('.payload-toast-item:has-text("Updated successfully.")'), - ).toBeVisible() - }).toPass({ - timeout: POLL_TOPASS_TIMEOUT, - }) + await saveDocAndAssert(page) const id = await page.locator('.id-label').getAttribute('title') @@ -2218,13 +2209,20 @@ describe('Versions', () => { }) test('should show option to publish current locale', async () => { + await page.goto(url.global(localizedGlobalSlug)) + const publishButton = page.locator('#action-save') + + await expect(publishButton).toContainText('English') + }) + + test('should show option to publish all locales in dropdown', async () => { await page.goto(url.global(localizedGlobalSlug)) const publishOptions = page.locator('.doc-controls__controls .popup') await publishOptions.click() - const publishLocaleContent = page.locator('.popup__content') + const publishAllLocalesContent = page.locator('.popup__content') - await expect(publishLocaleContent).toContainText('English') + await expect(publishAllLocalesContent).toContainText('Publish all locales') }) }) diff --git a/test/versions/payload-types.ts b/test/versions/payload-types.ts index e744707e245..cde9b3b3efc 100644 --- a/test/versions/payload-types.ts +++ b/test/versions/payload-types.ts @@ -62,30 +62,31 @@ export type SupportedTimezones = | 'Pacific/Fiji'; /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "LexicalNodes_E98BC274". + * via the `definition` "LexicalNodes_55A1DAD6". */ -export type LexicalNodes_E98BC274 = +export type LexicalNodes_55A1DAD6 = | SerializedTextNode | SerializedTabNode | SerializedLineBreakNode - | SerializedParagraphNode + | SerializedParagraphNode | SerializedBlockNode - | SerializedHeadingNode + | SerializedHeadingNode | SerializedUploadNode<'draft-with-upload'> | SerializedUploadNode<'draft-with-upload-cloud-storage'> | SerializedUploadNode<'media', LexicalUploadFields_1AB4670B> | SerializedUploadNode<'media2'> - | SerializedQuoteNode - | SerializedListNode - | SerializedListItemNode - | SerializedAutoLinkNode - | SerializedLinkNode + | SerializedQuoteNode + | SerializedListNode + | SerializedListItemNode + | SerializedAutoLinkNode + | SerializedLinkNode | SerializedRelationshipNode< | 'disable-publish' | 'posts' | 'autosave-posts' | 'autosave-with-draft-button-posts' | 'autosave-multi-select-posts' + | 'nested-array-select' | 'autosave-with-validate-posts' | 'draft-posts' | 'drafts-no-read-versions' @@ -118,6 +119,7 @@ export interface Config { 'autosave-posts': AutosavePost; 'autosave-with-draft-button-posts': AutosaveWithDraftButtonPost; 'autosave-multi-select-posts': AutosaveMultiSelectPost; + 'nested-array-select': NestedArraySelect; 'autosave-with-validate-posts': AutosaveWithValidatePost; 'draft-posts': DraftPost; 'drafts-no-read-versions': DraftsNoReadVersion; @@ -149,6 +151,7 @@ export interface Config { 'autosave-posts': AutosavePostsSelect | AutosavePostsSelect; 'autosave-with-draft-button-posts': AutosaveWithDraftButtonPostsSelect | AutosaveWithDraftButtonPostsSelect; 'autosave-multi-select-posts': AutosaveMultiSelectPostsSelect | AutosaveMultiSelectPostsSelect; + 'nested-array-select': NestedArraySelectSelect | NestedArraySelectSelect; 'autosave-with-validate-posts': AutosaveWithValidatePostsSelect | AutosaveWithValidatePostsSelect; 'draft-posts': DraftPostsSelect | DraftPostsSelect; 'drafts-no-read-versions': DraftsNoReadVersionsSelect | DraftsNoReadVersionsSelect; @@ -187,6 +190,7 @@ export interface Config { 'max-versions': MaxVersion; 'draft-unlimited-global': DraftUnlimitedGlobal; 'simple-draft-global': SimpleDraftGlobal; + 'payload-jobs-stats': PayloadJobsStat; }; globalsSelect: { 'autosave-global': AutosaveGlobalSelect | AutosaveGlobalSelect; @@ -198,6 +202,7 @@ export interface Config { 'max-versions': MaxVersionsSelect | MaxVersionsSelect; 'draft-unlimited-global': DraftUnlimitedGlobalSelect | DraftUnlimitedGlobalSelect; 'simple-draft-global': SimpleDraftGlobalSelect | SimpleDraftGlobalSelect; + 'payload-jobs-stats': PayloadJobsStatsSelect | PayloadJobsStatsSelect; }; locale: 'en' | 'es' | 'de'; widgets: { @@ -267,7 +272,7 @@ export interface AutosavePost { title: string; relationship?: (string | null) | Post; computedTitle?: string | null; - richText?: LexicalRichText | null; + richText?: LexicalRichText | null; json?: | { [k: string]: unknown; @@ -351,6 +356,27 @@ export interface AutosaveMultiSelectPost { createdAt: string; _status?: ('draft' | 'published') | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "nested-array-select". + */ +export interface NestedArraySelect { + id: string; + outer?: + | { + inner?: + | { + days?: ('monday' | 'tuesday' | 'wednesday')[] | null; + id?: string | null; + }[] + | null; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "autosave-with-validate-posts". @@ -566,8 +592,8 @@ export interface Diff { )[] | null; zeroDepthRelationship?: (string | null) | User; - richtext?: LexicalRichText | null; - richtextWithCustomDiff?: LexicalRichText | null; + richtext?: LexicalRichText | null; + richtextWithCustomDiff?: LexicalRichText | null; textInRow?: string | null; textCannotRead?: string | null; select?: ('option1' | 'option2') | null; @@ -808,6 +834,15 @@ export interface PayloadJob { | number | boolean | null; + meta?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; completedAt?: string | null; totalTried?: number | null; /** @@ -835,7 +870,7 @@ export interface PayloadJob { completedAt: string; taskSlug: 'inline' | 'schedulePublish'; taskID: string; - input?: + input: | { [k: string]: unknown; } @@ -863,13 +898,22 @@ export interface PayloadJob { | number | boolean | null; + parent?: { + taskSlug?: ('inline' | 'schedulePublish') | null; + taskID?: string | null; + }; id?: string | null; }[] | null; taskSlug?: ('inline' | 'schedulePublish') | null; queue?: string | null; waitUntil?: string | null; - processing?: boolean | null; + processingUntil?: string | null; + processingToken?: string | null; + /** + * Used for concurrency control. Jobs with the same key are subject to exclusive/supersedes rules. + */ + concurrencyKey?: string | null; updatedAt: string; createdAt: string; } @@ -900,6 +944,10 @@ export interface PayloadLockedDocument { relationTo: 'autosave-multi-select-posts'; value: string | AutosaveMultiSelectPost; } | null) + | ({ + relationTo: 'nested-array-select'; + value: string | NestedArraySelect; + } | null) | ({ relationTo: 'autosave-with-validate-posts'; value: string | AutosaveWithValidatePost; @@ -1077,6 +1125,26 @@ export interface AutosaveMultiSelectPostsSelect { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "nested-array-select_select". + */ +export interface NestedArraySelectSelect { + outer?: + | T + | { + inner?: + | T + | { + days?: T; + id?: T; + }; + id?: T; + }; + updatedAt?: T; + createdAt?: T; + _status?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "autosave-with-validate-posts_select". @@ -1476,6 +1544,7 @@ export interface UsersSelect { export interface PayloadJobsSelect { input?: T; taskStatus?: T; + meta?: T; completedAt?: T; totalTried?: T; hasError?: T; @@ -1491,12 +1560,20 @@ export interface PayloadJobsSelect { output?: T; state?: T; error?: T; + parent?: + | T + | { + taskSlug?: T; + taskID?: T; + }; id?: T; }; taskSlug?: T; queue?: T; waitUntil?: T; - processing?: T; + processingUntil?: T; + processingToken?: T; + concurrencyKey?: T; updatedAt?: T; createdAt?: T; } @@ -1632,6 +1709,24 @@ export interface SimpleDraftGlobal { updatedAt?: string | null; createdAt?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-jobs-stats". + */ +export interface PayloadJobsStat { + id: string; + stats?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt?: string | null; + createdAt?: string | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "autosave-global_select". @@ -1732,6 +1827,16 @@ export interface SimpleDraftGlobalSelect { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-jobs-stats_select". + */ +export interface PayloadJobsStatsSelect { + stats?: T; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "collections_widget". @@ -1755,6 +1860,7 @@ export interface CollectionQueryWidget { | 'autosave-posts' | 'autosave-with-draft-button-posts' | 'autosave-multi-select-posts' + | 'nested-array-select' | 'autosave-with-validate-posts' | 'draft-posts' | 'drafts-no-read-versions' @@ -1801,6 +1907,7 @@ export interface ActivityWidget { | 'autosave-posts' | 'autosave-with-draft-button-posts' | 'autosave-multi-select-posts' + | 'nested-array-select' | 'autosave-with-validate-posts' | 'draft-posts' | 'drafts-no-read-versions' From c1d3f7a912d4e2dc460832af35dbaba6ff15888f Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Tue, 11 Aug 2026 19:19:57 +0100 Subject: [PATCH 29/47] add more test coverage --- .../CustomValidateAllLocalesButton/index.tsx | 83 ++++++++++ .../index.tsx | 85 +++++++++++ .../validate/components/validationRequests.ts | 69 +++++++++ test/validate/config.ts | 34 +++++ test/validate/e2e.spec.ts | 144 ++++++++++++++++-- test/validate/int.spec.ts | 88 +++++++++++ 6 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 test/validate/components/CustomValidateAllLocalesButton/index.tsx create mode 100644 test/validate/components/CustomValidateOtherLocalesButtons/index.tsx create mode 100644 test/validate/components/validationRequests.ts diff --git a/test/validate/components/CustomValidateAllLocalesButton/index.tsx b/test/validate/components/CustomValidateAllLocalesButton/index.tsx new file mode 100644 index 00000000000..a65e277e5af --- /dev/null +++ b/test/validate/components/CustomValidateAllLocalesButton/index.tsx @@ -0,0 +1,83 @@ +'use client' +import type { ValidationResult } from 'payload' + +import { Button, useConfig, useDocumentInfo, useForm, useLocale } from '@payloadcms/ui' +import React, { useState } from 'react' + +import { + getValidateEndpoint, + requestValidation, + stripLocalizedFields, +} from '../validationRequests.js' + +const baseClass = 'custom-validate-all-locales-button' + +export function CustomValidateAllLocalesButton() { + const { id, collectionSlug, docConfig, globalSlug } = useDocumentInfo() + const { + config: { + localization, + routes: { api }, + }, + } = useConfig() + const { code: activeLocaleCode } = useLocale() + const { getData } = useForm() + + const [isValidating, setIsValidating] = useState(false) + const [result, setResult] = useState(null) + + const handleClick = async () => { + setIsValidating(true) + + const endpoint = getValidateEndpoint({ id, apiRoute: api, collectionSlug, globalSlug }) + const data = getData() + const fields = docConfig?.fields ?? [] + const siblingLocaleCodes = (localization ? localization.locales : []) + .map(({ code }) => code) + .filter((code) => code !== activeLocaleCode) + + const results = [await requestValidation({ body: data, endpoint, locales: [activeLocaleCode] })] + + if (siblingLocaleCodes.length > 0) { + results.push( + await requestValidation({ + body: stripLocalizedFields({ data, fields }), + endpoint, + locales: siblingLocaleCodes, + }), + ) + } + + setResult({ + errors: results.flatMap(({ errors }) => errors), + valid: results.every(({ valid }) => valid), + }) + setIsValidating(false) + } + + return ( +
+ + {result && ( +
+ {result.valid + ? 'All locales valid' + : result.errors.map((error, index) => ( +

+ {`${error.locale ?? ''} ${error.path}: ${error.message}`} +

+ ))} +
+ )} +
+ ) +} diff --git a/test/validate/components/CustomValidateOtherLocalesButtons/index.tsx b/test/validate/components/CustomValidateOtherLocalesButtons/index.tsx new file mode 100644 index 00000000000..f62affc6b9c --- /dev/null +++ b/test/validate/components/CustomValidateOtherLocalesButtons/index.tsx @@ -0,0 +1,85 @@ +'use client' +import type { ValidationResult } from 'payload' + +import { Button, useConfig, useDocumentInfo, useForm, useLocale } from '@payloadcms/ui' +import React, { useState } from 'react' + +import { + getValidateEndpoint, + requestValidation, + stripLocalizedFields, +} from '../validationRequests.js' + +const baseClass = 'custom-validate-other-locales-buttons' + +export function CustomValidateOtherLocalesButtons() { + const { id, collectionSlug, docConfig, globalSlug } = useDocumentInfo() + const { + config: { + localization, + routes: { api }, + }, + } = useConfig() + const { code: activeLocaleCode } = useLocale() + const { getData } = useForm() + + const [results, setResults] = useState>({}) + const [validatingLocale, setValidatingLocale] = useState(null) + + const otherLocales = localization + ? localization.locales.filter(({ code }) => code !== activeLocaleCode) + : [] + + const handleClick = async (localeCode: string) => { + setValidatingLocale(localeCode) + + const endpoint = getValidateEndpoint({ id, apiRoute: api, collectionSlug, globalSlug }) + const fields = docConfig?.fields ?? [] + + const validationResult = await requestValidation({ + body: stripLocalizedFields({ data: getData(), fields }), + endpoint, + locales: [localeCode], + }) + + setResults((previousResults) => ({ ...previousResults, [localeCode]: validationResult })) + setValidatingLocale(null) + } + + if (!otherLocales.length) { + return null + } + + return ( +
+ {otherLocales.map(({ code, required }) => { + const isValidating = validatingLocale === code + const result = results[code] + + return ( +
+ + {result && ( +
+ {result.valid + ? `${code} valid` + : result.errors.map((error, index) => ( +

{`${error.path}: ${error.message}`}

+ ))} +
+ )} +
+ ) + })} +
+ ) +} diff --git a/test/validate/components/validationRequests.ts b/test/validate/components/validationRequests.ts new file mode 100644 index 00000000000..3fb1f692c64 --- /dev/null +++ b/test/validate/components/validationRequests.ts @@ -0,0 +1,69 @@ +import type { ClientField, Data, ValidationResult } from 'payload' + +import { formatAdminURL } from 'payload/shared' + +/** + * A locale-scoped validate call carries one flat candidate. Localized field values + * belong only to the active locale being edited, so they must be stripped before the + * same candidate is used to validate any other locale. + */ +export function stripLocalizedFields({ + data, + fields, +}: { + data: Data + fields: ClientField[] +}): Data { + const projectedData = { ...data } + + for (const field of fields) { + if ('name' in field && 'localized' in field && field.localized) { + delete projectedData[field.name] + } + } + + return projectedData +} + +export function getValidateEndpoint({ + id, + apiRoute, + collectionSlug, + globalSlug, +}: { + apiRoute: string + collectionSlug?: string + globalSlug?: string + id?: number | string +}): string { + const path: `/${string}` = globalSlug + ? `/globals/${globalSlug}/validate` + : `/${collectionSlug}${id ? `/${id}` : ''}/validate` + + return formatAdminURL({ apiRoute, path }) +} + +export async function requestValidation({ + body, + endpoint, + locales, +}: { + body: Data + endpoint: string + locales: string[] +}): Promise { + const search = new URLSearchParams() + + for (const locale of locales) { + search.append('locale', locale) + } + + const response = await fetch(`${endpoint}?${search.toString()}`, { + body: JSON.stringify(body), + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + + return (await response.json()) as ValidationResult +} diff --git a/test/validate/config.ts b/test/validate/config.ts index 4dd14d7200f..6a4f46794fb 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -33,6 +33,7 @@ export const writeTargetsSlug = 'validation-write-targets' export const validationUploadsSlug = 'validation-uploads' export const validationPublishUploadsSlug = 'validation-publish-uploads' export const validationAdminCollectionSlug = 'validation-admin-items' +export const validationCustomButtonsCollectionSlug = 'validation-custom-buttons-items' export const validationDeniedCollectionSlug = 'validation-denied-items' export const validationNonLocalizedCollectionSlug = 'validation-non-localized-items' export const validationUploadsDir = path.resolve(dirname, 'validation-uploads') @@ -1185,6 +1186,38 @@ const validationAdminCollection: CollectionConfig = { }, } +const validationCustomButtonsCollection: CollectionConfig = { + slug: validationCustomButtonsCollectionSlug, + admin: { + components: { + edit: { + beforeDocumentControls: [ + '/components/CustomValidateAllLocalesButton/index.js#CustomValidateAllLocalesButton', + '/components/CustomValidateOtherLocalesButtons/index.js#CustomValidateOtherLocalesButtons', + ], + }, + }, + }, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + required: true, + }, + { + name: 'summary', + type: 'text', + required: true, + }, + ], + versions: { + drafts: { + validate: false, + }, + }, +} + const validationDeniedCollection: CollectionConfig = { slug: validationDeniedCollectionSlug, access: { @@ -1231,6 +1264,7 @@ export default buildConfigWithDefaults({ validationWhereCollection, publishCollection, validationAdminCollection, + validationCustomButtonsCollection, validationDeniedCollection, validationNonLocalizedCollection, { diff --git a/test/validate/e2e.spec.ts b/test/validate/e2e.spec.ts index ea14be71354..d8d354e82c0 100644 --- a/test/validate/e2e.spec.ts +++ b/test/validate/e2e.spec.ts @@ -4,18 +4,15 @@ import { expect, test } from '@playwright/test' import path from 'path' import { fileURLToPath } from 'url' -import { - changeLocale, - ensureCompilationIsDone, - initPageConsoleErrorCatch, - waitForFormReady, -} from '../__helpers/e2e/helpers.js' +import { changeLocale, waitForFormReady } from '../__helpers/e2e/helpers.js' import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js' import { RESTClient } from '../__helpers/shared/rest.js' +import { initPage } from '../__setup/e2e/initPage.js' import { publishGlobalSlug, validationAdminCollectionSlug, + validationCustomButtonsCollectionSlug, validationDeniedCollectionSlug, validationNonLocalizedCollectionSlug, } from './config.js' @@ -39,9 +36,7 @@ test.describe('Admin document validation', () => { }) const context = await browser.newContext() - page = await context.newPage() - initPageConsoleErrorCatch(page) - await ensureCompilationIsDone({ page, serverURL }) + ;({ page } = await initPage({ context, serverURL })) await client.login() }) @@ -179,3 +174,134 @@ test.describe('Admin document validation', () => { await waitForFormReady(page) } }) + +test.describe('Custom locale validation buttons', () => { + const createdIDs: string[] = [] + let client: RESTClient + let customButtonsURL: AdminUrlUtil + let page: Page + let serverURL: string + + test.beforeAll(async ({ browser }) => { + ;({ serverURL } = await initPayloadE2ENoConfig({ dirname })) + customButtonsURL = new AdminUrlUtil(serverURL, validationCustomButtonsCollectionSlug) + client = new RESTClient({ + defaultSlug: validationCustomButtonsCollectionSlug, + serverURL, + }) + + const context = await browser.newContext() + ;({ page } = await initPage({ context, serverURL })) + await client.login() + }) + + test.afterEach(async () => { + await page.goto(customButtonsURL.admin) + + for (const id of createdIDs) { + await client.delete(id, { + id, + slug: validationCustomButtonsCollectionSlug, + }) + } + createdIDs.length = 0 + }) + + test('should render a validate-all button and one validate button per other locale', async () => { + const id = await createDraft({}) + + await openDraft(id) + + await expect(page.locator('#custom-validate-all-locales-button')).toBeVisible() + await expect(page.locator('#custom-validate-locale-es')).toBeVisible() + await expect(page.locator('#custom-validate-locale-de')).toBeVisible() + await expect(page.locator('#custom-validate-locale-fr')).toBeVisible() + await expect(page.locator('#custom-validate-locale-en')).toHaveCount(0) + }) + + test('should update the rendered per-locale buttons when the active locale changes', async () => { + const id = await createDraft({}) + + await openDraft(id) + await expect(page.locator('#custom-validate-locale-en')).toHaveCount(0) + await expect(page.locator('#custom-validate-locale-de')).toBeVisible() + + await changeLocale(page, 'de') + await waitForFormReady(page) + + await expect(page.locator('#custom-validate-locale-en')).toBeVisible() + await expect(page.locator('#custom-validate-locale-de')).toHaveCount(0) + }) + + test('should validate a single sibling locale independently of the others', async () => { + const id = await createDraft({ germanTitle: 'Deutscher Titel' }) + + await openDraft(id) + + await page.locator('#custom-validate-locale-de').click() + await expect(page.locator('#custom-validate-locale-de-result')).toContainText('de valid') + + await page.locator('#custom-validate-locale-es').click() + await expect(page.locator('#custom-validate-locale-es-result')).toContainText('title') + }) + + test('should aggregate errors across every locale for the validate-all button', async () => { + const id = await createDraft({ germanTitle: 'Deutscher Titel' }) + + await openDraft(id) + await page.locator('#custom-validate-all-locales-button').click() + + const result = page.locator('#custom-validate-all-locales-result') + await expect(result).toContainText('es title') + await expect(result).toContainText('fr title') + await expect(result).not.toContainText('de title') + }) + + async function createDraft({ + frenchTitle, + germanTitle, + spanishTitle, + }: { + frenchTitle?: string + germanTitle?: string + spanishTitle?: string + }): Promise { + const response = await client.endpointWithAuth<{ doc: { id: number | string } }>( + `/api/${validationCustomButtonsCollectionSlug}?draft=true&locale=en`, + 'POST', + { + _status: 'draft', + summary: 'Shared summary', + title: 'English title', + }, + ) + const id = String(response.data.doc.id) + + createdIDs.push(id) + + for (const [locale, title] of [ + ['es', spanishTitle], + ['de', germanTitle], + ['fr', frenchTitle], + ]) { + if (title) { + await client.endpointWithAuth( + `/api/${validationCustomButtonsCollectionSlug}/${id}?draft=true&locale=${locale}`, + 'PATCH', + { + _status: 'draft', + title, + }, + ) + } + } + + return id + } + + async function openDraft(id: string): Promise { + await page.goto(customButtonsURL.edit(id)) + await changeLocale(page, 'en') + await waitForFormReady(page) + } +}) diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 959983ceea3..839ca6f314d 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -29,6 +29,7 @@ import { scheduledValidationEvents, validationAccessSourceGlobalSlug, validationCollectionSlug, + validationCustomButtonsCollectionSlug, validationDeniedCollectionSlug, validationDeniedGlobalSlug, validationDraftSourceGlobalSlug, @@ -101,6 +102,11 @@ describe('validate Local API', () => { disableTransaction: true, where: { id: { exists: true } }, }), + payload.delete({ + collection: validationCustomButtonsCollectionSlug, + disableTransaction: true, + where: { id: { exists: true } }, + }), payload.delete({ collection: validationDeniedCollectionSlug, disableTransaction: true, @@ -2063,6 +2069,88 @@ describe('validate Local API', () => { }, ]) }) + + it('should validate each requested locale independently when sibling localized fields are omitted', async () => { + const draft = await payload.create({ + collection: validationCustomButtonsCollectionSlug, + data: { + _status: 'draft', + summary: 'Shared summary', + title: 'English title', + }, + draft: true, + locale: 'en', + }) + await payload.update({ + id: draft.id, + collection: validationCustomButtonsCollectionSlug, + data: { + _status: 'draft', + title: 'Deutscher Titel', + }, + draft: true, + locale: 'de', + }) + + // This collection uses the default access control, which requires an + // authenticated admin user, so the shared restClient needs a token here. + const adminEmail = 'validate-custom-buttons-admin@example.com' + await payload.create({ + collection: 'users', + data: { + email: adminEmail, + password: devUser.password, + }, + }) + const { token } = await payload.login({ + collection: 'users', + data: { + email: adminEmail, + password: devUser.password, + }, + }) + const authHeaders = { Authorization: `JWT ${token}` } + + const activeLocaleResponse = await restClient.POST( + `/${validationCustomButtonsCollectionSlug}/${draft.id}/validate?locale=en`, + { + body: JSON.stringify({ + summary: 'Shared summary', + title: 'English title', + }), + headers: authHeaders, + }, + ) + + expect(activeLocaleResponse.status).toBe(200) + await expect(activeLocaleResponse.json()).resolves.toEqual({ + errors: [], + valid: true, + }) + + const siblingLocalesResponse = await restClient.POST( + `/${validationCustomButtonsCollectionSlug}/${draft.id}/validate?locale=de&locale=es&locale=fr`, + { + body: JSON.stringify({ + summary: 'Shared summary', + }), + headers: authHeaders, + }, + ) + const siblingResult = await siblingLocalesResponse.json() + + expect(siblingLocalesResponse.status).toBe(200) + expect(siblingResult.valid).toBe(false) + expect(siblingResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ locale: 'es', path: 'title' }), + expect.objectContaining({ locale: 'fr', path: 'title' }), + ]), + ) + expect(siblingResult.errors).not.toEqual( + expect.arrayContaining([expect.objectContaining({ locale: 'de', path: 'title' })]), + ) + }) }) describe('write safety', () => { From 1c97bdb14de1b8f810b5d67d818136a082663143 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 12 Aug 2026 14:36:04 +0100 Subject: [PATCH 30/47] remove validate all locales button from UI --- docs/validation/overview.mdx | 12 ++--- packages/translations/src/clientKeys.ts | 1 - packages/translations/src/languages/ar.ts | 1 - packages/translations/src/languages/az.ts | 1 - packages/translations/src/languages/bg.ts | 1 - packages/translations/src/languages/bnBd.ts | 1 - packages/translations/src/languages/bnIn.ts | 1 - packages/translations/src/languages/ca.ts | 1 - packages/translations/src/languages/cs.ts | 1 - packages/translations/src/languages/da.ts | 1 - packages/translations/src/languages/de.ts | 1 - packages/translations/src/languages/en.ts | 1 - packages/translations/src/languages/es.ts | 1 - packages/translations/src/languages/et.ts | 1 - packages/translations/src/languages/fa.ts | 1 - packages/translations/src/languages/fr.ts | 1 - packages/translations/src/languages/he.ts | 1 - packages/translations/src/languages/hr.ts | 1 - packages/translations/src/languages/hu.ts | 1 - packages/translations/src/languages/hy.ts | 1 - packages/translations/src/languages/id.ts | 1 - packages/translations/src/languages/is.ts | 1 - packages/translations/src/languages/it.ts | 1 - packages/translations/src/languages/ja.ts | 1 - packages/translations/src/languages/ko.ts | 1 - packages/translations/src/languages/lt.ts | 1 - packages/translations/src/languages/lv.ts | 1 - packages/translations/src/languages/my.ts | 1 - packages/translations/src/languages/nb.ts | 1 - packages/translations/src/languages/nl.ts | 1 - packages/translations/src/languages/pl.ts | 1 - packages/translations/src/languages/pt.ts | 1 - packages/translations/src/languages/ro.ts | 1 - packages/translations/src/languages/rs.ts | 1 - .../translations/src/languages/rsLatin.ts | 1 - packages/translations/src/languages/ru.ts | 1 - packages/translations/src/languages/sk.ts | 1 - packages/translations/src/languages/sl.ts | 1 - packages/translations/src/languages/sv.ts | 1 - packages/translations/src/languages/ta.ts | 1 - packages/translations/src/languages/th.ts | 1 - packages/translations/src/languages/tr.ts | 1 - packages/translations/src/languages/uk.ts | 1 - packages/translations/src/languages/vi.ts | 1 - packages/translations/src/languages/zh.ts | 1 - packages/translations/src/languages/zhTw.ts | 1 - .../src/elements/DocumentControls/index.tsx | 14 ----- .../elements/ValidateDocumentButton/index.tsx | 28 ---------- .../providers/DocumentValidation/index.tsx | 45 +--------------- .../src/utilities/documentValidation.spec.ts | 25 --------- .../ui/src/utilities/documentValidation.ts | 12 ----- test/validate/e2e.spec.ts | 53 +------------------ 52 files changed, 8 insertions(+), 226 deletions(-) delete mode 100644 packages/ui/src/elements/ValidateDocumentButton/index.tsx diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index 61335456afe..6b68bd2ff44 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -235,10 +235,8 @@ Publishing uses its normal `create` or `update` access policy; the internal vali require separate `validate` access. The default Admin Publish controls run a preflight so editors can see locale-grouped errors before -submission. The **Validate all locales** action uses current unsaved form data and is shown only for -localized documents when the user has `validate` permission. If you replace the default Publish -button, your custom UI owns any preflight feedback; the server-side publish validation remains -authoritative. +submission. If you replace the default Publish button, your custom UI owns any preflight feedback; +the server-side publish validation remains authoritative. Scheduled publishes revalidate the latest stored target when the job runs. An invalid target cancels the publish job without retrying or changing publication state. Unpublishing does not @@ -309,6 +307,6 @@ const beforeChange: CollectionBeforeChangeHook = async ({ ## Current scope -On-demand validation is available through the Local and REST APIs and as a localized document action -in the Admin UI. It does not add a GraphQL validation operation, bulk Admin validation, or -fallback-locale validation. +On-demand validation is available through the Local and REST APIs, and powers the Admin UI's Publish +preflight for localized documents. It does not add a standalone "validate all locales" Admin UI +action, a GraphQL validation operation, bulk Admin validation, or fallback-locale validation. diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index ff53c006e0b..a13f8ac3259 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -536,7 +536,6 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'validation:trueOrFalse', 'validation:timezoneRequired', 'validation:username', - 'validation:validateAllLocales', 'validation:validationResults', 'validation:validUploadID', diff --git a/packages/translations/src/languages/ar.ts b/packages/translations/src/languages/ar.ts index c21be217cb8..2106bbc3ef8 100644 --- a/packages/translations/src/languages/ar.ts +++ b/packages/translations/src/languages/ar.ts @@ -620,7 +620,6 @@ export const arTranslations: DefaultTranslationsObject = { trueOrFalse: 'يمكن أن يكون هذا الحقل مساويًا فقط للقيمتين صحيح أو خطأ.', username: 'يرجى إدخال اسم مستخدم صالح. يمكن أن يحتوي على أحرف، أرقام، شرطات، فواصل وشرطات سفلية.', - validateAllLocales: 'تحقق من جميع اللغات', validationResults: 'نتائج التحقق', validUploadID: 'هذا الحقل ليس معرّف تحميل صالح.', }, diff --git a/packages/translations/src/languages/az.ts b/packages/translations/src/languages/az.ts index dcb9d5291b0..1951eb6dc86 100644 --- a/packages/translations/src/languages/az.ts +++ b/packages/translations/src/languages/az.ts @@ -639,7 +639,6 @@ export const azTranslations: DefaultTranslationsObject = { trueOrFalse: 'Bu sahə yalnız doğru və ya yanlış ola bilər.', username: 'Zəhmət olmasa, etibarlı bir istifadəçi adı daxil edin. Hərflər, rəqəmlər, tire, nöqtə və alt xəttlər ola bilər.', - validateAllLocales: 'Bütün locale-ləri yoxlayın', validationResults: 'Doğrulama nəticələri', validUploadID: 'Bu sahə doğru yükləmə ID-si deyil.', }, diff --git a/packages/translations/src/languages/bg.ts b/packages/translations/src/languages/bg.ts index e1101c5f14f..f560ccceb4e 100644 --- a/packages/translations/src/languages/bg.ts +++ b/packages/translations/src/languages/bg.ts @@ -635,7 +635,6 @@ export const bgTranslations: DefaultTranslationsObject = { trueOrFalse: 'Това поле може да бъде само "true" или "false".', username: 'Моля, въведете валидно потребителско име. Може да съдържа букви, цифри, тирета, точки и долни черти.', - validateAllLocales: 'Валидирайте всички езици', validationResults: 'Резултати от валидация', validUploadID: 'Това поле не е валиден идентификатор на качването.', }, diff --git a/packages/translations/src/languages/bnBd.ts b/packages/translations/src/languages/bnBd.ts index 26227af9111..2628d403b3a 100644 --- a/packages/translations/src/languages/bnBd.ts +++ b/packages/translations/src/languages/bnBd.ts @@ -641,7 +641,6 @@ export const bnBdTranslations: DefaultTranslationsObject = { trueOrFalse: 'এই ক্ষেত্রটি শুধুমাত্র সত্য বা মিথ্যা হতে পারে।', username: 'একটি বৈধ ব্যবহারকারীর নাম লিখুন। এতে অক্ষর, সংখ্যা, হাইফেন, পিরিয়ড এবং আন্ডারস্কোর থাকতে পারে।', - validateAllLocales: 'সমস্ত Locale যাচাই করুন', validationResults: 'বৈধতা ফলাফল', validUploadID: 'এই ক্ষেত্রটি একটি বৈধ আপলোড আইডি নয়।', }, diff --git a/packages/translations/src/languages/bnIn.ts b/packages/translations/src/languages/bnIn.ts index 850ed053ca9..4376621f525 100644 --- a/packages/translations/src/languages/bnIn.ts +++ b/packages/translations/src/languages/bnIn.ts @@ -640,7 +640,6 @@ export const bnInTranslations: DefaultTranslationsObject = { trueOrFalse: 'এই ক্ষেত্রটি শুধুমাত্র সত্য বা মিথ্যা হতে পারে।', username: 'একটি বৈধ ব্যবহারকারীর নাম লিখুন। এতে অক্ষর, সংখ্যা, হাইফেন, পিরিয়ড এবং আন্ডারস্কোর থাকতে পারে।', - validateAllLocales: 'সমস্ত Locale বৈধতা পরীক্ষা করুন', validationResults: 'বৈধতা ফলাফল', validUploadID: 'এই ক্ষেত্রটি একটি বৈধ আপলোড আইডি নয়।', }, diff --git a/packages/translations/src/languages/ca.ts b/packages/translations/src/languages/ca.ts index 2a6753ffc64..c64515326eb 100644 --- a/packages/translations/src/languages/ca.ts +++ b/packages/translations/src/languages/ca.ts @@ -639,7 +639,6 @@ export const caTranslations: DefaultTranslationsObject = { trueOrFalse: 'Aquest camp només pot ser igual a true o false.', username: "Si us plau, introdueix un nom d'usuari vàlid. Pot contenir lletres, números, guions, punts i guions baixos.", - validateAllLocales: 'Valideu tots els idiomes', validationResults: 'Resultats de la validació', validUploadID: 'Aquest camp no és un ID de càrrega vàlid.', }, diff --git a/packages/translations/src/languages/cs.ts b/packages/translations/src/languages/cs.ts index c2d782a98aa..7d17581bdd0 100644 --- a/packages/translations/src/languages/cs.ts +++ b/packages/translations/src/languages/cs.ts @@ -629,7 +629,6 @@ export const csTranslations: DefaultTranslationsObject = { trueOrFalse: 'Toto pole může být rovno pouze true nebo false.', username: 'Prosím, zadejte platné uživatelské jméno. Může obsahovat písmena, čísla, pomlčky, tečky a podtržítka.', - validateAllLocales: 'Ověřit všechny jazyky', validationResults: 'Výsledky validace', validUploadID: 'Toto pole není platné ID pro odeslání.', }, diff --git a/packages/translations/src/languages/da.ts b/packages/translations/src/languages/da.ts index 93f18b7a3bf..17b6ccd5708 100644 --- a/packages/translations/src/languages/da.ts +++ b/packages/translations/src/languages/da.ts @@ -633,7 +633,6 @@ export const daTranslations: DefaultTranslationsObject = { trueOrFalse: 'Denne værdi kan kun være lig med sandt eller falsk.', username: 'Indtast et brugernavn. Kan indeholde bogstaver, tal, bindestreger, punktum og underscores.', - validateAllLocales: 'Validér alle locales', validationResults: 'Valideringsresultater', validUploadID: 'Dette felt er ikke en gyldig upload-ID.', }, diff --git a/packages/translations/src/languages/de.ts b/packages/translations/src/languages/de.ts index 288b2da60ab..efb7cf85ead 100644 --- a/packages/translations/src/languages/de.ts +++ b/packages/translations/src/languages/de.ts @@ -647,7 +647,6 @@ export const deTranslations: DefaultTranslationsObject = { trueOrFalse: 'Dieses Feld kann nur wahr oder falsch sein.', username: 'Bitte gib einen gültigen Benutzernamen ein. Dieser kann Buchstaben, Zahlen, Bindestriche, Punkte und Unterstriche enthalten.', - validateAllLocales: 'Alle Sprachen validieren', validationResults: 'Validierungsergebnisse', validUploadID: 'Dieses Feld enthält keine gültige Upload-ID.', }, diff --git a/packages/translations/src/languages/en.ts b/packages/translations/src/languages/en.ts index 388505c5952..64b9766cf10 100644 --- a/packages/translations/src/languages/en.ts +++ b/packages/translations/src/languages/en.ts @@ -632,7 +632,6 @@ export const enTranslations = { trueOrFalse: 'This field can only be equal to true or false.', username: 'Please enter a valid username. Can contain letters, numbers, hyphens, periods and underscores.', - validateAllLocales: 'Validate all locales', validationResults: 'Validation results', validUploadID: 'This field is not a valid upload ID.', }, diff --git a/packages/translations/src/languages/es.ts b/packages/translations/src/languages/es.ts index d64228c1a61..a811dff1b20 100644 --- a/packages/translations/src/languages/es.ts +++ b/packages/translations/src/languages/es.ts @@ -640,7 +640,6 @@ export const esTranslations: DefaultTranslationsObject = { trueOrFalse: 'Este campo solo puede ser verdadero o falso.', username: 'Por favor, introduce un nombre de usuario válido. Puede contener letras, números, guiones, puntos y guiones bajos.', - validateAllLocales: 'Validar todos los idiomas', validationResults: 'Resultados de validación', validUploadID: 'Este campo no es un ID de subida válido.', }, diff --git a/packages/translations/src/languages/et.ts b/packages/translations/src/languages/et.ts index d55f1282500..7a0886af57e 100644 --- a/packages/translations/src/languages/et.ts +++ b/packages/translations/src/languages/et.ts @@ -628,7 +628,6 @@ export const etTranslations: DefaultTranslationsObject = { trueOrFalse: 'See väli saab olla ainult tõene või väär.', username: 'Palun sisesta kehtiv kasutajanimi. Võib sisaldada tähti, numbreid, sidekriipse, punkte ja alakriipse.', - validateAllLocales: 'Kinnita kõik keeled', validationResults: 'Valideerimistulemused', validUploadID: 'See väli ei ole kehtiv üleslaadimise ID.', }, diff --git a/packages/translations/src/languages/fa.ts b/packages/translations/src/languages/fa.ts index d7544304bd1..1c20a757bc9 100644 --- a/packages/translations/src/languages/fa.ts +++ b/packages/translations/src/languages/fa.ts @@ -624,7 +624,6 @@ export const faTranslations: DefaultTranslationsObject = { trueOrFalse: 'مقدار این فیلد فقط می‌تواند "true" یا "false" باشد.', username: 'لطفاً یک نام کاربری معتبر وارد کنید. فقط حروف، اعداد، نقطه، خط تیره و زیرخط مجاز هستند.', - validateAllLocales: 'اعتبارسنجی تمامی Localeها', validationResults: 'نتایج اعتبارسنجی', validUploadID: 'شناسه آپلود معتبر نیست.', }, diff --git a/packages/translations/src/languages/fr.ts b/packages/translations/src/languages/fr.ts index 02b191b5266..22af78d08fe 100644 --- a/packages/translations/src/languages/fr.ts +++ b/packages/translations/src/languages/fr.ts @@ -648,7 +648,6 @@ export const frTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ce champ ne peut être égal qu’à vrai ou faux.', username: "Veuillez entrer un nom d'utilisateur valide. Il peut contenir des lettres, des chiffres, des tirets, des points et des tirets bas.", - validateAllLocales: 'Valider toutes les langues', validationResults: 'Résultats de la validation', validUploadID: 'Ce champ n’est pas un valide identifiant de fichier.', }, diff --git a/packages/translations/src/languages/he.ts b/packages/translations/src/languages/he.ts index 762e3e4cb45..6e85bbf2601 100644 --- a/packages/translations/src/languages/he.ts +++ b/packages/translations/src/languages/he.ts @@ -613,7 +613,6 @@ export const heTranslations: DefaultTranslationsObject = { timezoneRequired: 'נדרשת אזור זמן.', trueOrFalse: 'שדה זה יכול להיות רק true או false.', username: 'אנא הזן שם משתמש חוקי. יכול להכיל אותיות, מספרים, מקפים, נקודות וקווים תחתונים.', - validateAllLocales: 'אמת את כל ה-Locales', validationResults: 'תוצאות האימות', validUploadID: 'שדה זה אינו מזהה העלאה תקני.', }, diff --git a/packages/translations/src/languages/hr.ts b/packages/translations/src/languages/hr.ts index 2e9466581db..0501d049422 100644 --- a/packages/translations/src/languages/hr.ts +++ b/packages/translations/src/languages/hr.ts @@ -632,7 +632,6 @@ export const hrTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ovo polje može biti samo točno ili netočno', username: 'Unesite važeće korisničko ime. Može sadržavati slova, brojeve, crtice, točke i donje crte.', - validateAllLocales: 'Potvrdite sve locale', validationResults: 'Rezultati validacije', validUploadID: 'Ovo polje nije valjani ID prijenosa.', }, diff --git a/packages/translations/src/languages/hu.ts b/packages/translations/src/languages/hu.ts index 4f76a63ba3f..a05614d91d2 100644 --- a/packages/translations/src/languages/hu.ts +++ b/packages/translations/src/languages/hu.ts @@ -642,7 +642,6 @@ export const huTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ez a mező csak igaz vagy hamis lehet.', username: 'Adjon meg egy érvényes felhasználónevet. Tartalmazhat betűket, számokat, kötőjeleket, pontokat és aláhúzásokat.', - validateAllLocales: 'Az összes Locale érvényesítése', validationResults: 'Érvényesítési eredmények', validUploadID: 'Ez a mező nem érvényes feltöltési azonosító.', }, diff --git a/packages/translations/src/languages/hy.ts b/packages/translations/src/languages/hy.ts index 8da67bd24f7..cc41768a118 100644 --- a/packages/translations/src/languages/hy.ts +++ b/packages/translations/src/languages/hy.ts @@ -640,7 +640,6 @@ export const hyTranslations: DefaultTranslationsObject = { trueOrFalse: 'Այս դաշտի արժեքը կարող է միայն «ճիշտ» կամ «սխալ»։', username: 'Խնդրում ենք մուտքագրել վավեր օգտանուն։ Կարող է պարունակել տառեր, թվեր, գծիկներ, կետեր և ընդգծում։', - validateAllLocales: 'Հաստատել բոլոր locale-ները', validationResults: 'Վավերացման արդյունքներ', validUploadID: 'Այս դաշտը վավեր վերբեռնման ID չի պարունակում։', }, diff --git a/packages/translations/src/languages/id.ts b/packages/translations/src/languages/id.ts index 0325c1900d9..a508b42ca97 100644 --- a/packages/translations/src/languages/id.ts +++ b/packages/translations/src/languages/id.ts @@ -637,7 +637,6 @@ export const idTranslations: DefaultTranslationsObject = { trueOrFalse: 'Isian ini hanya bisa sama dengan benar atau salah.', username: 'Harap masukkan nama pengguna yang valid. Dapat berisi huruf, angka, tanda hubung, titik, dan garis bawah.', - validateAllLocales: 'Validasi semua Locale', validationResults: 'Hasil validasi', validUploadID: 'Isian ini bukan ID unggahan yang valid.', }, diff --git a/packages/translations/src/languages/is.ts b/packages/translations/src/languages/is.ts index 039ae2a53bd..103da0de0be 100644 --- a/packages/translations/src/languages/is.ts +++ b/packages/translations/src/languages/is.ts @@ -633,7 +633,6 @@ export const isTranslations: DefaultTranslationsObject = { trueOrFalse: 'Þetta svæði getur aðeins verið jafnt og satt eða ósatt.', username: 'Vinsamlegast settu inn gilt notandanafn. Getur innihaldið bókstafi, tölur, bandstrik, punkta og undirstrik.', - validateAllLocales: 'Staðfestið öll tungumál', validationResults: 'Staðfestingarniðurstöður', validUploadID: 'Þetta svæði er ekki gilt skráarupphleðslauðkenni.', }, diff --git a/packages/translations/src/languages/it.ts b/packages/translations/src/languages/it.ts index af8312ca44e..235b246e9a7 100644 --- a/packages/translations/src/languages/it.ts +++ b/packages/translations/src/languages/it.ts @@ -641,7 +641,6 @@ export const itTranslations: DefaultTranslationsObject = { trueOrFalse: "Questo campo può essere solo uguale a 'true' o 'false'.", username: 'Inserisci un nome utente valido. Può contenere lettere, numeri, trattini, punti e underscore.', - validateAllLocales: 'Convalida tutte le lingue', validationResults: 'Risultati della convalida', validUploadID: "'Questo campo non è un ID di Upload valido.'", }, diff --git a/packages/translations/src/languages/ja.ts b/packages/translations/src/languages/ja.ts index ce20de6e006..e10184a48c5 100644 --- a/packages/translations/src/languages/ja.ts +++ b/packages/translations/src/languages/ja.ts @@ -632,7 +632,6 @@ export const jaTranslations: DefaultTranslationsObject = { trueOrFalse: '"true" または "false" の値にする必要があります。', username: '有効なユーザー名を入力してください。文字、数字、ハイフン、ピリオド、アンダースコアを使用できます。', - validateAllLocales: 'すべてのLocaleを検証してください', validationResults: '検証結果', validUploadID: '有効なアップロードIDではありません。', }, diff --git a/packages/translations/src/languages/ko.ts b/packages/translations/src/languages/ko.ts index dcba97d537c..d27a63c3cb6 100644 --- a/packages/translations/src/languages/ko.ts +++ b/packages/translations/src/languages/ko.ts @@ -629,7 +629,6 @@ export const koTranslations: DefaultTranslationsObject = { trueOrFalse: '이 입력란은 true 또는 false만 가능합니다.', username: '유효한 사용자 이름을 입력해 주세요. 글자, 숫자, 하이픈, 마침표, 및 밑줄을 사용할 수 있습니다.', - validateAllLocales: '모든 Locale을 검증하십시오.', validationResults: '검증 결과', validUploadID: '이 입력란은 유효한 업로드 ID가 아닙니다.', }, diff --git a/packages/translations/src/languages/lt.ts b/packages/translations/src/languages/lt.ts index 4c83ed9d3e2..eb05f69b687 100644 --- a/packages/translations/src/languages/lt.ts +++ b/packages/translations/src/languages/lt.ts @@ -638,7 +638,6 @@ export const ltTranslations: DefaultTranslationsObject = { trueOrFalse: 'Šis laukas gali būti lygus tik „true“ ar „false“.', username: 'Įveskite galiojantį vartotojo vardą. Galima naudoti raides, skaičius, brūkšnelius, taškus ir pabraukimus.', - validateAllLocales: 'Patvirtinti visas kalbas', validationResults: 'Patikros rezultatai', validUploadID: 'Šis laukas nėra tinkamas įkėlimo ID.', }, diff --git a/packages/translations/src/languages/lv.ts b/packages/translations/src/languages/lv.ts index c14c37cbb71..2dc012bc8aa 100644 --- a/packages/translations/src/languages/lv.ts +++ b/packages/translations/src/languages/lv.ts @@ -631,7 +631,6 @@ export const lvTranslations: DefaultTranslationsObject = { trueOrFalse: 'Šis lauks var būt tikai "true" vai "false".', username: 'Lūdzu, ievadiet derīgu lietotājvārdu. Drīkst saturēt burtus, ciparus, defises, punktus un pasvītras.', - validateAllLocales: 'Validēt visus lokālus', validationResults: 'Validācijas rezultāti', validUploadID: 'Šis lauks nav derīgs augšupielādes ID.', }, diff --git a/packages/translations/src/languages/my.ts b/packages/translations/src/languages/my.ts index f75e9dd6e3d..486e58072d4 100644 --- a/packages/translations/src/languages/my.ts +++ b/packages/translations/src/languages/my.ts @@ -644,7 +644,6 @@ export const myTranslations: DefaultTranslationsObject = { trueOrFalse: 'ဤအကွက်သည် တစ်ခုခုဖြစ်ရပါမည်။', username: 'မှန်ကန်သော အသုံးပြုသူအမည် ထည့်သွင်းပါ။ အက္ခရာ၊ နံပါတ်၊ ဟိုင်ဖန်၊ အစက် နှင့် အောက်မျဉ်းတို့ ပါဝင်နိုင်ပါသည်။', - validateAllLocales: 'လူအပေါင်းတို့၏ဘာသာစကားများကိုအတည်ပြုပါ', validationResults: 'အတည်ပြုမှုရလဒ်များ', validUploadID: "'ဤအကွက်သည် မှန်ကန်သော အပ်လုဒ် ID မဟုတ်ပါ။'", }, diff --git a/packages/translations/src/languages/nb.ts b/packages/translations/src/languages/nb.ts index f91b3c8afb9..cd66942868c 100644 --- a/packages/translations/src/languages/nb.ts +++ b/packages/translations/src/languages/nb.ts @@ -636,7 +636,6 @@ export const nbTranslations: DefaultTranslationsObject = { trueOrFalse: 'Dette feltet kan bare være likt true eller false.', username: 'Vennligst oppgi et gyldig brukernavn. Kan inneholde bokstaver, nummer, bindestreker, punktum og understrek.', - validateAllLocales: 'Valider alle språk', validationResults: 'Valideringsresultater', validUploadID: 'Dette feltet er ikke en gyldig opplastings-ID.', }, diff --git a/packages/translations/src/languages/nl.ts b/packages/translations/src/languages/nl.ts index 1d26d48c668..d5f24b885ec 100644 --- a/packages/translations/src/languages/nl.ts +++ b/packages/translations/src/languages/nl.ts @@ -641,7 +641,6 @@ export const nlTranslations: DefaultTranslationsObject = { trueOrFalse: 'Dit veld kan alleen waar of onwaar zijn.', username: 'Voer een geldige gebruikersnaam in. Kan letters, cijfers, koppeltekens, punten en underscores bevatten.', - validateAllLocales: 'Valideer alle talen', validationResults: 'Validatieresultaten', validUploadID: 'Dit veld is geen geldige upload-ID.', }, diff --git a/packages/translations/src/languages/pl.ts b/packages/translations/src/languages/pl.ts index 4d600449b27..35cc172ea32 100644 --- a/packages/translations/src/languages/pl.ts +++ b/packages/translations/src/languages/pl.ts @@ -632,7 +632,6 @@ export const plTranslations: DefaultTranslationsObject = { trueOrFalse: "To pole może mieć wartość tylko 'true' lub 'false'.", username: 'Proszę wprowadzić prawidłową nazwę użytkownika. Może zawierać litery, cyfry, myślniki, kropki i podkreślniki.', - validateAllLocales: 'Zatwierdź wszystkie locale', validationResults: 'Wyniki walidacji', validUploadID: 'To pole nie jest prawidłowym identyfikatorem przesyłania.', }, diff --git a/packages/translations/src/languages/pt.ts b/packages/translations/src/languages/pt.ts index 2d3bdcfd164..01206363cdd 100644 --- a/packages/translations/src/languages/pt.ts +++ b/packages/translations/src/languages/pt.ts @@ -636,7 +636,6 @@ export const ptTranslations: DefaultTranslationsObject = { trueOrFalse: 'Esse campo pode ser apenas verdadeiro (true) ou falso (false)', username: 'Por favor, insira um nome de usuário válido. Pode conter letras, números, hifens, pontos e sublinhados.', - validateAllLocales: 'Validar todos os idiomas', validationResults: 'Resultados da validação', validUploadID: "'Esse campo não é um ID de upload válido.'", }, diff --git a/packages/translations/src/languages/ro.ts b/packages/translations/src/languages/ro.ts index 72b92b0fa80..d79f2a0dfdf 100644 --- a/packages/translations/src/languages/ro.ts +++ b/packages/translations/src/languages/ro.ts @@ -643,7 +643,6 @@ export const roTranslations: DefaultTranslationsObject = { trueOrFalse: 'Acest câmp poate fi doar egal cu true sau false.', username: 'Vă rugăm să introduceți un nume de utilizator valid. Poate conține litere, numere, cratime, puncte și sublinieri.', - validateAllLocales: 'Validați toate limbile', validationResults: 'Rezultatele validării', validUploadID: 'Acest câmp nu este un ID de încărcare valid.', }, diff --git a/packages/translations/src/languages/rs.ts b/packages/translations/src/languages/rs.ts index 540ab185747..39c3c6d773f 100644 --- a/packages/translations/src/languages/rs.ts +++ b/packages/translations/src/languages/rs.ts @@ -632,7 +632,6 @@ export const rsTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ово поље може бити само тачно или нетачно', username: 'Molimo unesite važeće korisničko ime. Može sadržati slova, brojeve, crtice, tačke i donje crte.', - validateAllLocales: 'Validirajte sve jezike', validationResults: 'Rezultati validacije', validUploadID: 'Ово поље не садржи валидан ИД преноса.', }, diff --git a/packages/translations/src/languages/rsLatin.ts b/packages/translations/src/languages/rsLatin.ts index 15dfe2ddd3d..03b7783079c 100644 --- a/packages/translations/src/languages/rsLatin.ts +++ b/packages/translations/src/languages/rsLatin.ts @@ -633,7 +633,6 @@ export const rsLatinTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ovo polje može biti samo tačno ili netačno', username: 'Molimo unesite važeće korisničko ime. Može sadržavati slova, brojeve, crtice, tačke i donje crte.', - validateAllLocales: 'Potvrdite sve jezike', validationResults: 'Rezultati validacije', validUploadID: 'Ovo polje ne sadrži validan ID prenosa.', }, diff --git a/packages/translations/src/languages/ru.ts b/packages/translations/src/languages/ru.ts index a91312361e8..e651364de55 100644 --- a/packages/translations/src/languages/ru.ts +++ b/packages/translations/src/languages/ru.ts @@ -638,7 +638,6 @@ export const ruTranslations: DefaultTranslationsObject = { trueOrFalse: 'Это поле может быть равно только true или false.', username: 'Пожалуйста, введите действительное имя пользователя. Может содержать буквы, цифры, дефисы, точки и подчёркивания.', - validateAllLocales: 'Проверить все языковые настройки', validationResults: 'Результаты проверки', validUploadID: "'Это поле не является действительным ID загрузки.'", }, diff --git a/packages/translations/src/languages/sk.ts b/packages/translations/src/languages/sk.ts index 44d6d0771af..3c099a52590 100644 --- a/packages/translations/src/languages/sk.ts +++ b/packages/translations/src/languages/sk.ts @@ -630,7 +630,6 @@ export const skTranslations: DefaultTranslationsObject = { trueOrFalse: 'Toto pole môže byť rovné iba true alebo false.', username: 'Prosím, zadajte platné používateľské meno. Môže obsahovať písmená, čísla, pomlčky, bodky a podčiarknutia.', - validateAllLocales: 'Overte všetky jazyky', validationResults: 'Výsledky validácie', validUploadID: 'Toto pole nie je platné ID pre odoslanie.', }, diff --git a/packages/translations/src/languages/sl.ts b/packages/translations/src/languages/sl.ts index 4fdc3d485e5..56eb3ff45d3 100644 --- a/packages/translations/src/languages/sl.ts +++ b/packages/translations/src/languages/sl.ts @@ -631,7 +631,6 @@ export const slTranslations: DefaultTranslationsObject = { trueOrFalse: 'To polje je lahko samo enako true ali false.', username: 'Vnesite veljavno uporabniško ime. Lahko vsebuje črke, številke, vezaje, pike in podčrtaje.', - validateAllLocales: 'Potrdite vse jezike', validationResults: 'Rezultati validacije', validUploadID: 'To polje ni veljaven ID nalaganja.', }, diff --git a/packages/translations/src/languages/sv.ts b/packages/translations/src/languages/sv.ts index cbbd4223a2a..762784fcd56 100644 --- a/packages/translations/src/languages/sv.ts +++ b/packages/translations/src/languages/sv.ts @@ -635,7 +635,6 @@ export const svTranslations: DefaultTranslationsObject = { trueOrFalse: 'Detta fält kan bara vara lika med sant eller falskt.', username: 'Var god ange ett giltigt användarnamn. Kan innehålla bokstäver, siffror, bindestreck, punkter och understreck.', - validateAllLocales: 'Validera alla språk', validationResults: 'Valideringsresultat', validUploadID: 'Det här fältet är inte ett giltigt uppladdnings-ID', }, diff --git a/packages/translations/src/languages/ta.ts b/packages/translations/src/languages/ta.ts index 3c451b920d7..bafd346b79a 100644 --- a/packages/translations/src/languages/ta.ts +++ b/packages/translations/src/languages/ta.ts @@ -637,7 +637,6 @@ export const taTranslations: DefaultTranslationsObject = { trueOrFalse: 'இந்த புலம் true அல்லது false ஆக மட்டுமே இருக்க வேண்டும்.', username: 'சரியான பயனர் பெயரை உள்ளிடவும். எழுத்துக்கள், எண்கள், ஹைஃபன்கள், புள்ளிகள் மற்றும் அடிக்கோடுகள் இருக்கலாம்.', - validateAllLocales: 'எல்லா Locale-களையும் சரிபார்க்கவும்', validationResults: 'சரிபார்ப்பு முடிவுகள்', validUploadID: 'இந்த புலம் சரியான பதிவேற்ற ID அல்ல.', }, diff --git a/packages/translations/src/languages/th.ts b/packages/translations/src/languages/th.ts index 64c8674873a..8985c0c5231 100644 --- a/packages/translations/src/languages/th.ts +++ b/packages/translations/src/languages/th.ts @@ -621,7 +621,6 @@ export const thTranslations: DefaultTranslationsObject = { timezoneRequired: 'ต้องการเขตเวลา', trueOrFalse: 'เป็นได้แค่ "ใช่" หรือ "ไม่ใช่"', username: 'กรุณาใส่ชื่อผู้ใช้ที่ถูกต้อง สามารถมีตัวอักษร ตัวเลข ขีดกลาง จุด และขีดล่าง', - validateAllLocales: 'ตรวจสอบความถูกต้องของทุก Locale', validationResults: 'ผลการตรวจสอบความถูกต้อง', validUploadID: 'ไม่ใช่ ID ของการอัปโหลดที่ถูกต้อง', }, diff --git a/packages/translations/src/languages/tr.ts b/packages/translations/src/languages/tr.ts index bb48601fbf4..694b30fa14d 100644 --- a/packages/translations/src/languages/tr.ts +++ b/packages/translations/src/languages/tr.ts @@ -640,7 +640,6 @@ export const trTranslations: DefaultTranslationsObject = { trueOrFalse: 'Bu alan yalnızca doğru ve yanlış olabilir.', username: 'Lütfen geçerli bir kullanıcı adı girin. Harfler, numaralar, kısa çizgiler, noktalar ve alt çizgiler içerebilir.', - validateAllLocales: "Tüm Locale'leri doğrulayın", validationResults: 'Doğrulama sonuçları', validUploadID: "'Bu alan geçerli bir karşıya yükleme ID'sine sahip değil.'", }, diff --git a/packages/translations/src/languages/uk.ts b/packages/translations/src/languages/uk.ts index 3e8bfae58a2..61ebd16a81f 100644 --- a/packages/translations/src/languages/uk.ts +++ b/packages/translations/src/languages/uk.ts @@ -629,7 +629,6 @@ export const ukTranslations: DefaultTranslationsObject = { trueOrFalse: 'Це поле може мати значення тільки true або false.', username: "Будь ласка, введіть коректне ім'я користувача. Може містити літери, цифри, дефіси, крапки та підкреслення.", - validateAllLocales: 'Перевірити всі Locale', validationResults: 'Результати валідації', validUploadID: 'Це поле не є коректним ID завантаження.', }, diff --git a/packages/translations/src/languages/vi.ts b/packages/translations/src/languages/vi.ts index 29d4ac6a7fb..2651d78b06b 100644 --- a/packages/translations/src/languages/vi.ts +++ b/packages/translations/src/languages/vi.ts @@ -634,7 +634,6 @@ export const viTranslations: DefaultTranslationsObject = { trueOrFalse: 'Trường này chỉ có thể chứa giá trị đúng hoặc sai.', username: 'Vui lòng nhập một tên người dùng hợp lệ. Có thể chứa các chữ cái, số, dấu gạch ngang, dấu chấm và dấu gạch dưới.', - validateAllLocales: 'Xác thực tất cả các Locale', validationResults: 'Kết quả xác thực', validUploadID: "'Field này không chứa ID tải lên hợp lệ.'", }, diff --git a/packages/translations/src/languages/zh.ts b/packages/translations/src/languages/zh.ts index 7978af8e433..94b770c634c 100644 --- a/packages/translations/src/languages/zh.ts +++ b/packages/translations/src/languages/zh.ts @@ -605,7 +605,6 @@ export const zhTranslations: DefaultTranslationsObject = { timezoneRequired: '需要选择一个时区。', trueOrFalse: '此项仅可选择"是"或"否"。', username: '请输入一个有效的用户名。可包含字母,数字,连字符,句点和下划线。', - validateAllLocales: '验证所有Locale', validationResults: '验证结果', validUploadID: '该字段不是有效的上传 ID。', }, diff --git a/packages/translations/src/languages/zhTw.ts b/packages/translations/src/languages/zhTw.ts index cfd5a6d3cd3..554dfd0b304 100644 --- a/packages/translations/src/languages/zhTw.ts +++ b/packages/translations/src/languages/zhTw.ts @@ -603,7 +603,6 @@ export const zhTwTranslations: DefaultTranslationsObject = { timezoneRequired: '請選取一個時區。', trueOrFalse: '此欄位只能為 true 或 false。', username: '請輸入有效的使用者名稱。可包含英文字母、數字、連字號、句點與底線。', - validateAllLocales: '驗證所有語系', validationResults: '驗證結果', validUploadID: '此欄位不是有效的上傳 ID。', }, diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index d0337ab7e9d..f5f3483c84e 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -27,9 +27,7 @@ import { DocumentValidationProvider } from '../../providers/DocumentValidation/i import { useEditDepth } from '../../providers/EditDepth/index.js' import { useLivePreviewContext } from '../../providers/LivePreview/context.js' import { useTranslation } from '../../providers/Translation/index.js' -import { shouldShowValidateAllLocales } from '../../utilities/documentValidation.js' import { formatDate, formatTimeToNow } from '../../utilities/formatDocTitle/formatDateTitle.js' -import { traverseForLocalizedFields } from '../../utilities/traverseForLocalizedFields.js' import { Autosave } from '../Autosave/index.js' import { Button } from '../Button/index.js' import { CopyLocaleData } from '../CopyLocaleData/index.js' @@ -48,7 +46,6 @@ import { SaveDraftButton } from '../SaveDraftButton/index.js' import { SchedulePublishButton } from '../SchedulePublishButton/index.js' import { Status } from '../Status/index.js' import { UnpublishButton } from '../UnpublishButton/index.js' -import { ValidateDocumentButton } from '../ValidateDocumentButton/index.js' import './index.css' const baseClass = 'doc-controls' @@ -144,7 +141,6 @@ const DocumentControlsContent: React.FC = (props) => { const collectionConfig = getEntityConfig({ collectionSlug: slug }) const globalConfig = getEntityConfig({ globalSlug: slug }) - const entityConfig = collectionConfig || globalConfig const { isLivePreviewEnabled } = useLivePreviewContext() @@ -233,13 +229,6 @@ const DocumentControlsContent: React.FC = (props) => { const showCopyToLocale = localization && !collectionConfig?.admin?.disableCopyToLocale const showLockedMetaIcon = user && readOnlyForIncomingUser - const showValidateAllLocales = shouldShowValidateAllLocales({ - hasLocalization: Boolean(localization), - hasLocalizedFields: traverseForLocalizedFields(entityConfig?.fields ?? [], { - blocksMap: config.blocksMap, - }), - hasValidatePermission: Boolean(permissions?.validate), - }) return (
= (props) => { )}
- {showValidateAllLocales && !disableActions && !isTrashed && !readOnlyForIncomingUser && ( - - )} {hasSavePermission && !isTrashed && !readOnlyForIncomingUser && ( {collectionHasDraftsEnabled || globalHasDraftsEnabled ? ( diff --git a/packages/ui/src/elements/ValidateDocumentButton/index.tsx b/packages/ui/src/elements/ValidateDocumentButton/index.tsx deleted file mode 100644 index 40052955bd3..00000000000 --- a/packages/ui/src/elements/ValidateDocumentButton/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client' - -import React from 'react' - -import { useFormInitializing, useFormProcessing } from '../../forms/Form/context.js' -import { useDocumentValidation } from '../../providers/DocumentValidation/index.js' -import { useTranslation } from '../../providers/Translation/index.js' -import { Button } from '../Button/index.js' - -export const ValidateDocumentButton: React.FC = () => { - const initializing = useFormInitializing() - const processing = useFormProcessing() - const { isValidating, validateAllLocales } = useDocumentValidation() - const { t } = useTranslation() - - return ( - - ) -} diff --git a/packages/ui/src/providers/DocumentValidation/index.tsx b/packages/ui/src/providers/DocumentValidation/index.tsx index 32cb9e3f352..b2d76f77dea 100644 --- a/packages/ui/src/providers/DocumentValidation/index.tsx +++ b/packages/ui/src/providers/DocumentValidation/index.tsx @@ -28,13 +28,11 @@ import { useLocale } from '../Locale/index.js' type DocumentValidationContext = { isValidating: boolean - validateAllLocales: () => Promise validateBeforePublish: (args: { isPublishAll: boolean }) => Promise } const Context = createContext({ isValidating: false, - validateAllLocales: () => Promise.resolve(true), validateBeforePublish: () => Promise.resolve(true), }) @@ -59,7 +57,6 @@ export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> const [isValidating, setIsValidating] = useState(false) const [result, setResult] = useState(null) const activeRequestRef = useRef(null) - const validResultTimeoutRef = useRef>(null) const drawerID = useId() const drawerSlug = useMemo( () => `document-validation-results-${drawerID.replaceAll(':', '')}`, @@ -77,11 +74,6 @@ export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> activeRequestRef.current?.abort() activeRequestRef.current = null - if (validResultTimeoutRef.current) { - clearTimeout(validResultTimeoutRef.current) - validResultTimeoutRef.current = null - } - setIsValidating(false) setResult(null) closeModal(drawerSlug) @@ -94,10 +86,6 @@ export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> useEffect(() => { return () => { activeRequestRef.current?.abort() - - if (validResultTimeoutRef.current) { - clearTimeout(validResultTimeoutRef.current) - } } }, []) @@ -105,11 +93,9 @@ export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> async ({ isPublishing, locales, - showValidResult, }: { isPublishing: boolean locales: string[] - showValidResult: boolean }): Promise => { if (!localization || !hasLocalizedFields) { return true @@ -117,11 +103,6 @@ export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> activeRequestRef.current?.abort() - if (validResultTimeoutRef.current) { - clearTimeout(validResultTimeoutRef.current) - validResultTimeoutRef.current = null - } - const abortController = new AbortController() activeRequestRef.current = abortController setIsValidating(true) @@ -150,18 +131,11 @@ export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> return false } - if (!validationResult.valid || showValidResult) { + if (!validationResult.valid) { setResult(validationResult) openModal(drawerSlug) } - if (validationResult.valid && showValidResult) { - validResultTimeoutRef.current = setTimeout(() => { - setResult(null) - closeModal(drawerSlug) - }, 4000) - } - return validationResult.valid } catch (error) { if (abortController.signal.aborted) { @@ -201,23 +175,10 @@ export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> id, localization, openModal, - closeModal, drawerSlug, ], ) - const validateAllLocales = useCallback(() => { - if (!localization) { - return Promise.resolve(true) - } - - return runValidation({ - isPublishing: false, - locales: localization.locales.map(({ code }) => code), - showValidResult: true, - }) - }, [localization, runValidation]) - const validateBeforePublish = useCallback( ({ isPublishAll }: { isPublishAll: boolean }) => { if (!localization) { @@ -231,7 +192,6 @@ export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> isPublishAll, locales: localization.locales, }), - showValidResult: false, }) }, [activeLocale, localization, runValidation], @@ -240,10 +200,9 @@ export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> const value = useMemo( () => ({ isValidating, - validateAllLocales, validateBeforePublish, }), - [isValidating, validateAllLocales, validateBeforePublish], + [isValidating, validateBeforePublish], ) return ( diff --git a/packages/ui/src/utilities/documentValidation.spec.ts b/packages/ui/src/utilities/documentValidation.spec.ts index d1539ce5f64..d23abc8e067 100644 --- a/packages/ui/src/utilities/documentValidation.spec.ts +++ b/packages/ui/src/utilities/documentValidation.spec.ts @@ -7,7 +7,6 @@ import { getValidationEndpoint, projectValidationDataForSiblingLocales, requestDocumentValidation, - shouldShowValidateAllLocales, validateDocumentLocales, } from './documentValidation.js' import { traverseForLocalizedFields } from './traverseForLocalizedFields.js' @@ -45,30 +44,6 @@ describe('document validation', () => { ).toBe('/api/globals/settings/validate') }) - it('should only show the action for localized entities with validation access', () => { - expect( - shouldShowValidateAllLocales({ - hasLocalizedFields: true, - hasLocalization: true, - hasValidatePermission: true, - }), - ).toBe(true) - expect( - shouldShowValidateAllLocales({ - hasLocalizedFields: false, - hasLocalization: true, - hasValidatePermission: true, - }), - ).toBe(false) - expect( - shouldShowValidateAllLocales({ - hasLocalizedFields: true, - hasLocalization: true, - hasValidatePermission: false, - }), - ).toBe(false) - }) - it('should detect localized fields inside referenced blocks', () => { const fields = [ { diff --git a/packages/ui/src/utilities/documentValidation.ts b/packages/ui/src/utilities/documentValidation.ts index b1b7db8784e..3ae7340b50c 100644 --- a/packages/ui/src/utilities/documentValidation.ts +++ b/packages/ui/src/utilities/documentValidation.ts @@ -49,18 +49,6 @@ export function getValidationEndpoint({ }) } -export function shouldShowValidateAllLocales({ - hasLocalization, - hasLocalizedFields, - hasValidatePermission, -}: { - hasLocalization: boolean - hasLocalizedFields: boolean - hasValidatePermission: boolean -}): boolean { - return hasLocalization && hasLocalizedFields && hasValidatePermission -} - export function getPublishValidationLocales({ activeLocale, isPublishAll, diff --git a/test/validate/e2e.spec.ts b/test/validate/e2e.spec.ts index d8d354e82c0..f70d865fc56 100644 --- a/test/validate/e2e.spec.ts +++ b/test/validate/e2e.spec.ts @@ -9,13 +9,7 @@ import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js' import { RESTClient } from '../__helpers/shared/rest.js' import { initPage } from '../__setup/e2e/initPage.js' -import { - publishGlobalSlug, - validationAdminCollectionSlug, - validationCustomButtonsCollectionSlug, - validationDeniedCollectionSlug, - validationNonLocalizedCollectionSlug, -} from './config.js' +import { validationAdminCollectionSlug, validationCustomButtonsCollectionSlug } from './config.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -52,51 +46,6 @@ test.describe('Admin document validation', () => { createdIDs.length = 0 }) - test('should only show the action on localized documents with validation access', async () => { - await page.goto(validationURL.create) - await expect(page.locator('#action-validate-all-locales')).toBeVisible() - - const nonLocalizedURL = new AdminUrlUtil(serverURL, validationNonLocalizedCollectionSlug) - await page.goto(nonLocalizedURL.create) - await expect(page.locator('#action-validate-all-locales')).toHaveCount(0) - - const deniedURL = new AdminUrlUtil(serverURL, validationDeniedCollectionSlug) - await page.goto(deniedURL.create) - await expect(page.locator('#action-validate-all-locales')).toHaveCount(0) - - await page.goto(validationURL.global(publishGlobalSlug)) - await expect(page.locator('#action-validate-all-locales')).toBeVisible() - }) - - test('should show sibling-locale errors without discarding unsaved active-locale data', async () => { - const id = await createDraft({ spanishTitle: 'Título en español' }) - - await openDraft(id) - await page.locator('#field-title').fill('Unsaved English title') - await page.locator('#action-validate-all-locales').click() - - const result = page.locator('.validation-results') - await expect(result.getByRole('alert')).toBeVisible() - await expect(result).toContainText('German') - await expect(result).toContainText('title') - await expect(page.locator('#field-title')).toHaveValue('Unsaved English title') - }) - - test('should announce a valid result accessibly', async () => { - const id = await createDraft({ - frenchTitle: 'Titre français', - germanTitle: 'Deutscher Titel', - spanishTitle: 'Título en español', - }) - - await openDraft(id) - await page.locator('#action-validate-all-locales').click() - - await expect(page.locator('.validation-results').getByRole('status')).toHaveText( - 'All selected locales are valid.', - ) - }) - test('should block publish-all for an invalid optional locale but allow normal publish', async () => { const id = await createDraft({ spanishTitle: 'Título en español' }) From 90bee459acbc9a5828847df24cb2bb1e8a8ba2ca Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 19 Aug 2026 17:13:13 +0100 Subject: [PATCH 31/47] reduce scope a bit --- docs/configuration/localization.mdx | 13 +- docs/validation/overview.mdx | 57 +-- docs/versions/drafts.mdx | 2 +- .../payload/src/auth/operations/logout.ts | 3 + .../payload/src/auth/operations/refresh.ts | 3 + .../src/auth/operations/resetPassword.ts | 3 + .../src/auth/operations/verifyEmail.ts | 3 + packages/payload/src/auth/sessions.spec.ts | 31 ++ packages/payload/src/auth/sessions.ts | 6 + .../local/resetLoginAttempts.spec.ts | 16 + .../strategies/local/resetLoginAttempts.ts | 4 + .../src/collections/operations/create.ts | 24 +- .../collections/operations/local/validate.ts | 7 + .../operations/utilities/update.ts | 25 +- .../src/collections/operations/validate.ts | 11 +- packages/payload/src/config/client.spec.ts | 33 -- packages/payload/src/config/client.ts | 2 - packages/payload/src/config/sanitize.ts | 2 - packages/payload/src/config/types.ts | 13 +- .../src/errors/ValidationError.spec.ts | 2 +- .../payload/src/errors/ValidationError.ts | 16 +- .../src/globals/operations/local/validate.ts | 15 +- .../payload/src/globals/operations/update.ts | 26 +- .../src/globals/operations/validate.ts | 11 +- .../hooks/collectionBeforeChange.spec.ts | 46 ++ .../hierarchy/hooks/collectionBeforeChange.ts | 2 +- .../src/utilities/applyLocaleFiltering.ts | 5 +- .../src/utilities/flattenDataByLocale.spec.ts | 58 +++ .../src/utilities/flattenDataByLocale.ts | 46 +- .../utilities/isPostHookPublishIntent.spec.ts | 74 --- .../src/utilities/isPostHookPublishIntent.ts | 37 -- .../utilities/parseValidationLocale.spec.ts | 96 ++++ .../utilities/resolvePublishLocales.spec.ts | 46 -- .../src/utilities/resolvePublishLocales.ts | 31 -- .../resolveValidationLocales.spec.ts | 99 +++- packages/translations/src/clientKeys.ts | 4 - packages/translations/src/languages/ar.ts | 4 - packages/translations/src/languages/az.ts | 4 - packages/translations/src/languages/bg.ts | 4 - packages/translations/src/languages/bnBd.ts | 4 - packages/translations/src/languages/bnIn.ts | 4 - packages/translations/src/languages/ca.ts | 4 - packages/translations/src/languages/cs.ts | 4 - packages/translations/src/languages/da.ts | 4 - packages/translations/src/languages/de.ts | 4 - packages/translations/src/languages/en.ts | 4 - packages/translations/src/languages/es.ts | 4 - packages/translations/src/languages/et.ts | 4 - packages/translations/src/languages/fa.ts | 4 - packages/translations/src/languages/fr.ts | 4 - packages/translations/src/languages/he.ts | 4 - packages/translations/src/languages/hr.ts | 4 - packages/translations/src/languages/hu.ts | 4 - packages/translations/src/languages/hy.ts | 4 - packages/translations/src/languages/id.ts | 4 - packages/translations/src/languages/is.ts | 4 - packages/translations/src/languages/it.ts | 4 - packages/translations/src/languages/ja.ts | 4 - packages/translations/src/languages/ko.ts | 4 - packages/translations/src/languages/lt.ts | 4 - packages/translations/src/languages/lv.ts | 4 - packages/translations/src/languages/my.ts | 4 - packages/translations/src/languages/nb.ts | 4 - packages/translations/src/languages/nl.ts | 4 - packages/translations/src/languages/pl.ts | 4 - packages/translations/src/languages/pt.ts | 4 - packages/translations/src/languages/ro.ts | 4 - packages/translations/src/languages/rs.ts | 4 - .../translations/src/languages/rsLatin.ts | 4 - packages/translations/src/languages/ru.ts | 4 - packages/translations/src/languages/sk.ts | 4 - packages/translations/src/languages/sl.ts | 4 - packages/translations/src/languages/sv.ts | 4 - packages/translations/src/languages/ta.ts | 4 - packages/translations/src/languages/th.ts | 4 - packages/translations/src/languages/tr.ts | 4 - packages/translations/src/languages/uk.ts | 4 - packages/translations/src/languages/vi.ts | 4 - packages/translations/src/languages/zh.ts | 4 - packages/translations/src/languages/zhTw.ts | 4 - .../src/elements/DocumentControls/index.tsx | 9 +- .../ui/src/elements/PublishButton/index.tsx | 24 +- .../ValidationResultsDrawer/index.css | 40 -- .../ValidationResultsDrawer/index.tsx | 77 --- .../providers/DocumentValidation/index.tsx | 219 --------- .../src/utilities/documentValidation.spec.ts | 234 --------- .../ui/src/utilities/documentValidation.ts | 380 --------------- test/validate/config.ts | 73 +-- test/validate/e2e.spec.ts | 19 +- test/validate/int.spec.ts | 444 +++++------------- 90 files changed, 668 insertions(+), 1899 deletions(-) create mode 100644 packages/payload/src/auth/sessions.spec.ts create mode 100644 packages/payload/src/auth/strategies/local/resetLoginAttempts.spec.ts create mode 100644 packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts delete mode 100644 packages/payload/src/utilities/isPostHookPublishIntent.spec.ts delete mode 100644 packages/payload/src/utilities/isPostHookPublishIntent.ts create mode 100644 packages/payload/src/utilities/parseValidationLocale.spec.ts delete mode 100644 packages/payload/src/utilities/resolvePublishLocales.spec.ts delete mode 100644 packages/payload/src/utilities/resolvePublishLocales.ts delete mode 100644 packages/ui/src/elements/ValidationResultsDrawer/index.css delete mode 100644 packages/ui/src/elements/ValidationResultsDrawer/index.tsx delete mode 100644 packages/ui/src/providers/DocumentValidation/index.tsx delete mode 100644 packages/ui/src/utilities/documentValidation.spec.ts delete mode 100644 packages/ui/src/utilities/documentValidation.ts diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index abcb72ca931..89ee307c32a 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -96,13 +96,12 @@ The locale codes do not need to be in any specific format. It's up to you to def #### Locale Object -| Option | Description | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`code`** \* | Unique code to identify the language throughout the APIs for `locale` and `fallbackLocale`. | -| **`label`** | A string to use for the selector when choosing a language, or an object keyed on the i18n keys for different languages in use. | -| **`rtl`** | A boolean that when true will make the admin UI display in Right-To-Left. | -| **`fallbackLocale`** | The code for this language to fallback to when properties of a document are not present. This can be a single locale or array of locales. | -| **`required`** | Require this locale to pass validation whenever any locale is published. Defaults to `false`. [More details](../validation/overview#publishing-and-required-locales). | +| Option | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| **`code`** \* | Unique code to identify the language throughout the APIs for `locale` and `fallbackLocale`. | +| **`label`** | A string to use for the selector when choosing a language, or an object keyed on the i18n keys for different languages in use. | +| **`rtl`** | A boolean that when true will make the admin UI display in Right-To-Left. | +| **`fallbackLocale`** | The code for this language to fallback to when properties of a document are not present. This can be a single locale or array of locales. | _\* An asterisk denotes that a property is required._ diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index 6b68bd2ff44..cdc6d18c5c6 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -200,44 +200,25 @@ same candidate into every selected locale, then aggregates the results. If a cus unsaved localized values for only its active locale, do not send those values as a flat multi-locale candidate. -The built-in Admin UI handles this distinction for you: it validates the active locale with the -current unsaved form data, then validates sibling locales with only non-localized candidate fields. -Sibling localized values therefore come from their stored document data, or remain missing for a -new document. +Publishing every locale at once has this same problem: the request only carries unsaved data for +the locale being edited. Payload validates that locale with the submitted data, then validates +every other locale using only its stored, non-localized-field-stripped candidate, so sibling +locales are never checked against localized values that were never meant for them. Fallback locale values are disabled during on-demand validation. Missing data must pass validation in the locale being checked. -## Publishing and required locales +## Publishing locales -Use locale configuration objects to require a locale whenever any locale is published: - -```ts -import { buildConfig } from 'payload' - -export default buildConfig({ - localization: { - defaultLocale: 'en', - locales: [ - { code: 'en', label: 'English' }, - { code: 'es', label: 'Spanish', required: true }, - { code: 'de', label: 'German' }, - ], - }, -}) -``` - -Publishing one locale validates that locale and every locale with `required: true`. Publishing all -locales validates every available locale. If any locale is invalid, Payload throws a -`ValidationError` before writing the document, version, or files. +Publishing one locale validates only that locale, using the same field validation the `update` +operation already runs. Publishing every locale at once additionally validates every other +available locale, since their currently stored data is about to go live too. If any of those +locales is invalid, Payload throws a `ValidationError` before writing the document, version, or +files. Publishing uses its normal `create` or `update` access policy; the internal validity check does not require separate `validate` access. -The default Admin Publish controls run a preflight so editors can see locale-grouped errors before -submission. If you replace the default Publish button, your custom UI owns any preflight feedback; -the server-side publish validation remains authoritative. - Scheduled publishes revalidate the latest stored target when the job runs. An invalid target cancels the publish job without retrying or changing publication state. Unpublishing does not require publish validation. @@ -305,8 +286,20 @@ const beforeChange: CollectionBeforeChangeHook = async ({ } ``` +## Admin UI + +The default Publish button uses this same validation when it publishes. Publishing the active +locale is validated by the normal update it performs; no separate request is made. Publishing every +locale runs the additional all-locales check described above as part of that same request, before +anything is written. If publishing fails, Payload reports the errors the way it reports any other +save failure: invalid fields for the active locale are flagged inline, and a toast lists every +invalid field, including those in other locales. + +If you replace the default Publish button, your custom UI owns how validation failures are +reported; the server-side publish validation remains authoritative. + ## Current scope -On-demand validation is available through the Local and REST APIs, and powers the Admin UI's Publish -preflight for localized documents. It does not add a standalone "validate all locales" Admin UI -action, a GraphQL validation operation, bulk Admin validation, or fallback-locale validation. +On-demand validation is available through the Local and REST APIs, and powers the Admin UI's default +Publish button. It does not add a standalone "validate all locales" Admin UI action, a GraphQL +validation operation, bulk Admin validation, or fallback-locale validation. diff --git a/docs/versions/drafts.mdx b/docs/versions/drafts.mdx index be79e8cc6d8..9da145d5970 100644 --- a/docs/versions/drafts.mdx +++ b/docs/versions/drafts.mdx @@ -293,7 +293,7 @@ Payload provides for an ability to schedule publishing / unpublishing events in You can enable this functionality on both collections and globals via the `versions.drafts.schedulePublish: true` property. At execution time, a scheduled publish [revalidates the latest stored -document](../validation/overview#publishing-and-required-locales). If validation fails, Payload +document](../validation/overview#publishing-locales). If validation fails, Payload cancels the job without changing publication state. diff --git a/packages/payload/src/auth/operations/logout.ts b/packages/payload/src/auth/operations/logout.ts index 424991f3dd0..3e7019550e1 100644 --- a/packages/payload/src/auth/operations/logout.ts +++ b/packages/payload/src/auth/operations/logout.ts @@ -5,6 +5,7 @@ import type { PayloadRequest } from '../../types/index.js' import { APIError } from '../../errors/index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -24,6 +25,8 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise req, } = incomingArgs + assertNoValidationWrite(req) + if (!user) { throw new APIError('No User', httpStatus.BAD_REQUEST) } diff --git a/packages/payload/src/auth/operations/refresh.ts b/packages/payload/src/auth/operations/refresh.ts index 81e5f29ae47..d66fb4565c0 100644 --- a/packages/payload/src/auth/operations/refresh.ts +++ b/packages/payload/src/auth/operations/refresh.ts @@ -5,6 +5,7 @@ import type { Document, PayloadRequest } from '../../types/index.js' import { buildAfterOperation } from '../../collections/operations/utilities/buildAfterOperation.js' import { buildBeforeOperation } from '../../collections/operations/utilities/buildBeforeOperation.js' import { Forbidden } from '../../errors/index.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -27,6 +28,8 @@ export type Arguments = { export const refreshOperation = async (incomingArgs: Arguments): Promise => { let args = incomingArgs + assertNoValidationWrite(args.req) + try { const shouldCommit = await initTransaction(args.req) diff --git a/packages/payload/src/auth/operations/resetPassword.ts b/packages/payload/src/auth/operations/resetPassword.ts index f4d2a867d8a..b08360dda51 100644 --- a/packages/payload/src/auth/operations/resetPassword.ts +++ b/packages/payload/src/auth/operations/resetPassword.ts @@ -8,6 +8,7 @@ import { buildAfterOperation } from '../../collections/operations/utilities/buil import { buildBeforeOperation } from '../../collections/operations/utilities/buildBeforeOperation.js' import { APIError, Forbidden } from '../../errors/index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -48,6 +49,8 @@ export const resetPasswordOperation = async ( req, } = args + assertNoValidationWrite(req) + if ( !Object.prototype.hasOwnProperty.call(data, 'token') || !Object.prototype.hasOwnProperty.call(data, 'password') diff --git a/packages/payload/src/auth/operations/verifyEmail.ts b/packages/payload/src/auth/operations/verifyEmail.ts index 83cf8481741..390f6288f83 100644 --- a/packages/payload/src/auth/operations/verifyEmail.ts +++ b/packages/payload/src/auth/operations/verifyEmail.ts @@ -5,6 +5,7 @@ import type { PayloadRequest } from '../../types/index.js' import { APIError, Forbidden } from '../../errors/index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' @@ -18,6 +19,8 @@ export type Args = { export const verifyEmailOperation = async (args: Args): Promise => { const { collection, req, token } = args + assertNoValidationWrite(req) + if (collection.config.auth.disableLocalStrategy) { throw new Forbidden(req.t) } diff --git a/packages/payload/src/auth/sessions.spec.ts b/packages/payload/src/auth/sessions.spec.ts new file mode 100644 index 00000000000..a2edc374ac6 --- /dev/null +++ b/packages/payload/src/auth/sessions.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { addSessionToUser, revokeSession } from './sessions.js' + +const validationRequest = { operation: 'validate' } as any +const collectionConfig = { auth: { useSessions: true }, slug: 'users' } as any + +describe('session helpers reject writes made through an active validation request', () => { + it('addSessionToUser should reject before writing a session', async () => { + await expect( + addSessionToUser({ + collectionConfig, + payload: {} as any, + req: validationRequest, + user: { id: '1' } as any, + }), + ).rejects.toThrow(/not allowed during validation/i) + }) + + it('revokeSession should reject before writing a session', async () => { + await expect( + revokeSession({ + collectionConfig, + payload: {} as any, + req: validationRequest, + sid: 'session-id', + user: { id: '1', sessions: [{ id: 'session-id' }] } as any, + }), + ).rejects.toThrow(/not allowed during validation/i) + }) +}) diff --git a/packages/payload/src/auth/sessions.ts b/packages/payload/src/auth/sessions.ts index 633daabd3b2..6d3bcd32461 100644 --- a/packages/payload/src/auth/sessions.ts +++ b/packages/payload/src/auth/sessions.ts @@ -4,6 +4,8 @@ import type { SanitizedCollectionConfig } from '../collections/config/types.js' import type { AuthenticatedUser, User, UserSession } from '../index.js' import type { Payload, PayloadRequest } from '../types/index.js' +import { assertNoValidationWrite } from '../utilities/assertNoValidationWrite.js' + /** * Removes expired sessions from an array of sessions */ @@ -31,6 +33,8 @@ export const addSessionToUser = async ({ req: PayloadRequest user: AuthenticatedUser }): Promise<{ sid?: string }> => { + assertNoValidationWrite(req) + let sid: string | undefined if (collectionConfig.auth.useSessions) { // Add session to user @@ -82,6 +86,8 @@ export const revokeSession = async ({ sid: string user: null | User }): Promise => { + assertNoValidationWrite(req) + if (collectionConfig.auth.useSessions && user && user.sessions?.length) { user.sessions = user.sessions.filter((session) => session.id !== sid) await payload.db.updateOne({ diff --git a/packages/payload/src/auth/strategies/local/resetLoginAttempts.spec.ts b/packages/payload/src/auth/strategies/local/resetLoginAttempts.spec.ts new file mode 100644 index 00000000000..611ff8500b4 --- /dev/null +++ b/packages/payload/src/auth/strategies/local/resetLoginAttempts.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' + +import { resetLoginAttempts } from './resetLoginAttempts.js' + +describe('resetLoginAttempts', () => { + it('should reject writes made through an active validation request', async () => { + await expect( + resetLoginAttempts({ + collection: { slug: 'users' } as any, + doc: { id: '1', loginAttempts: 3 } as any, + payload: {} as any, + req: { operation: 'validate' } as any, + }), + ).rejects.toThrow(/not allowed during validation/i) + }) +}) diff --git a/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts b/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts index 8865612d0ad..0c558b79d5a 100644 --- a/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts +++ b/packages/payload/src/auth/strategies/local/resetLoginAttempts.ts @@ -2,6 +2,8 @@ import type { SanitizedCollectionConfig, TypeWithID } from '../../../collections import type { Payload } from '../../../index.js' import type { PayloadRequest } from '../../../types/index.js' +import { assertNoValidationWrite } from '../../../utilities/assertNoValidationWrite.js' + type Args = { collection: SanitizedCollectionConfig doc: Record & TypeWithID @@ -15,6 +17,8 @@ export const resetLoginAttempts = async ({ payload, req, }: Args): Promise => { + assertNoValidationWrite(req) + if ( !('lockUntil' in doc && typeof doc.lockUntil === 'string') && (!('loginAttempts' in doc) || doc.loginAttempts === 0) diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 5c76cd79ceb..28b7fee2d0e 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -38,9 +38,7 @@ import { hasLocalizeStatusEnabled, } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' -import { isPostHookPublishIntent } from '../../utilities/isPostHookPublishIntent.js' import { killTransaction } from '../../utilities/killTransaction.js' -import { resolvePublishLocales } from '../../utilities/resolvePublishLocales.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' @@ -251,30 +249,16 @@ export const createOperation = async < skipValidation: isSavingDraft && !hasDraftValidationEnabled(collectionConfig), }) - if ( - hasDraftsEnabled(collectionConfig) && - isPostHookPublishIntent({ - locale: locale ?? (config.localization ? config.localization.defaultLocale : undefined), - publishAllLocales, - status: dataWithLocales._status, - }) - ) { + if (hasDraftsEnabled(collectionConfig) && config.localization && publishAllLocales) { const validationResult = await validateLocalWithDataLocale(payload, { collection: collectionConfig.slug, data: dataWithLocales, - locale: resolvePublishLocales({ - locale: locale ?? null, - localization: config.localization, - publishAllLocales, - }), + dataIsLocaleKeyed: true, + locale: 'all', overrideAccess: true, req, validationDataLocale: - locale && locale !== 'all' - ? locale - : config.localization - ? config.localization.defaultLocale - : undefined, + locale && locale !== 'all' ? locale : config.localization.defaultLocale, }) if (!validationResult.valid) { diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index af75de90d92..f94ca3752df 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -102,6 +102,11 @@ export type ValidateCollectionOptions = } & BaseOptions) type InternalValidateCollectionOptions = { + /** + * Whether `data` stores each localized field as a locale-code-keyed object, as the internal + * publish-all-locales candidate does, rather than a flat, single-locale candidate. + */ + dataIsLocaleKeyed?: boolean validationDataLocale?: string validationTrash?: boolean } & ValidateCollectionOptions @@ -142,6 +147,7 @@ export async function validateLocalWithDataLocale( id, collection: collectionSlug, data, + dataIsLocaleKeyed, draft = false, locale, overrideAccess = true, @@ -205,6 +211,7 @@ export async function validateLocalWithDataLocale( id, collection, data: validationData, + dataIsLocaleKeyed, draft, overrideAccess, req, diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 15cf20611c2..f3662e8368a 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -38,8 +38,6 @@ import { hasDraftValidationEnabled, hasLocalizeStatusEnabled, } from '../../../utilities/getVersionsConfig.js' -import { isPostHookPublishIntent } from '../../../utilities/isPostHookPublishIntent.js' -import { resolvePublishLocales } from '../../../utilities/resolvePublishLocales.js' import { buildLocalizedPublishData } from '../../../versions/buildSingleLocalePublishData.js' import { validateLocalWithDataLocale } from '../local/validate.js' export type SharedUpdateDocumentArgs = { @@ -259,31 +257,20 @@ export const updateDocument = async < if ( hasDraftsEnabled(collectionConfig) && - isPostHookPublishIntent({ - locale, - publishAllLocales, - status: result._status, - unpublishAllLocales, - }) + config.localization && + publishAllLocales && + !unpublishAllLocales ) { const validationResult = await validateLocalWithDataLocale(payload, { id, collection: collectionConfig.slug, data: result, + dataIsLocaleKeyed: true, draft: true, - locale: resolvePublishLocales({ - locale, - localization: config.localization, - publishAllLocales, - }), + locale: 'all', overrideAccess: true, req, - validationDataLocale: - locale !== 'all' - ? locale - : config.localization - ? config.localization.defaultLocale - : undefined, + validationDataLocale: locale !== 'all' ? locale : config.localization.defaultLocale, validationTrash: Boolean(originalDoc?.deletedAt), }) diff --git a/packages/payload/src/collections/operations/validate.ts b/packages/payload/src/collections/operations/validate.ts index 200bf31d7dc..6047d130399 100644 --- a/packages/payload/src/collections/operations/validate.ts +++ b/packages/payload/src/collections/operations/validate.ts @@ -20,6 +20,12 @@ import { replaceWithDraftIfAvailable } from '../../versions/drafts/replaceWithDr export type Arguments = { collection: Collection data?: DeepPartial> + /** + * Whether `data` stores each localized field as a locale-code-keyed object, as the internal + * publish-all-locales candidate does, rather than a flat, single-locale candidate. + * @default false + */ + dataIsLocaleKeyed?: boolean draft: boolean id?: number | string overrideAccess: boolean @@ -44,6 +50,7 @@ async function validateOperationWithScopedRequest( id, collection, data: incomingData, + dataIsLocaleKeyed = false, draft, overrideAccess, req, @@ -108,12 +115,10 @@ async function validateOperationWithScopedRequest( let data = flattenDataByLocale({ configBlockReferences: req.payload.config.blocks, + dataIsLocaleKeyed, docWithLocales: deepCopyObjectSimple(incomingData ?? {}) as JsonObject, fields: collectionConfig.fields, locale: req.locale!, - localeCodes: req.payload.config.localization - ? req.payload.config.localization.localeCodes - : undefined, }) data = await beforeValidate({ diff --git a/packages/payload/src/config/client.spec.ts b/packages/payload/src/config/client.spec.ts index b5ddb641e3a..8894d805267 100644 --- a/packages/payload/src/config/client.spec.ts +++ b/packages/payload/src/config/client.spec.ts @@ -7,39 +7,6 @@ import { describe, expect, it } from 'vitest' import { createClientConfig } from './client.js' describe('createClientConfig', () => { - it('should retain sanitized required locale metadata without locale filtering', () => { - const config = { - localization: { - defaultLocale: 'en', - localeCodes: ['en', 'es'], - locales: [ - { - code: 'en', - label: 'English', - required: false, - }, - { - code: 'es', - label: 'Spanish', - required: true, - }, - ], - }, - } as SanitizedConfig - - const clientConfig = createClientConfig({ - config, - i18n: {} as I18nClient, - importMap: {}, - user: true, - }) - - expect(clientConfig.localization && clientConfig.localization.locales).toMatchObject([ - { code: 'en', required: false }, - { code: 'es', required: true }, - ]) - }) - it('should omit baseAccess from the client config', () => { const clientConfig = createClientConfig({ config: { diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index bad37eb850a..3295af60020 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -303,8 +303,6 @@ export const createClientConfig = ({ clientLocale.label = locale.label } - clientLocale.required = locale.required - if (locale.rtl) { clientLocale.rtl = locale.rtl } diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index 2784918308d..c6c177ad133 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -285,7 +285,6 @@ export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => { ).locales.map((locale) => ({ code: locale, label: locale, - required: false, rtl: false, toString: () => locale, })) @@ -297,7 +296,6 @@ export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => { config.localization as LocalizationConfigWithLabels ).locales.map((locale) => ({ ...locale, - required: locale.required ?? false, toString: () => locale.code, })) } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 7ef3ecdcdc1..b6a5819c1b0 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -587,13 +587,6 @@ export type Locale = { * @example "English" */ label: Record | string - /** - * Require this locale to pass validation whenever any one locale is published. - * Publish-all operations validate every available locale regardless of this setting. - * @default false - * @see https://payloadcms.com/docs/validation/overview#publishing-and-required-locales - */ - required?: boolean /** * if true, defaults textAligmnent on text fields to RTL */ @@ -646,11 +639,7 @@ export type LocalizationConfigWithLabels = Prettify< } & BaseLocalizationConfig > -export type SanitizedLocale = Prettify< - { - required: boolean - } & Locale -> +export type SanitizedLocale = Locale export type SanitizedLocalizationConfig = Prettify< { diff --git a/packages/payload/src/errors/ValidationError.spec.ts b/packages/payload/src/errors/ValidationError.spec.ts index e3dac9055da..ecce28b439f 100644 --- a/packages/payload/src/errors/ValidationError.spec.ts +++ b/packages/payload/src/errors/ValidationError.spec.ts @@ -19,7 +19,7 @@ describe('ValidationError', () => { ], }) - expect(error.message).toBe('The following fields are invalid: Title, seo.description') + expect(error.message).toBe('The following fields are invalid: [de] Title, seo.description') expect(error.data.errors).toEqual([ { label: 'Title', diff --git a/packages/payload/src/errors/ValidationError.ts b/packages/payload/src/errors/ValidationError.ts index 2035071254c..4287c4d7402 100644 --- a/packages/payload/src/errors/ValidationError.ts +++ b/packages/payload/src/errors/ValidationError.ts @@ -52,30 +52,34 @@ export class ValidationError extends APIError<{ // delete to avoid logging the whole req delete results['req'] + const spansMultipleLocales = new Set(results.errors.map((f) => f.locale)).size > 1 + super( `${message} ${results.errors .map((f) => { + const localePrefix = spansMultipleLocales && f.locale ? `[${f.locale}] ` : '' + if (f.label) { if (typeof f.label === 'function') { if (!req || !req.i18n || !req.t) { - return f.path + return `${localePrefix}${f.path}` } - return f.label({ i18n: req.i18n, t: req.t }) + return `${localePrefix}${f.label({ i18n: req.i18n, t: req.t })}` } if (typeof f.label === 'object') { if (req?.i18n?.language) { - return f.label[req.i18n.language] + return `${localePrefix}${f.label[req.i18n.language]}` } - return f.label[Object.keys(f.label)[0]!] + return `${localePrefix}${f.label[Object.keys(f.label)[0]!]}` } - return f.label + return `${localePrefix}${f.label}` } - return f.path + return `${localePrefix}${f.path}` }) .join(', ')}`, httpStatus.BAD_REQUEST, diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index 21b2786352c..76f8af7fe82 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -55,6 +55,11 @@ export type ValidateGlobalOptions = { } & DraftFlagFromGlobalSlug type InternalValidateGlobalOptions = { + /** + * Whether `data` stores each localized field as a locale-code-keyed object, as the internal + * publish-all-locales candidate does, rather than a flat, single-locale candidate. + */ + dataIsLocaleKeyed?: boolean validationDataLocale?: string } & ValidateGlobalOptions @@ -78,7 +83,14 @@ export async function validateGlobalLocalWithDataLocale, ): Promise { - const { slug, data, locale, overrideAccess = true, validationDataLocale } = options + const { + slug, + data, + dataIsLocaleKeyed, + locale, + overrideAccess = true, + validationDataLocale, + } = options const { draft = false } = options if (locale === undefined) { @@ -130,6 +142,7 @@ export async function validateGlobalLocalWithDataLocale = { data?: DeepPartial, 'id'>> + /** + * Whether `data` stores each localized field as a locale-code-keyed object, as the internal + * publish-all-locales candidate does, rather than a flat, single-locale candidate. + * @default false + */ + dataIsLocaleKeyed?: boolean draft: boolean globalConfig: SanitizedGlobalConfig overrideAccess: boolean @@ -41,6 +47,7 @@ export async function validateOperation( async function validateOperationWithScopedRequest({ slug, data: incomingData, + dataIsLocaleKeyed = false, draft, globalConfig, overrideAccess, @@ -73,12 +80,10 @@ async function validateOperationWithScopedRequest({ let data = flattenDataByLocale({ configBlockReferences: req.payload.config.blocks, + dataIsLocaleKeyed, docWithLocales: deepCopyObjectSimple(incomingData ?? {}) as JsonObject, fields: globalConfig.fields, locale: req.locale!, - localeCodes: req.payload.config.localization - ? req.payload.config.localization.localeCodes - : undefined, }) data = await beforeValidate({ diff --git a/packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts b/packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts new file mode 100644 index 00000000000..5fcd1cd02c3 --- /dev/null +++ b/packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { hierarchyCollectionBeforeChange } from './collectionBeforeChange.js' + +describe('hierarchyCollectionBeforeChange', () => { + const hook = hierarchyCollectionBeforeChange({ parentFieldName: 'parent' }) + + it('should reject a self-referential parent on update', async () => { + await expect( + hook({ + collection: { hierarchy: { parentFieldName: 'parent' }, slug: 'folders' } as any, + context: {}, + data: { id: '1', parent: '1' }, + operation: 'update', + originalDoc: { id: '1', parent: null } as any, + req: {} as any, + }), + ).rejects.toThrow(/cannot be its own parent/i) + }) + + it('should reject a self-referential parent during on-demand validation', async () => { + await expect( + hook({ + collection: { hierarchy: { parentFieldName: 'parent' }, slug: 'folders' } as any, + context: {}, + data: { id: '1', parent: '1' }, + operation: 'validate', + originalDoc: { id: '1', parent: null } as any, + req: {} as any, + }), + ).rejects.toThrow(/cannot be its own parent/i) + }) + + it('should allow an unrelated parent change on create', async () => { + await expect( + hook({ + collection: { hierarchy: { parentFieldName: 'parent' }, slug: 'folders' } as any, + context: {}, + data: { id: '1', parent: '2' }, + operation: 'create', + originalDoc: undefined, + req: {} as any, + }), + ).resolves.toEqual({ id: '1', parent: '2' }) + }) +}) diff --git a/packages/payload/src/hierarchy/hooks/collectionBeforeChange.ts b/packages/payload/src/hierarchy/hooks/collectionBeforeChange.ts index e55253e06bb..ce7e114136d 100644 --- a/packages/payload/src/hierarchy/hooks/collectionBeforeChange.ts +++ b/packages/payload/src/hierarchy/hooks/collectionBeforeChange.ts @@ -29,7 +29,7 @@ export const hierarchyCollectionBeforeChange = const newParentID = data[parentFieldName] !== undefined ? data[parentFieldName] : originalDoc?.[parentFieldName] const parentChanged = - operation === 'update' && + (operation === 'update' || operation === 'validate') && data[parentFieldName] !== undefined && data[parentFieldName] !== originalDoc?.[parentFieldName] diff --git a/packages/payload/src/utilities/applyLocaleFiltering.ts b/packages/payload/src/utilities/applyLocaleFiltering.ts index 7b1c4e3fbf6..74c59eb4627 100644 --- a/packages/payload/src/utilities/applyLocaleFiltering.ts +++ b/packages/payload/src/utilities/applyLocaleFiltering.ts @@ -24,10 +24,7 @@ export async function applyLocaleFiltering({ locales: config.localization.locales, req, }) - ).map(({ toString, ...rest }) => ({ - ...rest, - required: rest.required ?? false, - })) + ).map(({ toString, ...rest }) => rest) clientConfig.localization.localeCodes = filteredLocales.map(({ code }) => code) clientConfig.localization.locales = filteredLocales diff --git a/packages/payload/src/utilities/flattenDataByLocale.spec.ts b/packages/payload/src/utilities/flattenDataByLocale.spec.ts index 842837ef0d5..15fc577b9b1 100644 --- a/packages/payload/src/utilities/flattenDataByLocale.spec.ts +++ b/packages/payload/src/utilities/flattenDataByLocale.spec.ts @@ -181,4 +181,62 @@ describe('flattenDataByLocale', () => { pointGroup: { groupPoint: [5, 6] }, }) }) + + it('should not mistake a candidate sub-field named after a locale code for a locale map', () => { + const fields: Field[] = [ + { + name: 'localizedGroup', + type: 'group', + fields: [localizedText('en'), localizedText('title')], + localized: true, + }, + ] + + const result = flattenDataByLocale({ + configBlockReferences: [], + dataIsLocaleKeyed: false, + docWithLocales: { + localizedGroup: { + en: 'sneaky value', + title: 'Hi', + }, + }, + fields, + locale: 'en', + }) + + expect(result).toEqual({ + localizedGroup: { + en: 'sneaky value', + title: 'Hi', + }, + }) + }) + + it('should still unwrap a locale-keyed value whose keys happen to include ordinary field names', () => { + const fields: Field[] = [ + { + name: 'localizedGroup', + type: 'group', + fields: [localizedText('en'), localizedText('title')], + localized: true, + }, + ] + + const result = flattenDataByLocale({ + configBlockReferences: [], + docWithLocales: { + localizedGroup: { + en: { en: 'English en value', title: 'English title' }, + es: { en: 'Spanish en value', title: 'Spanish title' }, + }, + }, + fields, + locale: 'es', + }) + + expect(result).toEqual({ + localizedGroup: { en: 'Spanish en value', title: 'Spanish title' }, + }) + }) }) diff --git a/packages/payload/src/utilities/flattenDataByLocale.ts b/packages/payload/src/utilities/flattenDataByLocale.ts index a5732ea9e5a..c15e9b3d80e 100644 --- a/packages/payload/src/utilities/flattenDataByLocale.ts +++ b/packages/payload/src/utilities/flattenDataByLocale.ts @@ -7,24 +7,30 @@ import { deepCopyObjectSimple } from './deepCopyObject.js' type Args = { configBlockReferences: SanitizedConfig['blocks'] + /** + * Whether `docWithLocales` stores each localized field as a locale-code-keyed object (the + * stored document representation, and the internal publish-all-locales candidate). Pass + * `false` for a flat, single-locale candidate, such as the data passed to `payload.validate()`. + * @default true + */ + dataIsLocaleKeyed?: boolean docWithLocales: JsonObject fields: Field[] locale: string - localeCodes?: string[] parentIsLocalized?: boolean } /** - * Returns a copy of stored locale-keyed data flattened to one locale and converts field storage + * Returns a copy of locale-keyed data flattened to one locale and converts field storage * representations needed by validators, without running after-read hooks, access control, * sanitization, or population. */ export function flattenDataByLocale({ configBlockReferences, + dataIsLocaleKeyed = true, docWithLocales, fields, locale, - localeCodes, parentIsLocalized = false, }: Args): JsonObject { const result = deepCopyObjectSimple(docWithLocales) @@ -32,9 +38,9 @@ export function flattenDataByLocale({ flattenFields({ configBlockReferences, data: result, + dataIsLocaleKeyed, fields, locale, - localeCodes, parentIsLocalized, }) @@ -44,18 +50,18 @@ export function flattenDataByLocale({ type FlattenFieldsArgs = { configBlockReferences: SanitizedConfig['blocks'] data: JsonObject + dataIsLocaleKeyed: boolean fields: Field[] locale: string - localeCodes?: string[] parentIsLocalized: boolean } function flattenFields({ configBlockReferences, data, + dataIsLocaleKeyed, fields, locale, - localeCodes, parentIsLocalized, }: FlattenFieldsArgs): void { for (const field of fields) { @@ -64,8 +70,8 @@ function flattenFields({ if (isLocalized) { data[field.name] = getLocaleValue({ + dataIsLocaleKeyed, locale, - localeCodes, value: data[field.name], }) } @@ -86,9 +92,9 @@ function flattenFields({ flattenFields({ configBlockReferences, data: row, + dataIsLocaleKeyed, fields: field.fields, locale, - localeCodes, parentIsLocalized: nestedParentIsLocalized, }) } @@ -117,9 +123,9 @@ function flattenFields({ flattenFields({ configBlockReferences, data: row, + dataIsLocaleKeyed, fields: block.fields, locale, - localeCodes, parentIsLocalized: nestedParentIsLocalized, }) } @@ -133,9 +139,9 @@ function flattenFields({ flattenFields({ configBlockReferences, data: fieldValue, + dataIsLocaleKeyed, fields: field.fields, locale, - localeCodes, parentIsLocalized: nestedParentIsLocalized, }) } @@ -153,9 +159,9 @@ function flattenFields({ flattenFields({ configBlockReferences, data, + dataIsLocaleKeyed, fields: field.fields, locale, - localeCodes, parentIsLocalized, }) break @@ -167,8 +173,8 @@ function flattenFields({ if (isLocalized) { data[tab.name] = getLocaleValue({ + dataIsLocaleKeyed, locale, - localeCodes, value: data[tab.name], }) } @@ -179,9 +185,9 @@ function flattenFields({ flattenFields({ configBlockReferences, data: tabData, + dataIsLocaleKeyed, fields: tab.fields, locale, - localeCodes, parentIsLocalized: parentIsLocalized || Boolean(tab.localized), }) } @@ -189,9 +195,9 @@ function flattenFields({ flattenFields({ configBlockReferences, data, + dataIsLocaleKeyed, fields: tab.fields, locale, - localeCodes, parentIsLocalized, }) } @@ -225,20 +231,16 @@ function transformStoredFieldValue({ field, value }: { field: Field; value: unkn } function getLocaleValue({ + dataIsLocaleKeyed, locale, - localeCodes, value, }: { + dataIsLocaleKeyed: boolean locale: string - localeCodes?: string[] value: unknown }): unknown { - if (value && typeof value === 'object' && !Array.isArray(value)) { - const objectValue = value as Record - const isLocaleMap = - !localeCodes || Object.keys(objectValue).some((key) => localeCodes.includes(key)) - - return isLocaleMap ? objectValue[locale] : value + if (dataIsLocaleKeyed && value && typeof value === 'object' && !Array.isArray(value)) { + return (value as Record)[locale] } return value diff --git a/packages/payload/src/utilities/isPostHookPublishIntent.spec.ts b/packages/payload/src/utilities/isPostHookPublishIntent.spec.ts deleted file mode 100644 index fc02d126083..00000000000 --- a/packages/payload/src/utilities/isPostHookPublishIntent.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { isPostHookPublishIntent } from './isPostHookPublishIntent.js' - -describe('isPostHookPublishIntent', () => { - it.each([ - { - args: { - locale: 'en', - publishAllLocales: true, - status: 'draft', - }, - expected: true, - name: 'explicit publish-all over draft status', - }, - { - args: { - locale: 'en', - publishAllLocales: true, - status: 'published', - unpublishAllLocales: true, - }, - expected: false, - name: 'explicit unpublish-all over publish-all', - }, - { - args: { - locale: 'en', - publishAllLocales: false, - status: 'published', - }, - expected: true, - name: 'flat published status', - }, - { - args: { - locale: 'en', - publishAllLocales: false, - status: { en: 'published', es: 'draft' }, - }, - expected: true, - name: 'published active localized status', - }, - { - args: { - locale: 'en', - publishAllLocales: false, - status: { en: 'draft', es: 'published' }, - }, - expected: false, - name: 'published inactive localized status', - }, - { - args: { - locale: 'all', - publishAllLocales: false, - status: { en: 'draft', es: 'published' }, - }, - expected: true, - name: 'published status in an all-locales candidate', - }, - { - args: { - locale: 'en', - publishAllLocales: false, - status: 'draft', - }, - expected: false, - name: 'ordinary draft status', - }, - ])('should resolve $name', ({ args, expected }) => { - expect(isPostHookPublishIntent(args)).toBe(expected) - }) -}) diff --git a/packages/payload/src/utilities/isPostHookPublishIntent.ts b/packages/payload/src/utilities/isPostHookPublishIntent.ts deleted file mode 100644 index 780ea5b0ba0..00000000000 --- a/packages/payload/src/utilities/isPostHookPublishIntent.ts +++ /dev/null @@ -1,37 +0,0 @@ -type Args = { - locale?: null | string - publishAllLocales: boolean - status: unknown - unpublishAllLocales?: boolean -} - -export function isPostHookPublishIntent({ - locale, - publishAllLocales, - status, - unpublishAllLocales, -}: Args): boolean { - if (unpublishAllLocales) { - return false - } - - if (publishAllLocales || status === 'published') { - return true - } - - if (!status || typeof status !== 'object' || Array.isArray(status)) { - return false - } - - const localizedStatus = status as Record - - if (locale === 'all') { - return Object.values(localizedStatus).some((localeStatus) => localeStatus === 'published') - } - - if (!locale) { - return false - } - - return localizedStatus[locale] === 'published' -} diff --git a/packages/payload/src/utilities/parseValidationLocale.spec.ts b/packages/payload/src/utilities/parseValidationLocale.spec.ts new file mode 100644 index 00000000000..72007135e93 --- /dev/null +++ b/packages/payload/src/utilities/parseValidationLocale.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' + +import { + assertValidationData, + parseSingleValidationLocale, + parseValidationLocale, + parseValidationLocaleSelector, +} from './parseValidationLocale.js' + +describe('parseValidationLocale', () => { + it('should parse a single locale string', () => { + expect(parseValidationLocale('en')).toEqual({ locale: 'en', type: 'single' }) + }) + + it('should parse "all" as the all type', () => { + expect(parseValidationLocale('all')).toEqual({ type: 'all' }) + }) + + it('should parse an array of locale strings', () => { + expect(parseValidationLocale(['en', 'es'])).toEqual({ locales: ['en', 'es'], type: 'multiple' }) + }) + + it('should reject an empty string', () => { + expect(() => parseValidationLocale('')).toThrow(/requires a locale/i) + }) + + it('should reject an empty array', () => { + expect(() => parseValidationLocale([])).toThrow(/requires a locale/i) + }) + + it('should reject an array containing an empty string', () => { + expect(() => parseValidationLocale(['en', ''])).toThrow(/requires a locale/i) + }) + + it('should reject a mixed-type array', () => { + expect(() => parseValidationLocale(['en', 1])).toThrow(/requires a locale/i) + }) + + it('should reject undefined', () => { + expect(() => parseValidationLocale(undefined)).toThrow(/requires a locale/i) + }) + + it('should reject a non-string, non-array value', () => { + expect(() => parseValidationLocale({ locale: 'en' })).toThrow(/requires a locale/i) + }) +}) + +describe('parseSingleValidationLocale', () => { + it('should return a single parsed locale', () => { + expect(parseSingleValidationLocale('en')).toBe('en') + }) + + it('should reject "all"', () => { + expect(() => parseSingleValidationLocale('all')).toThrow(/requires a single locale/i) + }) + + it('should reject a locale array', () => { + expect(() => parseSingleValidationLocale(['en', 'es'])).toThrow(/requires a single locale/i) + }) +}) + +describe('parseValidationLocaleSelector', () => { + it('should return "all" for the all type', () => { + expect(parseValidationLocaleSelector('all')).toBe('all') + }) + + it('should return a single locale string', () => { + expect(parseValidationLocaleSelector('en')).toBe('en') + }) + + it('should return a locale array for repeated values', () => { + expect(parseValidationLocaleSelector(['en', 'es'])).toEqual(['en', 'es']) + }) +}) + +describe('assertValidationData', () => { + it('should accept a plain object', () => { + expect(() => assertValidationData({ title: 'Hello' })).not.toThrow() + }) + + it('should reject null', () => { + expect(() => assertValidationData(null)).toThrow(/must be an object/i) + }) + + it('should reject undefined', () => { + expect(() => assertValidationData(undefined)).toThrow(/must be an object/i) + }) + + it('should reject an array', () => { + expect(() => assertValidationData([])).toThrow(/must be an object/i) + }) + + it('should reject a primitive', () => { + expect(() => assertValidationData('title')).toThrow(/must be an object/i) + }) +}) diff --git a/packages/payload/src/utilities/resolvePublishLocales.spec.ts b/packages/payload/src/utilities/resolvePublishLocales.spec.ts deleted file mode 100644 index 47300b9c081..00000000000 --- a/packages/payload/src/utilities/resolvePublishLocales.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import type { SanitizedLocalizationConfig } from '../config/types.js' - -import { resolvePublishLocales } from './resolvePublishLocales.js' - -const localization = { - locales: [ - { code: 'en', label: 'English', required: false }, - { code: 'es', label: 'Spanish', required: true }, - { code: 'de', label: 'German', required: false }, - { code: 'fr', label: 'French', required: true }, - ], -} as SanitizedLocalizationConfig - -describe('resolvePublishLocales', () => { - it('should return the current locale followed by configured required locales', () => { - expect( - resolvePublishLocales({ - locale: 'de', - localization, - publishAllLocales: false, - }), - ).toEqual(['de', 'es', 'fr']) - }) - - it('should exclude optional non-current locales and deduplicate required current locale', () => { - expect( - resolvePublishLocales({ - locale: 'es', - localization, - publishAllLocales: false, - }), - ).toEqual(['es', 'fr']) - }) - - it('should return all for explicit publish-all', () => { - expect( - resolvePublishLocales({ - locale: 'en', - localization, - publishAllLocales: true, - }), - ).toBe('all') - }) -}) diff --git a/packages/payload/src/utilities/resolvePublishLocales.ts b/packages/payload/src/utilities/resolvePublishLocales.ts deleted file mode 100644 index d34d3cd38e8..00000000000 --- a/packages/payload/src/utilities/resolvePublishLocales.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { SanitizedLocalizationConfig } from '../config/types.js' - -type Args = { - locale: null | string | undefined - localization: false | SanitizedLocalizationConfig - publishAllLocales: boolean -} - -export type PublishLocaleSelector = 'all' | [null | string, ...(null | string)[]] - -export function resolvePublishLocales({ - locale, - localization, - publishAllLocales, -}: Args): PublishLocaleSelector { - if (publishAllLocales || locale === 'all') { - return 'all' - } - - const locales = [locale ?? (localization ? localization.defaultLocale : null)] - - if (localization) { - for (const configuredLocale of localization.locales) { - if (configuredLocale.required) { - locales.push(configuredLocale.code) - } - } - } - - return [...new Set(locales)] as [null | string, ...(null | string)[]] -} diff --git a/packages/payload/src/utilities/resolveValidationLocales.spec.ts b/packages/payload/src/utilities/resolveValidationLocales.spec.ts index 90ad6dc1741..ba9d42a0928 100644 --- a/packages/payload/src/utilities/resolveValidationLocales.spec.ts +++ b/packages/payload/src/utilities/resolveValidationLocales.spec.ts @@ -1,8 +1,105 @@ import { describe, expect, it } from 'vitest' +import type { SanitizedLocalizationConfig } from '../config/types.js' +import type { PayloadRequest } from '../types/index.js' import type { TypedLocale } from '../index.js' -import { runValidationLocalePasses } from './resolveValidationLocales.js' +import { resolveValidationLocales, runValidationLocalePasses } from './resolveValidationLocales.js' + +function createReq(localization: false | SanitizedLocalizationConfig): PayloadRequest { + return { + payload: { + config: { + localization, + }, + }, + } as unknown as PayloadRequest +} + +describe('resolveValidationLocales', () => { + const localization = { + defaultLocale: 'en', + localeCodes: ['en', 'es', 'de'], + locales: [ + { code: 'en', label: 'English' }, + { code: 'es', label: 'Spanish' }, + { code: 'de', label: 'German' }, + ], + } as SanitizedLocalizationConfig + + it('should return a single requested locale as an array', async () => { + await expect( + resolveValidationLocales({ locale: 'es', req: createReq(localization) }), + ).resolves.toEqual(['es']) + }) + + it('should deduplicate requested locales while preserving order', async () => { + await expect( + resolveValidationLocales({ locale: ['es', 'en', 'es'], req: createReq(localization) }), + ).resolves.toEqual(['es', 'en']) + }) + + it('should resolve "all" to every configured locale when no filter is configured', async () => { + await expect( + resolveValidationLocales({ locale: 'all', req: createReq(localization) }), + ).resolves.toEqual(['en', 'es', 'de']) + }) + + it('should resolve "all" through filterAvailableLocales when configured', async () => { + const filteredLocalization = { + ...localization, + filterAvailableLocales: () => [{ code: 'en', label: 'English' }], + } as SanitizedLocalizationConfig + + await expect( + resolveValidationLocales({ locale: 'all', req: createReq(filteredLocalization) }), + ).resolves.toEqual(['en']) + }) + + it('should reject a locale excluded by filterAvailableLocales as unavailable', async () => { + const filteredLocalization = { + ...localization, + filterAvailableLocales: () => [{ code: 'en', label: 'English' }], + } as SanitizedLocalizationConfig + + await expect( + resolveValidationLocales({ locale: 'de', req: createReq(filteredLocalization) }), + ).rejects.toThrow(/not available/i) + }) + + it('should reject a locale that is not configured at all', async () => { + await expect( + resolveValidationLocales({ locale: 'fr', req: createReq(localization) }), + ).rejects.toThrow(/not configured/i) + }) + + it('should reject an empty locale array', async () => { + await expect( + resolveValidationLocales({ + locale: [] as unknown as TypedLocale, + req: createReq(localization), + }), + ).rejects.toThrow(/requires a locale/i) + }) + + it('should return [null] for "all" when localization is not configured', async () => { + await expect( + resolveValidationLocales({ locale: 'all', req: createReq(false) }), + ).resolves.toEqual([null]) + }) + + it('should return [null] for a null locale when localization is not configured', async () => { + await expect( + resolveValidationLocales({ locale: null as unknown as TypedLocale, req: createReq(false) }), + ).resolves.toEqual([null]) + }) + + it('should reject a non-null locale when localization is not configured', async () => { + await expect(resolveValidationLocales({ locale: 'en', req: createReq(false) })).rejects.toThrow( + /requires a locale/i, + ) + }) +}) describe('runValidationLocalePasses - concurrency', () => { const tenLocales = Array.from({ length: 10 }, (_, index) => `locale-${index}`) as TypedLocale[] diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index df5879f87e5..9316e50a675 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -515,8 +515,6 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'upload:noFile', 'upload:download', - 'validation:documentInvalid', - 'validation:documentValid', 'validation:emailAddress', 'validation:enterNumber', 'validation:fieldHasNo', @@ -533,7 +531,6 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'validation:longerThanMin', 'validation:notValidDate', 'validation:required', - 'validation:requiredLocale', 'validation:requiresAtLeast', 'validation:requiresNoMoreThan', 'validation:requiresTwoNumbers', @@ -541,7 +538,6 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'validation:trueOrFalse', 'validation:timezoneRequired', 'validation:username', - 'validation:validationResults', 'validation:validUploadID', 'version:aboutToPublishSelection', diff --git a/packages/translations/src/languages/ar.ts b/packages/translations/src/languages/ar.ts index 54652b02641..8b900d15ec1 100644 --- a/packages/translations/src/languages/ar.ts +++ b/packages/translations/src/languages/ar.ts @@ -598,8 +598,6 @@ export const arTranslations: DefaultTranslationsObject = { width: 'العرض', }, validation: { - documentInvalid: 'قم بإصلاح المشاكل التالية.', - documentValid: 'جميع اللغات المختارة صالحة.', emailAddress: 'يرجى إدخال عنوان بريد إلكتروني صحيح.', enterNumber: 'يرجى إدخال رقم صحيح.', fieldHasNo: 'هذا الحقل ليس لديه {{label}}', @@ -616,7 +614,6 @@ export const arTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'يجب أن تكون الخطوط الطولية بين -180 و 180.', notValidDate: '"{{value}}" ليس تاريخا صالحا.', required: 'هذا الحقل مطلوب.', - requiredLocale: 'مطلوب', requiresAtLeast: 'هذا الحقل يتطلب على الأقل {{count}} {{label}}.', requiresNoMoreThan: 'هذا الحقل يتطلب عدم تجاوز {{count}} {{label}}.', requiresTwoNumbers: 'هذا الحقل يتطلب رقمين.', @@ -625,7 +622,6 @@ export const arTranslations: DefaultTranslationsObject = { trueOrFalse: 'يمكن أن يكون هذا الحقل مساويًا فقط للقيمتين صحيح أو خطأ.', username: 'يرجى إدخال اسم مستخدم صالح. يمكن أن يحتوي على أحرف، أرقام، شرطات، فواصل وشرطات سفلية.', - validationResults: 'نتائج التحقق', validUploadID: 'هذا الحقل ليس معرّف تحميل صالح.', }, version: { diff --git a/packages/translations/src/languages/az.ts b/packages/translations/src/languages/az.ts index 5e9a17347bd..5a7a65bbbf5 100644 --- a/packages/translations/src/languages/az.ts +++ b/packages/translations/src/languages/az.ts @@ -617,8 +617,6 @@ export const azTranslations: DefaultTranslationsObject = { width: 'En', }, validation: { - documentInvalid: 'Aşağıdakı problemləri aradan qaldırın.', - documentValid: 'Seçilmiş bütün locale-lar etibarlıdır.', emailAddress: 'Xahiş edirik doğru elektron poçt ünvanını daxil edin.', enterNumber: 'Xahiş edirik doğru nömrəni daxil edin.', fieldHasNo: 'Bu sahədə heç bir {{label}} yoxdur', @@ -635,7 +633,6 @@ export const azTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Boylam -180 ilə 180 arasında olmalıdır.', notValidDate: '"{{value}}" doğru tarix deyil.', required: 'Bu sahə mütləq doldurulmalıdır.', - requiredLocale: 'Tələb olunur', requiresAtLeast: 'Bu sahə ən azı {{count}} {{label}} tələb edir.', requiresNoMoreThan: 'Bu sahə {{count}} {{label}}-dan çox olmamalıdır.', requiresTwoNumbers: 'Bu sahə iki nömrə tələb edir.', @@ -644,7 +641,6 @@ export const azTranslations: DefaultTranslationsObject = { trueOrFalse: 'Bu sahə yalnız doğru və ya yanlış ola bilər.', username: 'Zəhmət olmasa, etibarlı bir istifadəçi adı daxil edin. Hərflər, rəqəmlər, tire, nöqtə və alt xəttlər ola bilər.', - validationResults: 'Doğrulama nəticələri', validUploadID: 'Bu sahə doğru yükləmə ID-si deyil.', }, version: { diff --git a/packages/translations/src/languages/bg.ts b/packages/translations/src/languages/bg.ts index 462603b6ed0..371cfd729c5 100644 --- a/packages/translations/src/languages/bg.ts +++ b/packages/translations/src/languages/bg.ts @@ -611,8 +611,6 @@ export const bgTranslations: DefaultTranslationsObject = { width: 'Ширина', }, validation: { - documentInvalid: 'Отстранете следните проблеми.', - documentValid: 'Всички избрани езици са валидни.', emailAddress: 'Моля, въведи валиден имейл адрес.', enterNumber: 'Моля, въведи валиден номер.', fieldHasNo: 'Това поле няма {{label}}', @@ -630,7 +628,6 @@ export const bgTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Дължината трябва да бъде между -180 и 180.', notValidDate: '"{{value}}" не е валидна дата.', required: 'Това поле е задължително.', - requiredLocale: 'Задължително', requiresAtLeast: 'Това поле изисква поне {{count}} {{label}}.', requiresNoMoreThan: 'Това поле изисква не повече от {{count}} {{label}}.', requiresTwoNumbers: 'Това поле изисква 2 числа.', @@ -640,7 +637,6 @@ export const bgTranslations: DefaultTranslationsObject = { trueOrFalse: 'Това поле може да бъде само "true" или "false".', username: 'Моля, въведете валидно потребителско име. Може да съдържа букви, цифри, тирета, точки и долни черти.', - validationResults: 'Резултати от валидация', validUploadID: 'Това поле не е валиден идентификатор на качването.', }, version: { diff --git a/packages/translations/src/languages/bnBd.ts b/packages/translations/src/languages/bnBd.ts index e2e912ab026..5b877729aa3 100644 --- a/packages/translations/src/languages/bnBd.ts +++ b/packages/translations/src/languages/bnBd.ts @@ -619,8 +619,6 @@ export const bnBdTranslations: DefaultTranslationsObject = { width: 'প্রস্থ', }, validation: { - documentInvalid: 'নিম্নলিখিত সমস্যাগুলো সমাধান করুন।', - documentValid: 'সমস্ত নির্বাচিত Locale বৈধ।', emailAddress: 'একটি বৈধ ইমেইল ঠিকানা লিখুন।', enterNumber: 'একটি বৈধ সংখ্যা লিখুন।', fieldHasNo: 'এই ক্ষেত্রে কোনো {{label}} নেই', @@ -637,7 +635,6 @@ export const bnBdTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'দ্রাগিমাংস অবশ্যই -১৮০ এবং ১৮০ এর মধ্যে হতে হবে।', notValidDate: '"{{value}}" একটি বৈধ তারিখ নয়।', required: 'এই ক্ষেত্রটি প্রয়োজনীয়।', - requiredLocale: 'প্রয়োজনীয়', requiresAtLeast: 'এই ক্ষেত্রটির জন্য কমপক্ষে {{count}} {{label}} প্রয়োজন।', requiresNoMoreThan: 'এই ক্ষেত্রটির জন্য {{count}} {{label}} এর বেশি প্রয়োজন নেই।', requiresTwoNumbers: 'এই ক্ষেত্রটির জন্য দুটি সংখ্যা প্রয়োজন।', @@ -646,7 +643,6 @@ export const bnBdTranslations: DefaultTranslationsObject = { trueOrFalse: 'এই ক্ষেত্রটি শুধুমাত্র সত্য বা মিথ্যা হতে পারে।', username: 'একটি বৈধ ব্যবহারকারীর নাম লিখুন। এতে অক্ষর, সংখ্যা, হাইফেন, পিরিয়ড এবং আন্ডারস্কোর থাকতে পারে।', - validationResults: 'বৈধতা ফলাফল', validUploadID: 'এই ক্ষেত্রটি একটি বৈধ আপলোড আইডি নয়।', }, version: { diff --git a/packages/translations/src/languages/bnIn.ts b/packages/translations/src/languages/bnIn.ts index dd133208612..9737187ac1e 100644 --- a/packages/translations/src/languages/bnIn.ts +++ b/packages/translations/src/languages/bnIn.ts @@ -618,8 +618,6 @@ export const bnInTranslations: DefaultTranslationsObject = { width: 'প্রস্থ', }, validation: { - documentInvalid: 'নিম্নলিখিত সমস্যাগুলি সমাধান করুন।', - documentValid: 'সকল নির্বাচিত Locale বৈধ।', emailAddress: 'একটি বৈধ ইমেইল ঠিকানা লিখুন।', enterNumber: 'একটি বৈধ সংখ্যা লিখুন।', fieldHasNo: 'এই ক্ষেত্রে কোনো {{label}} নেই', @@ -636,7 +634,6 @@ export const bnInTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'দীর্ঘবিন্ন অবশ্যই -180 এবং 180 এর মধ্যে থাকতে হবে।', notValidDate: '"{{value}}" একটি বৈধ তারিখ নয়।', required: 'এই ক্ষেত্রটি প্রয়োজনীয়।', - requiredLocale: 'আবশ্যক', requiresAtLeast: 'এই ক্ষেত্রটির জন্য কমপক্ষে {{count}} {{label}} প্রয়োজন।', requiresNoMoreThan: 'এই ক্ষেত্রটির জন্য {{count}} {{label}} এর বেশি প্রয়োজন নেই।', requiresTwoNumbers: 'এই ক্ষেত্রটির জন্য দুটি সংখ্যা প্রয়োজন।', @@ -645,7 +642,6 @@ export const bnInTranslations: DefaultTranslationsObject = { trueOrFalse: 'এই ক্ষেত্রটি শুধুমাত্র সত্য বা মিথ্যা হতে পারে।', username: 'একটি বৈধ ব্যবহারকারীর নাম লিখুন। এতে অক্ষর, সংখ্যা, হাইফেন, পিরিয়ড এবং আন্ডারস্কোর থাকতে পারে।', - validationResults: 'বৈধতা ফলাফল', validUploadID: 'এই ক্ষেত্রটি একটি বৈধ আপলোড আইডি নয়।', }, version: { diff --git a/packages/translations/src/languages/ca.ts b/packages/translations/src/languages/ca.ts index 3723ebcc1b4..75983eabfd3 100644 --- a/packages/translations/src/languages/ca.ts +++ b/packages/translations/src/languages/ca.ts @@ -615,8 +615,6 @@ export const caTranslations: DefaultTranslationsObject = { width: 'Amplada', }, validation: { - documentInvalid: 'Solucioni els problemes següents.', - documentValid: 'Tots els idiomes seleccionats són vàlids.', emailAddress: 'Si us plau, introdueix una adreça de correu electrònic vàlida.', enterNumber: 'Si us plau, introdueix un número vàlid.', fieldHasNo: 'Aquest camp no té {{label}}', @@ -634,7 +632,6 @@ export const caTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'La longitud ha de ser entre -180 i 180.', notValidDate: '"{{value}}" no és una data vàlida.', required: 'Aquest camp és obligatori.', - requiredLocale: 'Obligatori', requiresAtLeast: 'Aquest camp requereix almenys {{count}} {{label}}.', requiresNoMoreThan: 'Aquest camp requereix com a màxim {{count}} {{label}}.', requiresTwoNumbers: 'Aquest camp requereix dos números.', @@ -644,7 +641,6 @@ export const caTranslations: DefaultTranslationsObject = { trueOrFalse: 'Aquest camp només pot ser igual a true o false.', username: "Si us plau, introdueix un nom d'usuari vàlid. Pot contenir lletres, números, guions, punts i guions baixos.", - validationResults: 'Resultats de la validació', validUploadID: 'Aquest camp no és un ID de càrrega vàlid.', }, version: { diff --git a/packages/translations/src/languages/cs.ts b/packages/translations/src/languages/cs.ts index 7f11a9cc141..03876ecec4d 100644 --- a/packages/translations/src/languages/cs.ts +++ b/packages/translations/src/languages/cs.ts @@ -607,8 +607,6 @@ export const csTranslations: DefaultTranslationsObject = { width: 'Šířka', }, validation: { - documentInvalid: 'Opravte následující problémy.', - documentValid: 'Všechny vybrané lokalizace jsou platné.', emailAddress: 'Zadejte prosím platnou e-mailovou adresu.', enterNumber: 'Zadejte prosím platné číslo.', fieldHasNo: 'Toto pole nemá {{label}}', @@ -625,7 +623,6 @@ export const csTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Zeměpisná délka musí být mezi -180 a 180.', notValidDate: '"{{value}}" není platné datum.', required: 'Toto pole je povinné.', - requiredLocale: 'Povinné', requiresAtLeast: 'Toto pole vyžaduje alespoň {{count}} {{label}}.', requiresNoMoreThan: 'Toto pole vyžaduje ne více než {{count}} {{label}}.', requiresTwoNumbers: 'Toto pole vyžaduje dvě čísla.', @@ -634,7 +631,6 @@ export const csTranslations: DefaultTranslationsObject = { trueOrFalse: 'Toto pole může být rovno pouze true nebo false.', username: 'Prosím, zadejte platné uživatelské jméno. Může obsahovat písmena, čísla, pomlčky, tečky a podtržítka.', - validationResults: 'Výsledky validace', validUploadID: 'Toto pole není platné ID pro odeslání.', }, version: { diff --git a/packages/translations/src/languages/da.ts b/packages/translations/src/languages/da.ts index be396eb9408..4ff2e538e94 100644 --- a/packages/translations/src/languages/da.ts +++ b/packages/translations/src/languages/da.ts @@ -611,8 +611,6 @@ export const daTranslations: DefaultTranslationsObject = { width: 'Bredde', }, validation: { - documentInvalid: 'Løs følgende problemer.', - documentValid: 'Alle valgte locales er gyldige.', emailAddress: 'Indtast venligst en gyldig e-mailadresse.', enterNumber: 'Indtast venligst et gyldigt nummer.', fieldHasNo: 'Dette felt har ingen {{label}}', @@ -629,7 +627,6 @@ export const daTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Længdegrad skal være mellem -180 og 180.', notValidDate: '"{{value}}" er ikke en gyldig dato.', required: 'Dette felt er påkrævet.', - requiredLocale: 'Påkrævet', requiresAtLeast: 'Dette felt kræver mindst {{count}} {{label}}.', requiresNoMoreThan: 'Dette felt kræver maks {{count}} {{label}}.', requiresTwoNumbers: 'Dette felt kræver to numre.', @@ -638,7 +635,6 @@ export const daTranslations: DefaultTranslationsObject = { trueOrFalse: 'Denne værdi kan kun være lig med sandt eller falsk.', username: 'Indtast et brugernavn. Kan indeholde bogstaver, tal, bindestreger, punktum og underscores.', - validationResults: 'Valideringsresultater', validUploadID: 'Dette felt er ikke en gyldig upload-ID.', }, version: { diff --git a/packages/translations/src/languages/de.ts b/packages/translations/src/languages/de.ts index b4b0cac2b9d..12198d4da8b 100644 --- a/packages/translations/src/languages/de.ts +++ b/packages/translations/src/languages/de.ts @@ -624,8 +624,6 @@ export const deTranslations: DefaultTranslationsObject = { width: 'Breite', }, validation: { - documentInvalid: 'Beheben Sie die folgenden Probleme.', - documentValid: 'Alle ausgewählten Locales sind gültig.', emailAddress: 'Bitte gib eine korrekte E-Mail-Adresse an.', enterNumber: 'Bitte gib eine gültige Nummer an.', fieldHasNo: 'Dieses Feld hat kein {{label}}', @@ -642,7 +640,6 @@ export const deTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Die Längengrad muss zwischen -180 und 180 liegen.', notValidDate: '"{{value}}" ist kein gültiges Datum.', required: 'Pflichtfeld', - requiredLocale: 'Erforderlich', requiresAtLeast: 'Dieses Feld muss mindestens {{count}} {{label}} enthalten.', requiresNoMoreThan: 'Dieses Feld darf nicht mehr als {{count}} {{label}} enthalten.', requiresTwoNumbers: 'Dieses Feld muss zwei Zahlen enthalten.', @@ -652,7 +649,6 @@ export const deTranslations: DefaultTranslationsObject = { trueOrFalse: 'Dieses Feld kann nur wahr oder falsch sein.', username: 'Bitte gib einen gültigen Benutzernamen ein. Dieser kann Buchstaben, Zahlen, Bindestriche, Punkte und Unterstriche enthalten.', - validationResults: 'Validierungsergebnisse', validUploadID: 'Dieses Feld enthält keine gültige Upload-ID.', }, version: { diff --git a/packages/translations/src/languages/en.ts b/packages/translations/src/languages/en.ts index dd7831da2f6..825b6fc4875 100644 --- a/packages/translations/src/languages/en.ts +++ b/packages/translations/src/languages/en.ts @@ -610,8 +610,6 @@ export const enTranslations = { width: 'Width', }, validation: { - documentInvalid: 'Fix the following issues.', - documentValid: 'All selected locales are valid.', emailAddress: 'Please enter a valid email address.', enterNumber: 'Please enter a valid number.', fieldHasNo: 'This field has no {{label}}', @@ -628,7 +626,6 @@ export const enTranslations = { longitudeOutOfBounds: 'Longitude must be between -180 and 180.', notValidDate: '"{{value}}" is not a valid date.', required: 'This field is required.', - requiredLocale: 'Required', requiresAtLeast: 'This field requires at least {{count}} {{label}}.', requiresNoMoreThan: 'This field requires no more than {{count}} {{label}}.', requiresTwoNumbers: 'This field requires two numbers.', @@ -637,7 +634,6 @@ export const enTranslations = { trueOrFalse: 'This field can only be equal to true or false.', username: 'Please enter a valid username. Can contain letters, numbers, hyphens, periods and underscores.', - validationResults: 'Validation results', validUploadID: 'This field is not a valid upload ID.', }, version: { diff --git a/packages/translations/src/languages/es.ts b/packages/translations/src/languages/es.ts index df17f51a1a5..4f6cef687cb 100644 --- a/packages/translations/src/languages/es.ts +++ b/packages/translations/src/languages/es.ts @@ -618,8 +618,6 @@ export const esTranslations: DefaultTranslationsObject = { width: 'Ancho', }, validation: { - documentInvalid: 'Corrija los siguientes problemas.', - documentValid: 'Todos los idiomas seleccionados son válidos.', emailAddress: 'Por favor, introduce un correo electrónico válido.', enterNumber: 'Por favor, introduce un número válido.', fieldHasNo: 'Este campo no tiene {{label}}', @@ -636,7 +634,6 @@ export const esTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'La longitud debe estar entre -180 y 180.', notValidDate: '"{{value}}" es una fecha inválida.', required: 'Este campo es obligatorio.', - requiredLocale: 'Obligatorio', requiresAtLeast: 'Este campo requiere al menos {{count}} {{label}}.', requiresNoMoreThan: 'Este campo require no más de {{count}} {{label}}', requiresTwoNumbers: 'Este campo requiere dos números.', @@ -645,7 +642,6 @@ export const esTranslations: DefaultTranslationsObject = { trueOrFalse: 'Este campo solo puede ser verdadero o falso.', username: 'Por favor, introduce un nombre de usuario válido. Puede contener letras, números, guiones, puntos y guiones bajos.', - validationResults: 'Resultados de validación', validUploadID: 'Este campo no es un ID de subida válido.', }, version: { diff --git a/packages/translations/src/languages/et.ts b/packages/translations/src/languages/et.ts index 10f7947aeb8..f521ac6dc02 100644 --- a/packages/translations/src/languages/et.ts +++ b/packages/translations/src/languages/et.ts @@ -606,8 +606,6 @@ export const etTranslations: DefaultTranslationsObject = { width: 'Laius', }, validation: { - documentInvalid: 'Parandage järgmised probleemid.', - documentValid: 'Kõik valitud töökeeled on kehtivad.', emailAddress: 'Palun sisesta kehtiv e-posti aadress.', enterNumber: 'Palun sisesta kehtiv number.', fieldHasNo: 'Sellel väljal pole {{label}}', @@ -624,7 +622,6 @@ export const etTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Pikkuskraad peab olema vahemikus -180 kuni 180.', notValidDate: '"{{value}}" ei ole kehtiv kuupäev.', required: 'See väli on kohustuslik.', - requiredLocale: 'Nõutav', requiresAtLeast: 'See väli nõuab vähemalt {{count}} {{label}}.', requiresNoMoreThan: 'See väli ei tohi sisaldada rohkem kui {{count}} {{label}}.', requiresTwoNumbers: 'See väli nõuab kahte numbrit.', @@ -633,7 +630,6 @@ export const etTranslations: DefaultTranslationsObject = { trueOrFalse: 'See väli saab olla ainult tõene või väär.', username: 'Palun sisesta kehtiv kasutajanimi. Võib sisaldada tähti, numbreid, sidekriipse, punkte ja alakriipse.', - validationResults: 'Valideerimistulemused', validUploadID: 'See väli ei ole kehtiv üleslaadimise ID.', }, version: { diff --git a/packages/translations/src/languages/fa.ts b/packages/translations/src/languages/fa.ts index e9348abf04f..373647d1672 100644 --- a/packages/translations/src/languages/fa.ts +++ b/packages/translations/src/languages/fa.ts @@ -602,8 +602,6 @@ export const faTranslations: DefaultTranslationsObject = { width: 'عرض', }, validation: { - documentInvalid: 'مشکلات زیر را برطرف کنید.', - documentValid: 'همه زبان‌های انتخاب‌شده معتبر هستند.', emailAddress: 'لطفاً یک آدرس ایمیل معتبر وارد کنید.', enterNumber: 'لطفاً یک عدد معتبر وارد کنید.', fieldHasNo: 'این فیلد نباید حاوی هیچ {{label}} باشد.', @@ -620,7 +618,6 @@ export const faTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'عرض جغرافیایی باید بین -180 و 180 باشد.', notValidDate: '"{{value}}" یک تاریخ معتبر نیست.', required: 'این فیلد الزامی است.', - requiredLocale: 'الزامی', requiresAtLeast: 'این فیلد به حداقل {{count}} {{label}} نیاز دارد.', requiresNoMoreThan: 'این فیلد نمی‌تواند بیشتر از {{count}} {{label}} داشته باشد.', requiresTwoNumbers: 'این فیلد به دو عدد نیاز دارد.', @@ -629,7 +626,6 @@ export const faTranslations: DefaultTranslationsObject = { trueOrFalse: 'مقدار این فیلد فقط می‌تواند "true" یا "false" باشد.', username: 'لطفاً یک نام کاربری معتبر وارد کنید. فقط حروف، اعداد، نقطه، خط تیره و زیرخط مجاز هستند.', - validationResults: 'نتایج اعتبارسنجی', validUploadID: 'شناسه آپلود معتبر نیست.', }, version: { diff --git a/packages/translations/src/languages/fr.ts b/packages/translations/src/languages/fr.ts index 704df89a323..57a405a5326 100644 --- a/packages/translations/src/languages/fr.ts +++ b/packages/translations/src/languages/fr.ts @@ -624,8 +624,6 @@ export const frTranslations: DefaultTranslationsObject = { width: 'Largeur', }, validation: { - documentInvalid: 'Corrigez les problèmes suivants.', - documentValid: 'Toutes les langues sélectionnées sont valides.', emailAddress: 'S’il vous plaît, veuillez entrer une adresse e-mail valide.', enterNumber: 'S’il vous plait, veuillez entrer un nombre valide.', fieldHasNo: 'Ce champ n’a pas de {{label}}', @@ -643,7 +641,6 @@ export const frTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'La longitude doit être comprise entre -180 et 180.', notValidDate: '"{{value}}" n’est pas une date valide.', required: 'Ce champ est requis.', - requiredLocale: 'Obligatoire', requiresAtLeast: 'Ce champ doit avoir au moins {{count}} {{label}}.', requiresNoMoreThan: 'Ce champ ne doit pas avoir plus de {{count}} {{label}}.', requiresTwoNumbers: 'Ce champ doit avoir deux chiffres.', @@ -653,7 +650,6 @@ export const frTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ce champ ne peut être égal qu’à vrai ou faux.', username: "Veuillez entrer un nom d'utilisateur valide. Il peut contenir des lettres, des chiffres, des tirets, des points et des tirets bas.", - validationResults: 'Résultats de la validation', validUploadID: 'Ce champ n’est pas un valide identifiant de fichier.', }, version: { diff --git a/packages/translations/src/languages/he.ts b/packages/translations/src/languages/he.ts index 7d6b941b699..b676899bad7 100644 --- a/packages/translations/src/languages/he.ts +++ b/packages/translations/src/languages/he.ts @@ -592,8 +592,6 @@ export const heTranslations: DefaultTranslationsObject = { width: 'רוחב', }, validation: { - documentInvalid: 'תקן את הבעיות הבאות.', - documentValid: 'כל ה-Locales שנבחרו תקינים.', emailAddress: 'נא להזין כתובת דוא"ל תקנית.', enterNumber: 'נא להזין מספר תקני.', fieldHasNo: 'שדה זה אינו מכיל {{label}}', @@ -610,7 +608,6 @@ export const heTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'אורך צריך להיות בין -180 ל-180.', notValidDate: '"{{value}}" אינו תאריך תקני.', required: 'שדה זה הוא שדה חובה.', - requiredLocale: 'נדרש', requiresAtLeast: 'שדה זה דורש לפחות {{count}} {{label}}.', requiresNoMoreThan: 'שדה זה דורש לא יותר מ-{{count}} {{label}}.', requiresTwoNumbers: 'שדה זה דורש שני מספרים.', @@ -618,7 +615,6 @@ export const heTranslations: DefaultTranslationsObject = { timezoneRequired: 'נדרשת אזור זמן.', trueOrFalse: 'שדה זה יכול להיות רק true או false.', username: 'אנא הזן שם משתמש חוקי. יכול להכיל אותיות, מספרים, מקפים, נקודות וקווים תחתונים.', - validationResults: 'תוצאות האימות', validUploadID: 'שדה זה אינו מזהה העלאה תקני.', }, version: { diff --git a/packages/translations/src/languages/hr.ts b/packages/translations/src/languages/hr.ts index 775704b663e..0ff2e95d64b 100644 --- a/packages/translations/src/languages/hr.ts +++ b/packages/translations/src/languages/hr.ts @@ -610,8 +610,6 @@ export const hrTranslations: DefaultTranslationsObject = { width: 'Širina', }, validation: { - documentInvalid: 'Ispravite sljedeće probleme.', - documentValid: 'Svi odabrani jezici su valjani.', emailAddress: 'Molimo unesite valjanu e-mail adresu.', enterNumber: 'Molimo unesite valjani broj.', fieldHasNo: 'Ovo polje nema {{label}}', @@ -628,7 +626,6 @@ export const hrTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Geografska dužina mora biti između -180 i 180.', notValidDate: '"{{value}}" nije valjan datum.', required: 'Ovo polje je obvezno.', - requiredLocale: 'Obavezno', requiresAtLeast: 'Ovo polje zahtjeva minimalno {{count}} {{label}}.', requiresNoMoreThan: 'Ovo polje zahtjeva ne više od {{count}} {{label}}.', requiresTwoNumbers: 'Ovo polje zahtjeva dva broja.', @@ -637,7 +634,6 @@ export const hrTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ovo polje može biti samo točno ili netočno', username: 'Unesite važeće korisničko ime. Može sadržavati slova, brojeve, crtice, točke i donje crte.', - validationResults: 'Rezultati validacije', validUploadID: 'Ovo polje nije valjani ID prijenosa.', }, version: { diff --git a/packages/translations/src/languages/hu.ts b/packages/translations/src/languages/hu.ts index 354c1a85f43..f582df4aaa2 100644 --- a/packages/translations/src/languages/hu.ts +++ b/packages/translations/src/languages/hu.ts @@ -617,8 +617,6 @@ export const huTranslations: DefaultTranslationsObject = { width: 'Szélesség', }, validation: { - documentInvalid: 'Javítsa ki a következő problémákat.', - documentValid: 'Az összes kiválasztott locale érvényes.', emailAddress: 'Kérjük, adjon meg egy érvényes e-mail címet.', enterNumber: 'Kérjük, adjon meg egy érvényes számot.', fieldHasNo: 'Ennek a mezőnek nincs {{label}}', @@ -637,7 +635,6 @@ export const huTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'A hosszúságnak -180 és 180 között kell lennie.', notValidDate: '" {{value}} " nem érvényes dátum.', required: 'Ez a mező kötelező.', - requiredLocale: 'Kötelező', requiresAtLeast: 'Ehhez a mezőhöz legalább {{count}} {{label}} szükséges.', requiresNoMoreThan: 'Ehhez a mezőhöz legfeljebb {{count}} {{label}} szükséges.', requiresTwoNumbers: 'Ehhez a mezőhöz két szám szükséges.', @@ -647,7 +644,6 @@ export const huTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ez a mező csak igaz vagy hamis lehet.', username: 'Adjon meg egy érvényes felhasználónevet. Tartalmazhat betűket, számokat, kötőjeleket, pontokat és aláhúzásokat.', - validationResults: 'Érvényesítési eredmények', validUploadID: 'Ez a mező nem érvényes feltöltési azonosító.', }, version: { diff --git a/packages/translations/src/languages/hy.ts b/packages/translations/src/languages/hy.ts index 8ae6f3ff902..fb56bfb37f7 100644 --- a/packages/translations/src/languages/hy.ts +++ b/packages/translations/src/languages/hy.ts @@ -615,8 +615,6 @@ export const hyTranslations: DefaultTranslationsObject = { width: 'Լայնություն', }, validation: { - documentInvalid: 'Ուղղեք հետևյալ խնդիրները։', - documentValid: 'Բոլոր ընտրված locale-ները վավեր են:', emailAddress: 'Խնդրում ենք մուտքագրել վավեր էլ. փոստի հասցե։', enterNumber: 'Խնդրում ենք մուտքագրել վավեր հեռախոսահամար։', fieldHasNo: 'Այս դաշտում {{label}} չկա', @@ -635,7 +633,6 @@ export const hyTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Լոնգիտուդը պետք է լինի -180-ի և 180-ի միջակայքում:', notValidDate: '"{{value}}"-ը վավեր ամսաթիվ չէ։', required: 'Այս դաշտը պարտադիր է։', - requiredLocale: 'Պահանջված', requiresAtLeast: 'Այս դաշտը պահանջում է առնվազն {{count}} {{label}}։', requiresNoMoreThan: 'Այս դաշտը պահանջում է ոչ ավելի, քան {{count}} {{label}}։', requiresTwoNumbers: 'Այս դաշտը պահանջում է երկու թիվ։', @@ -645,7 +642,6 @@ export const hyTranslations: DefaultTranslationsObject = { trueOrFalse: 'Այս դաշտի արժեքը կարող է միայն «ճիշտ» կամ «սխալ»։', username: 'Խնդրում ենք մուտքագրել վավեր օգտանուն։ Կարող է պարունակել տառեր, թվեր, գծիկներ, կետեր և ընդգծում։', - validationResults: 'Վավերացման արդյունքներ', validUploadID: 'Այս դաշտը վավեր վերբեռնման ID չի պարունակում։', }, version: { diff --git a/packages/translations/src/languages/id.ts b/packages/translations/src/languages/id.ts index 3302575f8df..3ba432af82d 100644 --- a/packages/translations/src/languages/id.ts +++ b/packages/translations/src/languages/id.ts @@ -615,8 +615,6 @@ export const idTranslations: DefaultTranslationsObject = { width: 'Lebar', }, validation: { - documentInvalid: 'Perbaiki masalah-masalah berikut.', - documentValid: 'Semua Locale yang dipilih valid.', emailAddress: 'Harap masukkan alamat email yang valid.', enterNumber: 'Harap masukkan nomor yang valid.', fieldHasNo: 'Isian ini tidak memiliki {{label}}', @@ -633,7 +631,6 @@ export const idTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Garis bujur harus berada di antara -180 dan 180.', notValidDate: '"{{value}}" bukan tanggal yang valid.', required: 'Isian ini wajib diisi.', - requiredLocale: 'Wajib', requiresAtLeast: 'Isian ini membutuhkan setidaknya {{count}} {{label}}.', requiresNoMoreThan: 'Isian ini membutuhkan tidak lebih dari {{count}} {{label}}.', requiresTwoNumbers: 'Isian ini membutuhkan dua angka.', @@ -642,7 +639,6 @@ export const idTranslations: DefaultTranslationsObject = { trueOrFalse: 'Isian ini hanya bisa sama dengan benar atau salah.', username: 'Harap masukkan nama pengguna yang valid. Dapat berisi huruf, angka, tanda hubung, titik, dan garis bawah.', - validationResults: 'Hasil validasi', validUploadID: 'Isian ini bukan ID unggahan yang valid.', }, version: { diff --git a/packages/translations/src/languages/is.ts b/packages/translations/src/languages/is.ts index 6e53f9dd50e..0e48df6ebea 100644 --- a/packages/translations/src/languages/is.ts +++ b/packages/translations/src/languages/is.ts @@ -609,8 +609,6 @@ export const isTranslations: DefaultTranslationsObject = { width: 'Breidd', }, validation: { - documentInvalid: 'Lagaðu eftirfarandi vandamál.', - documentValid: 'Öll valin tungumál eru gild.', emailAddress: 'Vinsamlegast settu inn gilt netfang.', enterNumber: 'Vinsamlegast settu inn gilda tölu.', fieldHasNo: 'Þetta svæði hefur ekkert {{label}}', @@ -628,7 +626,6 @@ export const isTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Lengdargráða verður að vera á bilinu -180 og 180.', notValidDate: '"{{value}}" er ekki gild dagsetning.', required: 'Þetta svæði er nauðsynlegt.', - requiredLocale: 'Nauðsynlegt', requiresAtLeast: 'Þetta svæði krefst að minnsta kosti {{count}} {{label}}.', requiresNoMoreThan: 'Þetta svæði krefst ekki meira en {{count}} {{label}}.', requiresTwoNumbers: 'Þetta svæði krefst tveggja talna.', @@ -638,7 +635,6 @@ export const isTranslations: DefaultTranslationsObject = { trueOrFalse: 'Þetta svæði getur aðeins verið jafnt og satt eða ósatt.', username: 'Vinsamlegast settu inn gilt notandanafn. Getur innihaldið bókstafi, tölur, bandstrik, punkta og undirstrik.', - validationResults: 'Staðfestingarniðurstöður', validUploadID: 'Þetta svæði er ekki gilt skráarupphleðslauðkenni.', }, version: { diff --git a/packages/translations/src/languages/it.ts b/packages/translations/src/languages/it.ts index bc647547892..7ba4a14ea21 100644 --- a/packages/translations/src/languages/it.ts +++ b/packages/translations/src/languages/it.ts @@ -617,8 +617,6 @@ export const itTranslations: DefaultTranslationsObject = { width: 'Larghezza', }, validation: { - documentInvalid: 'Correggi i seguenti problemi.', - documentValid: 'Tutte le lingue selezionate sono valide.', emailAddress: 'Si prega di inserire un indirizzo email valido.', enterNumber: 'Si prega di inserire un numero valido.', fieldHasNo: 'Questo campo non ha {{label}}', @@ -636,7 +634,6 @@ export const itTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'La longitudine deve essere compresa tra -180 e 180.', notValidDate: '"{{value}}" non è una data valida.', required: 'Questo campo è obbligatorio.', - requiredLocale: 'Obbligatorio', requiresAtLeast: 'Questo campo richiede almeno {{count}} {{label}}.', requiresNoMoreThan: 'Questo campo richiede non più di {{count}} {{label}}.', requiresTwoNumbers: 'Questo campo richiede due numeri.', @@ -646,7 +643,6 @@ export const itTranslations: DefaultTranslationsObject = { trueOrFalse: "Questo campo può essere solo uguale a 'true' o 'false'.", username: 'Inserisci un nome utente valido. Può contenere lettere, numeri, trattini, punti e underscore.', - validationResults: 'Risultati della convalida', validUploadID: "'Questo campo non è un ID di Upload valido.'", }, version: { diff --git a/packages/translations/src/languages/ja.ts b/packages/translations/src/languages/ja.ts index 39fa4a493a9..66deac53336 100644 --- a/packages/translations/src/languages/ja.ts +++ b/packages/translations/src/languages/ja.ts @@ -610,8 +610,6 @@ export const jaTranslations: DefaultTranslationsObject = { width: '横幅', }, validation: { - documentInvalid: '次の問題を修正してください。', - documentValid: 'すべての選択されたLocaleは有効です。', emailAddress: '有効なメールアドレスを入力してください。', enterNumber: '有効な数値を入力してください。', fieldHasNo: '{{label}} が必要です。', @@ -628,7 +626,6 @@ export const jaTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: '経度は-180から180の間でなければなりません。', notValidDate: '"{{value}}" は有効な日付ではありません。', required: '必須フィールドです。', - requiredLocale: '必須', requiresAtLeast: '少なくとも {{count}} {{label}} 以上が必要です。', requiresNoMoreThan: '最大で {{count}} {{label}} 以下にする必要があります。', requiresTwoNumbers: '2つの数値が必要です。', @@ -637,7 +634,6 @@ export const jaTranslations: DefaultTranslationsObject = { trueOrFalse: '"true" または "false" の値にする必要があります。', username: '有効なユーザー名を入力してください。文字、数字、ハイフン、ピリオド、アンダースコアを使用できます。', - validationResults: '検証結果', validUploadID: '有効なアップロードIDではありません。', }, version: { diff --git a/packages/translations/src/languages/ko.ts b/packages/translations/src/languages/ko.ts index ddb8ec74088..bfbacac9412 100644 --- a/packages/translations/src/languages/ko.ts +++ b/packages/translations/src/languages/ko.ts @@ -607,8 +607,6 @@ export const koTranslations: DefaultTranslationsObject = { width: '너비', }, validation: { - documentInvalid: '다음 문제를 수정하십시오.', - documentValid: '선택된 모든 Locale은 유효합니다.', emailAddress: '유효한 이메일 주소를 입력하세요.', enterNumber: '유효한 숫자를 입력하세요.', fieldHasNo: '이 입력란에는 {{label}}이(가) 없습니다.', @@ -625,7 +623,6 @@ export const koTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: '경도는 -180에서 180 사이여야 합니다.', notValidDate: '"{{value}}"은(는) 유효한 날짜가 아닙니다.', required: '이 입력란은 필수입니다.', - requiredLocale: '필수', requiresAtLeast: '이 입력란운 최소한 {{count}} {{label}}이 필요합니다.', requiresNoMoreThan: '이 입력란은 최대 {{count}} {{label}} 이하이어야 합니다.', requiresTwoNumbers: '이 입력란은 두 개의 숫자가 필요합니다.', @@ -634,7 +631,6 @@ export const koTranslations: DefaultTranslationsObject = { trueOrFalse: '이 입력란은 true 또는 false만 가능합니다.', username: '유효한 사용자 이름을 입력해 주세요. 글자, 숫자, 하이픈, 마침표, 및 밑줄을 사용할 수 있습니다.', - validationResults: '검증 결과', validUploadID: '이 입력란은 유효한 업로드 ID가 아닙니다.', }, version: { diff --git a/packages/translations/src/languages/lt.ts b/packages/translations/src/languages/lt.ts index b6f5b2b1e58..8c6444dbceb 100644 --- a/packages/translations/src/languages/lt.ts +++ b/packages/translations/src/languages/lt.ts @@ -613,8 +613,6 @@ export const ltTranslations: DefaultTranslationsObject = { width: 'Plotis', }, validation: { - documentInvalid: 'Išspręskite šias problemas.', - documentValid: 'Visi pasirinkti Locale yra galiojantys.', emailAddress: 'Įveskite galiojantį el. pašto adresą.', enterNumber: 'Įveskite galiojantį skaičių.', fieldHasNo: 'Šiame lauke nėra {{label}}', @@ -634,7 +632,6 @@ export const ltTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Ilguma turi būti tarp -180 ir 180.', notValidDate: '"{{value}}" nėra galiojanti data.', required: 'Šis laukas yra privalomas.', - requiredLocale: 'Privalomas', requiresAtLeast: 'Šis laukas reikalauja bent {{count}} {{label}}.', requiresNoMoreThan: 'Šiame laukelyje gali būti ne daugiau kaip {{count}} {{label}}.', requiresTwoNumbers: 'Šiame lauke reikia įvesti du skaičius.', @@ -643,7 +640,6 @@ export const ltTranslations: DefaultTranslationsObject = { trueOrFalse: 'Šis laukas gali būti lygus tik „true“ ar „false“.', username: 'Įveskite galiojantį vartotojo vardą. Galima naudoti raides, skaičius, brūkšnelius, taškus ir pabraukimus.', - validationResults: 'Patikros rezultatai', validUploadID: 'Šis laukas nėra tinkamas įkėlimo ID.', }, version: { diff --git a/packages/translations/src/languages/lv.ts b/packages/translations/src/languages/lv.ts index ca4adff892a..66f2cebe12e 100644 --- a/packages/translations/src/languages/lv.ts +++ b/packages/translations/src/languages/lv.ts @@ -609,8 +609,6 @@ export const lvTranslations: DefaultTranslationsObject = { width: 'Platums', }, validation: { - documentInvalid: 'Izlabojiet šādas problēmas.', - documentValid: 'Visas atlasītās lokalizācijas ir derīgas.', emailAddress: 'Lūdzu, ievadiet derīgu e-pasta adresi.', enterNumber: 'Lūdzu, ievadiet derīgu numuru.', fieldHasNo: 'Šim laukam nav {{label}}', @@ -627,7 +625,6 @@ export const lvTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Garuma līnijai jābūt starp -180 un 180.', notValidDate: '"{{value}}" nav derīgs datums.', required: 'Šis lauks ir obligāts.', - requiredLocale: 'Obligāts', requiresAtLeast: 'Šim laukam nepieciešami vismaz {{count}} {{label}}.', requiresNoMoreThan: 'Šim laukam nepieciešams ne vairāk kā {{count}} {{label}}.', requiresTwoNumbers: 'Šim laukam nepieciešami divi skaitļi.', @@ -636,7 +633,6 @@ export const lvTranslations: DefaultTranslationsObject = { trueOrFalse: 'Šis lauks var būt tikai "true" vai "false".', username: 'Lūdzu, ievadiet derīgu lietotājvārdu. Drīkst saturēt burtus, ciparus, defises, punktus un pasvītras.', - validationResults: 'Validācijas rezultāti', validUploadID: 'Šis lauks nav derīgs augšupielādes ID.', }, version: { diff --git a/packages/translations/src/languages/my.ts b/packages/translations/src/languages/my.ts index aedebe61866..d8b6257ea46 100644 --- a/packages/translations/src/languages/my.ts +++ b/packages/translations/src/languages/my.ts @@ -619,8 +619,6 @@ export const myTranslations: DefaultTranslationsObject = { width: 'အကျယ်', }, validation: { - documentInvalid: 'အောက်ပါပြဿနာများကို ပြုပြင်ပါ။', - documentValid: 'ရွေးချယ်ထားသည့် Locale အားလုံးမှန်ကန်ပါသည်။', emailAddress: 'မှန်ကန်သော အီးမေးလ်လိပ်စာကို ထည့်သွင်းပါ။', enterNumber: 'မှန်ကန်သောနံပါတ်တစ်ခုထည့်ပါ။', fieldHasNo: 'ဤအကွက်တွင် {{label}} မရှိပါ။', @@ -640,7 +638,6 @@ export const myTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'လောင်ဂျီကျူဒ်သည် -180 နှင့် 180 ကြားတွင် ရှိရမည်။', notValidDate: '"{{value}}" သည် တရားဝင်ရက်စွဲမဟုတ်ပါ။', required: 'ဤအကွက်ကို လိုအပ်သည်။', - requiredLocale: 'လိုအပ်သည်', requiresAtLeast: 'ဤအကွက်သည် အနည်းဆုံး {{count}} {{label}} လိုအပ်သည်', requiresNoMoreThan: 'ဤအကွက်တွင် {{count}} {{label}} ထက် မပိုရပါ။', requiresTwoNumbers: 'ဤအကွက်သည် နံပါတ်နှစ်ခု လိုအပ်ပါသည်။', @@ -649,7 +646,6 @@ export const myTranslations: DefaultTranslationsObject = { trueOrFalse: 'ဤအကွက်သည် တစ်ခုခုဖြစ်ရပါမည်။', username: 'မှန်ကန်သော အသုံးပြုသူအမည် ထည့်သွင်းပါ။ အက္ခရာ၊ နံပါတ်၊ ဟိုင်ဖန်၊ အစက် နှင့် အောက်မျဉ်းတို့ ပါဝင်နိုင်ပါသည်။', - validationResults: 'အတည်ပြုမှုရလဒ်များ', validUploadID: "'ဤအကွက်သည် မှန်ကန်သော အပ်လုဒ် ID မဟုတ်ပါ။'", }, version: { diff --git a/packages/translations/src/languages/nb.ts b/packages/translations/src/languages/nb.ts index 5a0dc3033ad..f4e2448b1d4 100644 --- a/packages/translations/src/languages/nb.ts +++ b/packages/translations/src/languages/nb.ts @@ -614,8 +614,6 @@ export const nbTranslations: DefaultTranslationsObject = { width: 'Bredde', }, validation: { - documentInvalid: 'Løs følgende problemer.', - documentValid: 'Alle valgte språk er gyldige.', emailAddress: 'Vennligst skriv inn en gyldig e-postadresse.', enterNumber: 'Vennligst skriv inn et gyldig tall.', fieldHasNo: 'Dette feltet har ingen {{label}}', @@ -632,7 +630,6 @@ export const nbTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Lengdegrad må være mellom -180 og 180.', notValidDate: '"{{value}}" er ikke en gyldig dato.', required: 'Dette feltet er påkrevd.', - requiredLocale: 'Obligatorisk', requiresAtLeast: 'Dette feltet krever minst {{count}} {{label}}.', requiresNoMoreThan: 'Dette feltet krever maksimalt {{count}} {{label}}.', requiresTwoNumbers: 'Dette feltet krever to tall.', @@ -641,7 +638,6 @@ export const nbTranslations: DefaultTranslationsObject = { trueOrFalse: 'Dette feltet kan bare være likt true eller false.', username: 'Vennligst oppgi et gyldig brukernavn. Kan inneholde bokstaver, nummer, bindestreker, punktum og understrek.', - validationResults: 'Valideringsresultater', validUploadID: 'Dette feltet er ikke en gyldig opplastings-ID.', }, version: { diff --git a/packages/translations/src/languages/nl.ts b/packages/translations/src/languages/nl.ts index 768eb560544..d9ff4738ef4 100644 --- a/packages/translations/src/languages/nl.ts +++ b/packages/translations/src/languages/nl.ts @@ -619,8 +619,6 @@ export const nlTranslations: DefaultTranslationsObject = { width: 'Breedte', }, validation: { - documentInvalid: 'Los de volgende problemen op.', - documentValid: 'Alle geselecteerde talen zijn geldig.', emailAddress: 'Voer een geldig e-mailadres in.', enterNumber: 'Voer een geldig nummer in.', fieldHasNo: 'Dit veld heeft geen {{label}}', @@ -637,7 +635,6 @@ export const nlTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Lengtegraad moet tussen -180 en 180 liggen.', notValidDate: '"{{value}}" is geen geldige datum.', required: 'Dit veld is verplicht.', - requiredLocale: 'Vereist', requiresAtLeast: 'Dit veld vereist minstens {{count}} {{label}}.', requiresNoMoreThan: 'Dit veld vereist niet meer dan {{count}} {{label}}.', requiresTwoNumbers: 'Dit veld vereist twee nummers.', @@ -646,7 +643,6 @@ export const nlTranslations: DefaultTranslationsObject = { trueOrFalse: 'Dit veld kan alleen waar of onwaar zijn.', username: 'Voer een geldige gebruikersnaam in. Kan letters, cijfers, koppeltekens, punten en underscores bevatten.', - validationResults: 'Validatieresultaten', validUploadID: 'Dit veld is geen geldige upload-ID.', }, version: { diff --git a/packages/translations/src/languages/pl.ts b/packages/translations/src/languages/pl.ts index fc60885ace6..cb265ad36fd 100644 --- a/packages/translations/src/languages/pl.ts +++ b/packages/translations/src/languages/pl.ts @@ -610,8 +610,6 @@ export const plTranslations: DefaultTranslationsObject = { width: 'Szerokość', }, validation: { - documentInvalid: 'Proszę naprawić następujące problemy.', - documentValid: 'Wszystkie wybrane locale są prawidłowe.', emailAddress: 'Wprowadź poprawny adres email.', enterNumber: 'Wprowadź poprawny numer telefonu.', fieldHasNo: 'To pole nie posiada {{label}}', @@ -628,7 +626,6 @@ export const plTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Długość geograficzna musi być pomiędzy -180 a 180.', notValidDate: '"{{value}}" nie jest prawidłową datą.', required: 'To pole jest wymagane.', - requiredLocale: 'Wymagane', requiresAtLeast: 'To pole wymaga co najmniej {{count}} {{label}}.', requiresNoMoreThan: 'To pole może posiadać co najmniej {{count}} {{label}}.', requiresTwoNumbers: 'To pole wymaga dwóch liczb.', @@ -637,7 +634,6 @@ export const plTranslations: DefaultTranslationsObject = { trueOrFalse: "To pole może mieć wartość tylko 'true' lub 'false'.", username: 'Proszę wprowadzić prawidłową nazwę użytkownika. Może zawierać litery, cyfry, myślniki, kropki i podkreślniki.', - validationResults: 'Wyniki walidacji', validUploadID: 'To pole nie jest prawidłowym identyfikatorem przesyłania.', }, version: { diff --git a/packages/translations/src/languages/pt.ts b/packages/translations/src/languages/pt.ts index ec180f8b8c4..605bfaafa85 100644 --- a/packages/translations/src/languages/pt.ts +++ b/packages/translations/src/languages/pt.ts @@ -614,8 +614,6 @@ export const ptTranslations: DefaultTranslationsObject = { width: 'Largura', }, validation: { - documentInvalid: 'Corrija os seguintes problemas.', - documentValid: 'Todos os idiomas selecionados são válidos.', emailAddress: 'Por favor, insira um endereço de email válido.', enterNumber: 'Por favor, insira um número válido.', fieldHasNo: 'Esse campo não contém {{label}}', @@ -632,7 +630,6 @@ export const ptTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'A longitude deve estar entre -180 e 180.', notValidDate: '"{{value}}" não é uma data válida.', required: 'Esse campo é obrigatório.', - requiredLocale: 'Obrigatório', requiresAtLeast: 'Esse campo requer no máximo {{count}} {{label}}.', requiresNoMoreThan: 'Esse campo requer pelo menos {{count}} {{label}}.', requiresTwoNumbers: 'Esse campo requer dois números.', @@ -641,7 +638,6 @@ export const ptTranslations: DefaultTranslationsObject = { trueOrFalse: 'Esse campo pode ser apenas verdadeiro (true) ou falso (false)', username: 'Por favor, insira um nome de usuário válido. Pode conter letras, números, hifens, pontos e sublinhados.', - validationResults: 'Resultados da validação', validUploadID: "'Esse campo não é um ID de upload válido.'", }, version: { diff --git a/packages/translations/src/languages/ro.ts b/packages/translations/src/languages/ro.ts index 6a5b87f3c2a..2a37b8e3c8d 100644 --- a/packages/translations/src/languages/ro.ts +++ b/packages/translations/src/languages/ro.ts @@ -617,8 +617,6 @@ export const roTranslations: DefaultTranslationsObject = { width: 'Lățime', }, validation: { - documentInvalid: 'Remediați următoarele probleme.', - documentValid: 'Toate localele selectate sunt valide.', emailAddress: 'Vă rugăm să introduceți o adresă de email validă.', enterNumber: 'Vă rugăm să introduceți un număr valid.', fieldHasNo: 'Acest câmp nu are un {{label}}', @@ -638,7 +636,6 @@ export const roTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Longitudinea trebuie să fie între -180 și 180.', notValidDate: '"{{value}}" nu este o dată valabilă.', required: 'Acest câmp este obligatoriu.', - requiredLocale: 'Obligatoriu', requiresAtLeast: 'Acest domeniu necesită cel puțin {{count}} {{label}}.', requiresNoMoreThan: 'Acest câmp nu necesită mai mult de {{count}} {{label}}.', requiresTwoNumbers: 'Acest câmp necesită două numere.', @@ -648,7 +645,6 @@ export const roTranslations: DefaultTranslationsObject = { trueOrFalse: 'Acest câmp poate fi doar egal cu true sau false.', username: 'Vă rugăm să introduceți un nume de utilizator valid. Poate conține litere, numere, cratime, puncte și sublinieri.', - validationResults: 'Rezultatele validării', validUploadID: 'Acest câmp nu este un ID de încărcare valid.', }, version: { diff --git a/packages/translations/src/languages/rs.ts b/packages/translations/src/languages/rs.ts index c4c3b019035..a3cfd99992a 100644 --- a/packages/translations/src/languages/rs.ts +++ b/packages/translations/src/languages/rs.ts @@ -610,8 +610,6 @@ export const rsTranslations: DefaultTranslationsObject = { width: 'Ширина', }, validation: { - documentInvalid: 'Ispravite sledeće probleme.', - documentValid: 'Svi izabrani jezici su validni.', emailAddress: 'Молимо Вас унесите валидну емаил адресу.', enterNumber: 'Молимо Вас унесите валидан број.', fieldHasNo: 'Ово поље нема {{label}}', @@ -628,7 +626,6 @@ export const rsTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Дужина мора бити између -180 и 180.', notValidDate: '"{{value}}" није валидан датум.', required: 'Ово поље је обавезно.', - requiredLocale: 'Obavezno', requiresAtLeast: 'Ово поље захтева минимално {{count}} {{label}}.', requiresNoMoreThan: 'Ово поље захтева не више од {{count}} {{label}}.', requiresTwoNumbers: 'Ово поље захтева два броја.', @@ -637,7 +634,6 @@ export const rsTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ово поље може бити само тачно или нетачно', username: 'Molimo unesite važeće korisničko ime. Može sadržati slova, brojeve, crtice, tačke i donje crte.', - validationResults: 'Rezultati validacije', validUploadID: 'Ово поље не садржи валидан ИД преноса.', }, version: { diff --git a/packages/translations/src/languages/rsLatin.ts b/packages/translations/src/languages/rsLatin.ts index 710920c7993..745159d7047 100644 --- a/packages/translations/src/languages/rsLatin.ts +++ b/packages/translations/src/languages/rsLatin.ts @@ -611,8 +611,6 @@ export const rsLatinTranslations: DefaultTranslationsObject = { width: 'Širina', }, validation: { - documentInvalid: 'Ispravite sledeće probleme.', - documentValid: 'Svi izabrani jezici su validni.', emailAddress: 'Molimo Vas unesite validnu email adresu.', enterNumber: 'Molimo Vas unesite validan broj.', fieldHasNo: 'Ovo polje nema {{label}}', @@ -629,7 +627,6 @@ export const rsLatinTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Geografska dužina mora biti između -180 i 180.', notValidDate: '"{{value}}" nije validan datum.', required: 'Ovo polje je obavezno.', - requiredLocale: 'Obavezno', requiresAtLeast: 'Ovo polje zahteva minimalno {{count}} {{label}}.', requiresNoMoreThan: 'Ovo polje zahteva ne više od {{count}} {{label}}.', requiresTwoNumbers: 'Ovo polje zahteva dva broja.', @@ -638,7 +635,6 @@ export const rsLatinTranslations: DefaultTranslationsObject = { trueOrFalse: 'Ovo polje može biti samo tačno ili netačno', username: 'Molimo unesite važeće korisničko ime. Može sadržavati slova, brojeve, crtice, tačke i donje crte.', - validationResults: 'Rezultati validacije', validUploadID: 'Ovo polje ne sadrži validan ID prenosa.', }, version: { diff --git a/packages/translations/src/languages/ru.ts b/packages/translations/src/languages/ru.ts index 2c078df1c1a..6525169ee63 100644 --- a/packages/translations/src/languages/ru.ts +++ b/packages/translations/src/languages/ru.ts @@ -616,8 +616,6 @@ export const ruTranslations: DefaultTranslationsObject = { width: 'Ширина', }, validation: { - documentInvalid: 'Исправьте следующие проблемы.', - documentValid: 'Все выбранные языки являются допустимыми.', emailAddress: 'Пожалуйста, введите корректный адрес email.', enterNumber: 'Пожалуйста, введите корректный номер.', fieldHasNo: 'У этого поля нет {{label}}', @@ -634,7 +632,6 @@ export const ruTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Долгота должна быть между -180 и 180.', notValidDate: '"{{value}}" это не действительная дата.', required: 'Это обязательное поле.', - requiredLocale: 'Обязательный', requiresAtLeast: 'Это поле требует не менее {{count}} {{label}}', requiresNoMoreThan: 'Это поле требует не более {{count}} {{label}}', requiresTwoNumbers: 'В этом поле требуется два числа.', @@ -643,7 +640,6 @@ export const ruTranslations: DefaultTranslationsObject = { trueOrFalse: 'Это поле может быть равно только true или false.', username: 'Пожалуйста, введите действительное имя пользователя. Может содержать буквы, цифры, дефисы, точки и подчёркивания.', - validationResults: 'Результаты проверки', validUploadID: "'Это поле не является действительным ID загрузки.'", }, version: { diff --git a/packages/translations/src/languages/sk.ts b/packages/translations/src/languages/sk.ts index 6ee9daa50c8..7c559bd1a1b 100644 --- a/packages/translations/src/languages/sk.ts +++ b/packages/translations/src/languages/sk.ts @@ -608,8 +608,6 @@ export const skTranslations: DefaultTranslationsObject = { width: 'Šírka', }, validation: { - documentInvalid: 'Opravte nasledujúce problémy.', - documentValid: 'Všetky vybrané jazyky sú platné.', emailAddress: 'Zadajte prosím platnú e-mailovú adresu.', enterNumber: 'Zadajte prosím platné číslo.', fieldHasNo: 'Toto pole nemá {{label}}', @@ -626,7 +624,6 @@ export const skTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Zemepisná dĺžka musí byť medzi -180 a 180.', notValidDate: '"{{value}}" nie je platný dátum.', required: 'Toto pole je povinné.', - requiredLocale: 'Povinné', requiresAtLeast: 'Toto pole vyžaduje aspoň {{count}} {{label}}.', requiresNoMoreThan: 'Toto pole vyžaduje nie viac ako {{count}} {{label}}.', requiresTwoNumbers: 'Toto pole vyžaduje dve čísla.', @@ -635,7 +632,6 @@ export const skTranslations: DefaultTranslationsObject = { trueOrFalse: 'Toto pole môže byť rovné iba true alebo false.', username: 'Prosím, zadajte platné používateľské meno. Môže obsahovať písmená, čísla, pomlčky, bodky a podčiarknutia.', - validationResults: 'Výsledky validácie', validUploadID: 'Toto pole nie je platné ID pre odoslanie.', }, version: { diff --git a/packages/translations/src/languages/sl.ts b/packages/translations/src/languages/sl.ts index 5d8be90cb4e..2ad6f10fd56 100644 --- a/packages/translations/src/languages/sl.ts +++ b/packages/translations/src/languages/sl.ts @@ -609,8 +609,6 @@ export const slTranslations: DefaultTranslationsObject = { width: 'Širina', }, validation: { - documentInvalid: 'Odpravite naslednje težave.', - documentValid: 'Vsi izbrani jeziki so veljavni.', emailAddress: 'Vnesite veljaven e-poštni naslov.', enterNumber: 'Vnesite veljavno številko.', fieldHasNo: 'To polje nima {{label}}', @@ -627,7 +625,6 @@ export const slTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Zemljepisna dolžina mora biti med -180 in 180.', notValidDate: '"{{value}}" ni veljaven datum.', required: 'To polje je obvezno.', - requiredLocale: 'Obvezno', requiresAtLeast: 'To polje zahteva vsaj {{count}} {{label}}.', requiresNoMoreThan: 'To polje zahteva največ {{count}} {{label}}.', requiresTwoNumbers: 'To polje zahteva dve številki.', @@ -636,7 +633,6 @@ export const slTranslations: DefaultTranslationsObject = { trueOrFalse: 'To polje je lahko samo enako true ali false.', username: 'Vnesite veljavno uporabniško ime. Lahko vsebuje črke, številke, vezaje, pike in podčrtaje.', - validationResults: 'Rezultati validacije', validUploadID: 'To polje ni veljaven ID nalaganja.', }, version: { diff --git a/packages/translations/src/languages/sv.ts b/packages/translations/src/languages/sv.ts index 1a7497025a4..4b0d89b35c6 100644 --- a/packages/translations/src/languages/sv.ts +++ b/packages/translations/src/languages/sv.ts @@ -613,8 +613,6 @@ export const svTranslations: DefaultTranslationsObject = { width: 'Bredd', }, validation: { - documentInvalid: 'Åtgärda följande problem.', - documentValid: 'Alla valda språk är giltiga.', emailAddress: 'Vänligen ange en giltig e-postadress.', enterNumber: 'Vänligen skriv in ett giltigt nummer.', fieldHasNo: 'Detta fält har ingen {{label}}', @@ -631,7 +629,6 @@ export const svTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Longituden måste vara mellan -180 och 180.', notValidDate: '"{{value}}" är inte ett giltigt datum.', required: 'Detta fält är obligatoriskt.', - requiredLocale: 'Obligatorisk', requiresAtLeast: 'Detta fält kräver minst {{count}} {{label}}.', requiresNoMoreThan: 'Detta fält kräver inte mer än {{count}} {{label}}.', requiresTwoNumbers: 'Detta fält kräver två nummer.', @@ -640,7 +637,6 @@ export const svTranslations: DefaultTranslationsObject = { trueOrFalse: 'Detta fält kan bara vara lika med sant eller falskt.', username: 'Var god ange ett giltigt användarnamn. Kan innehålla bokstäver, siffror, bindestreck, punkter och understreck.', - validationResults: 'Valideringsresultat', validUploadID: 'Det här fältet är inte ett giltigt uppladdnings-ID', }, version: { diff --git a/packages/translations/src/languages/ta.ts b/packages/translations/src/languages/ta.ts index 34c5c321313..a55ca6bfeb7 100644 --- a/packages/translations/src/languages/ta.ts +++ b/packages/translations/src/languages/ta.ts @@ -613,8 +613,6 @@ export const taTranslations: DefaultTranslationsObject = { width: 'அகலம்', }, validation: { - documentInvalid: 'பின்வரும் சிக்கல்களை சரிசெய்க.', - documentValid: 'தேர்ந்தெடுக்கப்பட்ட அனைத்து Locale-களும் செல்லுபடியாக உள்ளன.', emailAddress: 'சரியான மின்னஞ்சல் முகவரியை உள்ளிடவும்.', enterNumber: 'சரியான எண் ஒன்றை உள்ளிடவும்.', fieldHasNo: 'இந்த புலத்தில் {{label}} இல்லை', @@ -632,7 +630,6 @@ export const taTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'தீர்க்கக் கூறு -180 மற்றும் 180 இடையே இருக்க வேண்டும்.', notValidDate: '"{{value}}" ஒரு சரியான தேதி அல்ல.', required: 'இந்த புலம் அவசியம்.', - requiredLocale: 'தேவையானது', requiresAtLeast: 'இந்த புலம் குறைந்தது {{count}} {{label}} தேவைப்படுகிறது.', requiresNoMoreThan: 'இந்த புலம் அதிகபட்சம் {{count}} {{label}} மட்டுமே அனுமதிக்கிறது.', requiresTwoNumbers: 'இந்த புலத்தில் இரண்டு எண்கள் தேவை.', @@ -642,7 +639,6 @@ export const taTranslations: DefaultTranslationsObject = { trueOrFalse: 'இந்த புலம் true அல்லது false ஆக மட்டுமே இருக்க வேண்டும்.', username: 'சரியான பயனர் பெயரை உள்ளிடவும். எழுத்துக்கள், எண்கள், ஹைஃபன்கள், புள்ளிகள் மற்றும் அடிக்கோடுகள் இருக்கலாம்.', - validationResults: 'சரிபார்ப்பு முடிவுகள்', validUploadID: 'இந்த புலம் சரியான பதிவேற்ற ID அல்ல.', }, version: { diff --git a/packages/translations/src/languages/th.ts b/packages/translations/src/languages/th.ts index f822c0ed20f..855378d3bf4 100644 --- a/packages/translations/src/languages/th.ts +++ b/packages/translations/src/languages/th.ts @@ -600,8 +600,6 @@ export const thTranslations: DefaultTranslationsObject = { width: 'ความกว้าง', }, validation: { - documentInvalid: 'โปรดแก้ไขปัญหาต่อไปนี้', - documentValid: 'ภาษาทั้งหมดที่เลือกมีความถูกต้อง', emailAddress: 'กรุณาระบุอีเมลที่ถูกต้อง', enterNumber: 'กรุณาระบุตัวเลขที่ถูกต้อง', fieldHasNo: 'ช่องนี้ไม่มี {{label}}', @@ -618,7 +616,6 @@ export const thTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'ลองจิจูดต้องอยู่ระหว่าง -180 และ 180.', notValidDate: 'วันที่ "{{value}}" ไม่ถูกต้อง', required: 'จำเป็นต้องระบุค่า', - requiredLocale: 'จำเป็น', requiresAtLeast: 'ต้องมีอย่างน้อย {{count}} {{label}}', requiresNoMoreThan: 'ห้ามมีเกิน {{count}} {{label}}', requiresTwoNumbers: 'ต้องมีตัวเลข 2 ค่า', @@ -626,7 +623,6 @@ export const thTranslations: DefaultTranslationsObject = { timezoneRequired: 'ต้องการเขตเวลา', trueOrFalse: 'เป็นได้แค่ "ใช่" หรือ "ไม่ใช่"', username: 'กรุณาใส่ชื่อผู้ใช้ที่ถูกต้อง สามารถมีตัวอักษร ตัวเลข ขีดกลาง จุด และขีดล่าง', - validationResults: 'ผลการตรวจสอบความถูกต้อง', validUploadID: 'ไม่ใช่ ID ของการอัปโหลดที่ถูกต้อง', }, version: { diff --git a/packages/translations/src/languages/tr.ts b/packages/translations/src/languages/tr.ts index db266bd1b9c..7951670898d 100644 --- a/packages/translations/src/languages/tr.ts +++ b/packages/translations/src/languages/tr.ts @@ -618,8 +618,6 @@ export const trTranslations: DefaultTranslationsObject = { width: 'Genişlik', }, validation: { - documentInvalid: 'Aşağıdaki sorunları düzeltin.', - documentValid: 'Tüm seçili Locale’ler geçerlidir.', emailAddress: 'Lütfen geçerli bir e-posta adresi girin.', enterNumber: 'Lütfen geçerli bir sayı girin.', fieldHasNo: 'Bu alanda {{label}} girili değil.', @@ -636,7 +634,6 @@ export const trTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Boylam -180 ile 180 arasında olmalıdır.', notValidDate: '"{{value}}" geçerli bir tarih değil.', required: 'Bu alan gereklidir.', - requiredLocale: 'Zorunlu', requiresAtLeast: 'Bu alan en az {{count}} adet {{label}} gerektirmektedir.', requiresNoMoreThan: 'Bu alana {{count}} adetten fazla {{label}} girilemez.', requiresTwoNumbers: 'Bu alana en az iki rakam girilmesi zorunludur.', @@ -645,7 +642,6 @@ export const trTranslations: DefaultTranslationsObject = { trueOrFalse: 'Bu alan yalnızca doğru ve yanlış olabilir.', username: 'Lütfen geçerli bir kullanıcı adı girin. Harfler, numaralar, kısa çizgiler, noktalar ve alt çizgiler içerebilir.', - validationResults: 'Doğrulama sonuçları', validUploadID: "'Bu alan geçerli bir karşıya yükleme ID'sine sahip değil.'", }, version: { diff --git a/packages/translations/src/languages/uk.ts b/packages/translations/src/languages/uk.ts index 19bb336513f..403c76206cd 100644 --- a/packages/translations/src/languages/uk.ts +++ b/packages/translations/src/languages/uk.ts @@ -607,8 +607,6 @@ export const ukTranslations: DefaultTranslationsObject = { width: 'Ширина', }, validation: { - documentInvalid: 'Виправте наведені нижче проблеми.', - documentValid: 'Усі вибрані локалі є дійсними.', emailAddress: 'Будь ласка, введіть коректну адресу електронної пошти.', enterNumber: 'Будь ласка, введіть коректне число.', fieldHasNo: 'У цьому полі немає {{label}}', @@ -625,7 +623,6 @@ export const ukTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Довгота повинна бути в межах від -180 до 180.', notValidDate: '"{{value}}" - некоректна дата.', required: "Це поле є обов'язковим.", - requiredLocale: 'Обов’язково', requiresAtLeast: 'Це поле потребує не менше {{count}} {{label}}.', requiresNoMoreThan: 'Це поле потребує не більше {{count}} {{label}}.', requiresTwoNumbers: 'У цьому полі потрібно ввести два числа.', @@ -634,7 +631,6 @@ export const ukTranslations: DefaultTranslationsObject = { trueOrFalse: 'Це поле може мати значення тільки true або false.', username: "Будь ласка, введіть коректне ім'я користувача. Може містити літери, цифри, дефіси, крапки та підкреслення.", - validationResults: 'Результати валідації', validUploadID: 'Це поле не є коректним ID завантаження.', }, version: { diff --git a/packages/translations/src/languages/vi.ts b/packages/translations/src/languages/vi.ts index cc56ab9a55c..8aa0f3d95af 100644 --- a/packages/translations/src/languages/vi.ts +++ b/packages/translations/src/languages/vi.ts @@ -612,8 +612,6 @@ export const viTranslations: DefaultTranslationsObject = { width: 'Chiều rộng', }, validation: { - documentInvalid: 'Khắc phục các vấn đề sau.', - documentValid: 'Tất cả các locale được chọn đều hợp lệ.', emailAddress: 'Địa chỉ email không hợp lệ.', enterNumber: 'Vui lòng nhập số.', fieldHasNo: 'Field này không có: {{label}}', @@ -630,7 +628,6 @@ export const viTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: 'Kinh độ phải nằm giữa -180 và 180.', notValidDate: '"{{value}}" không phải là một ngày (date) hợp lệ.', required: 'Trường này là bắt buộc.', - requiredLocale: 'Bắt buộc', requiresAtLeast: 'Trường này cần tối thiểu {{count}} {{label}}.', requiresNoMoreThan: 'Trường này không thể vượt quá {{count}} {{label}}.', requiresTwoNumbers: 'Trường này cần tối thiểu 2 chữ số.', @@ -639,7 +636,6 @@ export const viTranslations: DefaultTranslationsObject = { trueOrFalse: 'Trường này chỉ có thể chứa giá trị đúng hoặc sai.', username: 'Vui lòng nhập một tên người dùng hợp lệ. Có thể chứa các chữ cái, số, dấu gạch ngang, dấu chấm và dấu gạch dưới.', - validationResults: 'Kết quả xác thực', validUploadID: "'Field này không chứa ID tải lên hợp lệ.'", }, version: { diff --git a/packages/translations/src/languages/zh.ts b/packages/translations/src/languages/zh.ts index 6ee0fabce36..e6dd9b52071 100644 --- a/packages/translations/src/languages/zh.ts +++ b/packages/translations/src/languages/zh.ts @@ -584,8 +584,6 @@ export const zhTranslations: DefaultTranslationsObject = { width: '宽度', }, validation: { - documentInvalid: '请修复以下问题。', - documentValid: '所有选定的Locale均有效。', emailAddress: '请输入一个有效的电子邮件地址。', enterNumber: '请输入一个有效的数字。', fieldHasNo: '这个字段没有 {{label}}', @@ -602,7 +600,6 @@ export const zhTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: '经度必须在-180和180之间。', notValidDate: '「{{value}}」不是一个有效的日期。', required: '该字段为必填项目。', - requiredLocale: '必填', requiresAtLeast: '该字段至少需要 {{count}} 个 {{label}}。', requiresNoMoreThan: '该字段要求不超过 {{count}} 个 {{label}}。', requiresTwoNumbers: '该字段需要两个数字。', @@ -610,7 +607,6 @@ export const zhTranslations: DefaultTranslationsObject = { timezoneRequired: '需要选择一个时区。', trueOrFalse: '此项仅可选择"是"或"否"。', username: '请输入一个有效的用户名。可包含字母,数字,连字符,句点和下划线。', - validationResults: '验证结果', validUploadID: '该字段不是有效的上传 ID。', }, version: { diff --git a/packages/translations/src/languages/zhTw.ts b/packages/translations/src/languages/zhTw.ts index 41e2012b302..0968947df55 100644 --- a/packages/translations/src/languages/zhTw.ts +++ b/packages/translations/src/languages/zhTw.ts @@ -582,8 +582,6 @@ export const zhTwTranslations: DefaultTranslationsObject = { width: '寬度', }, validation: { - documentInvalid: '請修正以下問題。', - documentValid: '所有已選擇的 Locale 均有效。', emailAddress: '請輸入有效的電子郵件地址。', enterNumber: '請輸入有效的數字。', fieldHasNo: '此欄位沒有 {{label}}', @@ -600,7 +598,6 @@ export const zhTwTranslations: DefaultTranslationsObject = { longitudeOutOfBounds: '經度必須介於 -180 和 180 之間。', notValidDate: '「{{value}}」不是有效的日期格式。', required: '此欄位為必填項目。', - requiredLocale: '必填', requiresAtLeast: '此欄位至少需要 {{count}} 個 {{label}}。', requiresNoMoreThan: '此欄位最多只能有 {{count}} 個 {{label}}。', requiresTwoNumbers: '此欄位需包含兩個數字。', @@ -608,7 +605,6 @@ export const zhTwTranslations: DefaultTranslationsObject = { timezoneRequired: '請選取一個時區。', trueOrFalse: '此欄位只能為 true 或 false。', username: '請輸入有效的使用者名稱。可包含英文字母、數字、連字號、句點與底線。', - validationResults: '驗證結果', validUploadID: '此欄位不是有效的上傳 ID。', }, version: { diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index f5f3483c84e..98bd75f7234 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -23,7 +23,6 @@ import { useElementHeightVariable } from '../../hooks/useElementHeightVariable.j import { MoreIcon } from '../../icons/More/index.js' import { useConfig } from '../../providers/Config/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' -import { DocumentValidationProvider } from '../../providers/DocumentValidation/index.js' import { useEditDepth } from '../../providers/EditDepth/index.js' import { useLivePreviewContext } from '../../providers/LivePreview/context.js' import { useTranslation } from '../../providers/Translation/index.js' @@ -95,7 +94,7 @@ type DocumentControlsProps = { readonly variant?: 'default' | 'drawerHeaderActions' } -const DocumentControlsContent: React.FC = (props) => { +export const DocumentControls: React.FC = (props) => { const { id, slug, @@ -462,9 +461,3 @@ const DocumentControlsContent: React.FC = (props) => { ) } - -export const DocumentControls: React.FC = (props) => ( - - - -) diff --git a/packages/ui/src/elements/PublishButton/index.tsx b/packages/ui/src/elements/PublishButton/index.tsx index 10ac030f789..4fcd0f5b991 100644 --- a/packages/ui/src/elements/PublishButton/index.tsx +++ b/packages/ui/src/elements/PublishButton/index.tsx @@ -12,7 +12,6 @@ import { FormSubmit } from '../../forms/Submit/index.js' import { useHotkey } from '../../hooks/useHotkey.js' import { useConfig } from '../../providers/Config/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' -import { useDocumentValidation } from '../../providers/DocumentValidation/index.js' import { useEditDepth } from '../../providers/EditDepth/index.js' import { useLocale } from '../../providers/Locale/index.js' import { useOperation } from '../../providers/Operation/index.js' @@ -38,12 +37,12 @@ export function PublishButton({ } = useDocumentInfo() const { config, getEntityConfig } = useConfig() - const { isValidating, validateBeforePublish } = useDocumentValidation() const { submit } = useForm() const modified = useFormModified() const editDepth = useEditDepth() const { code: localeCode } = useLocale() const { + blocksMap, localization, routes: { api }, } = config @@ -67,15 +66,14 @@ export function PublishButton({ const canPublish = hasPublishPermission && (modified || hasNewerVersions || !hasPublishedDoc) && - uploadStatus !== 'uploading' && - !isValidating + uploadStatus !== 'uploading' const [hasLocalizedFields, setHasLocalizedFields] = useState(false) useEffect(() => { - const hasLocalizedField = traverseForLocalizedFields(entityConfig?.fields) + const hasLocalizedField = traverseForLocalizedFields(entityConfig?.fields, { blocksMap }) setHasLocalizedFields(hasLocalizedField) - }, [entityConfig?.fields]) + }, [blocksMap, entityConfig?.fields]) const isSpecificLocalePublishEnabled = localization && hasLocalizedFields && hasPublishPermission @@ -147,12 +145,6 @@ export function PublishButton({ return } - const isValid = await validateBeforePublish({ isPublishAll: localizeStatusEnabled }) - - if (!isValid) { - return - } - const params = qs.stringify( { depth: 0, @@ -193,7 +185,6 @@ export function PublishButton({ setUnpublishedVersionCount, uploadStatus, setMostRecentVersionIsAutosaved, - validateBeforePublish, ]) const publishLocale = useCallback( @@ -202,12 +193,6 @@ export function PublishButton({ return } - const isValid = await validateBeforePublish({ isPublishAll: false }) - - if (!isValid) { - return - } - const params = qs.stringify( { depth: 0, @@ -247,7 +232,6 @@ export function PublishButton({ setUnpublishedVersionCount, submit, uploadStatus, - validateBeforePublish, ], ) diff --git a/packages/ui/src/elements/ValidationResultsDrawer/index.css b/packages/ui/src/elements/ValidationResultsDrawer/index.css deleted file mode 100644 index c2c1a848d4d..00000000000 --- a/packages/ui/src/elements/ValidationResultsDrawer/index.css +++ /dev/null @@ -1,40 +0,0 @@ -@layer payload-default { - .validation-results__content { - padding-block: var(--gutter-h); - } - - .validation-results__valid { - color: var(--theme-success-500); - } - - .validation-results__locale { - margin-block-start: var(--base); - - h3 { - align-items: center; - display: flex; - gap: calc(var(--base) / 2); - margin: 0 0 calc(var(--base) / 2); - } - - ul { - display: grid; - gap: calc(var(--base) / 2); - margin: 0; - padding-inline-start: var(--base); - } - - li { - display: grid; - gap: calc(var(--base) / 4); - } - } - - .validation-results__required { - background: var(--theme-elevation-100); - border-radius: var(--style-radius-s); - font-size: 0.75em; - font-weight: normal; - padding: calc(var(--base) / 8) calc(var(--base) / 4); - } -} diff --git a/packages/ui/src/elements/ValidationResultsDrawer/index.tsx b/packages/ui/src/elements/ValidationResultsDrawer/index.tsx deleted file mode 100644 index f19d810c074..00000000000 --- a/packages/ui/src/elements/ValidationResultsDrawer/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -'use client' - -import type { SanitizedLocale, ValidationFieldError, ValidationResult } from 'payload' - -import { getTranslation } from '@payloadcms/translations' -import React, { useMemo } from 'react' - -import { useTranslation } from '../../providers/Translation/index.js' -import { Drawer } from '../Drawer/index.js' -import { Gutter } from '../Gutter/index.js' -import './index.css' - -const baseClass = 'validation-results' - -export const ValidationResultsDrawer: React.FC<{ - activeLocale: string - locales: SanitizedLocale[] - result: null | ValidationResult - slug: string -}> = ({ slug, activeLocale, locales, result }) => { - const { i18n, t } = useTranslation() - const errorsByLocale = useMemo(() => { - return (result?.errors ?? []).reduce>( - (groups, error) => { - const locale = error.locale ?? activeLocale - groups[locale] = [...(groups[locale] ?? []), error] - return groups - }, - {}, - ) - }, [activeLocale, result?.errors]) - - if (!result) { - return null - } - - return ( - - - {result.valid ? ( -

- {t('validation:documentValid')} -

- ) : ( -
-

{t('validation:documentInvalid')}

- {Object.entries(errorsByLocale).map(([localeCode, errors]) => { - const locale = locales.find(({ code }) => code === localeCode) - const localeLabel = locale ? getTranslation(locale.label, i18n) : localeCode - - return ( -
-

- {localeLabel} - {locale?.required && ( - - {t('validation:requiredLocale')} - - )} -

-
    - {errors.map((error, index) => ( -
  • - {error.path && {error.path}} - {error.message} -
  • - ))} -
-
- ) - })} -
- )} -
-
- ) -} diff --git a/packages/ui/src/providers/DocumentValidation/index.tsx b/packages/ui/src/providers/DocumentValidation/index.tsx deleted file mode 100644 index b2d76f77dea..00000000000 --- a/packages/ui/src/providers/DocumentValidation/index.tsx +++ /dev/null @@ -1,219 +0,0 @@ -'use client' - -import type { ValidationFieldError, ValidationResult } from 'payload' - -import { useModal } from '@faceless-ui/modal' -import React, { - createContext, - use, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'react' - -import { ValidationResultsDrawer } from '../../elements/ValidationResultsDrawer/index.js' -import { useForm, useFormFields } from '../../forms/Form/context.js' -import { - getPublishValidationLocales, - getValidationEndpoint, - validateDocumentLocales, -} from '../../utilities/documentValidation.js' -import { traverseForLocalizedFields } from '../../utilities/traverseForLocalizedFields.js' -import { useConfig } from '../Config/index.js' -import { useDocumentInfo } from '../DocumentInfo/index.js' -import { useLocale } from '../Locale/index.js' - -type DocumentValidationContext = { - isValidating: boolean - validateBeforePublish: (args: { isPublishAll: boolean }) => Promise -} - -const Context = createContext({ - isValidating: false, - validateBeforePublish: () => Promise.resolve(true), -}) - -export const useDocumentValidation = (): DocumentValidationContext => use(Context) - -export const DocumentValidationProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const { - config: { - blocksMap, - localization, - routes: { api }, - }, - } = useConfig() - const { id, collectionSlug, docConfig, globalSlug } = useDocumentInfo() - const { code: activeLocale } = useLocale() - const { getData } = useForm() - const formState = useFormFields(([fields]) => fields) - const { closeModal, openModal } = useModal() - - const [isValidating, setIsValidating] = useState(false) - const [result, setResult] = useState(null) - const activeRequestRef = useRef(null) - const drawerID = useId() - const drawerSlug = useMemo( - () => `document-validation-results-${drawerID.replaceAll(':', '')}`, - [drawerID], - ) - - const fields = useMemo(() => docConfig?.fields ?? [], [docConfig?.fields]) - const hasLocalizedFields = useMemo( - () => traverseForLocalizedFields(fields, { blocksMap }), - [blocksMap, fields], - ) - const targetKey = `${collectionSlug ?? ''}:${globalSlug ?? ''}:${id ?? 'create'}` - - const clearResult = useCallback(() => { - activeRequestRef.current?.abort() - activeRequestRef.current = null - - setIsValidating(false) - setResult(null) - closeModal(drawerSlug) - }, [closeModal, drawerSlug]) - - useEffect(() => { - clearResult() - }, [activeLocale, clearResult, formState, targetKey]) - - useEffect(() => { - return () => { - activeRequestRef.current?.abort() - } - }, []) - - const runValidation = useCallback( - async ({ - isPublishing, - locales, - }: { - isPublishing: boolean - locales: string[] - }): Promise => { - if (!localization || !hasLocalizedFields) { - return true - } - - activeRequestRef.current?.abort() - - const abortController = new AbortController() - activeRequestRef.current = abortController - setIsValidating(true) - - try { - const data = { - ...getData(), - ...(isPublishing ? { _status: 'published' } : {}), - } - const validationResult = await validateDocumentLocales({ - activeLocale, - blocksMap, - data, - endpoint: getValidationEndpoint({ - id, - apiRoute: api, - collectionSlug, - globalSlug, - }), - fields, - locales, - signal: abortController.signal, - }) - - if (abortController.signal.aborted) { - return false - } - - if (!validationResult.valid) { - setResult(validationResult) - openModal(drawerSlug) - } - - return validationResult.valid - } catch (error) { - if (abortController.signal.aborted) { - return false - } - - const validationError: ValidationFieldError = { - locale: activeLocale, - message: error instanceof Error ? error.message : String(error), - path: '', - } - const validationResult = { - errors: [validationError], - valid: false, - } - - setResult(validationResult) - openModal(drawerSlug) - - return false - } finally { - if (activeRequestRef.current === abortController) { - activeRequestRef.current = null - setIsValidating(false) - } - } - }, - [ - activeLocale, - api, - blocksMap, - collectionSlug, - fields, - getData, - globalSlug, - hasLocalizedFields, - id, - localization, - openModal, - drawerSlug, - ], - ) - - const validateBeforePublish = useCallback( - ({ isPublishAll }: { isPublishAll: boolean }) => { - if (!localization) { - return Promise.resolve(true) - } - - return runValidation({ - isPublishing: true, - locales: getPublishValidationLocales({ - activeLocale, - isPublishAll, - locales: localization.locales, - }), - }) - }, - [activeLocale, localization, runValidation], - ) - - const value = useMemo( - () => ({ - isValidating, - validateBeforePublish, - }), - [isValidating, validateBeforePublish], - ) - - return ( - - {children} - - - ) -} diff --git a/packages/ui/src/utilities/documentValidation.spec.ts b/packages/ui/src/utilities/documentValidation.spec.ts deleted file mode 100644 index d23abc8e067..00000000000 --- a/packages/ui/src/utilities/documentValidation.spec.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { ClientBlock, ClientField, SanitizedLocale } from 'payload' - -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { - getPublishValidationLocales, - getValidationEndpoint, - projectValidationDataForSiblingLocales, - requestDocumentValidation, - validateDocumentLocales, -} from './documentValidation.js' -import { traverseForLocalizedFields } from './traverseForLocalizedFields.js' - -const locales = [ - { code: 'en', label: 'English', required: true }, - { code: 'de', label: 'German', required: false }, - { code: 'fr', label: 'French', required: true }, -] as SanitizedLocale[] - -describe('document validation', () => { - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('should construct create, update, and global validation endpoints', () => { - expect( - getValidationEndpoint({ - apiRoute: '/api', - collectionSlug: 'posts', - }), - ).toBe('/api/posts/validate') - expect( - getValidationEndpoint({ - apiRoute: '/api', - collectionSlug: 'posts', - id: 'post/id', - }), - ).toBe('/api/posts/post%2Fid/validate') - expect( - getValidationEndpoint({ - apiRoute: '/api', - globalSlug: 'settings', - }), - ).toBe('/api/globals/settings/validate') - }) - - it('should detect localized fields inside referenced blocks', () => { - const fields = [ - { - blocks: ['hero'], - name: 'layout', - type: 'blocks', - }, - ] as ClientField[] - const blocksMap = { - hero: { - fields: [{ localized: true, name: 'heading', type: 'text' }], - slug: 'hero', - }, - } as Record - - expect(traverseForLocalizedFields(fields, { blocksMap })).toBe(true) - }) - - it('should select all locales for publish-all and active plus required locales otherwise', () => { - expect( - getPublishValidationLocales({ - activeLocale: 'de', - isPublishAll: false, - locales, - }), - ).toEqual(['de', 'en', 'fr']) - expect( - getPublishValidationLocales({ - activeLocale: 'de', - isPublishAll: true, - locales, - }), - ).toEqual(['en', 'de', 'fr']) - }) - - it('should remove localized values from nested sibling-locale candidate data', () => { - const fields = [ - { localized: true, name: 'title', type: 'text' }, - { - fields: [ - { localized: true, name: 'strapline', type: 'text' }, - { name: 'theme', type: 'text' }, - ], - name: 'settings', - type: 'group', - }, - { - fields: [ - { localized: true, name: 'caption', type: 'text' }, - { name: 'kind', type: 'text' }, - ], - name: 'items', - type: 'array', - }, - { - blocks: [ - { - fields: [ - { localized: true, name: 'copy', type: 'text' }, - { name: 'style', type: 'text' }, - ], - slug: 'hero', - }, - ], - name: 'layout', - type: 'blocks', - }, - { - tabs: [ - { - fields: [{ name: 'body', type: 'richText' }], - localized: true, - name: 'seo', - }, - ], - type: 'tabs', - }, - { localized: true, name: 'metadata', type: 'json' }, - ] as ClientField[] - - expect( - projectValidationDataForSiblingLocales({ - blocksMap: {}, - data: { - layout: [{ blockType: 'hero', copy: 'Active copy', style: 'dark' }], - items: [{ caption: 'Active caption', kind: 'card' }], - metadata: { title: 'Active metadata' }, - settings: { strapline: 'Active strapline', theme: 'dark' }, - seo: { body: { root: {} } }, - title: 'Active title', - }, - fields, - }), - ).toEqual({ - layout: [{ blockType: 'hero', style: 'dark' }], - items: [{ kind: 'card' }], - settings: { theme: 'dark' }, - }) - }) - - it('should not let valid unsaved active-locale values satisfy invalid stored sibling locales', async () => { - const request = vi.fn(async ({ body, locales: requestedLocales }) => { - if (requestedLocales.includes('de') && 'title' in body) { - return { - errors: [], - valid: true, - } - } - - return { - errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], - valid: false, - } - }) - - const result = await validateDocumentLocales({ - activeLocale: 'de', - blocksMap: {}, - data: { summary: 'Shared summary', title: 'Unsaved German title' }, - endpoint: '/api/posts/123/validate', - fields: [ - { localized: true, name: 'title', type: 'text' }, - { name: 'summary', type: 'text' }, - ], - locales: ['en', 'de', 'fr'], - request, - }) - - expect(request).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - body: { summary: 'Shared summary', title: 'Unsaved German title' }, - locales: ['de'], - }), - ) - expect(request).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - body: { summary: 'Shared summary' }, - locales: ['en', 'fr'], - }), - ) - expect(result).toEqual({ - errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], - valid: false, - }) - }) - - it('should render validation errors returned in a non-2xx Payload error response', async () => { - const fetchMock = vi.fn(async () => { - return Response.json( - { - errors: [ - { - data: { - errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], - }, - message: 'The following field is invalid: title', - name: 'ValidationError', - }, - ], - }, - { status: 400 }, - ) - }) - - vi.stubGlobal('fetch', fetchMock) - - await expect( - requestDocumentValidation({ - body: { title: 'Titre' }, - endpoint: '/api/posts/123/validate', - locales: ['en', 'fr'], - }), - ).resolves.toEqual({ - errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], - valid: false, - }) - expect(fetchMock).toHaveBeenCalledWith( - '/api/posts/123/validate?locale=en&locale=fr', - expect.objectContaining({ - body: JSON.stringify({ title: 'Titre' }), - credentials: 'include', - method: 'POST', - }), - ) - }) -}) diff --git a/packages/ui/src/utilities/documentValidation.ts b/packages/ui/src/utilities/documentValidation.ts deleted file mode 100644 index 3ae7340b50c..00000000000 --- a/packages/ui/src/utilities/documentValidation.ts +++ /dev/null @@ -1,380 +0,0 @@ -import type { - ClientBlock, - ClientField, - Data, - SanitizedLocale, - ValidationFieldError, - ValidationResult, -} from 'payload' - -import { - fieldAffectsData, - fieldShouldBeLocalized, - formatAdminURL, - tabHasName, -} from 'payload/shared' - -export type DocumentValidationRequest = (args: { - body: Data - endpoint: string - locales: string[] - signal?: AbortSignal -}) => Promise - -type ValidationTarget = { - apiRoute: string - collectionSlug?: string - globalSlug?: string - id?: number | string -} - -export function getValidationEndpoint({ - id, - apiRoute, - collectionSlug, - globalSlug, -}: ValidationTarget): string { - if (!collectionSlug && !globalSlug) { - throw new Error('Document validation requires a collection or global slug.') - } - - const encodedID = id === undefined ? '' : `/${encodeURIComponent(String(id))}` - const path: `/${string}` = globalSlug - ? `/globals/${encodeURIComponent(globalSlug)}/validate` - : `/${encodeURIComponent(collectionSlug)}${encodedID}/validate` - - return formatAdminURL({ - apiRoute, - path, - }) -} - -export function getPublishValidationLocales({ - activeLocale, - isPublishAll, - locales, -}: { - activeLocale: string - isPublishAll: boolean - locales: SanitizedLocale[] -}): string[] { - if (isPublishAll) { - return locales.map(({ code }) => code) - } - - return [ - activeLocale, - ...locales.filter(({ required }) => required).map(({ code }) => code), - ].filter((locale, index, selectedLocales) => selectedLocales.indexOf(locale) === index) -} - -export function projectValidationDataForSiblingLocales({ - blocksMap, - data, - fields, -}: { - blocksMap: Record - data: Data - fields: ClientField[] -}): Data { - const projectedData = cloneValidationData(data) - - removeLocalizedData({ - blocksMap, - data: projectedData, - fields, - parentIsLocalized: false, - }) - - return projectedData -} - -export async function validateDocumentLocales({ - activeLocale, - blocksMap, - data, - endpoint, - fields, - locales, - request = requestDocumentValidation, - signal, -}: { - activeLocale: string - blocksMap: Record - data: Data - endpoint: string - fields: ClientField[] - locales: string[] - request?: DocumentValidationRequest - signal?: AbortSignal -}): Promise { - const selectedLocales = locales.filter((locale, index) => locales.indexOf(locale) === index) - const validationResults: ValidationResult[] = [] - - if (selectedLocales.includes(activeLocale)) { - validationResults.push( - await request({ - body: data, - endpoint, - locales: [activeLocale], - signal, - }), - ) - } - - const siblingLocales = selectedLocales.filter((locale) => locale !== activeLocale) - - if (siblingLocales.length > 0) { - validationResults.push( - await request({ - body: projectValidationDataForSiblingLocales({ - blocksMap, - data, - fields, - }), - endpoint, - locales: siblingLocales, - signal, - }), - ) - } - - const errors = validationResults.flatMap(({ errors }) => errors) - - return { - errors, - valid: validationResults.every(({ valid }) => valid), - } -} - -export async function requestDocumentValidation({ - body, - endpoint, - locales, - signal, -}: Parameters[0]): Promise { - const search = new URLSearchParams() - - for (const locale of locales) { - search.append('locale', locale) - } - - const response = await fetch(`${endpoint}?${search.toString()}`, { - body: JSON.stringify(body), - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - signal, - }) - const responseData = (await response.json()) as unknown - const result = parseValidationResult(responseData) - - if (result) { - return result - } - - throw new Error(getResponseErrorMessage(responseData) || response.statusText) -} - -function parseValidationResult(responseData: unknown): null | ValidationResult { - if (!isObject(responseData)) { - return null - } - - if (typeof responseData.valid === 'boolean' && Array.isArray(responseData.errors)) { - return { - errors: responseData.errors.filter(isValidationFieldError), - valid: responseData.valid, - } - } - - if (Array.isArray(responseData.errors)) { - const errors = responseData.errors.flatMap((error) => { - if (!isObject(error) || !isObject(error.data) || !Array.isArray(error.data.errors)) { - return [] - } - - return error.data.errors.filter(isValidationFieldError) - }) - - if (errors.length > 0) { - return { - errors, - valid: false, - } - } - } - - return null -} - -function getResponseErrorMessage(responseData: unknown): null | string { - if (!isObject(responseData)) { - return null - } - - if (typeof responseData.message === 'string') { - return responseData.message - } - - if (Array.isArray(responseData.errors)) { - const errorWithMessage = responseData.errors.find( - (error) => isObject(error) && typeof error.message === 'string', - ) - - if (isObject(errorWithMessage) && typeof errorWithMessage.message === 'string') { - return errorWithMessage.message - } - } - - return null -} - -function isValidationFieldError(value: unknown): value is ValidationFieldError { - return ( - isObject(value) && - typeof value.message === 'string' && - typeof value.path === 'string' && - (value.locale === undefined || typeof value.locale === 'string') - ) -} - -function cloneValidationData(value: T): T { - if (Array.isArray(value)) { - return value.map(cloneValidationData) as T - } - - if (isObject(value)) { - return Object.fromEntries( - Object.entries(value).map(([key, childValue]) => [key, cloneValidationData(childValue)]), - ) as T - } - - return value -} - -function removeLocalizedData({ - blocksMap, - data, - fields, - parentIsLocalized, -}: { - blocksMap: Record - data: Data - fields: ClientField[] - parentIsLocalized: boolean -}): void { - for (const field of fields) { - if (fieldAffectsData(field)) { - if (parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized })) { - delete data[field.name] - continue - } - - const fieldValue = data[field.name] - - switch (field.type) { - case 'array': { - if (Array.isArray(fieldValue)) { - for (const row of fieldValue) { - if (isObject(row)) { - removeLocalizedData({ - blocksMap, - data: row, - fields: field.fields, - parentIsLocalized: false, - }) - } - } - } - break - } - - case 'blocks': { - if (Array.isArray(fieldValue)) { - for (const row of fieldValue) { - if (!isObject(row) || typeof row.blockType !== 'string') { - continue - } - - const blockOrSlug = field.blocks.find((block) => { - return (typeof block === 'string' ? block : block.slug) === row.blockType - }) - const block = typeof blockOrSlug === 'string' ? blocksMap[blockOrSlug] : blockOrSlug - - if (block) { - removeLocalizedData({ - blocksMap, - data: row, - fields: block.fields, - parentIsLocalized: false, - }) - } - } - } - break - } - - case 'group': { - if (isObject(fieldValue)) { - removeLocalizedData({ - blocksMap, - data: fieldValue, - fields: field.fields, - parentIsLocalized: false, - }) - } - break - } - } - } else { - switch (field.type) { - case 'collapsible': - case 'group': - case 'row': { - const isLocalized = - parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized }) - - removeLocalizedData({ - blocksMap, - data, - fields: field.fields, - parentIsLocalized: isLocalized, - }) - break - } - - case 'tabs': { - for (const tab of field.tabs) { - if (tabHasName(tab)) { - if (parentIsLocalized || fieldShouldBeLocalized({ field: tab, parentIsLocalized })) { - delete data[tab.name] - } else if (isObject(data[tab.name])) { - removeLocalizedData({ - blocksMap, - data: data[tab.name], - fields: tab.fields, - parentIsLocalized: false, - }) - } - } else { - removeLocalizedData({ - blocksMap, - data, - fields: tab.fields, - parentIsLocalized, - }) - } - } - break - } - } - } - } -} - -function isObject(value: unknown): value is Data { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} diff --git a/test/validate/config.ts b/test/validate/config.ts index 6a4f46794fb..933c92e2ad8 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -6,7 +6,7 @@ import type { } from 'payload' import path from 'path' -import { saveVersion, ValidationError } from 'payload' +import { logoutOperation, refreshOperation, saveVersion, ValidationError } from 'payload' import { fileURLToPath } from 'url' // Direct internal import intentionally exercises the upload write guard. @@ -228,11 +228,34 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re }) break + case 'logout': + await logoutOperation({ + collection: req.payload.collections['users']!, + req, + }) + break + + case 'refresh': + await refreshOperation({ + collection: req.payload.collections['users']!, + req, + }) + break + + case 'resetPassword': + await req.payload.resetPassword({ + collection: 'users', + data: { password: 'must-not-be-set', token: 'any-token' }, + overrideAccess: true, + req, + }) + break + case 'restoreGlobalVersion': await req.payload.restoreGlobalVersion({ id: targetID!, - req, slug: validationWriteTargetGlobalSlug, + req, }) break @@ -297,6 +320,14 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re break } + case 'verifyEmail': + await req.payload.verifyEmail({ + collection: 'users', + req, + token: 'any-token', + }) + break + case 'version': await saveVersion({ id: targetID, @@ -430,12 +461,16 @@ const validationCollection: CollectionConfig = { 'create', 'delete', 'deleteMany', + 'logout', + 'refresh', + 'resetPassword', 'restoreGlobalVersion', 'restoreVersion', 'update', 'updateGlobal', 'updateMany', 'upload', + 'verifyEmail', 'version', ], }, @@ -1076,21 +1111,9 @@ const publishCollection: CollectionConfig = { }, ], hooks: { - beforeChange: [ - ({ data, operation, req }) => { - if (req.context.promoteDraftToPublished === true && operation !== 'validate') { - return { - ...data, - _status: 'published', - } - } - - return data - }, - ], beforeValidate: [ - ({ data, operation, req }) => { - if (operation === 'validate' && data?.title === 'throw scheduled validation error') { + ({ data, req }) => { + if (data?._status === 'published' && data?.title === 'throw scheduled validation error') { throw new ValidationError( { errors: [ @@ -1106,7 +1129,7 @@ const publishCollection: CollectionConfig = { ) } - if (operation === 'validate' && data?.title === 'throw transient scheduled error') { + if (data?._status === 'published' && data?.title === 'throw transient scheduled error') { throw new Error('transient scheduled validation error') } @@ -1142,20 +1165,6 @@ const publishGlobal: GlobalConfig = { required: true, }, ], - hooks: { - beforeChange: [ - ({ data, operation, req }) => { - if (req.context.promoteDraftToPublished === true && operation !== 'validate') { - return { - ...data, - _status: 'published', - } - } - - return data - }, - ], - }, versions: { drafts: { schedulePublish: true, @@ -1346,10 +1355,10 @@ export default buildConfigWithDefaults({ { code: 'es', label: 'Spanish', - required: true, }, { code: 'de', + fallbackLocale: 'en', label: 'German', }, { diff --git a/test/validate/e2e.spec.ts b/test/validate/e2e.spec.ts index f70d865fc56..aec00f7da66 100644 --- a/test/validate/e2e.spec.ts +++ b/test/validate/e2e.spec.ts @@ -46,33 +46,18 @@ test.describe('Admin document validation', () => { createdIDs.length = 0 }) - test('should block publish-all for an invalid optional locale but allow normal publish', async () => { + test('should report a blocked publish-all in a toast, but allow a normal publish of the current locale', async () => { const id = await createDraft({ spanishTitle: 'Título en español' }) await openDraft(id) await page.locator('#action-save-popup').click() await page.locator('#publish-all-locales').click() - await expect(page.locator('.validation-results').getByRole('alert')).toBeVisible() - await expect(page.locator('.validation-results')).toContainText('German') + await expect(page.locator('.payload-toast-container')).toContainText('[de] Title') - await page.locator('.validation-results .drawer__header__close').click() await page.locator('#action-save').click() await expect(page.locator('.payload-toast-container')).toContainText('successfully') - await expect(page.locator('.validation-results')).toHaveCount(0) - }) - - test('should block normal publish when a required locale is invalid', async () => { - const id = await createDraft({}) - - await openDraft(id) - await page.locator('#action-save').click() - - const result = page.locator('.validation-results') - await expect(result.getByRole('alert')).toBeVisible() - await expect(result).toContainText('Spanish') - await expect(result).toContainText('Required') }) async function createDraft({ diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 839ca6f314d..37bf5e5606a 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -1,6 +1,7 @@ import type { Payload, PayloadRequest } from 'payload' import { buildEditorState } from '@payloadcms/richtext-lexical' +import { randomUUID } from 'crypto' import fs from 'fs/promises' import path from 'path' import { createLocalReq, getFileByPath } from 'payload' @@ -210,8 +211,8 @@ describe('validate Local API', () => { it('should let an explicit null user override an authenticated reused collection request', async () => { const req = { user: { - collection: validationCollectionSlug, id: 'authenticated-user', + collection: validationCollectionSlug, } as never, } satisfies Partial @@ -540,15 +541,6 @@ describe('validate Local API', () => { expect(req.operation).toBe('update') }) - it('should expose required localization metadata after config sanitization', () => { - expect(payload.config.localization && payload.config.localization.locales).toMatchObject([ - { code: 'en', required: false }, - { code: 'es', required: true }, - { code: 'de', required: false }, - { code: 'fr', required: false }, - ]) - }) - it('should validate explicit locales and tag only the invalid locale', async () => { const stored = await payload.create({ collection: validationCollectionSlug, @@ -575,6 +567,33 @@ describe('validate Local API', () => { }) }) + it('should not honor fallbackLocale when the checked locale has no value of its own', async () => { + const stored = await payload.create({ + collection: validationCollectionSlug, + data: { + summary: 'stored summary', + title: 'English title', + }, + locale: 'en', + }) + + const result = await payload.validate({ + id: stored.id, + collection: validationCollectionSlug, + locale: 'de', + }) + + expect(result).toMatchObject({ + errors: [ + { + locale: 'de', + path: 'title', + }, + ], + valid: false, + }) + }) + it('should ignore internal projection flags passed to the public collection validate API', async () => { const stored = await payload.create({ collection: validationCollectionSlug, @@ -607,7 +626,7 @@ describe('validate Local API', () => { de: 'German optional', deletedAt: new Date().toISOString(), en: 'English draft', - es: 'Spanish required', + es: 'Spanish valid', }) await expect( @@ -967,7 +986,7 @@ describe('validate Local API', () => { const draft = await seedPublishCollection({ de: 'German optional', en: 'English draft', - es: 'Spanish required', + es: 'Spanish valid', }) await payload.update({ @@ -1174,8 +1193,8 @@ describe('validate Local API', () => { it('should let an explicit null user override an authenticated reused global request', async () => { const req = { user: { - collection: validationCollectionSlug, id: 'authenticated-user', + collection: validationCollectionSlug, } as never, } satisfies Partial @@ -1563,7 +1582,7 @@ describe('validate Local API', () => { await seedPublishGlobal({ de: 'German optional', en: 'English draft', - es: 'Spanish required', + es: 'Spanish valid', }) await payload.updateGlobal({ slug: publishGlobalSlug, @@ -1730,6 +1749,24 @@ describe('validate Local API', () => { expect(response.status).toBe(403) }) + it('should return 404 for a nonexistent collection document', async () => { + const stored = await payload.create({ + collection: validationCollectionSlug, + data: { + summary: 'stored summary', + title: 'English title', + }, + locale: 'en', + }) + const nonexistentID = typeof stored.id === 'number' ? stored.id + 1000 : randomUUID() + + const response = await restClient.POST( + `/${validationCollectionSlug}/${nonexistentID}/validate?locale=en`, + ) + + expect(response.status).toBe(404) + }) + it('should return invalid collection create validation without creating a document', async () => { const response = await restClient.POST(`/${validationCollectionSlug}/validate?locale=en`, { body: JSON.stringify({ @@ -1838,7 +1875,7 @@ describe('validate Local API', () => { const draft = await seedPublishCollection({ de: 'German optional', en: 'English draft', - es: 'Spanish required', + es: 'Spanish valid', }) await payload.update({ @@ -1880,7 +1917,7 @@ describe('validate Local API', () => { await seedPublishGlobal({ de: 'German optional', en: 'English draft', - es: 'Spanish required', + es: 'Spanish valid', }) await payload.updateGlobal({ slug: publishGlobalSlug, @@ -2361,6 +2398,46 @@ describe('validate Local API', () => { }) expect(versionsAfter).toEqual(versionsBefore) }) + + it('should reject a logout that reuses the validation request before a session is removed', async () => { + const usersBefore = await payload.count({ collection: 'users' }) + + await expect(runWriteAttempt('logout')).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + expect(await payload.count({ collection: 'users' })).toEqual(usersBefore) + }) + + it('should reject a refresh that reuses the validation request before a session is written', async () => { + const usersBefore = await payload.count({ collection: 'users' }) + + await expect(runWriteAttempt('refresh')).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + expect(await payload.count({ collection: 'users' })).toEqual(usersBefore) + }) + + it('should reject a reset password that reuses the validation request before a password is written', async () => { + const usersBefore = await payload.count({ collection: 'users' }) + + await expect(runWriteAttempt('resetPassword')).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + expect(await payload.count({ collection: 'users' })).toEqual(usersBefore) + }) + + it('should reject a verify email that reuses the validation request before a user is written', async () => { + const usersBefore = await payload.count({ collection: 'users' }) + + await expect(runWriteAttempt('verifyEmail')).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + expect(await payload.count({ collection: 'users' })).toEqual(usersBefore) + }) }) describe('publish enforcement', () => { @@ -2417,6 +2494,7 @@ describe('validate Local API', () => { }, locale: 'en', overrideAccess: false, + publishAllLocales: true, }), ).rejects.toMatchObject({ data: { @@ -2521,6 +2599,7 @@ describe('validate Local API', () => { }, file: replacementFile, locale: 'en', + publishAllLocales: true, }), ).rejects.toMatchObject({ data: { @@ -2566,120 +2645,6 @@ describe('validate Local API', () => { } }) - it('should block collection create when a hook promotes a draft to published', async () => { - await expect( - payload.create({ - collection: publishCollectionSlug, - context: { - promoteDraftToPublished: true, - }, - data: { - ...getPublishCollectionLocaleData({ title: 'English candidate' }), - _status: 'draft', - }, - locale: 'en', - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'es', - path: 'title', - }), - ]), - }, - }) - - const documents = await payload.find({ - collection: publishCollectionSlug, - draft: true, - limit: 1, - locale: 'all', - }) - - expect(documents.totalDocs).toBe(0) - }) - - it('should block collection update when a hook promotes a draft to published', async () => { - const draft = await seedPublishCollection({ - de: 'German optional', - en: 'English draft', - es: '', - }) - - await expect( - payload.update({ - id: draft.id, - collection: publishCollectionSlug, - context: { - promoteDraftToPublished: true, - }, - data: { - _status: 'draft', - title: 'English candidate', - }, - locale: 'en', - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'es', - path: 'title', - }), - ]), - }, - }) - - const latestDraft = await payload.findByID({ - id: draft.id, - collection: publishCollectionSlug, - draft: true, - locale: 'all', - }) - - expect(latestDraft._status.en).toBe('draft') - }) - - it('should block global update when a hook promotes a draft to published', async () => { - await seedPublishGlobal({ - de: 'German optional', - en: 'English draft', - es: '', - }) - - await expect( - payload.updateGlobal({ - slug: publishGlobalSlug, - context: { - promoteDraftToPublished: true, - }, - data: { - _status: 'draft', - title: 'English candidate', - }, - locale: 'en', - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'es', - path: 'title', - }), - ]), - }, - }) - - const latestDraft = await payload.findGlobal({ - slug: publishGlobalSlug, - draft: true, - locale: 'all', - }) - - expect(latestDraft._status.en).toBe('draft') - }) - it('should block collection create publish-all when status is draft without persisting', async () => { await expect( payload.create({ @@ -2746,7 +2711,7 @@ describe('validate Local API', () => { const draft = await seedPublishCollection({ de: '', en: 'English draft', - es: 'Spanish required', + es: 'Spanish valid', }) await expect( @@ -2789,7 +2754,7 @@ describe('validate Local API', () => { const draft = await seedPublishCollection({ de: '', en: 'English draft', - es: 'Spanish required', + es: 'Spanish valid', }) await expect( @@ -2832,7 +2797,7 @@ describe('validate Local API', () => { await seedPublishGlobal({ de: '', en: 'English draft', - es: 'Spanish required', + es: 'Spanish valid', }) await expect( @@ -2873,7 +2838,7 @@ describe('validate Local API', () => { await seedPublishGlobal({ de: '', en: 'English draft', - es: 'Spanish required', + es: 'Spanish valid', }) await expect( @@ -2915,7 +2880,7 @@ describe('validate Local API', () => { de: '', deletedAt: new Date().toISOString(), en: 'English draft', - es: 'Spanish required', + es: 'Spanish valid', }) await expect( @@ -2956,7 +2921,7 @@ describe('validate Local API', () => { }) }) - it('should not apply flat localized object candidates to a required sibling locale', async () => { + it('should not apply flat localized object candidates to a sibling locale during publish-all', async () => { const omittedFields: PublishCollectionLocalizedField[] = [ 'localizedArray', 'localizedBlocks', @@ -2969,7 +2934,7 @@ describe('validate Local API', () => { const draft = await seedPublishCollection({ de: 'German optional', en: 'English draft', - es: 'Spanish required', + es: 'Spanish sibling', omit: { es: omittedFields, }, @@ -2984,6 +2949,7 @@ describe('validate Local API', () => { _status: 'published', }, locale: 'en', + publishAllLocales: true, }), ).rejects.toMatchObject({ data: { @@ -3007,11 +2973,11 @@ describe('validate Local API', () => { }) }) - it('should not apply a flat localized JSON candidate to a required global sibling locale', async () => { + it('should not apply a flat localized JSON candidate to a global sibling locale during publish-all', async () => { await seedPublishGlobal({ de: 'German optional', en: 'English draft', - es: 'Spanish required', + es: 'Spanish sibling', omitLocalizedJSON: { es: true, }, @@ -3025,6 +2991,7 @@ describe('validate Local API', () => { _status: 'published', }, locale: 'en', + publishAllLocales: true, }), ).rejects.toMatchObject({ data: { @@ -3038,7 +3005,7 @@ describe('validate Local API', () => { }) }) - it('should bypass required-locale publish validation when trashing a draft', async () => { + it('should allow trashing a draft even when a sibling locale is invalid', async () => { const draft = await seedPublishCollection({ de: 'German optional', en: 'English draft', @@ -3060,7 +3027,7 @@ describe('validate Local API', () => { }) }) - it('should bypass required-locale publish validation when restoring a trashed draft', async () => { + it('should allow restoring a trashed draft even when a sibling locale is invalid', async () => { const draft = await seedPublishCollection({ de: 'German optional', deletedAt: new Date().toISOString(), @@ -3083,7 +3050,7 @@ describe('validate Local API', () => { }) }) - it('should bypass required-locale publish validation for collection unpublish metadata', async () => { + it('should allow collection unpublish metadata even when a sibling locale is invalid', async () => { const draft = await seedPublishCollection({ de: 'German optional', en: 'English draft', @@ -3104,7 +3071,7 @@ describe('validate Local API', () => { }) }) - it('should bypass required-locale publish validation for global unpublish metadata', async () => { + it('should allow global unpublish metadata even when a sibling locale is invalid', async () => { await seedPublishGlobal({ de: 'German optional', en: 'English draft', @@ -3124,67 +3091,12 @@ describe('validate Local API', () => { }) }) - it('should block collection publish when a required locale is invalid without changing status', async () => { + it('should publish a single locale even when every other locale is invalid', async () => { const draft = await seedPublishCollection({ de: '', en: 'English draft', es: '', }) - const versionsBefore = await payload.countVersions({ - collection: publishCollectionSlug, - where: { - parent: { - equals: draft.id, - }, - }, - }) - - await expect( - payload.update({ - id: draft.id, - collection: publishCollectionSlug, - data: { - _status: 'published', - title: 'English published', - }, - locale: 'en', - }), - ).rejects.toMatchObject({ - data: { - errors: [ - { - locale: 'es', - path: 'title', - }, - ], - }, - }) - - const latestDraft = await payload.findByID({ - id: draft.id, - collection: publishCollectionSlug, - draft: true, - locale: 'all', - }) - const versionsAfter = await payload.countVersions({ - collection: publishCollectionSlug, - where: { - parent: { - equals: draft.id, - }, - }, - }) - - expect(latestDraft._status.en).toBe('draft') - expect(versionsAfter).toEqual(versionsBefore) - }) - - it('should allow collection publish when only an optional non-current locale is invalid', async () => { - const draft = await seedPublishCollection({ - de: '', - en: 'English draft', - es: 'Spanish required', - }) await payload.update({ id: draft.id, @@ -3206,11 +3118,11 @@ describe('validate Local API', () => { expect(published._status.en).toBe('published') }) - it('should block collection publish-all when an optional locale is invalid', async () => { + it('should block collection publish-all when a locale is invalid', async () => { const draft = await seedPublishCollection({ de: '', en: 'English draft', - es: 'Spanish required', + es: 'Spanish sibling', }) await expect( @@ -3245,55 +3157,12 @@ describe('validate Local API', () => { expect(latestDraft._status.en).toBe('draft') }) - it('should block global publish when a required locale is invalid without changing status', async () => { + it('should publish a single global locale even when every other locale is invalid', async () => { await seedPublishGlobal({ de: '', en: 'English draft', es: '', }) - const versionsBefore = await payload.countGlobalVersions({ - global: publishGlobalSlug, - }) - - await expect( - payload.updateGlobal({ - slug: publishGlobalSlug, - data: { - _status: 'published', - title: 'English published', - }, - locale: 'en', - }), - ).rejects.toMatchObject({ - data: { - errors: [ - { - locale: 'es', - path: 'title', - }, - ], - }, - }) - - const latestDraft = await payload.findGlobal({ - slug: publishGlobalSlug, - draft: true, - locale: 'all', - }) - const versionsAfter = await payload.countGlobalVersions({ - global: publishGlobalSlug, - }) - - expect(latestDraft._status.en).toBe('draft') - expect(versionsAfter).toEqual(versionsBefore) - }) - - it('should allow global publish when only an optional non-current locale is invalid', async () => { - await seedPublishGlobal({ - de: '', - en: 'English draft', - es: 'Spanish required', - }) await payload.updateGlobal({ slug: publishGlobalSlug, @@ -3313,11 +3182,11 @@ describe('validate Local API', () => { expect(published._status.en).toBe('published') }) - it('should block global publish-all when an optional locale is invalid', async () => { + it('should block global publish-all when a locale is invalid', async () => { await seedPublishGlobal({ de: '', en: 'English draft', - es: 'Spanish required', + es: 'Spanish sibling', }) await expect( @@ -3355,7 +3224,7 @@ describe('validate Local API', () => { const draft = await seedPublishCollection({ de: 'German initially valid', en: 'private English scheduled value', - es: 'Spanish required', + es: 'Spanish valid', fr: 'French optional', }) const job = await payload.jobs.queue({ @@ -3416,7 +3285,7 @@ describe('validate Local API', () => { const draft = await seedPublishCollection({ de: 'German optional', en: 'English scheduled', - es: 'Spanish required', + es: 'Spanish valid', fr: 'French optional', }) const job = await payload.jobs.queue({ @@ -3452,11 +3321,11 @@ describe('validate Local API', () => { }) }) - it('should validate the current and required collection locales but skip optional siblings', async () => { + it('should validate only the current locale and skip every sibling during a single-locale scheduled publish', async () => { const draft = await seedPublishCollection({ de: '', en: 'English scheduled', - es: 'Spanish required', + es: '', fr: '', }) const job = await payload.jobs.queue({ @@ -3487,41 +3356,11 @@ describe('validate Local API', () => { }) }) - it('should cancel a current-locale collection job when a required locale is invalid', async () => { - const draft = await seedPublishCollection({ - de: 'German optional', - en: 'English scheduled', - es: '', - }) - const job = await payload.jobs.queue({ - input: { - doc: { - relationTo: publishCollectionSlug, - value: draft.id.toString(), - }, - locale: 'en', - }, - task: 'schedulePublish', - }) - - await payload.jobs.run({ silent: true }) - - const failedJob = await payload.findByID({ - id: job.id, - collection: 'payload-jobs', - depth: 0, - }) - const error = failedJob.error as { cancelled?: boolean; message?: string } - - expect(error.cancelled).toBe(true) - expect(error.message).toContain('[es] title') - }) - it('should cancel invalid global publish-all jobs', async () => { await seedPublishGlobal({ de: 'German initially valid', en: 'private English global value', - es: 'Spanish required', + es: 'Spanish valid', fr: 'French optional', }) const job = await payload.jobs.queue({ @@ -3566,7 +3405,7 @@ describe('validate Local API', () => { await seedPublishGlobal({ de: 'German optional', en: 'English scheduled', - es: 'Spanish required', + es: 'Spanish valid', fr: 'French optional', }) const job = await payload.jobs.queue({ @@ -3661,11 +3500,11 @@ describe('validate Local API', () => { expect(scheduledValidationEvents).toEqual([]) }) - it('should validate the current and required global locales but skip optional siblings', async () => { + it('should validate only the current locale and skip every sibling during a single-locale global scheduled publish', async () => { await seedPublishGlobal({ de: '', en: 'English scheduled', - es: 'Spanish required', + es: '', fr: '', }) const job = await payload.jobs.queue({ @@ -3692,38 +3531,11 @@ describe('validate Local API', () => { }) }) - it('should cancel a current-locale global job when a required locale is invalid', async () => { - await seedPublishGlobal({ - de: 'German optional', - en: 'English scheduled', - es: '', - }) - const job = await payload.jobs.queue({ - input: { - global: publishGlobalSlug, - locale: 'en', - }, - task: 'schedulePublish', - }) - - await payload.jobs.run({ silent: true }) - - const failedJob = await payload.findByID({ - id: job.id, - collection: 'payload-jobs', - depth: 0, - }) - const error = failedJob.error as { cancelled?: boolean; message?: string } - - expect(error.cancelled).toBe(true) - expect(error.message).toContain('[es] title') - }) - it('should cancel validation errors thrown by scheduled validation hooks without retrying', async () => { const draft = await seedPublishCollection({ de: 'German optional', en: 'throw scheduled validation error', - es: 'Spanish required', + es: 'Spanish valid', }) const job = await payload.jobs.queue({ input: { @@ -3771,7 +3583,7 @@ describe('validate Local API', () => { const draft = await seedPublishCollection({ de: 'German optional', en: 'throw transient scheduled error', - es: 'Spanish required', + es: 'Spanish valid', }) const job = await payload.jobs.queue({ input: { @@ -3815,12 +3627,16 @@ async function runWriteAttempt( | 'create' | 'delete' | 'deleteMany' + | 'logout' + | 'refresh' + | 'resetPassword' | 'restoreGlobalVersion' | 'restoreVersion' | 'update' | 'updateGlobal' | 'updateMany' | 'upload' + | 'verifyEmail' | 'version', targetID?: number | string, ) { From 18ce62dfbc48a2e33438749090ed4b9597612c4c Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 19 Aug 2026 21:01:53 +0100 Subject: [PATCH 32/47] updates --- docs/migration-guide/v4.mdx | 18 ---- packages/codemod/README.md | 1 - packages/codemod/src/registry.ts | 2 - .../basic.input.ts | 10 --- .../basic.output.ts | 9 -- .../index.spec.ts | 58 ------------ .../index.ts | 36 -------- .../non-matching.input.ts | 9 -- .../value-all.input.ts | 10 --- packages/payload/src/auth/operations/login.ts | 3 + .../collections/operations/local/validate.ts | 67 +++----------- packages/payload/src/config/client.ts | 5 ++ packages/payload/src/config/types.ts | 8 ++ .../src/globals/operations/local/validate.ts | 67 +++----------- packages/payload/src/queues/localAPI.ts | 3 + .../payload/src/queues/utilities/updateJob.ts | 3 + .../utilities/parseValidationLocale.spec.ts | 15 ---- .../src/utilities/parseValidationLocale.ts | 10 --- .../src/utilities/projectNonLocalizedData.ts | 6 +- .../utilities/runLocaleScopedValidation.ts | 89 +++++++++++++++++++ .../ui/src/elements/PublishButton/index.tsx | 28 ++++-- test/admin/config.ts | 1 + test/admin/e2e/document-view/e2e.spec.ts | 4 +- test/localization/e2e.spec.ts | 16 ++-- test/validate/config.ts | 26 ++++++ test/validate/int.spec.ts | 38 ++++++++ 26 files changed, 236 insertions(+), 306 deletions(-) delete mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/basic.input.ts delete mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/basic.output.ts delete mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/index.spec.ts delete mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/index.ts delete mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/non-matching.input.ts delete mode 100644 packages/codemod/src/transforms/remove-default-locale-publish-option/value-all.input.ts create mode 100644 packages/payload/src/utilities/runLocaleScopedValidation.ts diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index 2662ef66f65..cd465b22977 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1650,21 +1650,3 @@ The Admin UI styles were migrated from SCSS to plain CSS. The `@payloadcms/ui/sc The `--theme-elevation-*` scale was removed. Use the semantic `--color-*` tokens (e.g. `--color-bg`, `--color-text`, `--color-border`, `--color-icon`) or the raw `--ramp-*` palette instead. See [Customizing CSS](../admin/customizing-css) for the full list of available tokens. Any custom component `.scss` files you authored should be renamed to `.css` and updated to plain CSS syntax — Payload no longer ships a SCSS toolchain. - -### Admin Publish button now defaults to the active locale - -`localization.defaultLocalePublishOption` was removed. For collections and globals with localized fields, the main Publish button now always publishes only the currently active locale. "Publish all locales" is available via the secondary button next to it. - -Previously, the button defaulted to publishing every locale unless `defaultLocalePublishOption: 'active'` was set, with "Publish in {locale}" as the secondary option. That split is gone — publishing a single locale is now the primary action for every project, and there's no config to change it. - -```diff - localization: { - defaultLocale: 'en', -- defaultLocalePublishOption: 'active', - locales: [...], - }, -``` - -Run `npx @payloadcms/codemod --transform remove-default-locale-publish-option` to migrate automatically. - -If you had `defaultLocalePublishOption: 'all'` (or left it unset) and relied on the Publish button publishing every locale by default, click the secondary "Publish all locales" button instead, or call `payload.update()`/`payload.updateGlobal()` with `publishAllLocales: true` directly. diff --git a/packages/codemod/README.md b/packages/codemod/README.md index ca7028abfb1..5c5cd27e6cc 100644 --- a/packages/codemod/README.md +++ b/packages/codemod/README.md @@ -45,7 +45,6 @@ The tool loads your project via [ts-morph](https://ts-morph.com/), using your `t - `migrate-versions-default` — adds `versions: false` to every `CollectionConfig` or `GlobalConfig` object that does not already have a `versions` property. Preserves the previous opt-in behaviour now that `versions` defaults to `true` for both collections and globals. Detects the three common annotation forms: `: CollectionConfig`, `satisfies GlobalConfig`, and `as CollectionConfig`. - `remove-versions-true` — removes the now-redundant `versions: true` property from `CollectionConfig` and `GlobalConfig` objects. Only removes the bare boolean `true`; object-form configs (e.g. `versions: { drafts: true }`) are left untouched. - `remove-group-by-true` — removes `admin.groupBy` from `CollectionConfig` objects. The experimental `groupBy` flag has been removed; groupBy is now an always-available per-user UI preference. -- `remove-default-locale-publish-option` — removes `defaultLocalePublishOption` from the root `localization` config. The admin Publish button now always defaults to the active locale, with "Publish all locales" available via the secondary button; the option no longer has any effect. - `rename-typescript-schema-to-json-schema` — renames the `typescriptSchema` field-config property to `jsonSchema` (it always accepted JSON Schema, not TypeScript). Skips any object that already defines a `jsonSchema` sibling and surfaces it as a note for manual review. - `migrate-build-script` — rewrites the `build` npm script in `package.json` from `next build` to `payload build`, so the Import Map (and types) are generated before the Next.js build. Matches the `next build` invocation only (leaves `next build-storybook` and the like untouched) and is a no-op when `build` is already `payload build`. - `migrate-slug-field` — converts the removed experimental `slugField()` helper (imported from `payload`) into the native `{ type: 'slug' }` field, mapping `useAsSlug`/`fieldToUse`, `slugify`, `required`, `localized`, `disableUnique` (→ `unique: false`), and `position` (→ `admin.position`), and dropping the obsolete `checkboxName`. Removes the now-unused `slugField` import. Calls using `overrides` (or other unrecognized options) are left in place with a note for manual migration. diff --git a/packages/codemod/src/registry.ts b/packages/codemod/src/registry.ts index 32541d0e93c..13b0eeaa818 100644 --- a/packages/codemod/src/registry.ts +++ b/packages/codemod/src/registry.ts @@ -19,7 +19,6 @@ import { migrateNextSubpathExports } from './transforms/migrate-next-subpath-exp import { migrateSlugField } from './transforms/migrate-slug-field/index.js' import { migrateStorageAdaptersToConfig } from './transforms/migrate-storage-adapters-to-config/index.js' import { migrateVersionsDefault } from './transforms/migrate-versions-default/index.js' -import { removeDefaultLocalePublishOption } from './transforms/remove-default-locale-publish-option/index.js' import { removeGroupByTrue } from './transforms/remove-group-by-true/index.js' import { removeLocalizeStatusConfig } from './transforms/remove-localize-status-config/index.js' import { removePublishSpecificLocale } from './transforms/remove-publish-specific-locale/index.js' @@ -51,7 +50,6 @@ export const transforms: Transform[] = [ migrateVersionsDefault, removeGroupByTrue, removeLocalizeStatusConfig, - removeDefaultLocalePublishOption, removeVersionsTrue, removePublishSpecificLocale, renameTypescriptSchemaToJsonSchema, diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.input.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.input.ts deleted file mode 100644 index 968bd8b4dc8..00000000000 --- a/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.input.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { buildConfig } from 'payload' - -export default buildConfig({ - localization: { - defaultLocale: 'en', - defaultLocalePublishOption: 'active', - locales: [{ code: 'en' }, { code: 'es' }], - }, - collections: [], -}) diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.output.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.output.ts deleted file mode 100644 index b5e73f60418..00000000000 --- a/packages/codemod/src/transforms/remove-default-locale-publish-option/basic.output.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { buildConfig } from 'payload' - -export default buildConfig({ - localization: { - defaultLocale: 'en', - locales: [{ code: 'en' }, { code: 'es' }], - }, - collections: [], -}) diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/index.spec.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/index.spec.ts deleted file mode 100644 index fcb201ea8ae..00000000000 --- a/packages/codemod/src/transforms/remove-default-locale-publish-option/index.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -import { runTransform } from '../../utils/test-helpers.js' -import { removeDefaultLocalePublishOption } from './index.js' - -const here = dirname(fileURLToPath(import.meta.url)) -const fixture = (name: string) => readFile(join(here, name), 'utf8') - -describe('remove-default-locale-publish-option', () => { - it("removes defaultLocalePublishOption: 'active'", async () => { - const input = await fixture('basic.input.ts') - const output = await fixture('basic.output.ts') - - const result = await runTransform({ - source: input, - transform: removeDefaultLocalePublishOption, - }) - - expect(result).toBe(output) - }) - - it("removes defaultLocalePublishOption: 'all'", async () => { - const input = await fixture('value-all.input.ts') - const output = await fixture('basic.output.ts') - - const result = await runTransform({ - source: input, - transform: removeDefaultLocalePublishOption, - }) - - expect(result).toBe(output) - }) - - it('is idempotent', async () => { - const output = await fixture('basic.output.ts') - - const result = await runTransform({ - source: output, - transform: removeDefaultLocalePublishOption, - }) - - expect(result).toBe(output) - }) - - it('is a no-op when the property is not present', async () => { - const input = await fixture('non-matching.input.ts') - - const result = await runTransform({ - source: input, - transform: removeDefaultLocalePublishOption, - }) - - expect(result).toBe(input) - }) -}) diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/index.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/index.ts deleted file mode 100644 index 0f14a22d21f..00000000000 --- a/packages/codemod/src/transforms/remove-default-locale-publish-option/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Transform } from '../../types.js' - -/** - * Removes `defaultLocalePublishOption` from the root `localization` config. - * - * The admin Publish button now always defaults to publishing the active locale; - * "Publish all locales" is available via the secondary button. The config - * property has no effect and is no longer typed. - */ -export const removeDefaultLocalePublishOption: Transform = { - name: 'remove-default-locale-publish-option', - apply: ({ project }) => { - const filesChanged = new Set() - - for (const sourceFile of project.getSourceFiles()) { - let text = sourceFile.getFullText() - const original = text - - // Remove `defaultLocalePublishOption: 'active',` / `defaultLocalePublishOption: 'all',` - // (single or double quotes, with or without a trailing comma). - text = text.replace( - /^[^\S\n]*defaultLocalePublishOption:\s*['"](?:active|all)['"],?\s*\n/gm, - '', - ) - - if (text !== original) { - sourceFile.replaceWithText(text) - filesChanged.add(sourceFile.getFilePath()) - } - } - - return { filesChanged: [...filesChanged] } - }, - description: - "Remove `defaultLocalePublishOption` from the root localization config. The admin Publish button now always defaults to the active locale, with 'Publish all locales' available via the secondary button; the option no longer has any effect.", -} diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/non-matching.input.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/non-matching.input.ts deleted file mode 100644 index b5e73f60418..00000000000 --- a/packages/codemod/src/transforms/remove-default-locale-publish-option/non-matching.input.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { buildConfig } from 'payload' - -export default buildConfig({ - localization: { - defaultLocale: 'en', - locales: [{ code: 'en' }, { code: 'es' }], - }, - collections: [], -}) diff --git a/packages/codemod/src/transforms/remove-default-locale-publish-option/value-all.input.ts b/packages/codemod/src/transforms/remove-default-locale-publish-option/value-all.input.ts deleted file mode 100644 index 01bf51bcf4d..00000000000 --- a/packages/codemod/src/transforms/remove-default-locale-publish-option/value-all.input.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { buildConfig } from 'payload' - -export default buildConfig({ - localization: { - defaultLocale: 'en', - defaultLocalePublishOption: 'all', - locales: [{ code: 'en' }, { code: 'es' }], - }, - collections: [], -}) diff --git a/packages/payload/src/auth/operations/login.ts b/packages/payload/src/auth/operations/login.ts index f89579ee0bc..00ce7f67588 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -18,6 +18,7 @@ import { import { afterRead } from '../../fields/hooks/afterRead/index.js' import { commitTransaction, Forbidden, initTransaction } from '../../index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' import { getFieldsToSign } from '../getFieldsToSign.js' @@ -74,6 +75,8 @@ export const loginOperation = async ( ): Promise> => { let args = incomingArgs + assertNoValidationWrite(args.req) + if (args.collection.config.auth.disableLocalStrategy) { throw new Forbidden(args.req.t) } diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index f94ca3752df..6481e5c7c4e 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -9,7 +9,7 @@ import type { User, ValidationFieldError, } from '../../../index.js' -import type { JsonObject, PayloadRequest } from '../../../types/index.js' +import type { PayloadRequest } from '../../../types/index.js' import type { ValidationLocaleSelector } from '../../../utilities/resolveValidationLocales.js' import type { DataFromCollectionSlug, @@ -18,15 +18,7 @@ import type { } from '../../config/types.js' import { APIError } from '../../../errors/index.js' -import { createLocalReq } from '../../../utilities/createLocalReq.js' -import { projectNonLocalizedData } from '../../../utilities/projectNonLocalizedData.js' -import { - cloneValidationRequest, - cloneValidationValue, - resolveValidationConcurrency, - resolveValidationLocales, - runValidationLocalePasses, -} from '../../../utilities/resolveValidationLocales.js' +import { runLocaleScopedValidation } from '../../../utilities/runLocaleScopedValidation.js' import { validateOperation } from '../validate.js' /** @@ -171,43 +163,15 @@ export async function validateLocalWithDataLocale( ) } - const baseReq = await createLocalReq( - { - context: cloneValidationValue(options.context), - fallbackLocale: false, - req: cloneValidationRequest(options.req), - user: cloneValidationValue(options.user), - }, - payload, - ) - baseReq.operation = 'validate' - const locales = await resolveValidationLocales({ + return runLocaleScopedValidation({ + context: options.context, + data, + fields: collection.config.fields, locale, - req: baseReq, - }) - const results = await runValidationLocalePasses({ - concurrency: resolveValidationConcurrency(options.req), - locales, - validate: async (validationLocale) => { - const req = await createLocalReq( - { - fallbackLocale: false, - locale: validationLocale ?? undefined, - req: cloneValidationRequest(baseReq), - }, - payload, - ) - const validationCandidateData = cloneValidationValue(data) - const validationData = - validationDataLocale && validationLocale !== validationDataLocale && validationCandidateData - ? projectNonLocalizedData({ - configBlockReferences: payload.config.blocks, - data: validationCandidateData as JsonObject, - fields: collection.config.fields, - }) - : validationCandidateData - - return validateOperation({ + payload, + req: options.req, + runPass: ({ data: validationData, req }) => + validateOperation({ id, collection, data: validationData, @@ -216,13 +180,8 @@ export async function validateLocalWithDataLocale( overrideAccess, req, trash: validationTrash, - }) - }, + }), + user: options.user, + validationDataLocale, }) - const errors = results.flatMap((result) => result.errors) - - return { - errors, - valid: errors.length === 0, - } } diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 3295af60020..a49ad19d964 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -276,6 +276,11 @@ export const createClientConfig = ({ clientConfig.localization.defaultLocale = config.localization.defaultLocale } + if (config.localization.defaultLocalePublishOption) { + clientConfig.localization.defaultLocalePublishOption = + config.localization.defaultLocalePublishOption + } + if (config.localization.fallback) { clientConfig.localization.fallback = config.localization.fallback } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index b6a5819c1b0..48039c1fcdf 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -599,6 +599,14 @@ export type BaseLocalizationConfig = { * @example `"en"` */ defaultLocale: string + /** + * Change the locale used by the default Publish button. + * If set to `all`, all locales will be published. + * If set to `active`, only the locale currently being edited will be published. + * The non-default option will be available via the secondary button. + * @default 'all' + */ + defaultLocalePublishOption?: 'active' | 'all' /** Set to `true` to let missing values in localised fields fall back to the values in `defaultLocale` * * If false, then no requests will fallback unless a fallbackLocale is specified in the request. diff --git a/packages/payload/src/globals/operations/local/validate.ts b/packages/payload/src/globals/operations/local/validate.ts index 76f8af7fe82..0864471c2fd 100644 --- a/packages/payload/src/globals/operations/local/validate.ts +++ b/packages/payload/src/globals/operations/local/validate.ts @@ -3,21 +3,13 @@ import type { DeepPartial } from 'ts-essentials' import { status as httpStatus } from 'http-status' import type { ValidationResult } from '../../../collections/operations/local/validate.js' -import type { GlobalSlug, JsonObject, Payload, RequestContext, User } from '../../../index.js' +import type { GlobalSlug, Payload, RequestContext, User } from '../../../index.js' import type { PayloadRequest } from '../../../types/index.js' import type { ValidationLocaleSelector } from '../../../utilities/resolveValidationLocales.js' import type { DataFromGlobalSlug, DraftFlagFromGlobalSlug } from '../../config/types.js' import { APIError } from '../../../errors/index.js' -import { createLocalReq } from '../../../utilities/createLocalReq.js' -import { projectNonLocalizedData } from '../../../utilities/projectNonLocalizedData.js' -import { - cloneValidationRequest, - cloneValidationValue, - resolveValidationConcurrency, - resolveValidationLocales, - runValidationLocalePasses, -} from '../../../utilities/resolveValidationLocales.js' +import { runLocaleScopedValidation } from '../../../utilities/runLocaleScopedValidation.js' import { validateOperation } from '../validate.js' /** @@ -103,43 +95,15 @@ export async function validateGlobalLocalWithDataLocale { - const req = await createLocalReq( - { - fallbackLocale: false, - locale: validationLocale ?? undefined, - req: cloneValidationRequest(baseReq), - }, - payload, - ) - const validationCandidateData = cloneValidationValue(data) - const validationData = - validationDataLocale && validationLocale !== validationDataLocale && validationCandidateData - ? projectNonLocalizedData({ - configBlockReferences: payload.config.blocks, - data: validationCandidateData as JsonObject, - fields: globalConfig.fields, - }) - : validationCandidateData - - return validateOperation({ + payload, + req: options.req, + runPass: ({ data: validationData, req }) => + validateOperation({ slug, data: validationData, dataIsLocaleKeyed, @@ -147,13 +111,8 @@ export async function validateGlobalLocalWithDataLocale result.errors) - - return { - errors, - valid: errors.length === 0, - } } diff --git a/packages/payload/src/queues/localAPI.ts b/packages/payload/src/queues/localAPI.ts index 4d555a6b068..1b4799147d6 100644 --- a/packages/payload/src/queues/localAPI.ts +++ b/packages/payload/src/queues/localAPI.ts @@ -10,6 +10,7 @@ import { type TypedJobs, type Where, } from '../index.js' +import { assertNoValidationWrite } from '../utilities/assertNoValidationWrite.js' import { jobAfterRead, jobsCollectionSlug } from './config/collection.js' import { handleSchedules, type HandleSchedulesResult } from './operations/handleSchedules/index.js' import { runJobs } from './operations/runJobs/index.js' @@ -112,6 +113,8 @@ export const getJobsLocalAPI = (payload: Payload) => ({ const overrideAccess = args?.overrideAccess !== false const req: PayloadRequest = args.req ?? (await createLocalReq({}, payload)) + assertNoValidationWrite(req) + if (!overrideAccess) { /** * By default, jobsConfig.access.queue will be `defaultAccess` which is a function that returns `true` if the user is logged in. diff --git a/packages/payload/src/queues/utilities/updateJob.ts b/packages/payload/src/queues/utilities/updateJob.ts index 4e8da873814..616d70f2754 100644 --- a/packages/payload/src/queues/utilities/updateJob.ts +++ b/packages/payload/src/queues/utilities/updateJob.ts @@ -2,6 +2,7 @@ import type { UpdateJobsArgs } from '../../database/types.js' import type { Job } from '../../index.js' import type { PayloadRequest, Sort, Where } from '../../types/index.js' +import { assertNoValidationWrite } from '../../utilities/assertNoValidationWrite.js' import { jobAfterRead } from '../config/collection.js' import { getCurrentDate } from './getCurrentDate.js' @@ -52,6 +53,8 @@ export async function updateJobs({ sort, where: whereArg, }: RunJobsArgs): Promise { + assertNoValidationWrite(req) + const limit = id ? 1 : limitArg const where = id ? { id: { equals: id } } : whereArg diff --git a/packages/payload/src/utilities/parseValidationLocale.spec.ts b/packages/payload/src/utilities/parseValidationLocale.spec.ts index 72007135e93..7e30567b3f0 100644 --- a/packages/payload/src/utilities/parseValidationLocale.spec.ts +++ b/packages/payload/src/utilities/parseValidationLocale.spec.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { assertValidationData, - parseSingleValidationLocale, parseValidationLocale, parseValidationLocaleSelector, } from './parseValidationLocale.js' @@ -45,20 +44,6 @@ describe('parseValidationLocale', () => { }) }) -describe('parseSingleValidationLocale', () => { - it('should return a single parsed locale', () => { - expect(parseSingleValidationLocale('en')).toBe('en') - }) - - it('should reject "all"', () => { - expect(() => parseSingleValidationLocale('all')).toThrow(/requires a single locale/i) - }) - - it('should reject a locale array', () => { - expect(() => parseSingleValidationLocale(['en', 'es'])).toThrow(/requires a single locale/i) - }) -}) - describe('parseValidationLocaleSelector', () => { it('should return "all" for the all type', () => { expect(parseValidationLocaleSelector('all')).toBe('all') diff --git a/packages/payload/src/utilities/parseValidationLocale.ts b/packages/payload/src/utilities/parseValidationLocale.ts index 87bdf42ab6b..32b0e296302 100644 --- a/packages/payload/src/utilities/parseValidationLocale.ts +++ b/packages/payload/src/utilities/parseValidationLocale.ts @@ -52,16 +52,6 @@ export function parseValidationLocale(locale: unknown): ValidationLocale { throw new APIError('Validation requires a locale.', httpStatus.BAD_REQUEST) } -export function parseSingleValidationLocale(locale: unknown): TypedLocale { - const parsedLocale = parseValidationLocale(locale) - - if (parsedLocale.type !== 'single') { - throw new APIError('Validation requires a single locale.', httpStatus.BAD_REQUEST) - } - - return parsedLocale.locale -} - /** * Parses a REST `locale` query value. Repeated query parameters are represented as an array and * `locale=all` selects all locales. diff --git a/packages/payload/src/utilities/projectNonLocalizedData.ts b/packages/payload/src/utilities/projectNonLocalizedData.ts index 3ecffb9760d..bfa15ab256c 100644 --- a/packages/payload/src/utilities/projectNonLocalizedData.ts +++ b/packages/payload/src/utilities/projectNonLocalizedData.ts @@ -2,7 +2,7 @@ import type { Block, Field, FlattenedBlock } from '../fields/config/types.js' import type { SanitizedConfig } from '../index.js' import type { JsonObject } from '../types/index.js' -import { fieldAffectsData, fieldIsLocalized, tabHasName } from '../fields/config/types.js' +import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../fields/config/types.js' import { deepCopyObjectSimple } from './deepCopyObject.js' type ProjectNonLocalizedDataArgs = { @@ -40,7 +40,7 @@ function removeLocalizedData({ }: RemoveLocalizedDataArgs): void { for (const field of fields) { if (fieldAffectsData(field)) { - if (parentIsLocalized || fieldIsLocalized(field)) { + if (parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized })) { delete data[field.name] continue } @@ -122,7 +122,7 @@ function removeLocalizedData({ case 'tabs': { for (const tab of field.tabs) { if (tabHasName(tab)) { - if (parentIsLocalized || fieldIsLocalized(tab)) { + if (parentIsLocalized || fieldShouldBeLocalized({ field: tab, parentIsLocalized })) { delete data[tab.name] } else if (isObject(data[tab.name])) { removeLocalizedData({ diff --git a/packages/payload/src/utilities/runLocaleScopedValidation.ts b/packages/payload/src/utilities/runLocaleScopedValidation.ts new file mode 100644 index 00000000000..a37e7c6bd41 --- /dev/null +++ b/packages/payload/src/utilities/runLocaleScopedValidation.ts @@ -0,0 +1,89 @@ +import type { ValidationResult } from '../collections/operations/local/validate.js' +import type { Field } from '../fields/config/types.js' +import type { Payload, RequestContext, User } from '../index.js' +import type { JsonObject, PayloadRequest } from '../types/index.js' + +import { createLocalReq } from './createLocalReq.js' +import { projectNonLocalizedData } from './projectNonLocalizedData.js' +import { + cloneValidationRequest, + cloneValidationValue, + resolveValidationConcurrency, + resolveValidationLocales, + runValidationLocalePasses, + type ValidationLocaleSelector, +} from './resolveValidationLocales.js' + +/** + * Clones the caller's request into one scoped to `validate`, resolves the selected locales, runs + * `runPass` once per locale against an independent request/data clone, and aggregates the field + * errors. Shared by the collection and global local validate wrappers so the locale-cloning and + * pass-running plumbing has one owner instead of two copies that can drift apart. + */ +export async function runLocaleScopedValidation({ + context, + data, + fields, + locale, + payload, + req, + runPass, + user, + validationDataLocale, +}: { + context: RequestContext | undefined + data: TData + fields: Field[] + locale: ValidationLocaleSelector + payload: Payload + req: Partial | undefined + runPass: (args: { data: TData; req: PayloadRequest }) => Promise + user: null | undefined | User + validationDataLocale: string | undefined +}): Promise { + const baseReq = await createLocalReq( + { + context: cloneValidationValue(context), + fallbackLocale: false, + req: cloneValidationRequest(req), + user: cloneValidationValue(user), + }, + payload, + ) + baseReq.operation = 'validate' + const locales = await resolveValidationLocales({ + locale, + req: baseReq, + }) + const results = await runValidationLocalePasses({ + concurrency: resolveValidationConcurrency(req), + locales, + validate: async (validationLocale) => { + const localeReq = await createLocalReq( + { + fallbackLocale: false, + locale: validationLocale ?? undefined, + req: cloneValidationRequest(baseReq), + }, + payload, + ) + const validationCandidateData = cloneValidationValue(data) + const validationData: TData = + validationDataLocale && validationLocale !== validationDataLocale && validationCandidateData + ? (projectNonLocalizedData({ + configBlockReferences: payload.config.blocks, + data: validationCandidateData as JsonObject, + fields, + }) as TData) + : validationCandidateData + + return runPass({ data: validationData, req: localeReq }) + }, + }) + const errors = results.flatMap((result) => result.errors) + + return { + errors, + valid: errors.length === 0, + } +} diff --git a/packages/ui/src/elements/PublishButton/index.tsx b/packages/ui/src/elements/PublishButton/index.tsx index 4fcd0f5b991..13c60f2b5af 100644 --- a/packages/ui/src/elements/PublishButton/index.tsx +++ b/packages/ui/src/elements/PublishButton/index.tsx @@ -235,6 +235,11 @@ export function PublishButton({ ], ) + // Publish to all locales unless there are localized fields AND defaultLocalePublishOption is 'active' + const isDefaultPublishAll = + !isSpecificLocalePublishEnabled || + (localization && localization?.defaultLocalePublishOption !== 'active') + const activeLocale = localization && localization?.locales.find((locale) => @@ -252,18 +257,27 @@ export function PublishButton({ publishLocale(activeLocale.code) : publish} + onClick={isDefaultPublishAll ? publish : () => publishLocale(activeLocale.code)} size="medium" SubMenuPopupContent={ isSpecificLocalePublishEnabled ? ({ close }) => { return ( - - - {t('version:publishAllLocales')} - - + {isSpecificLocalePublishEnabled && ( + + publishLocale(activeLocale.code) : publish + } + > + {isDefaultPublishAll + ? t('version:publishIn', { locale: activeLocaleLabel }) + : t('version:publishAllLocales')} + + + )} ) } @@ -271,7 +285,7 @@ export function PublishButton({ } type="button" > - {isSpecificLocalePublishEnabled ? ( + {!isDefaultPublishAll ? ( t('version:publishIn', { locale: activeLocaleLabel }) ) : ( diff --git a/test/admin/config.ts b/test/admin/config.ts index 7353630ec43..22f92a3cf55 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -282,6 +282,7 @@ export default buildConfigWithDefaults({ }, localization: { defaultLocale: 'en', + defaultLocalePublishOption: 'active', locales: [ { code: 'es', diff --git a/test/admin/e2e/document-view/e2e.spec.ts b/test/admin/e2e/document-view/e2e.spec.ts index 7e24ea23770..a0e0f4623f6 100644 --- a/test/admin/e2e/document-view/e2e.spec.ts +++ b/test/admin/e2e/document-view/e2e.spec.ts @@ -751,14 +751,14 @@ describe('Document View', () => { }) describe('publish button', () => { - test('should show publish active locale button by default when localized fields exist', async () => { + test('should show publish active locale button with defaultLocalePublishOption set to active', async () => { await navigateToDoc(page, localizedURL) const publishButton = page.locator('#action-save') await expect(publishButton).toBeVisible() await expect(publishButton).toContainText('Publish in English') }) - test('should not show publish active locale button when no localized fields exist', async () => { + test('should not show publish active locale button with defaultLocalePublishOption set to active but no localized fields', async () => { await navigateToDoc(page, postsUrl) const publishButton = page.locator('#action-save') await expect(publishButton).toBeVisible() diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index 91be28f5e0a..07a9e294d1f 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -791,7 +791,7 @@ describe('Localization', () => { await page.goto(urlPostsWithDrafts.create) await changeLocale(page, 'es') await fillValues({ title: 'Created In Spanish' }) - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') await expect(page.locator('#field-title')).toHaveValue('Created In Spanish') await changeLocale(page, defaultLocale) @@ -833,7 +833,7 @@ describe('Localization', () => { test('should show unpublish in specific locale when localized fields exist', async () => { await page.goto(urlAllFieldsLocalized.create) await page.locator('#field-text').fill('EN Published') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') await openDocControls(page) await expect(page.locator('#action-unpublish')).toBeVisible() @@ -852,11 +852,11 @@ describe('Localization', () => { }) }) - test('should not show publish all locales button when no localized fields exist', async () => { + test('should not show publish specific locale button when no localized fields exist', async () => { await page.goto(urlPostsWithDrafts.create) - await expect(page.locator('#publish-all-locales')).toHaveCount(1) + await expect(page.locator('#publish-locale')).toHaveCount(1) await page.goto(noLocalizedFieldsURL.create) - await expect(page.locator('#publish-all-locales')).toHaveCount(0) + await expect(page.locator('#publish-locale')).toHaveCount(0) }) describe('duplicate selected locales', () => { @@ -926,7 +926,7 @@ describe('Localization', () => { // publish en await page.locator('#field-text').fill('EN Published') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') await page.goto(urlAllFieldsLocalized.versions(docID)) @@ -940,7 +940,7 @@ describe('Localization', () => { // publish en await page.locator('#field-text').fill('EN Published') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') const docID = (await page.locator('.render-title').getAttribute('data-doc-id')) as string @@ -952,7 +952,7 @@ describe('Localization', () => { // publish es await page.locator('#field-text').fill('ES Published') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#publish-locale') await page.goto(urlAllFieldsLocalized.versions(docID)) await changeLocale(page, defaultLocale) diff --git a/test/validate/config.ts b/test/validate/config.ts index 933c92e2ad8..43edfef87ab 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -228,6 +228,22 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re }) break + case 'jobsQueue': + await req.payload.jobs.queue({ + task: 'validationWriteGuardProbe', + input: {}, + req, + }) + break + + case 'login': + await req.payload.login({ + collection: 'users', + data: { email: devUser.email, password: 'not-the-real-password' }, + req, + }) + break + case 'logout': await logoutOperation({ collection: req.payload.collections['users']!, @@ -461,6 +477,8 @@ const validationCollection: CollectionConfig = { 'create', 'delete', 'deleteMany', + 'jobsQueue', + 'login', 'logout', 'refresh', 'resetPassword', @@ -1336,6 +1354,14 @@ export default buildConfigWithDefaults({ ], jobs: { deleteJobOnComplete: false, + tasks: [ + { + slug: 'validationWriteGuardProbe', + inputSchema: [], + outputSchema: [], + handler: () => ({ output: {} }), + }, + ], }, localization: { defaultLocale: 'en', diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 37bf5e5606a..dc757227ca2 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -2399,6 +2399,42 @@ describe('validate Local API', () => { expect(versionsAfter).toEqual(versionsBefore) }) + it('should reject a job queue call that reuses the validation request before a job is written', async () => { + const jobsBefore = await payload.count({ collection: 'payload-jobs' }) + + await expect(runWriteAttempt('jobsQueue')).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + expect(await payload.count({ collection: 'payload-jobs' })).toEqual(jobsBefore) + }) + + it('should reject a login that reuses the validation request before login attempts are recorded', async () => { + const user = await payload.create({ + collection: 'users', + data: { + email: 'validation-write-guard-login@example.com', + password: 'correct-password', + }, + }) + + try { + await expect(runWriteAttempt('login')).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) + + const userAfter = await payload.findByID({ + id: user.id, + collection: 'users', + showHiddenFields: true, + }) + + expect(userAfter.loginAttempts).toEqual(0) + } finally { + await payload.delete({ id: user.id, collection: 'users' }) + } + }) + it('should reject a logout that reuses the validation request before a session is removed', async () => { const usersBefore = await payload.count({ collection: 'users' }) @@ -3627,6 +3663,8 @@ async function runWriteAttempt( | 'create' | 'delete' | 'deleteMany' + | 'jobsQueue' + | 'login' | 'logout' | 'refresh' | 'resetPassword' From 6388cee18f0d3ed5f00ed7820eaa8b24582ef3c8 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 19 Aug 2026 22:02:48 +0100 Subject: [PATCH 33/47] add gql mutation --- docs/validation/overview.mdx | 32 +++- packages/graphql/src/index.ts | 2 + .../src/resolvers/collections/validate.ts | 59 +++++++ .../graphql/src/resolvers/globals/validate.ts | 45 +++++ .../src/schema/buildValidationResultType.ts | 28 +++ .../graphql/src/schema/initCollections.ts | 16 ++ packages/graphql/src/schema/initGlobals.ts | 15 ++ .../src/collections/operations/create.ts | 11 +- .../operations/utilities/update.ts | 12 +- .../src/collections/operations/validate.ts | 50 +++--- packages/payload/src/config/types.ts | 1 + .../payload/src/globals/operations/update.ts | 12 +- .../src/globals/operations/validate.ts | 135 +++++++++------ .../src/utilities/assertNoValidationWrite.ts | 2 +- .../resolvePublishAllLocales.spec.ts | 82 +++++++++ .../src/utilities/resolvePublishAllLocales.ts | 22 +++ test/validate/config.ts | 22 +++ test/validate/int.spec.ts | 160 ++++++++++++++++++ 18 files changed, 607 insertions(+), 99 deletions(-) create mode 100644 packages/graphql/src/resolvers/collections/validate.ts create mode 100644 packages/graphql/src/resolvers/globals/validate.ts create mode 100644 packages/graphql/src/schema/buildValidationResultType.ts create mode 100644 packages/payload/src/utilities/resolvePublishAllLocales.spec.ts create mode 100644 packages/payload/src/utilities/resolvePublishAllLocales.ts diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index cdc6d18c5c6..cfca0c45dfb 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -188,6 +188,31 @@ controls come from the HTTP request, not body properties. Other errors use Payload's normal REST error handling. REST validation always enforces access control. +## GraphQL API + +Every collection and global gets a `validate` / `validateGlobal` mutation, matching the +`create`/`update` mutations already generated for it. Omit `id` to validate create candidate data; +supply `id` to validate a stored document, with the same draft-fallback behavior as the Local and +REST APIs. + +```graphql +mutation { + validatePost(data: { title: "Launch announcement" }) { + valid + errors { + path + message + locale + } + } +} +``` + +Unlike the Local and REST APIs, the GraphQL mutations only validate one locale — the request's +resolved locale, or the `locale` argument when the collection or global has localized fields. There +is no GraphQL equivalent of `locale: 'all'` or a locale array; validate every locale individually, or +use the Local or REST API for that. GraphQL validation always enforces access control. + ## Locale semantics `locale` accepts one locale, a non-empty array, or `'all'`. Duplicate locales are removed. `'all'` @@ -300,6 +325,7 @@ reported; the server-side publish validation remains authoritative. ## Current scope -On-demand validation is available through the Local and REST APIs, and powers the Admin UI's default -Publish button. It does not add a standalone "validate all locales" Admin UI action, a GraphQL -validation operation, bulk Admin validation, or fallback-locale validation. +On-demand validation is available through the Local, REST, and GraphQL APIs, and powers the Admin +UI's default Publish button. GraphQL only validates one locale per call; multi-locale and `'all'` +validation are Local and REST API only. It does not add a standalone "validate all locales" Admin UI +action, bulk Admin validation, or fallback-locale validation. diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 3dec8ebd7d0..e340aad78b1 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -12,6 +12,7 @@ import { accessResolver } from './resolvers/auth/access.js' import { buildFallbackLocaleInputType } from './schema/buildFallbackLocaleInputType.js' import { buildLocaleInputType } from './schema/buildLocaleInputType.js' import { buildPoliciesType } from './schema/buildPoliciesType.js' +import { buildValidationResultType } from './schema/buildValidationResultType.js' import { initCollections } from './schema/initCollections.js' import { initGlobals } from './schema/initGlobals.js' import { wrapCustomFields } from './utilities/wrapCustomResolver.js' @@ -49,6 +50,7 @@ export function configToSchema(config: SanitizedConfig): { blockTypes: {}, groupTypes: {}, tabTypes: {}, + validationResultType: buildValidationResultType(), }, } diff --git a/packages/graphql/src/resolvers/collections/validate.ts b/packages/graphql/src/resolvers/collections/validate.ts new file mode 100644 index 00000000000..9a439d8717e --- /dev/null +++ b/packages/graphql/src/resolvers/collections/validate.ts @@ -0,0 +1,59 @@ +import type { + Collection, + CollectionSlug, + PayloadRequest, + RequiredDataFromCollectionSlug, + ValidationResult, +} from 'payload' +import type { DeepPartial } from 'ts-essentials' + +import type { Context } from '../types.js' + +export type Resolver = ( + _: unknown, + args: { + data?: DeepPartial> + draft?: boolean + id?: number | string + locale?: string + }, + context: { + req: PayloadRequest + }, +) => Promise + +/** + * Validates a single locale — the request's resolved locale, or the `locale` argument when + * provided. Unlike the Local and REST APIs, this does not support validating multiple locales or + * `locale: 'all'` in one call. + */ +export function validateResolver( + collection: Collection, +): Resolver { + return async function resolver(_, args, context: Context) { + const { req } = context + const collectionSlug = collection.config.slug as TSlug + const locale = req.payload.config.localization ? args.locale || req.locale : null + + if (args.id === undefined) { + return req.payload.validate({ + collection: collectionSlug, + data: args.data, + draft: args.draft, + locale, + overrideAccess: false, + req, + }) + } + + return req.payload.validate({ + id: args.id, + collection: collectionSlug, + data: args.data, + draft: args.draft, + locale, + overrideAccess: false, + req, + }) + } +} diff --git a/packages/graphql/src/resolvers/globals/validate.ts b/packages/graphql/src/resolvers/globals/validate.ts new file mode 100644 index 00000000000..79a026d0bc1 --- /dev/null +++ b/packages/graphql/src/resolvers/globals/validate.ts @@ -0,0 +1,45 @@ +import type { + DataFromGlobalSlug, + GlobalSlug, + PayloadRequest, + SanitizedGlobalConfig, + ValidationResult, +} from 'payload' +import type { DeepPartial } from 'ts-essentials' + +import type { Context } from '../types.js' + +export type Resolver = ( + _: unknown, + args: { + data?: DeepPartial, 'id'>> + draft?: boolean + locale?: string + }, + context: { + req: PayloadRequest + }, +) => Promise + +/** + * Validates a single locale — the request's resolved locale, or the `locale` argument when + * provided. Unlike the Local and REST APIs, this does not support validating multiple locales or + * `locale: 'all'` in one call. + */ +export function validateResolver( + globalConfig: SanitizedGlobalConfig, +): Resolver { + return async function resolver(_, args, context: Context) { + const { req } = context + const locale = req.payload.config.localization ? args.locale || req.locale : null + + return req.payload.validateGlobal({ + slug: globalConfig.slug as TSlug, + data: args.data, + draft: args.draft, + locale, + overrideAccess: false, + req, + }) + } +} diff --git a/packages/graphql/src/schema/buildValidationResultType.ts b/packages/graphql/src/schema/buildValidationResultType.ts new file mode 100644 index 00000000000..dea3ce83dcc --- /dev/null +++ b/packages/graphql/src/schema/buildValidationResultType.ts @@ -0,0 +1,28 @@ +import { + GraphQLBoolean, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLString, +} from 'graphql' + +const validationFieldErrorType = new GraphQLObjectType({ + name: 'ValidationFieldError', + fields: { + locale: { type: GraphQLString }, + message: { type: new GraphQLNonNull(GraphQLString) }, + path: { type: new GraphQLNonNull(GraphQLString) }, + }, +}) + +export const buildValidationResultType = (): GraphQLObjectType => { + return new GraphQLObjectType({ + name: 'ValidationResult', + fields: { + errors: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(validationFieldErrorType))), + }, + valid: { type: new GraphQLNonNull(GraphQLBoolean) }, + }, + }) +} diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 0364d8544b5..4c2826b572d 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -38,6 +38,7 @@ import { findVersionByIDResolver } from '../resolvers/collections/findVersionByI import { findVersionsResolver } from '../resolvers/collections/findVersions.js' import { restoreVersionResolver } from '../resolvers/collections/restoreVersion.js' import { updateResolver } from '../resolvers/collections/update.js' +import { validateResolver } from '../resolvers/collections/validate.js' import { formatName } from '../utilities/formatName.js' import { buildMutationInputType, getCollectionIDType } from './buildMutationInputType.js' import { buildObjectType } from './buildObjectType.js' @@ -306,6 +307,21 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ resolve: updateResolver(collection), } + graphqlResult.Mutation.fields[`validate${singularName}`] = { + type: graphqlResult.types.validationResultType, + args: { + id: { type: idType }, + ...(updateMutationInputType ? { data: { type: updateMutationInputType } } : {}), + draft: { type: GraphQLBoolean }, + ...(config.localization + ? { + locale: { type: graphqlResult.types.localeInputType }, + } + : {}), + }, + resolve: validateResolver(collection), + } + graphqlResult.Mutation.fields[`delete${singularName}`] = { type: collection.graphQL.type, args: { diff --git a/packages/graphql/src/schema/initGlobals.ts b/packages/graphql/src/schema/initGlobals.ts index eae3a5cd31d..b04b1f3a4f0 100644 --- a/packages/graphql/src/schema/initGlobals.ts +++ b/packages/graphql/src/schema/initGlobals.ts @@ -13,6 +13,7 @@ import { findVersionByID } from '../resolvers/globals/findVersionByID.js' import { findVersions } from '../resolvers/globals/findVersions.js' import { restoreVersion } from '../resolvers/globals/restoreVersion.js' import { update } from '../resolvers/globals/update.js' +import { validateResolver as validateGlobalResolver } from '../resolvers/globals/validate.js' import { formatName } from '../utilities/formatName.js' import { buildMutationInputType } from './buildMutationInputType.js' import { buildObjectType } from './buildObjectType.js' @@ -109,6 +110,20 @@ export function initGlobals({ config, graphqlResult }: InitGlobalsGraphQLArgs): }, resolve: update(global), } + + graphqlResult.Mutation.fields[`validate${formattedName}`] = { + type: graphqlResult.types.validationResultType, + args: { + ...(updateMutationInputType ? { data: { type: updateMutationInputType } } : {}), + draft: { type: GraphQLBoolean }, + ...(config.localization + ? { + locale: { type: graphqlResult.types.localeInputType }, + } + : {}), + }, + resolve: validateGlobalResolver(global), + } } if (global.versions) { diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 28b7fee2d0e..a3f4d284025 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -39,6 +39,7 @@ import { } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' +import { resolvePublishAllLocales } from '../../utilities/resolvePublishAllLocales.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' @@ -122,11 +123,11 @@ export const createOperation = async < let { data } = args - // Explicit publish-all intent wins; otherwise new non-drafts publish all applicable locales. - const publishAllLocales = - publishAllLocalesArg === true || - (!draft && - (publishAllLocalesArg ?? (hasLocalizeStatusEnabled(collectionConfig) ? false : true))) + const publishAllLocales = resolvePublishAllLocales({ + draft, + hasLocalizeStatusEnabled: hasLocalizeStatusEnabled(collectionConfig), + publishAllLocalesArg, + }) const isSavingDraft = Boolean(draft && hasDraftsEnabled(collectionConfig) && !publishAllLocales) if (isSavingDraft) { diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index f3662e8368a..c3a0ef1523d 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -38,6 +38,7 @@ import { hasDraftValidationEnabled, hasLocalizeStatusEnabled, } from '../../../utilities/getVersionsConfig.js' +import { resolvePublishAllLocales } from '../../../utilities/resolvePublishAllLocales.js' import { buildLocalizedPublishData } from '../../../versions/buildSingleLocalePublishData.js' import { validateLocalWithDataLocale } from '../local/validate.js' export type SharedUpdateDocumentArgs = { @@ -102,11 +103,12 @@ export const updateDocument = async < unpublishAllLocales: unpublishAllLocalesArg, }: SharedUpdateDocumentArgs): Promise> => { const password = data?.password - const publishAllLocales = - publishAllLocalesArg === true || - (!draftArg && - (publishAllLocalesArg ?? - (hasLocalizeStatusEnabled(collectionConfig) && locale !== 'all' ? false : true))) + const publishAllLocales = resolvePublishAllLocales({ + draft: draftArg, + hasLocalizeStatusEnabled: hasLocalizeStatusEnabled(collectionConfig), + locale, + publishAllLocalesArg, + }) const unpublishAllLocales = typeof unpublishAllLocalesArg === 'string' ? unpublishAllLocalesArg === 'true' diff --git a/packages/payload/src/collections/operations/validate.ts b/packages/payload/src/collections/operations/validate.ts index 6047d130399..5f3e6b9bb6f 100644 --- a/packages/payload/src/collections/operations/validate.ts +++ b/packages/payload/src/collections/operations/validate.ts @@ -133,35 +133,35 @@ async function validateOperationWithScopedRequest( req, }) - if (collectionConfig.hooks.beforeValidate?.length) { - for (const hook of collectionConfig.hooks.beforeValidate) { - data = - (await hook({ - collection: collectionConfig, - context: req.context, - data, - operation: 'validate', - originalDoc, - req, - })) || data + try { + if (collectionConfig.hooks.beforeValidate?.length) { + for (const hook of collectionConfig.hooks.beforeValidate) { + data = + (await hook({ + collection: collectionConfig, + context: req.context, + data, + operation: 'validate', + originalDoc, + req, + })) || data + } } - } - if (collectionConfig.hooks.beforeChange?.length) { - for (const hook of collectionConfig.hooks.beforeChange) { - data = - (await hook({ - collection: collectionConfig, - context: req.context, - data, - operation: 'validate', - originalDoc, - req, - })) || data + if (collectionConfig.hooks.beforeChange?.length) { + for (const hook of collectionConfig.hooks.beforeChange) { + data = + (await hook({ + collection: collectionConfig, + context: req.context, + data, + operation: 'validate', + originalDoc, + req, + })) || data + } } - } - try { await beforeChange({ id, collection: collectionConfig, diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 48039c1fcdf..46b6e3f3ca6 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -323,6 +323,7 @@ export type GraphQLInfo = { groupTypes: Record localeInputType?: GraphQL.GraphQLEnumType | GraphQL.GraphQLScalarType tabTypes: Record + validationResultType?: GraphQL.GraphQLObjectType } } export type GraphQLExtension = ( diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 61c42c9dc3e..d98798e32bd 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -34,6 +34,7 @@ import { } from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' +import { resolvePublishAllLocales } from '../../utilities/resolvePublishAllLocales.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' import { buildLocalizedPublishData } from '../../versions/buildSingleLocalePublishData.js' @@ -106,11 +107,12 @@ export const updateOperation = async < let { data } = args - const publishAllLocales = - publishAllLocalesArg === true || - (!draftArg && - (publishAllLocalesArg ?? - (hasLocalizeStatusEnabled(globalConfig) && locale !== 'all' ? false : true))) + const publishAllLocales = resolvePublishAllLocales({ + draft: draftArg, + hasLocalizeStatusEnabled: hasLocalizeStatusEnabled(globalConfig), + locale, + publishAllLocalesArg, + }) const unpublishAllLocales = typeof unpublishAllLocalesArg === 'string' ? unpublishAllLocalesArg === 'true' diff --git a/packages/payload/src/globals/operations/validate.ts b/packages/payload/src/globals/operations/validate.ts index 6eabfd15fe0..ebd49da3a70 100644 --- a/packages/payload/src/globals/operations/validate.ts +++ b/packages/payload/src/globals/operations/validate.ts @@ -97,37 +97,37 @@ async function validateOperationWithScopedRequest({ req, }) - if (globalConfig.hooks.beforeValidate?.length) { - for (const hook of globalConfig.hooks.beforeValidate) { - data = - (await hook({ - context: req.context, - data, - global: globalConfig, - operation: 'validate', - originalDoc, - overrideAccess, - req, - })) || data + try { + if (globalConfig.hooks.beforeValidate?.length) { + for (const hook of globalConfig.hooks.beforeValidate) { + data = + (await hook({ + context: req.context, + data, + global: globalConfig, + operation: 'validate', + originalDoc, + overrideAccess, + req, + })) || data + } } - } - if (globalConfig.hooks.beforeChange?.length) { - for (const hook of globalConfig.hooks.beforeChange) { - data = - (await hook({ - context: req.context, - data, - global: globalConfig, - operation: 'validate', - originalDoc, - overrideAccess, - req, - })) || data + if (globalConfig.hooks.beforeChange?.length) { + for (const hook of globalConfig.hooks.beforeChange) { + data = + (await hook({ + context: req.context, + data, + global: globalConfig, + operation: 'validate', + originalDoc, + overrideAccess, + req, + })) || data + } } - } - try { await beforeChange({ collection: null, context: req.context, @@ -159,13 +159,19 @@ async function validateOperationWithScopedRequest({ } } -async function resolveValidationGlobalSource({ +/** + * Loads the global through `where`, then substitutes the newest available draft when requested. + * Reports whether a main document existed so the caller can tell "no document yet" apart from + * "a document exists but this candidate wasn't derived from it" once a draft substitution runs. + */ +async function loadValidationGlobalCandidate({ slug, accessResult, draft, globalConfig, overrideAccess, req, + where, }: { accessResult: AccessResult draft: boolean @@ -173,54 +179,73 @@ async function resolveValidationGlobalSource({ overrideAccess: boolean req: PayloadRequest slug: string -}): Promise { - const accessWhere = overrideAccess ? undefined : (accessResult as Where) - const accessibleMain = await req.payload.db.findGlobal({ + where: undefined | Where +}): Promise<{ base: JsonObject; hasMain: boolean; source: JsonObject }> { + const main = await req.payload.db.findGlobal({ slug, locale: req.locale!, req, - where: accessWhere, + where, }) - const hasAccessibleMain = hasGlobalSource(accessibleMain) - const accessibleBase = hasAccessibleMain ? accessibleMain : { globalType: slug } + const hasMain = hasGlobalSource(main) + const base = hasMain ? main : { globalType: slug } // Global version lookups are slug-scoped; the shared helper's ID constraint applies to collections. - const accessibleSource = + const source = draft && globalConfig.versions?.drafts ? await replaceWithDraftIfAvailable({ accessResult, - doc: accessibleBase as JsonObject & TypeWithID, + doc: base as JsonObject & TypeWithID, entity: globalConfig, entityType: 'global', overrideAccess, req, }) - : accessibleBase + : base + + return { base, hasMain, source } +} + +async function resolveValidationGlobalSource({ + slug, + accessResult, + draft, + globalConfig, + overrideAccess, + req, +}: { + accessResult: AccessResult + draft: boolean + globalConfig: SanitizedGlobalConfig + overrideAccess: boolean + req: PayloadRequest + slug: string +}): Promise { + const accessible = await loadValidationGlobalCandidate({ + slug, + accessResult, + draft, + globalConfig, + overrideAccess, + req, + where: overrideAccess ? undefined : (accessResult as Where), + }) - if (hasAccessibleMain || accessibleSource !== accessibleBase) { - return accessibleSource + if (accessible.hasMain || accessible.source !== accessible.base) { + return accessible.source } if (hasWhereAccessResult(accessResult)) { - const unrestrictedMain = await req.payload.db.findGlobal({ + const unrestricted = await loadValidationGlobalCandidate({ slug, - locale: req.locale!, + accessResult: true, + draft, + globalConfig, + overrideAccess: true, req, + where: undefined, }) - const hasUnrestrictedMain = hasGlobalSource(unrestrictedMain) - const unrestrictedBase = hasUnrestrictedMain ? unrestrictedMain : { globalType: slug } - const unrestrictedSource = - draft && globalConfig.versions?.drafts - ? await replaceWithDraftIfAvailable({ - accessResult: true, - doc: unrestrictedBase as JsonObject & TypeWithID, - entity: globalConfig, - entityType: 'global', - overrideAccess: true, - req, - }) - : unrestrictedBase - if (hasUnrestrictedMain || unrestrictedSource !== unrestrictedBase) { + if (unrestricted.hasMain || unrestricted.source !== unrestricted.base) { throw new Forbidden(req.t) } } diff --git a/packages/payload/src/utilities/assertNoValidationWrite.ts b/packages/payload/src/utilities/assertNoValidationWrite.ts index a041a2b6876..3d55842ba1d 100644 --- a/packages/payload/src/utilities/assertNoValidationWrite.ts +++ b/packages/payload/src/utilities/assertNoValidationWrite.ts @@ -9,6 +9,6 @@ import { APIError } from '../errors/index.js' */ export function assertNoValidationWrite(req?: Partial): void { if (req?.operation === 'validate') { - throw new APIError('Payload writes are not allowed during validation.', 400) + throw new APIError('Payload writes are not allowed during validation.') } } diff --git a/packages/payload/src/utilities/resolvePublishAllLocales.spec.ts b/packages/payload/src/utilities/resolvePublishAllLocales.spec.ts new file mode 100644 index 00000000000..16106710c5d --- /dev/null +++ b/packages/payload/src/utilities/resolvePublishAllLocales.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' + +import { resolvePublishAllLocales } from './resolvePublishAllLocales.js' + +describe('resolvePublishAllLocales', () => { + it('should win over draft when publishAllLocalesArg is explicitly true', () => { + expect( + resolvePublishAllLocales({ + draft: true, + hasLocalizeStatusEnabled: true, + locale: 'en', + publishAllLocalesArg: true, + }), + ).toBe(true) + }) + + it('should be false while saving a draft without an explicit publishAllLocalesArg', () => { + expect( + resolvePublishAllLocales({ + draft: true, + hasLocalizeStatusEnabled: false, + locale: 'en', + publishAllLocalesArg: undefined, + }), + ).toBe(false) + }) + + it('should default to true for a non-draft save when localize status is disabled', () => { + expect( + resolvePublishAllLocales({ + draft: false, + hasLocalizeStatusEnabled: false, + locale: 'en', + publishAllLocalesArg: undefined, + }), + ).toBe(true) + }) + + it('should default to false for a non-draft save of one locale when localize status is enabled', () => { + expect( + resolvePublishAllLocales({ + draft: false, + hasLocalizeStatusEnabled: true, + locale: 'en', + publishAllLocalesArg: undefined, + }), + ).toBe(false) + }) + + it('should default to true when localize status is enabled but locale is "all"', () => { + expect( + resolvePublishAllLocales({ + draft: false, + hasLocalizeStatusEnabled: true, + locale: 'all', + publishAllLocalesArg: undefined, + }), + ).toBe(true) + }) + + it('should default to false when localize status is enabled and no locale is given, matching create candidates', () => { + expect( + resolvePublishAllLocales({ + draft: false, + hasLocalizeStatusEnabled: true, + locale: undefined, + publishAllLocalesArg: undefined, + }), + ).toBe(false) + }) + + it('should honor an explicit false publishAllLocalesArg for a non-draft save', () => { + expect( + resolvePublishAllLocales({ + draft: false, + hasLocalizeStatusEnabled: false, + locale: 'en', + publishAllLocalesArg: false, + }), + ).toBe(false) + }) +}) diff --git a/packages/payload/src/utilities/resolvePublishAllLocales.ts b/packages/payload/src/utilities/resolvePublishAllLocales.ts new file mode 100644 index 00000000000..8e2a71feb9b --- /dev/null +++ b/packages/payload/src/utilities/resolvePublishAllLocales.ts @@ -0,0 +1,22 @@ +/** + * Explicit publish-all-locales intent wins over `draft`; otherwise non-draft saves publish all + * locales unless localize status is enabled for one specific locale. Shared by create, and the + * collection and global update operations, so the formula has one owner instead of three copies + * that can drift apart. + */ +export function resolvePublishAllLocales({ + draft, + hasLocalizeStatusEnabled, + locale, + publishAllLocalesArg, +}: { + draft: boolean | undefined + hasLocalizeStatusEnabled: boolean + locale?: null | string + publishAllLocalesArg: boolean | undefined +}): boolean { + return ( + publishAllLocalesArg === true || + (!draft && (publishAllLocalesArg ?? !(hasLocalizeStatusEnabled && locale !== 'all'))) + ) +} diff --git a/test/validate/config.ts b/test/validate/config.ts index 43edfef87ab..d4ca0054742 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -601,6 +601,17 @@ const validationCollection: CollectionConfig = { throw new Error('collection validation hook failure') } + if (req.context.throwValidationErrorHook === true) { + throw new ValidationError( + { + collection: validationCollectionSlug, + errors: [{ message: 'Collection hook validation failure', path: 'title' }], + req, + }, + req.t, + ) + } + return data }, runWriteAttempt, @@ -745,6 +756,17 @@ const validationGlobal: GlobalConfig = { throw new Error('global validation hook failure') } + if (req.context.throwValidationErrorHook === true) { + throw new ValidationError( + { + errors: [{ message: 'Global hook validation failure', path: 'title' }], + global: validationGlobalSlug, + req, + }, + req.t, + ) + } + return data }, ], diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index dc757227ca2..972734129df 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -53,6 +53,9 @@ let restClient: NextRESTClient const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) +const formatGraphQLID = (id: number | string) => + payload.db.defaultIDType === 'number' ? id : `"${id}"` + describe('validate Local API', () => { beforeAll(async () => { ;({ payload, restClient } = await initPayloadInt(dirname)) @@ -1147,6 +1150,30 @@ describe('validate Local API', () => { expect(req.operation).toBe('create') }) + + it('should return a validation result instead of throwing when a collection hook throws a ValidationError', async () => { + const result = await payload.validate({ + collection: validationCollectionSlug, + context: { + throwValidationErrorHook: true, + }, + data: { + summary: 'candidate summary', + title: 'Candidate title', + }, + locale: 'en', + }) + + expect(result).toEqual({ + errors: [ + expect.objectContaining({ + message: 'Collection hook validation failure', + path: 'title', + }), + ], + valid: false, + }) + }) }) describe('globals', () => { @@ -1691,6 +1718,26 @@ describe('validate Local API', () => { expect(req.operation).toBe('create') }) + + it('should return a validation result instead of throwing when a global hook throws a ValidationError', async () => { + const result = await payload.validateGlobal({ + slug: validationGlobalSlug, + context: { + throwValidationErrorHook: true, + }, + locale: 'en', + }) + + expect(result).toEqual({ + errors: [ + expect.objectContaining({ + message: 'Global hook validation failure', + path: 'title', + }), + ], + valid: false, + }) + }) }) describe('REST API', () => { @@ -2190,6 +2237,119 @@ describe('validate Local API', () => { }) }) + describe('GraphQL API', () => { + let graphqlUserID: number | string + + beforeAll(async () => { + const user = await payload.create({ + collection: 'users', + data: { + email: 'validation-graphql-api@example.com', + password: 'validation-graphql-api-password', + }, + }) + graphqlUserID = user.id + + await restClient.login({ + slug: 'users', + credentials: { + email: 'validation-graphql-api@example.com', + password: 'validation-graphql-api-password', + }, + }) + }) + + afterAll(async () => { + await payload.delete({ id: graphqlUserID, collection: 'users' }) + }) + + it('should return a validation result for an invalid collection create candidate without persisting it', async () => { + const docsBefore = await payload.count({ collection: writeTargetsSlug }) + + const query = `mutation { + validateValidationWriteTarget(data: {}) { + valid + errors { + path + message + } + } + }` + + const { data } = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((res) => res.json()) + + expect(data.validateValidationWriteTarget.valid).toBe(false) + expect(data.validateValidationWriteTarget.errors).toEqual( + expect.arrayContaining([expect.objectContaining({ path: 'title' })]), + ) + expect(await payload.count({ collection: writeTargetsSlug })).toEqual(docsBefore) + }) + + it('should return a valid result for a valid collection create candidate', async () => { + const query = `mutation { + validateValidationWriteTarget(data: { title: "GraphQL candidate" }) { + valid + errors { + path + message + } + } + }` + + const { data } = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((res) => res.json()) + + expect(data.validateValidationWriteTarget).toEqual({ errors: [], valid: true }) + }) + + it('should validate a stored collection document by id without persisting the candidate', async () => { + const target = await createWriteTarget() + + const query = `mutation { + validateValidationWriteTarget(id: ${formatGraphQLID(target.id)}, data: { title: "" }) { + valid + errors { + path + message + } + } + }` + + const { data } = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((res) => res.json()) + + expect(data.validateValidationWriteTarget.valid).toBe(false) + await expect( + payload.findByID({ id: target.id, collection: writeTargetsSlug }), + ).resolves.toMatchObject({ title: 'stored target' }) + }) + + it('should return a validation result for a global document', async () => { + const query = `mutation { + validateValidationWriteTargetSetting(data: { title: "" }) { + valid + errors { + path + message + } + } + }` + + const { data } = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((res) => res.json()) + + expect(data.validateValidationWriteTargetSetting.valid).toBe(false) + expect(data.validateValidationWriteTargetSetting.errors).toEqual( + expect.arrayContaining([expect.objectContaining({ path: 'title' })]), + ) + }) + }) + describe('write safety', () => { it('should reject a create that reuses the validation request before a row is written', async () => { await expect(runWriteAttempt('create')).rejects.toThrow( From 4815952d6464ce4e1ee78bbd3fb00541be09a3ad Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 20 Aug 2026 13:03:04 +0100 Subject: [PATCH 34/47] updates --- docs/migration-guide/v4.mdx | 32 ++++++++++ .../src/collections/operations/create.ts | 2 + .../collections/operations/local/validate.ts | 3 + .../resolveValidationLocales.spec.ts | 63 ++++++++++++++++++- .../src/utilities/resolveValidationLocales.ts | 26 +++----- 5 files changed, 108 insertions(+), 18 deletions(-) diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index cd465b22977..ec4ae58acc0 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1650,3 +1650,35 @@ The Admin UI styles were migrated from SCSS to plain CSS. The `@payloadcms/ui/sc The `--theme-elevation-*` scale was removed. Use the semantic `--color-*` tokens (e.g. `--color-bg`, `--color-text`, `--color-border`, `--color-icon`) or the raw `--ramp-*` palette instead. See [Customizing CSS](../admin/customizing-css) for the full list of available tokens. Any custom component `.scss` files you authored should be renamed to `.css` and updated to plain CSS syntax — Payload no longer ships a SCSS toolchain. + +### Publishing every locale now validates every locale first + +For a collection or global with `versions.drafts` and `localization` enabled, publishing every +locale at once now validates every locale's data before writing anything, not only the locale in +the request. If any other locale's stored or draft data fails field validation, the write is +rejected with a `ValidationError` and nothing is saved — the document, its version, and any files +are left untouched. + +This applies wherever a write ends up publishing every locale: the Local API and REST with +`publishAllLocales: true`, a non-draft `create` on a collection with localized fields, and +scheduled publish jobs, which now revalidate the stored target at run time. A job that was valid +when scheduled can fail when it runs if another locale's data has become invalid in the meantime; +scheduled jobs are not retried after this kind of failure. + +There is no configuration option to turn this off. If a locale's data was already invalid before +upgrading, publishing every locale will now fail where it previously succeeded. Fix the invalid +locale's data, or publish one locale at a time instead of every locale at once. + +```ts +// Now validates de/fr/etc. as well as en before writing, and throws if any of them is invalid +await payload.update({ + id, + collection: 'posts', + data: { title: 'Ready to publish' }, + locale: 'en', + publishAllLocales: true, +}) +``` + +See [On-demand Validation](../validation/overview#publishing-locales) for the full validation +behavior this is built on. diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index a3f4d284025..99f718e945b 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -250,6 +250,8 @@ export const createOperation = async < skipValidation: isSavingDraft && !hasDraftValidationEnabled(collectionConfig), }) + // No `!unpublishAllLocales` check here, unlike the update operations: a brand new document has + // no prior publication to unpublish, so create never receives that argument at all. if (hasDraftsEnabled(collectionConfig) && config.localization && publishAllLocales) { const validationResult = await validateLocalWithDataLocale(payload, { collection: collectionConfig.slug, diff --git a/packages/payload/src/collections/operations/local/validate.ts b/packages/payload/src/collections/operations/local/validate.ts index 6481e5c7c4e..0860d1e12a3 100644 --- a/packages/payload/src/collections/operations/local/validate.ts +++ b/packages/payload/src/collections/operations/local/validate.ts @@ -117,6 +117,9 @@ export async function validateLocal( user: options.user, } + // Both branches call the same function with the same data; the split exists only because + // `InternalValidateCollectionOptions`'s `id` follows the same discriminated union as the public + // `ValidateCollectionOptions`, so `id` must be omitted entirely rather than passed as `undefined`. if (options.id === undefined) { return validateLocalWithDataLocale(payload, { ...publicOptions, diff --git a/packages/payload/src/utilities/resolveValidationLocales.spec.ts b/packages/payload/src/utilities/resolveValidationLocales.spec.ts index ba9d42a0928..26d72eb396b 100644 --- a/packages/payload/src/utilities/resolveValidationLocales.spec.ts +++ b/packages/payload/src/utilities/resolveValidationLocales.spec.ts @@ -4,7 +4,11 @@ import type { SanitizedLocalizationConfig } from '../config/types.js' import type { PayloadRequest } from '../types/index.js' import type { TypedLocale } from '../index.js' -import { resolveValidationLocales, runValidationLocalePasses } from './resolveValidationLocales.js' +import { + cloneValidationRequest, + resolveValidationLocales, + runValidationLocalePasses, +} from './resolveValidationLocales.js' function createReq(localization: false | SanitizedLocalizationConfig): PayloadRequest { return { @@ -187,3 +191,60 @@ describe('runValidationLocalePasses - concurrency', () => { expect(maxObservedActiveCount).toBe(tenLocales.length) }) }) + +describe('cloneValidationRequest', () => { + it('should return an empty object for an undefined request', () => { + expect(cloneValidationRequest(undefined)).toEqual({}) + }) + + it('should clone headers into a new instance while sharing the abort signal', () => { + const request = new Request('https://example.com/api/posts', { + headers: { 'x-test': 'value' }, + method: 'POST', + }) as unknown as PayloadRequest + + const cloned = cloneValidationRequest(request) + + expect(cloned.url).toBe('https://example.com/api/posts') + expect(cloned.method).toBe('POST') + // `Request` derives its own `.signal` rather than exposing the one passed to its constructor + // by reference, so this compares against the request's own signal, not a controller's. + expect(cloned.signal).toBe(request.signal) + expect(cloned.headers).not.toBe(request.headers) + expect((cloned.headers as unknown as Headers).get('x-test')).toBe('value') + }) + + it('should clone own enumerable properties independently of the source request', () => { + const request = { + context: { marker: 'original' }, + } as unknown as PayloadRequest + + const cloned = cloneValidationRequest(request) + + expect(cloned.context).toEqual({ marker: 'original' }) + expect(cloned.context).not.toBe(request.context) + }) + + it('should default context, query, and routeParams to empty objects when absent from the source request', () => { + const request = {} as unknown as PayloadRequest + + const cloned = cloneValidationRequest(request) + + expect(cloned.context).toEqual({}) + expect(cloned.query).toEqual({}) + expect(cloned.routeParams).toEqual({}) + }) + + it('should share, not clone, the properties reused across validation locale passes', () => { + const payload = {} + const request = { + payload, + transactionID: 'txn-1', + } as unknown as PayloadRequest + + const cloned = cloneValidationRequest(request) + + expect(cloned.payload).toBe(payload) + expect(cloned.transactionID).toBe('txn-1') + }) +}) diff --git a/packages/payload/src/utilities/resolveValidationLocales.ts b/packages/payload/src/utilities/resolveValidationLocales.ts index b8271079bca..b024217d13b 100644 --- a/packages/payload/src/utilities/resolveValidationLocales.ts +++ b/packages/payload/src/utilities/resolveValidationLocales.ts @@ -157,29 +157,21 @@ export function cloneValidationRequest( : cloneValidationValue(value) } + // `context`/`query`/`routeParams` default to an empty object even when the source request never + // set them, since downstream code reads their properties without checking for `undefined` first. Object.assign(clonedRequest, { context: cloneValidationValue(request.context ?? {}), - data: cloneValidationValue(request.data), - file: cloneValidationValue(request.file), - files: cloneValidationValue(request.files), - hash: request.hash, - headers: cloneValidationValue(request.headers), - host: request.host, - href: request.href, - method: request.method, - origin: request.origin, - pathname: request.pathname, - payloadUploadSizes: cloneValidationValue(request.payloadUploadSizes), - port: request.port, - protocol: request.protocol, query: cloneValidationValue(request.query ?? {}), - responseHeaders: cloneValidationValue(request.responseHeaders), routeParams: cloneValidationValue(request.routeParams ?? {}), - search: request.search, - searchParams: cloneValidationValue(request.searchParams), + }) + + // `headers`/`method`/`signal`/`url` come from the underlying Fetch `Request` prototype as + // getters rather than own properties, so the loop above never sees them to clone or share. + Object.assign(clonedRequest, { + headers: cloneValidationValue(request.headers), + method: request.method, signal: request.signal, url: request.url, - user: cloneValidationValue(request.user), }) return clonedRequest as Partial From cbcca9c51a62fd796152d5bb063c66d55faef8ad Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 20 Aug 2026 13:49:53 +0100 Subject: [PATCH 35/47] fix some test fixtures --- docs/configuration/localization.mdx | 2 -- test/__helpers/e2e/helpers.ts | 4 +-- test/versions/e2e.spec.ts | 40 +++++++++++++++-------------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index 89ee307c32a..d721f425e18 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -230,8 +230,6 @@ Passing `locale: 'all'` together with `_status: 'published'` also publishes ever The same parameters are available on `payload.updateGlobal()` for globals, as well as on the REST and Local APIs. -In the admin UI, the document controls' main Publish button always publishes only the currently active locale. A secondary "Publish all locales" button, next to the main one, calls `publishAllLocales` for every locale you have access to. - ## Retrieving Localized Docs When retrieving documents, you can specify which locale you'd like to receive as well as which fallback locale should be diff --git a/test/__helpers/e2e/helpers.ts b/test/__helpers/e2e/helpers.ts index d04add2fbd6..db3a0b25808 100644 --- a/test/__helpers/e2e/helpers.ts +++ b/test/__helpers/e2e/helpers.ts @@ -101,7 +101,7 @@ export async function saveDocAndAssert( | '#action-publish' | '#action-save' | '#action-save-draft' - | '#publish-all-locales' + | '#publish-locale' | string = '#action-save', expectation: 'error' | 'success' = 'success', options?: { @@ -112,7 +112,7 @@ export async function saveDocAndAssert( }, ): Promise { await wait(500) // TODO: Fix this - if (selector === '#publish-all-locales') { + if (selector === '#publish-locale') { // open dropdown const chevronButton = page.locator('.form-submit .popup__trigger-wrap > .popup-button') await chevronButton.click() diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 4d9fa8a56e5..3ce880889fb 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -1721,20 +1721,13 @@ describe('Versions', () => { }) test('should show option to publish current locale', async () => { - await page.goto(url.create) - const publishButton = page.locator('#action-save') - - await expect(publishButton).toContainText('English') - }) - - test('should show option to publish all locales in dropdown', async () => { await page.goto(url.create) const publishOptions = page.locator('.doc-controls__controls .popup') await publishOptions.click() - const publishAllLocalesContent = page.locator('.popup__content') + const publishLocaleContent = page.locator('.popup__content') - await expect(publishAllLocalesContent).toContainText('Publish all locales') + await expect(publishLocaleContent).toContainText('English') }) test('should publish specific locale', async () => { @@ -1753,7 +1746,23 @@ describe('Versions', () => { await changeLocale(page, 'en') await textField.fill('english published') - await saveDocAndAssert(page) + + const publishOptions = page.locator('#action-save-popup') + await publishOptions.click() + + const publishLocaleButton = page.locator('#publish-locale') + await expect(publishLocaleButton).toContainText('English') + await publishLocaleButton.click() + + await wait(500) + + await expect(async () => { + await expect( + page.locator('.payload-toast-item:has-text("Updated successfully.")'), + ).toBeVisible() + }).toPass({ + timeout: POLL_TOPASS_TIMEOUT, + }) const id = await page.locator('.id-label').getAttribute('title') @@ -2231,20 +2240,13 @@ describe('Versions', () => { }) test('should show option to publish current locale', async () => { - await page.goto(url.global(localizedGlobalSlug)) - const publishButton = page.locator('#action-save') - - await expect(publishButton).toContainText('English') - }) - - test('should show option to publish all locales in dropdown', async () => { await page.goto(url.global(localizedGlobalSlug)) const publishOptions = page.locator('.doc-controls__controls .popup') await publishOptions.click() - const publishAllLocalesContent = page.locator('.popup__content') + const publishLocaleContent = page.locator('.popup__content') - await expect(publishAllLocalesContent).toContainText('Publish all locales') + await expect(publishLocaleContent).toContainText('English') }) }) From ea0032afbd34a38f0214fa92ec757e3424375913 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 20 Aug 2026 14:23:05 +0100 Subject: [PATCH 36/47] remove breaking change --- docs/configuration/localization.mdx | 3 +- docs/validation/overview.mdx | 35 +- docs/versions/drafts.mdx | 4 - .../src/collections/operations/create.ts | 28 - .../operations/utilities/update.ts | 38 +- .../payload/src/globals/operations/update.ts | 32 - packages/payload/src/versions/schedule/job.ts | 88 +- test/validate/config.ts | 24 - test/validate/e2e.spec.ts | 97 +- test/validate/int.spec.ts | 1172 ----------------- test/versions/e2e.spec.ts | 6 +- test/versions/int.spec.ts | 22 +- 12 files changed, 42 insertions(+), 1507 deletions(-) diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index d721f425e18..dcc752e3ce9 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -10,8 +10,7 @@ Localization is one of the most important features of a modern CMS. It allows yo With Localization, you can begin to serve personalized content to your users based on their specific language preferences, such as a multilingual website or multi-site application. There are no limits to the number of locales you can add to your Payload project. -You can also [validate one or more locales without saving](../validation/overview) and require -specific locales before publishing. +You can also [validate one or more locales without saving](../validation/overview). To configure Localization, use the `localization` key in your [Payload Config](./overview): diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index cfca0c45dfb..8a26ac3644c 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -233,21 +233,6 @@ locales are never checked against localized values that were never meant for the Fallback locale values are disabled during on-demand validation. Missing data must pass validation in the locale being checked. -## Publishing locales - -Publishing one locale validates only that locale, using the same field validation the `update` -operation already runs. Publishing every locale at once additionally validates every other -available locale, since their currently stored data is about to go live too. If any of those -locales is invalid, Payload throws a `ValidationError` before writing the document, version, or -files. - -Publishing uses its normal `create` or `update` access policy; the internal validity check does not -require separate `validate` access. - -Scheduled publishes revalidate the latest stored target when the job runs. An invalid target -cancels the publish job without retrying or changing publication state. Unpublishing does not -require publish validation. - ## Access control and hooks Validation falls back to the corresponding `update` access function for collections, globals, and @@ -311,21 +296,9 @@ const beforeChange: CollectionBeforeChangeHook = async ({ } ``` -## Admin UI - -The default Publish button uses this same validation when it publishes. Publishing the active -locale is validated by the normal update it performs; no separate request is made. Publishing every -locale runs the additional all-locales check described above as part of that same request, before -anything is written. If publishing fails, Payload reports the errors the way it reports any other -save failure: invalid fields for the active locale are flagged inline, and a toast lists every -invalid field, including those in other locales. - -If you replace the default Publish button, your custom UI owns how validation failures are -reported; the server-side publish validation remains authoritative. - ## Current scope -On-demand validation is available through the Local, REST, and GraphQL APIs, and powers the Admin -UI's default Publish button. GraphQL only validates one locale per call; multi-locale and `'all'` -validation are Local and REST API only. It does not add a standalone "validate all locales" Admin UI -action, bulk Admin validation, or fallback-locale validation. +On-demand validation is available through the Local, REST, and GraphQL APIs. GraphQL only +validates one locale per call; multi-locale and `'all'` validation are Local and REST API only. It +does not add a standalone "validate all locales" Admin UI action, bulk Admin validation, or +fallback-locale validation. diff --git a/docs/versions/drafts.mdx b/docs/versions/drafts.mdx index 9da145d5970..f16ba79d98d 100644 --- a/docs/versions/drafts.mdx +++ b/docs/versions/drafts.mdx @@ -292,10 +292,6 @@ Payload provides for an ability to schedule publishing / unpublishing events in You can enable this functionality on both collections and globals via the `versions.drafts.schedulePublish: true` property. -At execution time, a scheduled publish [revalidates the latest stored -document](../validation/overview#publishing-locales). If validation fails, Payload -cancels the job without changing publication state. - **Important:** if you are going to enable scheduled publish / unpublish, you need to make sure your Payload app is set up to process diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 99f718e945b..b6f1dda22bf 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -20,7 +20,6 @@ import { executeAccess } from '../../auth/executeAccess.js' import { sendVerificationEmail } from '../../auth/sendVerificationEmail.js' import { registerLocalStrategy } from '../../auth/strategies/local/register.js' import { getDuplicateDocumentData } from '../../duplicateDocument/index.js' -import { ValidationError } from '../../errors/index.js' import { fillEmptyLocalizedSlugs } from '../../fields/baseFields/slug/fillEmptyLocalizedSlugs.js' import { afterChange } from '../../fields/hooks/afterChange/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' @@ -43,7 +42,6 @@ import { resolvePublishAllLocales } from '../../utilities/resolvePublishAllLocal import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' -import { validateLocalWithDataLocale } from './local/validate.js' import { buildAfterOperation } from './utilities/buildAfterOperation.js' import { buildBeforeOperation } from './utilities/buildBeforeOperation.js' @@ -250,32 +248,6 @@ export const createOperation = async < skipValidation: isSavingDraft && !hasDraftValidationEnabled(collectionConfig), }) - // No `!unpublishAllLocales` check here, unlike the update operations: a brand new document has - // no prior publication to unpublish, so create never receives that argument at all. - if (hasDraftsEnabled(collectionConfig) && config.localization && publishAllLocales) { - const validationResult = await validateLocalWithDataLocale(payload, { - collection: collectionConfig.slug, - data: dataWithLocales, - dataIsLocaleKeyed: true, - locale: 'all', - overrideAccess: true, - req, - validationDataLocale: - locale && locale !== 'all' ? locale : config.localization.defaultLocale, - }) - - if (!validationResult.valid) { - throw new ValidationError( - { - collection: collectionConfig.slug, - errors: validationResult.errors, - req, - }, - req.t, - ) - } - } - // When locale='all' or when beforeChange doesn't convert the string (e.g. no locale hook ran), // the localized _status remains a plain string. Expand it to a per-locale object so MongoDB // doesn't reject the write. This covers both draft and non-draft operations. diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index c3a0ef1523d..d7dfba5f850 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -24,7 +24,6 @@ import type { import { ensureUsernameOrEmail } from '../../../auth/ensureUsernameOrEmail.js' import { generatePasswordSaltHash } from '../../../auth/strategies/local/generatePasswordSaltHash.js' -import { ValidationError } 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' @@ -40,7 +39,6 @@ import { } from '../../../utilities/getVersionsConfig.js' import { resolvePublishAllLocales } from '../../../utilities/resolvePublishAllLocales.js' import { buildLocalizedPublishData } from '../../../versions/buildSingleLocalePublishData.js' -import { validateLocalWithDataLocale } from '../local/validate.js' export type SharedUpdateDocumentArgs = { autosave: boolean collectionConfig: SanitizedCollectionConfig @@ -257,40 +255,8 @@ export const updateDocument = async < let result: JsonObject = await beforeChange(beforeChangeArgs) - if ( - hasDraftsEnabled(collectionConfig) && - config.localization && - publishAllLocales && - !unpublishAllLocales - ) { - const validationResult = await validateLocalWithDataLocale(payload, { - id, - collection: collectionConfig.slug, - data: result, - dataIsLocaleKeyed: true, - draft: true, - locale: 'all', - overrideAccess: true, - req, - validationDataLocale: locale !== 'all' ? locale : config.localization.defaultLocale, - validationTrash: Boolean(originalDoc?.deletedAt), - }) - - if (!validationResult.valid) { - throw new ValidationError( - { - id, - collection: collectionConfig.slug, - errors: validationResult.errors, - req, - }, - req.t, - ) - } - } - - // File deletion and writes must occur after the final post-hook publication validation barrier. - // Validation failures leave both the persisted upload and local files untouched. + // File deletion and writes must occur after beforeChange's field validation. Validation + // failures leave both the persisted upload and local files untouched. if (!isDraftOverPublished) { await deleteAssociatedFiles({ collectionConfig, diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index d98798e32bd..ec8dd1fa678 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -17,7 +17,6 @@ import type { } from '../config/types.js' import { executeAccess } from '../../auth/executeAccess.js' -import { ValidationError } 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' @@ -40,7 +39,6 @@ import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' import { buildLocalizedPublishData } from '../../versions/buildSingleLocalePublishData.js' import { getLatestGlobalVersion } from '../../versions/getLatestGlobalVersion.js' import { saveVersion } from '../../versions/saveVersion.js' -import { validateGlobalLocalWithDataLocale } from './local/validate.js' type Args = { autosave?: boolean data: DeepPartial, 'id'>> @@ -269,36 +267,6 @@ export const updateOperation = async < let result: JsonObject = await beforeChange(beforeChangeArgs) - if ( - hasDraftsEnabled(globalConfig) && - payload.config.localization && - publishAllLocales && - !unpublishAllLocales - ) { - const validationResult = await validateGlobalLocalWithDataLocale(payload, { - slug: globalConfig.slug, - data: result, - dataIsLocaleKeyed: true, - draft: true, - locale: 'all', - overrideAccess: true, - req, - validationDataLocale: - locale && locale !== 'all' ? locale : payload.config.localization.defaultLocale, - }) - - if (!validationResult.valid) { - throw new ValidationError( - { - errors: validationResult.errors, - global: globalConfig.slug, - req, - }, - req.t, - ) - } - } - if ( config?.localization && hasLocalizeStatusEnabled(globalConfig) && diff --git a/packages/payload/src/versions/schedule/job.ts b/packages/payload/src/versions/schedule/job.ts index fce9973306c..1de1088cd2c 100644 --- a/packages/payload/src/versions/schedule/job.ts +++ b/packages/payload/src/versions/schedule/job.ts @@ -3,46 +3,12 @@ import type { User } from '../../index.js' import type { TaskConfig } from '../../queues/config/types/taskTypes.js' import type { SchedulePublishTaskInput } from './types.js' -import { ValidationError } from '../../errors/index.js' -import { JobCancelledError } from '../../queues/errors/index.js' - type Args = { adminUserSlug: string collections: string[] globals: string[] } -function throwScheduledPublishValidationError( - errors: { - locale?: string - message: string - path: string - }[], -): never { - const details = errors - .map( - ({ locale, message, path }) => - `[${locale ?? 'default'}] ${path}${message ? `: ${message}` : ''}`, - ) - .join('; ') - - throw new JobCancelledError(`Scheduled publish validation failed: ${details}`) -} - -async function runWithScheduledPublishValidationErrorHandling( - operation: () => Promise, -): Promise { - try { - return await operation() - } catch (error) { - if (error instanceof ValidationError) { - throwScheduledPublishValidationError(error.data.errors) - } - - throw error - } -} - export const getSchedulePublishTask = ({ adminUserSlug, collections, @@ -79,40 +45,36 @@ export const getSchedulePublishTask = ({ 'text' const id = idType === 'number' ? Number(input.doc.value) : input.doc.value - await runWithScheduledPublishValidationErrorHandling(() => - req.payload.update({ - id, - collection: collectionSlug, - data: { - _status, - }, - depth: 0, - locale: input.locale, - overrideAccess: user === null, - publishAllLocales: _status === 'published' && isPublishAllLocales, - req, - user, - }), - ) + await req.payload.update({ + id, + collection: collectionSlug, + data: { + _status, + }, + depth: 0, + locale: input.locale, + overrideAccess: user === null, + publishAllLocales: _status === 'published' && isPublishAllLocales, + req, + user, + }) } if (input.global) { const globalSlug = input.global - await runWithScheduledPublishValidationErrorHandling(() => - req.payload.updateGlobal({ - slug: globalSlug, - data: { - _status, - }, - depth: 0, - locale: input.locale, - overrideAccess: user === null, - publishAllLocales: _status === 'published' && isPublishAllLocales, - req, - user, - }), - ) + await req.payload.updateGlobal({ + slug: globalSlug, + data: { + _status, + }, + depth: 0, + locale: input.locale, + overrideAccess: user === null, + publishAllLocales: _status === 'published' && isPublishAllLocales, + req, + user, + }) } return { diff --git a/test/validate/config.ts b/test/validate/config.ts index d4ca0054742..e1230cfb84a 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -32,7 +32,6 @@ export const publishGlobalSlug = 'validation-publish-settings' export const writeTargetsSlug = 'validation-write-targets' export const validationUploadsSlug = 'validation-uploads' export const validationPublishUploadsSlug = 'validation-publish-uploads' -export const validationAdminCollectionSlug = 'validation-admin-items' export const validationCustomButtonsCollectionSlug = 'validation-custom-buttons-items' export const validationDeniedCollectionSlug = 'validation-denied-items' export const validationNonLocalizedCollectionSlug = 'validation-non-localized-items' @@ -1213,28 +1212,6 @@ const publishGlobal: GlobalConfig = { }, } -const validationAdminCollection: CollectionConfig = { - slug: validationAdminCollectionSlug, - fields: [ - { - name: 'title', - type: 'text', - localized: true, - required: true, - }, - { - name: 'summary', - type: 'text', - required: true, - }, - ], - versions: { - drafts: { - validate: false, - }, - }, -} - const validationCustomButtonsCollection: CollectionConfig = { slug: validationCustomButtonsCollectionSlug, admin: { @@ -1312,7 +1289,6 @@ export default buildConfigWithDefaults({ validationFallbackCollection, validationWhereCollection, publishCollection, - validationAdminCollection, validationCustomButtonsCollection, validationDeniedCollection, validationNonLocalizedCollection, diff --git a/test/validate/e2e.spec.ts b/test/validate/e2e.spec.ts index aec00f7da66..c5ec591dc71 100644 --- a/test/validate/e2e.spec.ts +++ b/test/validate/e2e.spec.ts @@ -9,106 +9,11 @@ import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js' import { RESTClient } from '../__helpers/shared/rest.js' import { initPage } from '../__setup/e2e/initPage.js' -import { validationAdminCollectionSlug, validationCustomButtonsCollectionSlug } from './config.js' +import { validationCustomButtonsCollectionSlug } from './config.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) -test.describe('Admin document validation', () => { - const createdIDs: string[] = [] - let client: RESTClient - let page: Page - let serverURL: string - let validationURL: AdminUrlUtil - - test.beforeAll(async ({ browser }) => { - ;({ serverURL } = await initPayloadE2ENoConfig({ dirname })) - validationURL = new AdminUrlUtil(serverURL, validationAdminCollectionSlug) - client = new RESTClient({ - defaultSlug: validationAdminCollectionSlug, - serverURL, - }) - - const context = await browser.newContext() - ;({ page } = await initPage({ context, serverURL })) - await client.login() - }) - - test.afterEach(async () => { - await page.goto(validationURL.admin) - - for (const id of createdIDs) { - await client.delete(id, { - id, - slug: validationAdminCollectionSlug, - }) - } - createdIDs.length = 0 - }) - - test('should report a blocked publish-all in a toast, but allow a normal publish of the current locale', async () => { - const id = await createDraft({ spanishTitle: 'Título en español' }) - - await openDraft(id) - await page.locator('#action-save-popup').click() - await page.locator('#publish-all-locales').click() - - await expect(page.locator('.payload-toast-container')).toContainText('[de] Title') - - await page.locator('#action-save').click() - - await expect(page.locator('.payload-toast-container')).toContainText('successfully') - }) - - async function createDraft({ - frenchTitle, - germanTitle, - spanishTitle, - }: { - frenchTitle?: string - germanTitle?: string - spanishTitle?: string - }): Promise { - const response = await client.endpointWithAuth<{ doc: { id: number | string } }>( - `/api/${validationAdminCollectionSlug}?draft=true&locale=en`, - 'POST', - { - _status: 'draft', - summary: 'Shared summary', - title: 'English title', - }, - ) - const id = String(response.data.doc.id) - - createdIDs.push(id) - - for (const [locale, title] of [ - ['es', spanishTitle], - ['de', germanTitle], - ['fr', frenchTitle], - ]) { - if (title) { - await client.endpointWithAuth( - `/api/${validationAdminCollectionSlug}/${id}?draft=true&locale=${locale}`, - 'PATCH', - { - _status: 'draft', - title, - }, - ) - } - } - - return id - } - - async function openDraft(id: string): Promise { - await page.goto(validationURL.edit(id)) - await changeLocale(page, 'en') - await waitForFormReady(page) - } -}) - test.describe('Custom locale validation buttons', () => { const createdIDs: string[] = [] let client: RESTClient diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 972734129df..188390f34b6 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -27,7 +27,6 @@ import { permissionOperationEvents, publishCollectionSlug, publishGlobalSlug, - scheduledValidationEvents, validationAccessSourceGlobalSlug, validationCollectionSlug, validationCustomButtonsCollectionSlug, @@ -2635,1177 +2634,6 @@ describe('validate Local API', () => { expect(await payload.count({ collection: 'users' })).toEqual(usersBefore) }) }) - - describe('publish enforcement', () => { - it('should not require validate access after collection create access succeeds', async () => { - await expect( - payload.create({ - collection: validationDeniedCollectionSlug, - data: { - _status: 'published', - title: 'Published with create access', - }, - locale: 'en', - overrideAccess: false, - }), - ).resolves.toMatchObject({ - _status: 'published', - title: 'Published with create access', - }) - }) - - it('should not require validate access after global update access succeeds', async () => { - await expect( - payload.updateGlobal({ - slug: validationDeniedGlobalSlug, - data: { - _status: 'published', - title: 'Published with update access', - }, - locale: 'en', - overrideAccess: false, - }), - ).resolves.toMatchObject({ - _status: 'published', - title: 'Published with update access', - }) - }) - - it('should validate the complete publish candidate despite restrictive validate field access', async () => { - const draft = await seedPublishCollection({ - de: 'German optional', - en: 'English title', - es: '', - }) - - await expect( - payload.update({ - id: draft.id, - collection: publishCollectionSlug, - context: { - denyPublishFieldValidation: true, - }, - data: { - _status: 'published', - }, - locale: 'en', - overrideAccess: false, - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'es', - path: 'title', - }), - ]), - }, - }) - }) - - it('should preserve upload files and persisted state when replacement publish validation fails', async () => { - const originalFile = await getFileByPath(path.resolve(dirname, '../uploads/image.png')) - originalFile.name = 'validation-published-original.png' - - const draft = await payload.create({ - collection: validationPublishUploadsSlug, - data: { - _status: 'draft', - title: 'English title', - }, - draft: true, - file: originalFile, - locale: 'en', - }) - - await payload.update({ - id: draft.id, - collection: validationPublishUploadsSlug, - data: { - _status: 'draft', - title: 'Spanish title', - }, - draft: true, - locale: 'es', - }) - await payload.update({ - id: draft.id, - collection: validationPublishUploadsSlug, - data: { - _status: 'published', - title: 'English title', - }, - locale: 'en', - }) - await payload.update({ - id: draft.id, - collection: validationPublishUploadsSlug, - data: { - _status: 'draft', - title: '', - }, - draft: true, - locale: 'es', - }) - - const mainBefore = await payload.findByID({ - id: draft.id, - collection: validationPublishUploadsSlug, - locale: 'all', - }) - const draftBefore = await payload.findByID({ - id: draft.id, - collection: validationPublishUploadsSlug, - draft: true, - locale: 'all', - }) - const versionsBefore = await payload.countVersions({ - collection: validationPublishUploadsSlug, - where: { - parent: { - equals: draft.id, - }, - }, - }) - const filenamesBefore = (await fs.readdir(validationPublishUploadsDir)).sort() - const fileContentsBefore = new Map( - await Promise.all( - filenamesBefore.map(async (filename) => [ - filename, - await fs.readFile(path.join(validationPublishUploadsDir, filename)), - ]), - ), - ) - - expect(filenamesBefore).toEqual( - expect.arrayContaining([mainBefore.filename, mainBefore.sizes.thumbnail.filename]), - ) - - const replacementFile = await getFileByPath(path.resolve(dirname, '../uploads/small.png')) - replacementFile.name = 'validation-rejected-replacement.png' - - await expect( - payload.update({ - id: draft.id, - collection: validationPublishUploadsSlug, - data: { - _status: 'published', - title: 'Replacement title', - }, - file: replacementFile, - locale: 'en', - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'es', - path: 'title', - }), - ]), - }, - }) - - const mainAfter = await payload.findByID({ - id: draft.id, - collection: validationPublishUploadsSlug, - locale: 'all', - }) - const draftAfter = await payload.findByID({ - id: draft.id, - collection: validationPublishUploadsSlug, - draft: true, - locale: 'all', - }) - const versionsAfter = await payload.countVersions({ - collection: validationPublishUploadsSlug, - where: { - parent: { - equals: draft.id, - }, - }, - }) - const filenamesAfter = (await fs.readdir(validationPublishUploadsDir)).sort() - - expect(mainAfter).toEqual(mainBefore) - expect(draftAfter).toEqual(draftBefore) - expect(versionsAfter).toEqual(versionsBefore) - expect(filenamesAfter).toEqual(filenamesBefore) - - for (const [filename, contents] of fileContentsBefore) { - await expect( - fs.readFile(path.join(validationPublishUploadsDir, filename)), - ).resolves.toEqual(contents) - } - }) - - it('should block collection create publish-all when status is draft without persisting', async () => { - await expect( - payload.create({ - collection: publishCollectionSlug, - data: { - ...getPublishCollectionLocaleData({ title: 'English candidate' }), - _status: 'draft', - }, - locale: 'en', - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'de', - path: 'title', - }), - ]), - }, - }) - - const documents = await payload.find({ - collection: publishCollectionSlug, - draft: true, - limit: 1, - locale: 'all', - }) - - expect(documents.totalDocs).toBe(0) - }) - - it('should block collection create publish-all when draft saving is requested', async () => { - await expect( - payload.create({ - collection: publishCollectionSlug, - data: getPublishCollectionLocaleData({ title: 'English candidate' }), - draft: true, - locale: 'en', - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'de', - path: 'title', - }), - ]), - }, - }) - - const documents = await payload.find({ - collection: publishCollectionSlug, - draft: true, - limit: 1, - locale: 'all', - }) - - expect(documents.totalDocs).toBe(0) - }) - - it('should block collection publish-all when status is draft without changing status', async () => { - const draft = await seedPublishCollection({ - de: '', - en: 'English draft', - es: 'Spanish valid', - }) - - await expect( - payload.update({ - id: draft.id, - collection: publishCollectionSlug, - data: { - _status: 'draft', - title: 'English candidate', - }, - locale: 'en', - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'de', - path: 'title', - }), - ]), - }, - }) - - const latestDraft = await payload.findByID({ - id: draft.id, - collection: publishCollectionSlug, - draft: true, - locale: 'all', - }) - - expect(latestDraft._status).toMatchObject({ - de: 'draft', - en: 'draft', - es: 'draft', - }) - }) - - it('should block collection publish-all when draft saving is requested', async () => { - const draft = await seedPublishCollection({ - de: '', - en: 'English draft', - es: 'Spanish valid', - }) - - await expect( - payload.update({ - id: draft.id, - collection: publishCollectionSlug, - data: { - title: 'English candidate', - }, - draft: true, - locale: 'en', - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'de', - path: 'title', - }), - ]), - }, - }) - - const latestDraft = await payload.findByID({ - id: draft.id, - collection: publishCollectionSlug, - draft: true, - locale: 'all', - }) - - expect(latestDraft._status).toMatchObject({ - de: 'draft', - en: 'draft', - es: 'draft', - }) - }) - - it('should block global publish-all when status is draft without changing status', async () => { - await seedPublishGlobal({ - de: '', - en: 'English draft', - es: 'Spanish valid', - }) - - await expect( - payload.updateGlobal({ - slug: publishGlobalSlug, - data: { - _status: 'draft', - title: 'English candidate', - }, - locale: 'en', - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'de', - path: 'title', - }), - ]), - }, - }) - - const latestDraft = await payload.findGlobal({ - slug: publishGlobalSlug, - draft: true, - locale: 'all', - }) - - expect(latestDraft._status).toMatchObject({ - de: 'draft', - en: 'draft', - es: 'draft', - }) - }) - - it('should block global publish-all when draft saving is requested', async () => { - await seedPublishGlobal({ - de: '', - en: 'English draft', - es: 'Spanish valid', - }) - - await expect( - payload.updateGlobal({ - slug: publishGlobalSlug, - data: { - title: 'English candidate', - }, - draft: true, - locale: 'en', - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'de', - path: 'title', - }), - ]), - }, - }) - - const latestDraft = await payload.findGlobal({ - slug: publishGlobalSlug, - draft: true, - locale: 'all', - }) - - expect(latestDraft._status).toMatchObject({ - de: 'draft', - en: 'draft', - es: 'draft', - }) - }) - - it('should block publish-all from a trashed source when status is omitted', async () => { - const draft = await seedPublishCollection({ - de: '', - deletedAt: new Date().toISOString(), - en: 'English draft', - es: 'Spanish valid', - }) - - await expect( - payload.update({ - id: draft.id, - collection: publishCollectionSlug, - data: { - title: 'English candidate', - }, - locale: 'en', - publishAllLocales: true, - trash: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'de', - path: 'title', - }), - ]), - }, - }) - - const latestDraft = await payload.findByID({ - id: draft.id, - collection: publishCollectionSlug, - draft: true, - locale: 'all', - trash: true, - }) - - expect(latestDraft.deletedAt).toBeTruthy() - expect(latestDraft._status).toMatchObject({ - de: 'draft', - en: 'draft', - es: 'draft', - }) - }) - - it('should not apply flat localized object candidates to a sibling locale during publish-all', async () => { - const omittedFields: PublishCollectionLocalizedField[] = [ - 'localizedArray', - 'localizedBlocks', - 'localizedGroup', - 'localizedJSON', - 'localizedRichText', - 'localizedTab', - 'nested.localizedJSON', - ] - const draft = await seedPublishCollection({ - de: 'German optional', - en: 'English draft', - es: 'Spanish sibling', - omit: { - es: omittedFields, - }, - }) - - await expect( - payload.update({ - id: draft.id, - collection: publishCollectionSlug, - data: { - ...getPublishCollectionLocaleData({ title: 'English published' }), - _status: 'published', - }, - locale: 'en', - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining( - [ - 'localizedArray', - 'localizedBlocks', - 'localizedGroup.value', - 'localizedJSON', - 'localizedRichText', - 'localizedTab.value', - 'nested.localizedJSON', - ].map((path) => - expect.objectContaining({ - locale: 'es', - path, - }), - ), - ), - }, - }) - }) - - it('should not apply a flat localized JSON candidate to a global sibling locale during publish-all', async () => { - await seedPublishGlobal({ - de: 'German optional', - en: 'English draft', - es: 'Spanish sibling', - omitLocalizedJSON: { - es: true, - }, - }) - - await expect( - payload.updateGlobal({ - slug: publishGlobalSlug, - data: { - ...getPublishGlobalLocaleData({ title: 'English published' }), - _status: 'published', - }, - locale: 'en', - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'es', - path: 'localizedJSON', - }), - ]), - }, - }) - }) - - it('should allow trashing a draft even when a sibling locale is invalid', async () => { - const draft = await seedPublishCollection({ - de: 'German optional', - en: 'English draft', - es: '', - }) - const deletedAt = new Date().toISOString() - - await expect( - payload.update({ - id: draft.id, - collection: publishCollectionSlug, - data: { - deletedAt, - }, - locale: 'en', - }), - ).resolves.toMatchObject({ - deletedAt, - }) - }) - - it('should allow restoring a trashed draft even when a sibling locale is invalid', async () => { - const draft = await seedPublishCollection({ - de: 'German optional', - deletedAt: new Date().toISOString(), - en: 'English draft', - es: '', - }) - - await expect( - payload.update({ - id: draft.id, - collection: publishCollectionSlug, - data: { - deletedAt: null, - }, - locale: 'en', - trash: true, - }), - ).resolves.toMatchObject({ - deletedAt: null, - }) - }) - - it('should allow collection unpublish metadata even when a sibling locale is invalid', async () => { - const draft = await seedPublishCollection({ - de: 'German optional', - en: 'English draft', - es: '', - }) - - await expect( - payload.update({ - id: draft.id, - collection: publishCollectionSlug, - data: { - _status: 'draft', - }, - locale: 'en', - }), - ).resolves.toMatchObject({ - _status: 'draft', - }) - }) - - it('should allow global unpublish metadata even when a sibling locale is invalid', async () => { - await seedPublishGlobal({ - de: 'German optional', - en: 'English draft', - es: '', - }) - - await expect( - payload.updateGlobal({ - slug: publishGlobalSlug, - data: { - _status: 'draft', - }, - locale: 'en', - }), - ).resolves.toMatchObject({ - _status: 'draft', - }) - }) - - it('should publish a single locale even when every other locale is invalid', async () => { - const draft = await seedPublishCollection({ - de: '', - en: 'English draft', - es: '', - }) - - await payload.update({ - id: draft.id, - collection: publishCollectionSlug, - data: { - _status: 'published', - title: 'English published', - }, - locale: 'en', - }) - - const published = await payload.findByID({ - id: draft.id, - collection: publishCollectionSlug, - draft: true, - locale: 'all', - }) - - expect(published._status.en).toBe('published') - }) - - it('should block collection publish-all when a locale is invalid', async () => { - const draft = await seedPublishCollection({ - de: '', - en: 'English draft', - es: 'Spanish sibling', - }) - - await expect( - payload.update({ - id: draft.id, - collection: publishCollectionSlug, - data: { - _status: 'published', - title: 'English published', - }, - locale: 'en', - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'de', - path: 'title', - }), - ]), - }, - }) - - const latestDraft = await payload.findByID({ - id: draft.id, - collection: publishCollectionSlug, - draft: true, - locale: 'all', - }) - - expect(latestDraft._status.en).toBe('draft') - }) - - it('should publish a single global locale even when every other locale is invalid', async () => { - await seedPublishGlobal({ - de: '', - en: 'English draft', - es: '', - }) - - await payload.updateGlobal({ - slug: publishGlobalSlug, - data: { - _status: 'published', - title: 'English published', - }, - locale: 'en', - }) - - const published = await payload.findGlobal({ - slug: publishGlobalSlug, - draft: true, - locale: 'all', - }) - - expect(published._status.en).toBe('published') - }) - - it('should block global publish-all when a locale is invalid', async () => { - await seedPublishGlobal({ - de: '', - en: 'English draft', - es: 'Spanish sibling', - }) - - await expect( - payload.updateGlobal({ - slug: publishGlobalSlug, - data: { - _status: 'published', - title: 'English published', - }, - locale: 'en', - publishAllLocales: true, - }), - ).rejects.toMatchObject({ - data: { - errors: expect.arrayContaining([ - expect.objectContaining({ - locale: 'de', - path: 'title', - }), - ]), - }, - }) - - const latestDraft = await payload.findGlobal({ - slug: publishGlobalSlug, - draft: true, - locale: 'all', - }) - - expect(latestDraft._status.en).toBe('draft') - }) - - describe('scheduled publish', () => { - it('should cancel invalid collection publish-all jobs with safe locale details and no retry', async () => { - const draft = await seedPublishCollection({ - de: 'German initially valid', - en: 'private English scheduled value', - es: 'Spanish valid', - fr: 'French optional', - }) - const job = await payload.jobs.queue({ - input: { - doc: { - relationTo: publishCollectionSlug, - value: draft.id.toString(), - }, - }, - task: 'schedulePublish', - }) - - await payload.update({ - id: draft.id, - collection: publishCollectionSlug, - data: getPublishCollectionLocaleData({ title: '' }), - draft: true, - locale: 'de', - }) - - const firstRun = await payload.jobs.run({ silent: true }) - const failedJob = await payload.findByID({ - id: job.id, - collection: 'payload-jobs', - depth: 0, - }) - const error = failedJob.error as { cancelled?: boolean; message?: string } - - expect(firstRun.jobStatus?.[job.id]?.status).toBe('error-reached-max-retries') - expect(failedJob.hasError).toBe(true) - expect(error.cancelled).toBe(true) - expect(error.message).toContain('[de] title') - expect(error.message).not.toContain('private English scheduled value') - - const secondRun = await payload.jobs.run({ silent: true }) - const jobAfterSecondRun = await payload.findByID({ - id: job.id, - collection: 'payload-jobs', - depth: 0, - }) - const latestDraft = await payload.findByID({ - id: draft.id, - collection: publishCollectionSlug, - draft: true, - locale: 'all', - }) - - expect(secondRun.jobStatus).toBeUndefined() - expect(jobAfterSecondRun.totalTried).toBe(failedJob.totalTried) - expect(latestDraft._status).toMatchObject({ - de: 'draft', - en: 'draft', - es: 'draft', - }) - }) - - it('should publish every collection locale when a publish-all job is valid', async () => { - const draft = await seedPublishCollection({ - de: 'German optional', - en: 'English scheduled', - es: 'Spanish valid', - fr: 'French optional', - }) - const job = await payload.jobs.queue({ - input: { - doc: { - relationTo: publishCollectionSlug, - value: draft.id.toString(), - }, - }, - task: 'schedulePublish', - }) - - const result = await payload.jobs.run({ silent: true }) - const published = await payload.findByID({ - id: draft.id, - collection: publishCollectionSlug, - draft: true, - locale: 'all', - }) - const completedJob = await payload.findByID({ - id: job.id, - collection: 'payload-jobs', - depth: 0, - }) - - expect(result.jobStatus?.[job.id]?.status).toBe('success') - expect(completedJob.hasError).toBe(false) - expect(published._status).toMatchObject({ - de: 'published', - en: 'published', - es: 'published', - fr: 'published', - }) - }) - - it('should validate only the current locale and skip every sibling during a single-locale scheduled publish', async () => { - const draft = await seedPublishCollection({ - de: '', - en: 'English scheduled', - es: '', - fr: '', - }) - const job = await payload.jobs.queue({ - input: { - doc: { - relationTo: publishCollectionSlug, - value: draft.id.toString(), - }, - locale: 'en', - }, - task: 'schedulePublish', - }) - - const result = await payload.jobs.run({ silent: true }) - const published = await payload.findByID({ - id: draft.id, - collection: publishCollectionSlug, - draft: true, - locale: 'all', - }) - - expect(result.jobStatus?.[job.id]?.status).toBe('success') - expect(published._status).toMatchObject({ - de: 'draft', - en: 'published', - es: 'draft', - fr: 'draft', - }) - }) - - it('should cancel invalid global publish-all jobs', async () => { - await seedPublishGlobal({ - de: 'German initially valid', - en: 'private English global value', - es: 'Spanish valid', - fr: 'French optional', - }) - const job = await payload.jobs.queue({ - input: { - global: publishGlobalSlug, - }, - task: 'schedulePublish', - }) - - await payload.updateGlobal({ - slug: publishGlobalSlug, - data: getPublishGlobalLocaleData({ title: '' }), - draft: true, - locale: 'de', - }) - - await payload.jobs.run({ silent: true }) - - const failedJob = await payload.findByID({ - id: job.id, - collection: 'payload-jobs', - depth: 0, - }) - const error = failedJob.error as { cancelled?: boolean; message?: string } - const latestDraft = await payload.findGlobal({ - slug: publishGlobalSlug, - draft: true, - locale: 'all', - }) - - expect(error.cancelled).toBe(true) - expect(error.message).toContain('[de] title') - expect(error.message).not.toContain('private English global value') - expect(latestDraft._status).toMatchObject({ - de: 'draft', - en: 'draft', - es: 'draft', - }) - }) - - it('should publish every global locale when a publish-all job is valid', async () => { - await seedPublishGlobal({ - de: 'German optional', - en: 'English scheduled', - es: 'Spanish valid', - fr: 'French optional', - }) - const job = await payload.jobs.queue({ - input: { - global: publishGlobalSlug, - }, - task: 'schedulePublish', - }) - - const result = await payload.jobs.run({ silent: true }) - const published = await payload.findGlobal({ - slug: publishGlobalSlug, - draft: true, - locale: 'all', - }) - const completedJob = await payload.findByID({ - id: job.id, - collection: 'payload-jobs', - depth: 0, - }) - - expect(result.jobStatus?.[job.id]?.status).toBe('success') - expect(completedJob.hasError).toBe(false) - expect(published._status).toMatchObject({ - de: 'published', - en: 'published', - es: 'published', - fr: 'published', - }) - }) - - it('should not require validate access after scheduled global update access succeeds', async () => { - const adminUser = await payload.create({ - collection: 'users', - data: devUser, - }) - - await payload.updateGlobal({ - slug: validationDeniedGlobalSlug, - data: { - _status: 'draft', - title: 'Scheduled with update access', - }, - draft: true, - locale: 'en', - }) - - const job = await payload.jobs.queue({ - input: { - global: validationDeniedGlobalSlug, - user: adminUser.id, - }, - task: 'schedulePublish', - }) - - const result = await payload.jobs.run({ silent: true }) - - expect(result.jobStatus?.[job.id]?.status).toBe('success') - expect(scheduledValidationEvents).toEqual(['validate', 'validate', 'validate', 'validate']) - }) - - it('should check scheduled update access before running validation hooks', async () => { - const revokedUser = await payload.create({ - collection: 'users', - data: { - ...devUser, - email: 'revoked@example.com', - }, - }) - - await payload.updateGlobal({ - slug: validationDeniedGlobalSlug, - data: { - _status: 'draft', - title: 'Must not be validated', - }, - draft: true, - locale: 'en', - }) - - const job = await payload.jobs.queue({ - input: { - global: validationDeniedGlobalSlug, - user: revokedUser.id, - }, - task: 'schedulePublish', - }) - - const result = await payload.jobs.run({ silent: true }) - - expect(result.jobStatus?.[job.id]?.status).toBe('error-reached-max-retries') - expect(scheduledValidationEvents).toEqual([]) - }) - - it('should validate only the current locale and skip every sibling during a single-locale global scheduled publish', async () => { - await seedPublishGlobal({ - de: '', - en: 'English scheduled', - es: '', - fr: '', - }) - const job = await payload.jobs.queue({ - input: { - global: publishGlobalSlug, - locale: 'en', - }, - task: 'schedulePublish', - }) - - const result = await payload.jobs.run({ silent: true }) - const published = await payload.findGlobal({ - slug: publishGlobalSlug, - draft: true, - locale: 'all', - }) - - expect(result.jobStatus?.[job.id]?.status).toBe('success') - expect(published._status).toMatchObject({ - de: 'draft', - en: 'published', - es: 'draft', - fr: 'draft', - }) - }) - - it('should cancel validation errors thrown by scheduled validation hooks without retrying', async () => { - const draft = await seedPublishCollection({ - de: 'German optional', - en: 'throw scheduled validation error', - es: 'Spanish valid', - }) - const job = await payload.jobs.queue({ - input: { - doc: { - relationTo: publishCollectionSlug, - value: draft.id.toString(), - }, - locale: 'en', - }, - task: 'schedulePublish', - }) - - const firstRun = await payload.jobs.run({ silent: true }) - const failedJob = await payload.findByID({ - id: job.id, - collection: 'payload-jobs', - depth: 0, - }) - const error = failedJob.error as { cancelled?: boolean; message?: string } - const latestDraft = await payload.findByID({ - id: draft.id, - collection: publishCollectionSlug, - draft: true, - locale: 'all', - }) - - expect(firstRun.jobStatus?.[job.id]?.status).toBe('error-reached-max-retries') - expect(error.cancelled).toBe(true) - expect(error.message).toContain('[en] title: Scheduled validation hook rejected the title') - expect(error.message).not.toContain('throw scheduled validation error') - expect(latestDraft._status.en).toBe('draft') - - const secondRun = await payload.jobs.run({ silent: true }) - const jobAfterSecondRun = await payload.findByID({ - id: job.id, - collection: 'payload-jobs', - depth: 0, - }) - - expect(secondRun.jobStatus).toBeUndefined() - expect(jobAfterSecondRun.totalTried).toBe(failedJob.totalTried) - }) - - it('should preserve normal queue error handling for transient validation failures', async () => { - const draft = await seedPublishCollection({ - de: 'German optional', - en: 'throw transient scheduled error', - es: 'Spanish valid', - }) - const job = await payload.jobs.queue({ - input: { - doc: { - relationTo: publishCollectionSlug, - value: draft.id.toString(), - }, - locale: 'en', - }, - task: 'schedulePublish', - }) - - const result = await payload.jobs.run({ silent: true }) - const failedJob = await payload.findByID({ - id: job.id, - collection: 'payload-jobs', - depth: 0, - }) - const error = failedJob.error as { cancelled?: boolean; message?: string } - - expect(result.jobStatus?.[job.id]?.status).toBe('error-reached-max-retries') - expect(error.cancelled).toBe(false) - expect(error.message).toContain('transient scheduled validation error') - }) - }) - }) }) async function createWriteTarget() { diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 3ce880889fb..3e6998f5e6f 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -1522,7 +1522,7 @@ describe('Versions', () => { await expect(page.locator('#schedule-publish-button')).toBeHidden() // save draft then try to schedule publish - await saveDocAndAssert(page, '#action-save-draft') + await saveDocAndAssert(page) await page.locator('#schedule-publish-button').click() // drawer should open @@ -1554,7 +1554,7 @@ describe('Versions', () => { await expect(page.locator('#schedule-publish-button')).toBeHidden() // save draft then try to schedule publish - await saveDocAndAssert(page, '#action-save-draft') + await saveDocAndAssert(page) await page.locator('#schedule-publish-button').click() // drawer should open @@ -1582,7 +1582,7 @@ describe('Versions', () => { await page.locator('#field-title').fill('scheduled publish positioning') await page.locator('#field-description').fill('scheduled publish positioning description') - await saveDocAndAssert(page, '#action-save-draft') + await saveDocAndAssert(page) await page.locator('#schedule-publish-button').click() await expect(page.locator('.drawer__header')).toBeVisible() diff --git a/test/versions/int.spec.ts b/test/versions/int.spec.ts index 0a562af085f..56739fc9c73 100644 --- a/test/versions/int.spec.ts +++ b/test/versions/int.spec.ts @@ -3187,22 +3187,16 @@ describe('Versions', () => { it('should have different createdAt in a new version while the same version.createdAt', async () => { const doc = await payload.updateGlobal({ slug: autoSaveGlobalSlug, - data: { - _status: 'published', - title: 'asd', - }, - locale: 'en', + data: { title: 'asd' }, + publishAllLocales: true, }) await wait(10) const upd = await payload.updateGlobal({ slug: autoSaveGlobalSlug, - data: { - _status: 'published', - title: 'asd2', - }, - locale: 'en', + data: { title: 'asd2' }, + publishAllLocales: true, }) expect(upd.createdAt).toBe(doc.createdAt) @@ -3406,10 +3400,9 @@ describe('Versions', () => { const updatedGlobal = await payload.updateGlobal({ slug: autoSaveGlobalSlug, data: { - _status: 'published', title: title2, }, - locale: 'en', + publishAllLocales: true, }) expect(updatedGlobal.title).toBe(title2) @@ -3452,7 +3445,7 @@ describe('Versions', () => { description: 'kjnjyhbbdsfseankuhsjsfghb', title: originalTitle, }, - locale: 'en', + publishAllLocales: true, }) const publishedGlobal = await payload.findGlobal({ @@ -3671,7 +3664,6 @@ describe('Versions', () => { relationTo: draftCollectionSlug, value: draft.id, }, - locale: 'en', }, task: 'schedulePublish', waitUntil: new Date(currentDate.getTime() + 3000), @@ -3716,7 +3708,6 @@ describe('Versions', () => { relationTo: draftCollectionSlug, value: draft.id, }, - locale: 'en', user: user.id, }, task: 'schedulePublish', @@ -3900,7 +3891,6 @@ describe('Versions', () => { await payload.jobs.queue({ input: { global: draftGlobalSlug, - locale: 'en', }, task: 'schedulePublish', waitUntil: new Date(currentDate.getTime() + 3000), From 8945178926ab3d9e2d5c510fda874ac353568943 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 20 Aug 2026 14:32:49 +0100 Subject: [PATCH 37/47] remove now descoped breaking change --- docs/migration-guide/v4.mdx | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index ec4ae58acc0..cd465b22977 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1650,35 +1650,3 @@ The Admin UI styles were migrated from SCSS to plain CSS. The `@payloadcms/ui/sc The `--theme-elevation-*` scale was removed. Use the semantic `--color-*` tokens (e.g. `--color-bg`, `--color-text`, `--color-border`, `--color-icon`) or the raw `--ramp-*` palette instead. See [Customizing CSS](../admin/customizing-css) for the full list of available tokens. Any custom component `.scss` files you authored should be renamed to `.css` and updated to plain CSS syntax — Payload no longer ships a SCSS toolchain. - -### Publishing every locale now validates every locale first - -For a collection or global with `versions.drafts` and `localization` enabled, publishing every -locale at once now validates every locale's data before writing anything, not only the locale in -the request. If any other locale's stored or draft data fails field validation, the write is -rejected with a `ValidationError` and nothing is saved — the document, its version, and any files -are left untouched. - -This applies wherever a write ends up publishing every locale: the Local API and REST with -`publishAllLocales: true`, a non-draft `create` on a collection with localized fields, and -scheduled publish jobs, which now revalidate the stored target at run time. A job that was valid -when scheduled can fail when it runs if another locale's data has become invalid in the meantime; -scheduled jobs are not retried after this kind of failure. - -There is no configuration option to turn this off. If a locale's data was already invalid before -upgrading, publishing every locale will now fail where it previously succeeded. Fix the invalid -locale's data, or publish one locale at a time instead of every locale at once. - -```ts -// Now validates de/fr/etc. as well as en before writing, and throws if any of them is invalid -await payload.update({ - id, - collection: 'posts', - data: { title: 'Ready to publish' }, - locale: 'en', - publishAllLocales: true, -}) -``` - -See [On-demand Validation](../validation/overview#publishing-locales) for the full validation -behavior this is built on. From 0c0fd894bddc8f4f146f449f652d28db3ba5b5b5 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 20 Aug 2026 15:52:07 +0100 Subject: [PATCH 38/47] updates --- docs/validation/overview.mdx | 7 +- .../src/collections/config/sanitize.ts | 2 +- .../payload/src/globals/config/sanitize.ts | 2 +- .../src/utilities/addCollectionAccess.spec.ts | 20 ++++ .../src/utilities/addCollectionAccess.ts | 9 +- test/base-access/config.ts | 14 +++ test/base-access/int.spec.ts | 38 ++++++++ test/validate/int.spec.ts | 97 ++++++++++--------- 8 files changed, 136 insertions(+), 53 deletions(-) diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index 8a26ac3644c..9cacdbd8e68 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -190,10 +190,9 @@ control. ## GraphQL API -Every collection and global gets a `validate` / `validateGlobal` mutation, matching the -`create`/`update` mutations already generated for it. Omit `id` to validate create candidate data; -supply `id` to validate a stored document, with the same draft-fallback behavior as the Local and -REST APIs. +Every collection and global gets a `validate` mutation, matching the `create`/`update` +mutations already generated for it. Omit `id` to validate create candidate data; supply `id` to +validate a stored document, with the same draft-fallback behavior as the Local and REST APIs. ```graphql mutation { diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index db3a02442cf..e40ad1b5017 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -338,7 +338,7 @@ export const sanitizeCollection = ( sanitized.admin!.pagination!.limits = collection.admin.pagination.limits } - for (const operation of ['create', 'delete', 'read', 'unlock', 'update'] as const) { + for (const operation of ['create', 'delete', 'read', 'unlock', 'update', 'validate'] as const) { sanitized.access![operation] = withBaseAccess({ slug: sanitized.slug, access: sanitized.access?.[operation], diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 544c2b52b88..d721ec79867 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -193,7 +193,7 @@ export const sanitizeGlobal = ( }) } - for (const operation of ['read', 'update'] as const) { + for (const operation of ['read', 'update', 'validate'] as const) { global.access[operation] = withBaseAccess({ slug: global.slug, access: global.access[operation], diff --git a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts index b4324ae9f9f..96d19087c27 100644 --- a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts +++ b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts @@ -65,6 +65,26 @@ describe('addCollectionAccess', () => { await expect(config.baseAccess?.collections?.create?.(createArgs())).resolves.toBe(true) }) + it('falls back to update access for validate when accessResultCallback is configured', async () => { + const documentUpdate = vi.fn(() => true) + const accessResultCallback = vi.fn(({ accessResult }) => accessResult) + const collection: CollectionConfig = { + slug: 'posts', + access: { update: documentUpdate }, + fields: [], + } + const config = {} as Config + + addCollectionAccess({ + config, + scopes: [createScope(collection, accessResultCallback)], + }) + + await collection.access?.validate?.(createArgs()) + + expect(documentUpdate).toHaveBeenCalled() + }) + it('keeps callback wrapping when an access result override is configured', async () => { const documentResult = { published: { equals: true } } const documentRead = vi.fn(() => documentResult) diff --git a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts index fb053c62355..deee1c7d484 100644 --- a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts +++ b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts @@ -78,8 +78,15 @@ const wrapCollectionAccess = (scope: TenantAccessConfig): void => { scope.collection.access ??= {} for (const accessKey of collectionAccessKeys) { + // A collection without its own `validate` access is governed by `update`, matching core's + // fallback contract, rather than the generic "any authenticated user" default below. + const updateAccessFallback = + accessKey === 'validate' ? scope.collection.access.update : undefined + const accessFunction = - scope.collection.access[accessKey] ?? (({ req }: AccessArgs) => Boolean(req.user)) + scope.collection.access[accessKey] ?? + updateAccessFallback ?? + (({ req }: AccessArgs) => Boolean(req.user)) scope.collection.access[accessKey] = async (args) => getTenantAccessResult({ diff --git a/test/base-access/config.ts b/test/base-access/config.ts index a6d20d67edc..dda8bfef71d 100644 --- a/test/base-access/config.ts +++ b/test/base-access/config.ts @@ -40,6 +40,13 @@ const baseAccess: BaseAccess = { } } + return true + }, + validate: ({ slug, req }) => { + if (req.headers.get(denyHeader) === 'true' && slug === postsSlug) { + return false + } + return true }, }, @@ -49,6 +56,13 @@ const baseAccess: BaseAccess = { return false } + return true + }, + validate: ({ slug, req }) => { + if (req.headers.get(denyHeader) === 'true' && slug === settingsSlug) { + return false + } + return true }, }, diff --git a/test/base-access/int.spec.ts b/test/base-access/int.spec.ts index 42357e54eb4..87f0e209a99 100644 --- a/test/base-access/int.spec.ts +++ b/test/base-access/int.spec.ts @@ -126,6 +126,44 @@ describe('baseAccess', () => { ).rejects.toThrow(Forbidden) }) + it('should enforce base access for collection validate operation', async () => { + const req = await createRequest({ + [denyHeader]: 'true', + }) + + await expect( + payload.validate({ + collection: postsSlug, + data: { + status: 'published', + tenant: 'tenant-1', + title: 'denied', + }, + locale: null, + overrideAccess: false, + req, + }), + ).rejects.toThrow(Forbidden) + }) + + it('should enforce base access for globals validate operation', async () => { + const req = await createRequest({ + [denyHeader]: 'true', + }) + + await expect( + payload.validateGlobal({ + slug: settingsSlug, + data: { + title: 'denied', + }, + locale: null, + overrideAccess: false, + req, + }), + ).rejects.toThrow(Forbidden) + }) + it('should enforce base access for generated collections', async () => { const req = await createRequest({ [denyHeader]: 'true', diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 188390f34b6..231296d878b 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -2178,61 +2178,66 @@ describe('validate Local API', () => { // This collection uses the default access control, which requires an // authenticated admin user, so the shared restClient needs a token here. const adminEmail = 'validate-custom-buttons-admin@example.com' - await payload.create({ + const adminUser = await payload.create({ collection: 'users', data: { email: adminEmail, password: devUser.password, }, }) - const { token } = await payload.login({ - collection: 'users', - data: { - email: adminEmail, - password: devUser.password, - }, - }) - const authHeaders = { Authorization: `JWT ${token}` } - const activeLocaleResponse = await restClient.POST( - `/${validationCustomButtonsCollectionSlug}/${draft.id}/validate?locale=en`, - { - body: JSON.stringify({ - summary: 'Shared summary', - title: 'English title', - }), - headers: authHeaders, - }, - ) + try { + const { token } = await payload.login({ + collection: 'users', + data: { + email: adminEmail, + password: devUser.password, + }, + }) + const authHeaders = { Authorization: `JWT ${token}` } - expect(activeLocaleResponse.status).toBe(200) - await expect(activeLocaleResponse.json()).resolves.toEqual({ - errors: [], - valid: true, - }) + const activeLocaleResponse = await restClient.POST( + `/${validationCustomButtonsCollectionSlug}/${draft.id}/validate?locale=en`, + { + body: JSON.stringify({ + summary: 'Shared summary', + title: 'English title', + }), + headers: authHeaders, + }, + ) - const siblingLocalesResponse = await restClient.POST( - `/${validationCustomButtonsCollectionSlug}/${draft.id}/validate?locale=de&locale=es&locale=fr`, - { - body: JSON.stringify({ - summary: 'Shared summary', - }), - headers: authHeaders, - }, - ) - const siblingResult = await siblingLocalesResponse.json() - - expect(siblingLocalesResponse.status).toBe(200) - expect(siblingResult.valid).toBe(false) - expect(siblingResult.errors).toEqual( - expect.arrayContaining([ - expect.objectContaining({ locale: 'es', path: 'title' }), - expect.objectContaining({ locale: 'fr', path: 'title' }), - ]), - ) - expect(siblingResult.errors).not.toEqual( - expect.arrayContaining([expect.objectContaining({ locale: 'de', path: 'title' })]), - ) + expect(activeLocaleResponse.status).toBe(200) + await expect(activeLocaleResponse.json()).resolves.toEqual({ + errors: [], + valid: true, + }) + + const siblingLocalesResponse = await restClient.POST( + `/${validationCustomButtonsCollectionSlug}/${draft.id}/validate?locale=de&locale=es&locale=fr`, + { + body: JSON.stringify({ + summary: 'Shared summary', + }), + headers: authHeaders, + }, + ) + const siblingResult = await siblingLocalesResponse.json() + + expect(siblingLocalesResponse.status).toBe(200) + expect(siblingResult.valid).toBe(false) + expect(siblingResult.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ locale: 'es', path: 'title' }), + expect.objectContaining({ locale: 'fr', path: 'title' }), + ]), + ) + expect(siblingResult.errors).not.toEqual( + expect.arrayContaining([expect.objectContaining({ locale: 'de', path: 'title' })]), + ) + } finally { + await payload.delete({ id: adminUser.id, collection: 'users' }) + } }) }) From 21cf8ce9b36b637a69e6fb9ba84b19043190b20f Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 20 Aug 2026 18:07:11 +0100 Subject: [PATCH 39/47] updates --- docs/validation/overview.mdx | 7 +++++ .../resolvers/collections/validate.spec.ts | 30 +++++++++++++++++++ .../src/resolvers/collections/validate.ts | 6 ++-- .../src/resolvers/globals/validate.spec.ts | 30 +++++++++++++++++++ .../graphql/src/resolvers/globals/validate.ts | 4 ++- test/validate/int.spec.ts | 16 ++++++++++ 6 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 packages/graphql/src/resolvers/collections/validate.spec.ts create mode 100644 packages/graphql/src/resolvers/globals/validate.spec.ts diff --git a/docs/validation/overview.mdx b/docs/validation/overview.mdx index 9cacdbd8e68..5985d5280e6 100644 --- a/docs/validation/overview.mdx +++ b/docs/validation/overview.mdx @@ -274,6 +274,13 @@ functions all receive the first-class and collection/global `beforeValidate` and `beforeChange` hooks also receive `operation: 'validate'`. Payload never simulates `'create'` or `'update'` as the access operation. +When `validate` access returns a `where` constraint and a document exists but doesn't match it, +validation throws `Forbidden` rather than treating the document as absent. Without this, candidate +data could validate against an empty base as though the restricted document didn't exist yet. +Collections already apply this through their existing `update`-by-ID access checks; globals apply +the same rule for validation specifically, even though a global's `find` and `update` stay silent +about a document being restricted rather than absent. + The validation lifecycle itself does not persist the candidate. Payload also rejects document, global, upload, and version writes that reuse the active validation request. Hooks still run, however, and can cause side effects through other Payload APIs, an external service, a raw database diff --git a/packages/graphql/src/resolvers/collections/validate.spec.ts b/packages/graphql/src/resolvers/collections/validate.spec.ts new file mode 100644 index 00000000000..7c583821974 --- /dev/null +++ b/packages/graphql/src/resolvers/collections/validate.spec.ts @@ -0,0 +1,30 @@ +import type { Collection, PayloadRequest } from 'payload' + +import { describe, expect, it, vi } from 'vitest' + +import { validateResolver } from './validate.js' + +const createContext = (validate: (args: { req: PayloadRequest }) => Promise) => ({ + req: { + locale: 'en', + payload: { + config: { localization: false }, + validate, + }, + } as unknown as PayloadRequest, +}) + +describe('collections validateResolver', () => { + it('isolates transactionID from the shared GraphQL request context', async () => { + const validate = vi.fn(async ({ req }: { req: PayloadRequest }) => { + req.transactionID = 'validate-transaction' + return { errors: [], valid: true } + }) + const context = createContext(validate) + const collection = { config: { slug: 'posts' } } as unknown as Collection + + await validateResolver(collection)(null, { data: { title: 'x' } }, context) + + expect(context.req.transactionID).toBeUndefined() + }) +}) diff --git a/packages/graphql/src/resolvers/collections/validate.ts b/packages/graphql/src/resolvers/collections/validate.ts index 9a439d8717e..a9b39bc8222 100644 --- a/packages/graphql/src/resolvers/collections/validate.ts +++ b/packages/graphql/src/resolvers/collections/validate.ts @@ -7,6 +7,8 @@ import type { } from 'payload' import type { DeepPartial } from 'ts-essentials' +import { isolateObjectProperty } from 'payload' + import type { Context } from '../types.js' export type Resolver = ( @@ -42,7 +44,7 @@ export function validateResolver( draft: args.draft, locale, overrideAccess: false, - req, + req: isolateObjectProperty(req, 'transactionID'), }) } @@ -53,7 +55,7 @@ export function validateResolver( draft: args.draft, locale, overrideAccess: false, - req, + req: isolateObjectProperty(req, 'transactionID'), }) } } diff --git a/packages/graphql/src/resolvers/globals/validate.spec.ts b/packages/graphql/src/resolvers/globals/validate.spec.ts new file mode 100644 index 00000000000..64ad873a593 --- /dev/null +++ b/packages/graphql/src/resolvers/globals/validate.spec.ts @@ -0,0 +1,30 @@ +import type { PayloadRequest, SanitizedGlobalConfig } from 'payload' + +import { describe, expect, it, vi } from 'vitest' + +import { validateResolver } from './validate.js' + +const createContext = (validateGlobal: (args: { req: PayloadRequest }) => Promise) => ({ + req: { + locale: 'en', + payload: { + config: { localization: false }, + validateGlobal, + }, + } as unknown as PayloadRequest, +}) + +describe('globals validateResolver', () => { + it('isolates transactionID from the shared GraphQL request context', async () => { + const validateGlobal = vi.fn(async ({ req }: { req: PayloadRequest }) => { + req.transactionID = 'validate-transaction' + return { errors: [], valid: true } + }) + const context = createContext(validateGlobal) + const globalConfig = { slug: 'settings' } as unknown as SanitizedGlobalConfig + + await validateResolver(globalConfig)(null, { data: { title: 'x' } }, context) + + expect(context.req.transactionID).toBeUndefined() + }) +}) diff --git a/packages/graphql/src/resolvers/globals/validate.ts b/packages/graphql/src/resolvers/globals/validate.ts index 79a026d0bc1..0c3167dc25a 100644 --- a/packages/graphql/src/resolvers/globals/validate.ts +++ b/packages/graphql/src/resolvers/globals/validate.ts @@ -7,6 +7,8 @@ import type { } from 'payload' import type { DeepPartial } from 'ts-essentials' +import { isolateObjectProperty } from 'payload' + import type { Context } from '../types.js' export type Resolver = ( @@ -39,7 +41,7 @@ export function validateResolver( draft: args.draft, locale, overrideAccess: false, - req, + req: isolateObjectProperty(req, 'transactionID'), }) } } diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 231296d878b..a9016da15aa 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -663,6 +663,22 @@ describe('validate Local API', () => { expect(localeFilterOperationEvents).toEqual(['validate']) }) + it('should not duplicate a non-localized field error once per resolved locale', async () => { + const result = await payload.validate({ + collection: validationCollectionSlug, + context: { + availableLocaleCodes: ['en', 'de'], + }, + data: { + title: 'Candidate title', + }, + locale: 'all', + }) + + expect(result.valid).toBe(false) + expect(result.errors.filter((error) => error.path === 'summary')).toHaveLength(1) + }) + it('should deduplicate explicit locales in deterministic order', async () => { const result = await payload.validate({ collection: validationCollectionSlug, From b817d18071b5b6117c10bc437acf81bae6a3dd93 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 20 Aug 2026 18:15:33 +0100 Subject: [PATCH 40/47] reuse util for throwing errors from validation --- .../src/collections/operations/validate.ts | 15 +- .../src/globals/operations/validate.ts | 21 +- .../isValidationErrorPathLocalized.spec.ts | 219 ++++++++++++++++++ .../isValidationErrorPathLocalized.ts | 214 +++++++++++++++++ .../utilities/runLocaleScopedValidation.ts | 56 ++++- .../src/utilities/toValidationResult.spec.ts | 39 ++++ .../src/utilities/toValidationResult.ts | 30 +++ 7 files changed, 568 insertions(+), 26 deletions(-) create mode 100644 packages/payload/src/utilities/isValidationErrorPathLocalized.spec.ts create mode 100644 packages/payload/src/utilities/isValidationErrorPathLocalized.ts create mode 100644 packages/payload/src/utilities/toValidationResult.spec.ts create mode 100644 packages/payload/src/utilities/toValidationResult.ts diff --git a/packages/payload/src/collections/operations/validate.ts b/packages/payload/src/collections/operations/validate.ts index 5f3e6b9bb6f..a8432c2d2bc 100644 --- a/packages/payload/src/collections/operations/validate.ts +++ b/packages/payload/src/collections/operations/validate.ts @@ -9,12 +9,13 @@ import type { ValidationResult } from './local/validate.js' import { executeAccess } from '../../auth/executeAccess.js' import { hasWhereAccessResult } from '../../auth/types.js' import { combineQueries } from '../../database/combineQueries.js' -import { Forbidden, NotFound, ValidationError } from '../../errors/index.js' +import { Forbidden, NotFound } from '../../errors/index.js' import { beforeChange } from '../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js' import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' import { flattenDataByLocale } from '../../utilities/flattenDataByLocale.js' +import { toValidationResult } from '../../utilities/toValidationResult.js' import { replaceWithDraftIfAvailable } from '../../versions/drafts/replaceWithDraftIfAvailable.js' export type Arguments = { @@ -175,17 +176,7 @@ async function validateOperationWithScopedRequest( req, }) } catch (error) { - if (error instanceof ValidationError) { - return { - errors: error.data.errors.map((validationError) => ({ - ...validationError, - locale: req.locale ?? undefined, - })), - valid: false, - } - } - - throw error + return toValidationResult({ error, req }) } return { diff --git a/packages/payload/src/globals/operations/validate.ts b/packages/payload/src/globals/operations/validate.ts index ebd49da3a70..60d5c1a8b6f 100644 --- a/packages/payload/src/globals/operations/validate.ts +++ b/packages/payload/src/globals/operations/validate.ts @@ -9,11 +9,12 @@ import type { DataFromGlobalSlug, SanitizedGlobalConfig } from '../config/types. import { executeAccess } from '../../auth/executeAccess.js' import { hasWhereAccessResult } from '../../auth/types.js' -import { Forbidden, ValidationError } from '../../errors/index.js' +import { Forbidden } from '../../errors/index.js' import { beforeChange } from '../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js' import { deepCopyObjectSimple } from '../../utilities/deepCopyObject.js' import { flattenDataByLocale } from '../../utilities/flattenDataByLocale.js' +import { toValidationResult } from '../../utilities/toValidationResult.js' import { replaceWithDraftIfAvailable } from '../../versions/drafts/replaceWithDraftIfAvailable.js' export type Arguments = { @@ -140,17 +141,7 @@ async function validateOperationWithScopedRequest({ req, }) } catch (error) { - if (error instanceof ValidationError) { - return { - errors: error.data.errors.map((validationError) => ({ - ...validationError, - locale: req.locale ?? undefined, - })), - valid: false, - } - } - - throw error + return toValidationResult({ error, req }) } return { @@ -205,6 +196,12 @@ async function loadValidationGlobalCandidate({ return { base, hasMain, source } } +/** + * Deliberately more revealing than `findOne`/`update`, which treat an access-restricted global + * the same as an unconfigured one and stay silent. Validation instead throws `Forbidden` once it + * confirms real data exists behind the `where` policy - otherwise a restricted, already-configured + * global would validate the candidate against an empty base, as if it were still unset. + */ async function resolveValidationGlobalSource({ slug, accessResult, diff --git a/packages/payload/src/utilities/isValidationErrorPathLocalized.spec.ts b/packages/payload/src/utilities/isValidationErrorPathLocalized.spec.ts new file mode 100644 index 00000000000..c1d6a114950 --- /dev/null +++ b/packages/payload/src/utilities/isValidationErrorPathLocalized.spec.ts @@ -0,0 +1,219 @@ +import type { Field } from '../fields/config/types.js' + +import { describe, expect, it } from 'vitest' + +import { isValidationErrorPathLocalized } from './isValidationErrorPathLocalized.js' + +const fields: Field[] = [ + { name: 'shared', type: 'text' }, + { name: 'localizedJSON', type: 'json', localized: true }, + { + name: 'localizedGroup', + type: 'group', + fields: [{ name: 'value', type: 'text' }], + localized: true, + }, + { + name: 'nested', + type: 'group', + fields: [ + { name: 'localizedJSON', type: 'json', localized: true }, + { name: 'shared', type: 'text' }, + ], + }, + { + name: 'nestedArray', + type: 'array', + fields: [ + { name: 'localizedJSON', type: 'json', localized: true }, + { name: 'shared', type: 'text' }, + ], + }, + { + name: 'nestedBlocks', + type: 'blocks', + blocks: [ + { + slug: 'nested', + fields: [ + { name: 'localizedJSON', type: 'json', localized: true }, + { name: 'shared', type: 'text' }, + ], + }, + ], + }, + { + type: 'row', + fields: [{ name: 'rowShared', type: 'text' }], + }, + { + type: 'tabs', + tabs: [ + { + name: 'localizedTab', + fields: [{ name: 'value', type: 'text' }], + localized: true, + }, + { + fields: [{ name: 'unnamedTabShared', type: 'text' }], + }, + ], + }, +] + +const data = { + shared: 'shared value', + localizedJSON: { value: 'active JSON' }, + localizedGroup: { value: 'active group' }, + localizedTab: { value: 'active tab' }, + nested: { + localizedJSON: { value: 'active nested JSON' }, + shared: 'shared value', + }, + nestedArray: [ + { + localizedJSON: { value: 'active array JSON' }, + shared: 'shared array value', + }, + ], + nestedBlocks: [ + { + blockType: 'nested', + localizedJSON: { value: 'active block JSON' }, + shared: 'shared block value', + }, + ], + rowShared: 'row value', + unnamedTabShared: 'unnamed tab value', +} + +describe('isValidationErrorPathLocalized', () => { + it('returns false for a top-level non-localized field', () => { + expect( + isValidationErrorPathLocalized({ configBlockReferences: [], data, fields, path: 'shared' }), + ).toBe(false) + }) + + it('returns true for a top-level localized field', () => { + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'localizedJSON', + }), + ).toBe(true) + }) + + it('returns true for any field nested inside a localized group, regardless of its own flag', () => { + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'localizedGroup.value', + }), + ).toBe(true) + }) + + it('returns false for a non-localized field nested inside a non-localized group', () => { + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'nested.shared', + }), + ).toBe(false) + }) + + it('returns true for a localized field nested inside a non-localized group', () => { + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'nested.localizedJSON', + }), + ).toBe(true) + }) + + it('resolves array row indices without consuming a field name', () => { + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'nestedArray.0.shared', + }), + ).toBe(false) + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'nestedArray.0.localizedJSON', + }), + ).toBe(true) + }) + + it('resolves blocks row indices via the row blockType', () => { + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'nestedBlocks.0.shared', + }), + ).toBe(false) + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'nestedBlocks.0.localizedJSON', + }), + ).toBe(true) + }) + + it('sees through unnamed presentational row fields', () => { + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'rowShared', + }), + ).toBe(false) + }) + + it('resolves named and unnamed tabs', () => { + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'localizedTab.value', + }), + ).toBe(true) + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'unnamedTabShared', + }), + ).toBe(false) + }) + + it('conservatively treats an unresolvable path as localized', () => { + expect( + isValidationErrorPathLocalized({ + configBlockReferences: [], + data, + fields, + path: 'doesNotExist', + }), + ).toBe(true) + }) +}) diff --git a/packages/payload/src/utilities/isValidationErrorPathLocalized.ts b/packages/payload/src/utilities/isValidationErrorPathLocalized.ts new file mode 100644 index 00000000000..f65f32df3a8 --- /dev/null +++ b/packages/payload/src/utilities/isValidationErrorPathLocalized.ts @@ -0,0 +1,214 @@ +import type { Block, Field, FlattenedBlock } from '../fields/config/types.js' +import type { SanitizedConfig } from '../index.js' +import type { JsonObject } from '../types/index.js' + +import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../fields/config/types.js' + +type IsValidationErrorPathLocalizedArgs = { + configBlockReferences: SanitizedConfig['blocks'] + data: JsonObject + fields: Field[] + path: string +} + +/** + * Reports whether a `ValidationFieldError.path` refers to a localized field, by walking `fields` + * and `data` together the same way `projectNonLocalizedData` does. Used to tell apart a candidate + * error that's inherently per-locale from one for a shared, non-localized field that every locale + * pass in `runLocaleScopedValidation` re-validates identically. A path that can't be resolved + * (unknown field, malformed path) is conservatively treated as localized, since wrongly collapsing + * a genuinely per-locale error into one is worse than leaving an occasional duplicate. + */ +export function isValidationErrorPathLocalized({ + configBlockReferences, + data, + fields, + path, +}: IsValidationErrorPathLocalizedArgs): boolean { + return ( + resolvePathLocalization({ + configBlockReferences, + data, + fields, + parentIsLocalized: false, + segments: path.split('.'), + }) ?? true + ) +} + +type ResolvePathLocalizationArgs = { + configBlockReferences: SanitizedConfig['blocks'] + data: unknown + fields: Field[] + parentIsLocalized: boolean + segments: string[] +} + +/** + * Returns `undefined` when `segments[0]` matches no field at this level, so the caller can keep + * searching sibling fields (relevant for `row`/`collapsible`/unnamed `tabs`, which don't consume a + * path segment themselves). + */ +function resolvePathLocalization({ + configBlockReferences, + data, + fields, + parentIsLocalized, + segments, +}: ResolvePathLocalizationArgs): boolean | undefined { + const [segment, ...remainingSegments] = segments + + if (segment === undefined) { + return parentIsLocalized + } + + if (/^\d+$/.test(segment) && Array.isArray(data)) { + return resolvePathLocalization({ + configBlockReferences, + data: data[Number(segment)], + fields, + parentIsLocalized, + segments: remainingSegments, + }) + } + + for (const field of fields) { + if (fieldAffectsData(field)) { + if (field.name !== segment) { + continue + } + + const isLocalized = parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized }) + + if (remainingSegments.length === 0) { + return isLocalized + } + + const fieldValue = isObject(data) ? data[segment] : undefined + + switch (field.type) { + case 'array': + case 'group': + return ( + resolvePathLocalization({ + configBlockReferences, + data: fieldValue, + fields: field.fields, + parentIsLocalized: isLocalized, + segments: remainingSegments, + }) ?? isLocalized + ) + + case 'blocks': { + if (!Array.isArray(fieldValue)) { + return isLocalized + } + + const [rowSegment, ...afterRow] = remainingSegments + const row = + rowSegment && /^\d+$/.test(rowSegment) ? fieldValue[Number(rowSegment)] : undefined + + if (!isObject(row)) { + return isLocalized + } + + const blockOrSlug = field.blocks.find((block) => { + const slug = typeof block === 'string' ? block : block.slug + return slug === row.blockType + }) + const block: Block | FlattenedBlock | undefined = + typeof blockOrSlug === 'string' + ? configBlockReferences?.find(({ slug }) => slug === blockOrSlug) + : blockOrSlug + + if (!block) { + return isLocalized + } + + return ( + resolvePathLocalization({ + configBlockReferences, + data: row, + fields: block.fields, + parentIsLocalized: isLocalized, + segments: afterRow, + }) ?? isLocalized + ) + } + + default: + return isLocalized + } + } + + switch (field.type) { + case 'collapsible': + case 'row': { + const nested = resolvePathLocalization({ + configBlockReferences, + data, + fields: field.fields, + parentIsLocalized, + segments, + }) + + if (nested !== undefined) { + return nested + } + + continue + } + + case 'tabs': { + for (const tab of field.tabs) { + if (!tabHasName(tab)) { + const nested = resolvePathLocalization({ + configBlockReferences, + data, + fields: tab.fields, + parentIsLocalized, + segments, + }) + + if (nested !== undefined) { + return nested + } + + continue + } + + if (tab.name !== segment) { + continue + } + + const isLocalized = + parentIsLocalized || fieldShouldBeLocalized({ field: tab, parentIsLocalized }) + + if (remainingSegments.length === 0) { + return isLocalized + } + + const tabValue = isObject(data) ? data[segment] : undefined + + return ( + resolvePathLocalization({ + configBlockReferences, + data: tabValue, + fields: tab.fields, + parentIsLocalized: isLocalized, + segments: remainingSegments, + }) ?? isLocalized + ) + } + + continue + } + } + } + + return undefined +} + +function isObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} diff --git a/packages/payload/src/utilities/runLocaleScopedValidation.ts b/packages/payload/src/utilities/runLocaleScopedValidation.ts index a37e7c6bd41..58339794d6b 100644 --- a/packages/payload/src/utilities/runLocaleScopedValidation.ts +++ b/packages/payload/src/utilities/runLocaleScopedValidation.ts @@ -1,9 +1,11 @@ import type { ValidationResult } from '../collections/operations/local/validate.js' +import type { ValidationFieldError } from '../errors/index.js' import type { Field } from '../fields/config/types.js' -import type { Payload, RequestContext, User } from '../index.js' +import type { Payload, RequestContext, SanitizedConfig, User } from '../index.js' import type { JsonObject, PayloadRequest } from '../types/index.js' import { createLocalReq } from './createLocalReq.js' +import { isValidationErrorPathLocalized } from './isValidationErrorPathLocalized.js' import { projectNonLocalizedData } from './projectNonLocalizedData.js' import { cloneValidationRequest, @@ -80,10 +82,60 @@ export async function runLocaleScopedValidation({ return runPass({ data: validationData, req: localeReq }) }, }) - const errors = results.flatMap((result) => result.errors) + const rawErrors = results.flatMap((result) => result.errors) + + // A non-localized field carries one shared value, so every locale pass validates it + // identically and would otherwise report the same failure once per resolved locale. + const errors = + locales.length > 1 + ? dedupeNonLocalizedFieldErrors({ + configBlockReferences: payload.config.blocks, + data: data as JsonObject, + errors: rawErrors, + fields, + }) + : rawErrors return { errors, valid: errors.length === 0, } } + +function dedupeNonLocalizedFieldErrors({ + configBlockReferences, + data, + errors, + fields, +}: { + configBlockReferences: SanitizedConfig['blocks'] + data: JsonObject + errors: ValidationFieldError[] + fields: Field[] +}): ValidationFieldError[] { + const seenNonLocalizedPaths = new Set() + const deduped: ValidationFieldError[] = [] + + for (const error of errors) { + const isLocalized = isValidationErrorPathLocalized({ + configBlockReferences, + data, + fields, + path: error.path, + }) + + if (isLocalized) { + deduped.push(error) + continue + } + + if (seenNonLocalizedPaths.has(error.path)) { + continue + } + + seenNonLocalizedPaths.add(error.path) + deduped.push({ ...error, locale: undefined }) + } + + return deduped +} diff --git a/packages/payload/src/utilities/toValidationResult.spec.ts b/packages/payload/src/utilities/toValidationResult.spec.ts new file mode 100644 index 00000000000..c897afbe2eb --- /dev/null +++ b/packages/payload/src/utilities/toValidationResult.spec.ts @@ -0,0 +1,39 @@ +import type { PayloadRequest } from '../types/index.js' + +import { describe, expect, it } from 'vitest' + +import { ValidationError } from '../errors/index.js' +import { toValidationResult } from './toValidationResult.js' + +describe('toValidationResult', () => { + it('maps a ValidationError to field errors tagged with the request locale', () => { + const req = { locale: 'de' } as PayloadRequest + const error = new ValidationError({ + errors: [{ label: 'Title', message: 'Title is required', path: 'title' }], + }) + + expect(toValidationResult({ error, req })).toEqual({ + errors: [{ label: 'Title', locale: 'de', message: 'Title is required', path: 'title' }], + valid: false, + }) + }) + + it('omits the locale when the request has none', () => { + const req = {} as PayloadRequest + const error = new ValidationError({ + errors: [{ message: 'Title is required', path: 'title' }], + }) + + expect(toValidationResult({ error, req })).toEqual({ + errors: [{ locale: undefined, message: 'Title is required', path: 'title' }], + valid: false, + }) + }) + + it('rethrows errors that are not a ValidationError', () => { + const req = { locale: 'en' } as PayloadRequest + const error = new Error('boom') + + expect(() => toValidationResult({ error, req })).toThrow(error) + }) +}) diff --git a/packages/payload/src/utilities/toValidationResult.ts b/packages/payload/src/utilities/toValidationResult.ts new file mode 100644 index 00000000000..61b71fb578a --- /dev/null +++ b/packages/payload/src/utilities/toValidationResult.ts @@ -0,0 +1,30 @@ +import type { ValidationResult } from '../collections/operations/local/validate.js' +import type { PayloadRequest } from '../types/index.js' + +import { ValidationError } from '../errors/index.js' + +/** + * Maps a caught `ValidationError` to a `ValidationResult`, tagging each field error with the + * request's locale. Rethrows any other error, since only field validation failures are part of + * the validate operation's contract. Meant to be called from a `catch` block around the + * `beforeValidate`/`beforeChange` hook sequence. + */ +export function toValidationResult({ + error, + req, +}: { + error: unknown + req: PayloadRequest +}): ValidationResult { + if (!(error instanceof ValidationError)) { + throw error + } + + return { + errors: error.data.errors.map((validationError) => ({ + ...validationError, + locale: req.locale ?? undefined, + })), + valid: false, + } +} From faff19da1e10e1122e8ca4ca49f901f64d29b9cf Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 20 Aug 2026 20:10:42 +0100 Subject: [PATCH 41/47] initial commit --- packages/translations/src/clientKeys.ts | 1 + .../ui/src/elements/PublishButton/index.tsx | 43 ++- .../PublishButton/validateAllLocales.spec.ts | 191 ++++++++++ .../PublishButton/validateAllLocales.ts | 360 ++++++++++++++++++ test/validate/e2e.spec.ts | 32 +- 5 files changed, 624 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts create mode 100644 packages/ui/src/elements/PublishButton/validateAllLocales.ts diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index 9316e50a675..d8bed70b8e1 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -105,6 +105,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'error:autosaving', 'error:correctInvalidFields', 'error:deletingTitle', + 'error:followingFieldsInvalid', 'error:documentNotFound', 'error:emailOrPasswordIncorrect', 'error:usernameOrPasswordIncorrect', diff --git a/packages/ui/src/elements/PublishButton/index.tsx b/packages/ui/src/elements/PublishButton/index.tsx index 13c60f2b5af..88000637f22 100644 --- a/packages/ui/src/elements/PublishButton/index.tsx +++ b/packages/ui/src/elements/PublishButton/index.tsx @@ -6,6 +6,7 @@ import { getTranslation } from '@payloadcms/translations' import { formatAdminURL, hasAutosaveEnabled, hasLocalizeStatusEnabled } from 'payload/shared' import * as qs from 'qs-esm' import React, { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' import { useForm, useFormModified } from '../../forms/Form/context.js' import { FormSubmit } from '../../forms/Submit/index.js' @@ -18,6 +19,8 @@ import { useOperation } from '../../providers/Operation/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { traverseForLocalizedFields } from '../../utilities/traverseForLocalizedFields.js' import { PopupList } from '../Popup/index.js' +import { FieldErrorsToast } from '../Toasts/fieldErrors.js' +import { getValidationEndpoint, validateDocumentLocales } from './validateAllLocales.js' import './index.css' export function PublishButton({ @@ -37,7 +40,7 @@ export function PublishButton({ } = useDocumentInfo() const { config, getEntityConfig } = useConfig() - const { submit } = useForm() + const { getData, submit } = useForm() const modified = useFormModified() const editDepth = useEditDepth() const { code: localeCode } = useLocale() @@ -69,6 +72,7 @@ export function PublishButton({ uploadStatus !== 'uploading' const [hasLocalizedFields, setHasLocalizedFields] = useState(false) + const [isValidatingLocales, setIsValidatingLocales] = useState(false) useEffect(() => { const hasLocalizedField = traverseForLocalizedFields(entityConfig?.fields, { blocksMap }) @@ -145,6 +149,34 @@ export function PublishButton({ return } + if (localization && hasLocalizedFields) { + setIsValidatingLocales(true) + let validation + + try { + validation = await validateDocumentLocales({ + activeLocale: localeCode, + blocksMap, + data: { ...getData(), _status: 'published' }, + endpoint: getValidationEndpoint({ id, apiRoute: api, collectionSlug, globalSlug }), + fields: entityConfig?.fields ?? [], + locales: localization.locales.map(({ code }) => code), + }) + } finally { + setIsValidatingLocales(false) + } + + if (!validation.valid) { + const introMessage = t('error:followingFieldsInvalid', { count: validation.errors.length }) + const fieldList = validation.errors + .map((error) => `${error.locale ? `[${error.locale}] ` : ''}${error.label ?? error.path}`) + .join(', ') + + toast.error() + return + } + } + const params = qs.stringify( { depth: 0, @@ -177,12 +209,18 @@ export function PublishButton({ localeCode, localizeStatusEnabled, api, + blocksMap, collectionSlug, + entityConfig?.fields, + getData, globalSlug, + hasLocalizedFields, id, + localization, setHasPublishedDoc, submit, setUnpublishedVersionCount, + t, uploadStatus, setMostRecentVersionIsAutosaved, ]) @@ -256,7 +294,8 @@ export function PublishButton({ publishLocale(activeLocale.code)} size="medium" SubMenuPopupContent={ diff --git a/packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts b/packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts new file mode 100644 index 00000000000..746f0ed47db --- /dev/null +++ b/packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts @@ -0,0 +1,191 @@ +import type { ClientField } from 'payload' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + getValidationEndpoint, + projectValidationDataForSiblingLocales, + requestDocumentValidation, + validateDocumentLocales, +} from './validateAllLocales.js' + +describe('validate all locales before publish', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should construct create, update, and global validation endpoints', () => { + expect( + getValidationEndpoint({ + apiRoute: '/api', + collectionSlug: 'posts', + }), + ).toBe('/api/posts/validate') + expect( + getValidationEndpoint({ + apiRoute: '/api', + collectionSlug: 'posts', + id: 'post/id', + }), + ).toBe('/api/posts/post%2Fid/validate') + expect( + getValidationEndpoint({ + apiRoute: '/api', + globalSlug: 'settings', + }), + ).toBe('/api/globals/settings/validate') + }) + + it('should remove localized values from nested sibling-locale candidate data', () => { + const fields = [ + { localized: true, name: 'title', type: 'text' }, + { + fields: [ + { localized: true, name: 'strapline', type: 'text' }, + { name: 'theme', type: 'text' }, + ], + name: 'settings', + type: 'group', + }, + { + fields: [ + { localized: true, name: 'caption', type: 'text' }, + { name: 'kind', type: 'text' }, + ], + name: 'items', + type: 'array', + }, + { + blocks: [ + { + fields: [ + { localized: true, name: 'copy', type: 'text' }, + { name: 'style', type: 'text' }, + ], + slug: 'hero', + }, + ], + name: 'layout', + type: 'blocks', + }, + { + tabs: [ + { + fields: [{ name: 'body', type: 'richText' }], + localized: true, + name: 'seo', + }, + ], + type: 'tabs', + }, + { localized: true, name: 'metadata', type: 'json' }, + ] as ClientField[] + + expect( + projectValidationDataForSiblingLocales({ + blocksMap: {}, + data: { + layout: [{ blockType: 'hero', copy: 'Active copy', style: 'dark' }], + items: [{ caption: 'Active caption', kind: 'card' }], + metadata: { title: 'Active metadata' }, + settings: { strapline: 'Active strapline', theme: 'dark' }, + seo: { body: { root: {} } }, + title: 'Active title', + }, + fields, + }), + ).toEqual({ + layout: [{ blockType: 'hero', style: 'dark' }], + items: [{ kind: 'card' }], + settings: { theme: 'dark' }, + }) + }) + + it('should not let valid unsaved active-locale values satisfy invalid stored sibling locales', async () => { + const request = vi.fn(async ({ body, locales: requestedLocales }) => { + if (requestedLocales.includes('de') && 'title' in body) { + return { + errors: [], + valid: true, + } + } + + return { + errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], + valid: false, + } + }) + + const result = await validateDocumentLocales({ + activeLocale: 'de', + blocksMap: {}, + data: { summary: 'Shared summary', title: 'Unsaved German title' }, + endpoint: '/api/posts/123/validate', + fields: [ + { localized: true, name: 'title', type: 'text' }, + { name: 'summary', type: 'text' }, + ], + locales: ['en', 'de', 'fr'], + request, + }) + + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + body: { summary: 'Shared summary', title: 'Unsaved German title' }, + locales: ['de'], + }), + ) + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + body: { summary: 'Shared summary' }, + locales: ['en', 'fr'], + }), + ) + expect(result).toEqual({ + errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], + valid: false, + }) + }) + + it('should render validation errors returned in a non-2xx Payload error response', async () => { + const fetchMock = vi.fn(async () => { + return Response.json( + { + errors: [ + { + data: { + errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], + }, + message: 'The following field is invalid: title', + name: 'ValidationError', + }, + ], + }, + { status: 400 }, + ) + }) + + vi.stubGlobal('fetch', fetchMock) + + await expect( + requestDocumentValidation({ + body: { title: 'Titre' }, + endpoint: '/api/posts/123/validate', + locales: ['en', 'fr'], + }), + ).resolves.toEqual({ + errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], + valid: false, + }) + expect(fetchMock).toHaveBeenCalledWith( + '/api/posts/123/validate?locale=en&locale=fr', + expect.objectContaining({ + body: JSON.stringify({ title: 'Titre' }), + credentials: 'include', + method: 'POST', + }), + ) + }) +}) diff --git a/packages/ui/src/elements/PublishButton/validateAllLocales.ts b/packages/ui/src/elements/PublishButton/validateAllLocales.ts new file mode 100644 index 00000000000..3e5504d3d7c --- /dev/null +++ b/packages/ui/src/elements/PublishButton/validateAllLocales.ts @@ -0,0 +1,360 @@ +import type { + ClientBlock, + ClientField, + Data, + ValidationFieldError, + ValidationResult, +} from 'payload' + +import { + fieldAffectsData, + fieldShouldBeLocalized, + formatAdminURL, + tabHasName, +} from 'payload/shared' + +export type DocumentValidationRequest = (args: { + body: Data + endpoint: string + locales: string[] + signal?: AbortSignal +}) => Promise + +type ValidationTarget = { + apiRoute: string + collectionSlug?: string + globalSlug?: string + id?: number | string +} + +export function getValidationEndpoint({ + id, + apiRoute, + collectionSlug, + globalSlug, +}: ValidationTarget): string { + if (!collectionSlug && !globalSlug) { + throw new Error('Document validation requires a collection or global slug.') + } + + const encodedID = id === undefined ? '' : `/${encodeURIComponent(String(id))}` + const path: `/${string}` = globalSlug + ? `/globals/${encodeURIComponent(globalSlug)}/validate` + : `/${encodeURIComponent(collectionSlug)}${encodedID}/validate` + + return formatAdminURL({ + apiRoute, + path, + }) +} + +export function projectValidationDataForSiblingLocales({ + blocksMap, + data, + fields, +}: { + blocksMap: Record + data: Data + fields: ClientField[] +}): Data { + const projectedData = cloneValidationData(data) + + removeLocalizedData({ + blocksMap, + data: projectedData, + fields, + parentIsLocalized: false, + }) + + return projectedData +} + +export async function validateDocumentLocales({ + activeLocale, + blocksMap, + data, + endpoint, + fields, + locales, + request = requestDocumentValidation, + signal, +}: { + activeLocale: string + blocksMap: Record + data: Data + endpoint: string + fields: ClientField[] + locales: string[] + request?: DocumentValidationRequest + signal?: AbortSignal +}): Promise { + const selectedLocales = locales.filter((locale, index) => locales.indexOf(locale) === index) + const validationResults: ValidationResult[] = [] + + if (selectedLocales.includes(activeLocale)) { + validationResults.push( + await request({ + body: data, + endpoint, + locales: [activeLocale], + signal, + }), + ) + } + + const siblingLocales = selectedLocales.filter((locale) => locale !== activeLocale) + + if (siblingLocales.length > 0) { + validationResults.push( + await request({ + body: projectValidationDataForSiblingLocales({ + blocksMap, + data, + fields, + }), + endpoint, + locales: siblingLocales, + signal, + }), + ) + } + + const errors = validationResults.flatMap(({ errors }) => errors) + + return { + errors, + valid: validationResults.every(({ valid }) => valid), + } +} + +export async function requestDocumentValidation({ + body, + endpoint, + locales, + signal, +}: Parameters[0]): Promise { + const search = new URLSearchParams() + + for (const locale of locales) { + search.append('locale', locale) + } + + const response = await fetch(`${endpoint}?${search.toString()}`, { + body: JSON.stringify(body), + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + signal, + }) + const responseData = (await response.json()) as unknown + const result = parseValidationResult(responseData) + + if (result) { + return result + } + + throw new Error(getResponseErrorMessage(responseData) || response.statusText) +} + +function parseValidationResult(responseData: unknown): null | ValidationResult { + if (!isObject(responseData)) { + return null + } + + if (typeof responseData.valid === 'boolean' && Array.isArray(responseData.errors)) { + return { + errors: responseData.errors.filter(isValidationFieldError), + valid: responseData.valid, + } + } + + if (Array.isArray(responseData.errors)) { + const errors = responseData.errors.flatMap((error) => { + if (!isObject(error) || !isObject(error.data) || !Array.isArray(error.data.errors)) { + return [] + } + + return error.data.errors.filter(isValidationFieldError) + }) + + if (errors.length > 0) { + return { + errors, + valid: false, + } + } + } + + return null +} + +function getResponseErrorMessage(responseData: unknown): null | string { + if (!isObject(responseData)) { + return null + } + + if (typeof responseData.message === 'string') { + return responseData.message + } + + if (Array.isArray(responseData.errors)) { + const errorWithMessage = responseData.errors.find( + (error) => isObject(error) && typeof error.message === 'string', + ) + + if (isObject(errorWithMessage) && typeof errorWithMessage.message === 'string') { + return errorWithMessage.message + } + } + + return null +} + +function isValidationFieldError(value: unknown): value is ValidationFieldError { + return ( + isObject(value) && + typeof value.message === 'string' && + typeof value.path === 'string' && + (value.locale === undefined || typeof value.locale === 'string') + ) +} + +function cloneValidationData(value: T): T { + if (Array.isArray(value)) { + return value.map(cloneValidationData) as T + } + + if (isObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, childValue]) => [key, cloneValidationData(childValue)]), + ) as T + } + + return value +} + +function removeLocalizedData({ + blocksMap, + data, + fields, + parentIsLocalized, +}: { + blocksMap: Record + data: Data + fields: ClientField[] + parentIsLocalized: boolean +}): void { + for (const field of fields) { + if (fieldAffectsData(field)) { + if (parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized })) { + delete data[field.name] + continue + } + + const fieldValue = data[field.name] + + switch (field.type) { + case 'array': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (isObject(row)) { + removeLocalizedData({ + blocksMap, + data: row, + fields: field.fields, + parentIsLocalized: false, + }) + } + } + } + break + } + + case 'blocks': { + if (Array.isArray(fieldValue)) { + for (const row of fieldValue) { + if (!isObject(row) || typeof row.blockType !== 'string') { + continue + } + + const blockOrSlug = field.blocks.find((block) => { + return (typeof block === 'string' ? block : block.slug) === row.blockType + }) + const block = typeof blockOrSlug === 'string' ? blocksMap[blockOrSlug] : blockOrSlug + + if (block) { + removeLocalizedData({ + blocksMap, + data: row, + fields: block.fields, + parentIsLocalized: false, + }) + } + } + } + break + } + + case 'group': { + if (isObject(fieldValue)) { + removeLocalizedData({ + blocksMap, + data: fieldValue, + fields: field.fields, + parentIsLocalized: false, + }) + } + break + } + } + } else { + switch (field.type) { + case 'collapsible': + case 'group': + case 'row': { + const isLocalized = + parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized }) + + removeLocalizedData({ + blocksMap, + data, + fields: field.fields, + parentIsLocalized: isLocalized, + }) + break + } + + case 'tabs': { + for (const tab of field.tabs) { + if (tabHasName(tab)) { + if (parentIsLocalized || fieldShouldBeLocalized({ field: tab, parentIsLocalized })) { + delete data[tab.name] + } else if (isObject(data[tab.name])) { + removeLocalizedData({ + blocksMap, + data: data[tab.name], + fields: tab.fields, + parentIsLocalized: false, + }) + } + } else { + removeLocalizedData({ + blocksMap, + data, + fields: tab.fields, + parentIsLocalized, + }) + } + } + break + } + } + } + } +} + +function isObject(value: unknown): value is Data { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} diff --git a/test/validate/e2e.spec.ts b/test/validate/e2e.spec.ts index c5ec591dc71..b06e5ff53b2 100644 --- a/test/validate/e2e.spec.ts +++ b/test/validate/e2e.spec.ts @@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test' import path from 'path' import { fileURLToPath } from 'url' -import { changeLocale, waitForFormReady } from '../__helpers/e2e/helpers.js' +import { changeLocale, saveDocAndAssert, waitForFormReady } from '../__helpers/e2e/helpers.js' import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js' import { RESTClient } from '../__helpers/shared/rest.js' @@ -96,6 +96,36 @@ test.describe('Custom locale validation buttons', () => { await expect(result).not.toContainText('de title') }) + test('should block publishing all locales and toast the invalid locale when a sibling locale is missing required data', async () => { + const id = await createDraft({ spanishTitle: 'Título en español' }) + + await openDraft(id) + await saveDocAndAssert(page, '#action-save', 'error') + await expect(page.locator('.payload-toast-container')).toContainText('[de]') + + await expect + .poll(async () => { + const { doc } = await client.findByID({ + id, + slug: validationCustomButtonsCollectionSlug, + }) + + return doc._status + }) + .not.toBe('published') + }) + + test('should publish successfully when every locale has valid data', async () => { + const id = await createDraft({ + frenchTitle: 'Titre en français', + germanTitle: 'Deutscher Titel', + spanishTitle: 'Título en español', + }) + + await openDraft(id) + await saveDocAndAssert(page, '#action-save', 'success') + }) + async function createDraft({ frenchTitle, germanTitle, From 944a068da874f0ad0703816a949f61bd05909195 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 21 Aug 2026 13:27:08 +0100 Subject: [PATCH 42/47] fix versions test --- test/versions/e2e.spec.ts | 104 +++++++++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 12 deletions(-) diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 3e6998f5e6f..0a06eef538b 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -283,7 +283,26 @@ describe('Versions', () => { }) test('should restore version with correct data', async () => { - await page.goto(url.create) + // Seed the sibling locales so the later publish click (which now validates every + // locale) doesn't get blocked by their otherwise-empty required title field. + const seed = await payload.create({ + collection: draftCollectionSlug, + data: { title: 'seed' }, + draft: true, + overrideAccess: true, + }) + + for (const locale of ['es', 'de', 'fr']) { + await payload.update({ + id: seed.id, + collection: draftCollectionSlug, + data: { title: 'seed' }, + locale, + overrideAccess: true, + }) + } + + await page.goto(url.edit(seed.id)) await waitForFormReady(page) await page.locator('#field-title').fill('v1') await page.locator('#field-description').fill('hello') @@ -303,7 +322,27 @@ describe('Versions', () => { }) test('should restore version as draft', async () => { - await page.goto(url.create) + // Seed the sibling locales so the later publish clicks (which now validate every + // locale) don't get blocked by their otherwise-empty required title field. + const seed = await payload.create({ + collection: draftCollectionSlug, + data: { title: 'seed' }, + draft: true, + overrideAccess: true, + }) + + for (const locale of ['es', 'de', 'fr']) { + await payload.update({ + id: seed.id, + collection: draftCollectionSlug, + data: { title: 'seed' }, + locale, + overrideAccess: true, + }) + } + + await page.goto(url.edit(seed.id)) + await waitForFormReady(page) await page.locator('#field-title').fill('v1') await saveDocAndAssert(page, '#action-save-draft') await page.locator('#field-title').fill('v2') @@ -600,7 +639,27 @@ describe('Versions', () => { }) test('collection - should update updatedAt', async () => { - await page.goto(url.create) + // Seed the sibling locales so the later publish click (which now validates every + // locale) doesn't get blocked by their otherwise-empty required title field. + const seed = await payload.create({ + collection: draftCollectionSlug, + data: { title: 'seed' }, + draft: true, + overrideAccess: true, + }) + + for (const locale of ['es', 'de', 'fr']) { + await payload.update({ + id: seed.id, + collection: draftCollectionSlug, + data: { title: 'seed' }, + locale, + overrideAccess: true, + }) + } + + await page.goto(url.edit(seed.id)) + await waitForFormReady(page) // fill out doc in english await page.locator('#field-title').fill('title') @@ -679,7 +738,28 @@ describe('Versions', () => { const spanishTitle = 'spanish title' const englishTitle = 'english title' - await page.goto(url.create) + // Seed the sibling locales so the later publish clicks (which now validate every + // locale) don't get blocked by their otherwise-empty required title field. This adds + // one extra version, accounted for in the version-count assertion below. + const seed = await payload.create({ + collection: draftCollectionSlug, + data: { title: 'seed' }, + draft: true, + overrideAccess: true, + }) + + for (const locale of ['es', 'de', 'fr']) { + await payload.update({ + id: seed.id, + collection: draftCollectionSlug, + data: { title: 'seed' }, + locale, + overrideAccess: true, + }) + } + + await page.goto(url.edit(seed.id)) + await waitForFormReady(page) // fill out doc in english await page.locator('#field-title').fill(englishTitle) @@ -700,7 +780,7 @@ describe('Versions', () => { await page.locator('.doc-tab[aria-label="Versions"] .pill-version-count').textContent(), { timeout: POLL_TOPASS_TIMEOUT }, ) - .toEqual('2') + .toEqual('3') // fill out draft content in spanish await page.locator('#field-title').fill(`${spanishTitle}--draft`) @@ -720,14 +800,14 @@ describe('Versions', () => { await page.locator('#field-title').fill('first post title') await expect(page.locator('#field-description')).toBeEnabled() await page.locator('#field-description').fill('first post description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#action-save-draft') await page.goto(autosaveURL.create) await wait(500) await expect(page.locator('#field-title')).toBeEnabled() await page.locator('#field-title').fill('second post title') await expect(page.locator('#field-description')).toBeEnabled() await page.locator('#field-description').fill('second post description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#action-save-draft') await page.locator('#field-title').fill('updated second post title') await page.locator('#field-description').fill('updated second post description') await waitForAutoSaveToRunAndComplete(page) @@ -1522,7 +1602,7 @@ describe('Versions', () => { await expect(page.locator('#schedule-publish-button')).toBeHidden() // save draft then try to schedule publish - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#action-save-draft') await page.locator('#schedule-publish-button').click() // drawer should open @@ -1554,7 +1634,7 @@ describe('Versions', () => { await expect(page.locator('#schedule-publish-button')).toBeHidden() // save draft then try to schedule publish - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#action-save-draft') await page.locator('#schedule-publish-button').click() // drawer should open @@ -1582,7 +1662,7 @@ describe('Versions', () => { await page.locator('#field-title').fill('scheduled publish positioning') await page.locator('#field-description').fill('scheduled publish positioning description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#action-save-draft') await page.locator('#schedule-publish-button').click() await expect(page.locator('.drawer__header')).toBeVisible() @@ -1607,7 +1687,7 @@ describe('Versions', () => { await page.locator('#field-title').fill('scheduled publish') await page.locator('#field-description').fill('scheduled publish description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#action-save-draft') await page.locator('#field-title').fill('scheduled publish updated') @@ -1682,7 +1762,7 @@ describe('Versions', () => { await page.locator('#field-title').fill('test past times') await page.locator('#field-description').fill('test past times description') - await saveDocAndAssert(page) + await saveDocAndAssert(page, '#action-save-draft') await page.locator('#schedule-publish-button').click() await expect(page.locator('.drawer__header')).toBeVisible() From c8c30d21ffeca29f470f26f0ebebc43afdd1db16 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 26 Aug 2026 13:41:37 +0100 Subject: [PATCH 43/47] fix versions test --- test/versions/e2e.spec.ts | 82 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 0a06eef538b..aa9d3681b5a 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -297,6 +297,7 @@ describe('Versions', () => { id: seed.id, collection: draftCollectionSlug, data: { title: 'seed' }, + draft: true, locale, overrideAccess: true, }) @@ -336,6 +337,7 @@ describe('Versions', () => { id: seed.id, collection: draftCollectionSlug, data: { title: 'seed' }, + draft: true, locale, overrideAccess: true, }) @@ -653,6 +655,7 @@ describe('Versions', () => { id: seed.id, collection: draftCollectionSlug, data: { title: 'seed' }, + draft: true, locale, overrideAccess: true, }) @@ -753,6 +756,7 @@ describe('Versions', () => { id: seed.id, collection: draftCollectionSlug, data: { title: 'seed' }, + draft: true, locale, overrideAccess: true, }) @@ -795,19 +799,62 @@ describe('Versions', () => { }) test('collection — autosave should only update the current document', async () => { - await page.goto(autosaveURL.create) + // Seed the sibling locales so the later publish clicks (which now validate every + // locale) don't get blocked by their otherwise-empty required title field. Autosave + // collections don't render #action-save-draft (no showSaveDraftButton opt-in), so a + // real publish click is unavoidable here. + const seedFirst = await payload.create({ + collection: autosaveCollectionSlug, + data: { title: 'seed-first' }, + draft: true, + overrideAccess: true, + }) + + for (const locale of ['es', 'de', 'fr']) { + await payload.update({ + id: seedFirst.id, + collection: autosaveCollectionSlug, + data: { title: 'seed-first' }, + draft: true, + locale, + overrideAccess: true, + }) + } + + await page.goto(autosaveURL.edit(seedFirst.id)) + await waitForFormReady(page) await expect(page.locator('#field-title')).toBeEnabled() await page.locator('#field-title').fill('first post title') await expect(page.locator('#field-description')).toBeEnabled() await page.locator('#field-description').fill('first post description') - await saveDocAndAssert(page, '#action-save-draft') - await page.goto(autosaveURL.create) + await saveDocAndAssert(page) + + const seedSecond = await payload.create({ + collection: autosaveCollectionSlug, + data: { title: 'seed-second' }, + draft: true, + overrideAccess: true, + }) + + for (const locale of ['es', 'de', 'fr']) { + await payload.update({ + id: seedSecond.id, + collection: autosaveCollectionSlug, + data: { title: 'seed-second' }, + draft: true, + locale, + overrideAccess: true, + }) + } + + await page.goto(autosaveURL.edit(seedSecond.id)) + await waitForFormReady(page) await wait(500) await expect(page.locator('#field-title')).toBeEnabled() await page.locator('#field-title').fill('second post title') await expect(page.locator('#field-description')).toBeEnabled() await page.locator('#field-description').fill('second post description') - await saveDocAndAssert(page, '#action-save-draft') + await saveDocAndAssert(page) await page.locator('#field-title').fill('updated second post title') await page.locator('#field-description').fill('updated second post description') await waitForAutoSaveToRunAndComplete(page) @@ -1683,11 +1730,34 @@ describe('Versions', () => { }) test('can still schedule publish once autosave is triggered', async () => { - await page.goto(autosaveURL.create) + // Seed the sibling locales so the publish click (which now validates every locale) + // doesn't get blocked by their otherwise-empty required title field. Autosave + // collections don't render #action-save-draft (no showSaveDraftButton opt-in), so a + // real publish click is unavoidable here. + const seed = await payload.create({ + collection: autosaveCollectionSlug, + data: { title: 'seed' }, + draft: true, + overrideAccess: true, + }) + + for (const locale of ['es', 'de', 'fr']) { + await payload.update({ + id: seed.id, + collection: autosaveCollectionSlug, + data: { title: 'seed' }, + draft: true, + locale, + overrideAccess: true, + }) + } + + await page.goto(autosaveURL.edit(seed.id)) + await waitForFormReady(page) await page.locator('#field-title').fill('scheduled publish') await page.locator('#field-description').fill('scheduled publish description') - await saveDocAndAssert(page, '#action-save-draft') + await saveDocAndAssert(page) await page.locator('#field-title').fill('scheduled publish updated') From 30d48a9615c838873d915212f3589ec12c433744 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Wed, 26 Aug 2026 16:45:47 +0100 Subject: [PATCH 44/47] fix --- test/versions/e2e.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index aa9d3681b5a..ba37ccd0919 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -743,7 +743,8 @@ describe('Versions', () => { // Seed the sibling locales so the later publish clicks (which now validate every // locale) don't get blocked by their otherwise-empty required title field. This adds - // one extra version, accounted for in the version-count assertion below. + // 4 extra versions (1 create + 3 locale backfills), accounted for in the + // version-count assertion below. const seed = await payload.create({ collection: draftCollectionSlug, data: { title: 'seed' }, @@ -784,7 +785,7 @@ describe('Versions', () => { await page.locator('.doc-tab[aria-label="Versions"] .pill-version-count').textContent(), { timeout: POLL_TOPASS_TIMEOUT }, ) - .toEqual('3') + .toEqual('6') // fill out draft content in spanish await page.locator('#field-title').fill(`${spanishTitle}--draft`) From a347939abc0943ba1e1d16d7330035965f1c67f6 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Thu, 1 Oct 2026 23:53:08 +0100 Subject: [PATCH 45/47] chore: reduce validation test duplication Written with AI --- .../resolvers/collections/validate.spec.ts | 2 +- .../src/resolvers/globals/validate.spec.ts | 2 +- .../src/errors/ValidationError.spec.ts | 36 - .../fields/hooks/beforeChange/promise.spec.ts | 42 - .../fields/hooks/beforeValidate/index.spec.ts | 41 - .../hooks/collectionBeforeChange.spec.ts | 42 +- .../isValidationErrorPathLocalized.spec.ts | 138 +-- .../utilities/parseValidationLocale.spec.ts | 88 +- .../resolveValidationLocales.spec.ts | 93 +- .../src/utilities/toValidationResult.spec.ts | 39 - .../src/utilities/addCollectionAccess.spec.ts | 8 +- .../src/elements/PublishButton/index.spec.ts | 30 +- .../PublishButton/validateAllLocales.spec.ts | 96 +- .../CustomValidateAllLocalesButton/index.tsx | 87 -- .../index.tsx | 85 -- .../validate/components/validationRequests.ts | 69 -- test/validate/config.ts | 243 +----- test/validate/e2e.spec.ts | 97 +-- test/validate/int.spec.ts | 822 ++---------------- 19 files changed, 191 insertions(+), 1869 deletions(-) delete mode 100644 packages/payload/src/errors/ValidationError.spec.ts delete mode 100644 packages/payload/src/utilities/toValidationResult.spec.ts delete mode 100644 test/validate/components/CustomValidateAllLocalesButton/index.tsx delete mode 100644 test/validate/components/CustomValidateOtherLocalesButtons/index.tsx delete mode 100644 test/validate/components/validationRequests.ts diff --git a/packages/graphql/src/resolvers/collections/validate.spec.ts b/packages/graphql/src/resolvers/collections/validate.spec.ts index 7c583821974..0fa50570f7e 100644 --- a/packages/graphql/src/resolvers/collections/validate.spec.ts +++ b/packages/graphql/src/resolvers/collections/validate.spec.ts @@ -15,7 +15,7 @@ const createContext = (validate: (args: { req: PayloadRequest }) => Promise { - it('isolates transactionID from the shared GraphQL request context', async () => { + it('should isolate transactionID from the shared GraphQL request context', async () => { const validate = vi.fn(async ({ req }: { req: PayloadRequest }) => { req.transactionID = 'validate-transaction' return { errors: [], valid: true } diff --git a/packages/graphql/src/resolvers/globals/validate.spec.ts b/packages/graphql/src/resolvers/globals/validate.spec.ts index 64ad873a593..509eadd711d 100644 --- a/packages/graphql/src/resolvers/globals/validate.spec.ts +++ b/packages/graphql/src/resolvers/globals/validate.spec.ts @@ -15,7 +15,7 @@ const createContext = (validateGlobal: (args: { req: PayloadRequest }) => Promis }) describe('globals validateResolver', () => { - it('isolates transactionID from the shared GraphQL request context', async () => { + it('should isolate transactionID from the shared GraphQL request context', async () => { const validateGlobal = vi.fn(async ({ req }: { req: PayloadRequest }) => { req.transactionID = 'validate-transaction' return { errors: [], valid: true } diff --git a/packages/payload/src/errors/ValidationError.spec.ts b/packages/payload/src/errors/ValidationError.spec.ts deleted file mode 100644 index ecce28b439f..00000000000 --- a/packages/payload/src/errors/ValidationError.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { ValidationError } from './ValidationError.js' - -describe('ValidationError', () => { - it('should retain locales while listing field labels and paths in the message', () => { - const error = new ValidationError({ - errors: [ - { - label: 'Title', - locale: 'de', - message: 'Title is required', - path: 'title', - }, - { - message: 'SEO description is required', - path: 'seo.description', - }, - ], - }) - - expect(error.message).toBe('The following fields are invalid: [de] Title, seo.description') - expect(error.data.errors).toEqual([ - { - label: 'Title', - locale: 'de', - message: 'Title is required', - path: 'title', - }, - { - message: 'SEO description is required', - path: 'seo.description', - }, - ]) - }) -}) diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.spec.ts b/packages/payload/src/fields/hooks/beforeChange/promise.spec.ts index dbb406c8dd6..d3e93a40aca 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.spec.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.spec.ts @@ -17,48 +17,6 @@ const req = { } as PayloadRequest describe('beforeChange field traversal', () => { - it('should add the active locale to ordinary field validation errors', async () => { - const errors = [] - - await promise({ - collection: null, - context: {}, - data: { title: '' }, - doc: {}, - docWithLocales: {}, - errors, - field: { - name: 'title', - type: 'text', - validate: () => 'Title is required', - } as Field, - fieldIndex: 0, - fieldLabelPath: '', - global: null, - mergeLocaleActions: [], - operation: 'update', - overrideAccess: false, - parentIndexPath: '', - parentIsLocalized: false, - parentPath: '', - parentSchemaPath: '', - req, - siblingData: { title: '' }, - siblingDoc: {}, - siblingDocWithLocales: {}, - skipValidation: false, - }) - - expect(errors).toEqual([ - { - label: 'Title', - locale: 'de', - message: 'Title is required', - path: 'title', - }, - ]) - }) - it('should add the active locale to invalid block filter errors', async () => { const errors = [] diff --git a/packages/payload/src/fields/hooks/beforeValidate/index.spec.ts b/packages/payload/src/fields/hooks/beforeValidate/index.spec.ts index 58ffe64a763..ae6fd028be8 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/index.spec.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/index.spec.ts @@ -38,47 +38,6 @@ const runBeforeValidate = async ({ } describe('beforeValidate field access results', () => { - it('should apply the validate field access policy while hooks receive validate', async () => { - const hookOperations: string[] = [] - const accessOperations: string[] = [] - const data = { title: 'restricted' } - - await beforeValidate({ - collection: { - fields: [ - { - access: { - validate: () => { - accessOperations.push('validate') - return false - }, - }, - hooks: { - beforeValidate: [ - ({ operation }) => { - hookOperations.push(operation!) - }, - ], - }, - name: 'title', - type: 'text', - } as Field, - ], - } as SanitizedCollectionConfig, - context: {}, - data, - doc: {}, - global: null, - operation: 'validate', - overrideAccess: false, - req: { context: {}, payload: {} } as PayloadRequest, - }) - - expect(hookOperations).toEqual(['validate']) - expect(accessOperations).toEqual(['validate']) - expect(data).toEqual({}) - }) - it('should report explicit and implicit field access results', async () => { const fieldAccessResults = await runBeforeValidate({ fields: [ diff --git a/packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts b/packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts index 5fcd1cd02c3..33c16f76506 100644 --- a/packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts +++ b/packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts @@ -5,33 +5,23 @@ import { hierarchyCollectionBeforeChange } from './collectionBeforeChange.js' describe('hierarchyCollectionBeforeChange', () => { const hook = hierarchyCollectionBeforeChange({ parentFieldName: 'parent' }) - it('should reject a self-referential parent on update', async () => { - await expect( - hook({ - collection: { hierarchy: { parentFieldName: 'parent' }, slug: 'folders' } as any, - context: {}, - data: { id: '1', parent: '1' }, - operation: 'update', - originalDoc: { id: '1', parent: null } as any, - req: {} as any, - }), - ).rejects.toThrow(/cannot be its own parent/i) - }) - - it('should reject a self-referential parent during on-demand validation', async () => { - await expect( - hook({ - collection: { hierarchy: { parentFieldName: 'parent' }, slug: 'folders' } as any, - context: {}, - data: { id: '1', parent: '1' }, - operation: 'validate', - originalDoc: { id: '1', parent: null } as any, - req: {} as any, - }), - ).rejects.toThrow(/cannot be its own parent/i) - }) + it.each(['update', 'validate'] as const)( + 'should reject a self-referential parent during %s', + async (operation) => { + await expect( + hook({ + collection: { hierarchy: { parentFieldName: 'parent' }, slug: 'folders' } as any, + context: {}, + data: { id: '1', parent: '1' }, + operation, + originalDoc: { id: '1', parent: null } as any, + req: {} as any, + }), + ).rejects.toThrow(/cannot be its own parent/i) + }, + ) - it('should allow an unrelated parent change on create', async () => { + it('should allow an unrelated parent change during create', async () => { await expect( hook({ collection: { hierarchy: { parentFieldName: 'parent' }, slug: 'folders' } as any, diff --git a/packages/payload/src/utilities/isValidationErrorPathLocalized.spec.ts b/packages/payload/src/utilities/isValidationErrorPathLocalized.spec.ts index c1d6a114950..f395d705001 100644 --- a/packages/payload/src/utilities/isValidationErrorPathLocalized.spec.ts +++ b/packages/payload/src/utilities/isValidationErrorPathLocalized.spec.ts @@ -88,132 +88,28 @@ const data = { } describe('isValidationErrorPathLocalized', () => { - it('returns false for a top-level non-localized field', () => { - expect( - isValidationErrorPathLocalized({ configBlockReferences: [], data, fields, path: 'shared' }), - ).toBe(false) - }) - - it('returns true for a top-level localized field', () => { - expect( - isValidationErrorPathLocalized({ - configBlockReferences: [], - data, - fields, - path: 'localizedJSON', - }), - ).toBe(true) - }) - - it('returns true for any field nested inside a localized group, regardless of its own flag', () => { - expect( - isValidationErrorPathLocalized({ - configBlockReferences: [], - data, - fields, - path: 'localizedGroup.value', - }), - ).toBe(true) - }) - - it('returns false for a non-localized field nested inside a non-localized group', () => { - expect( - isValidationErrorPathLocalized({ - configBlockReferences: [], - data, - fields, - path: 'nested.shared', - }), - ).toBe(false) - }) - - it('returns true for a localized field nested inside a non-localized group', () => { - expect( - isValidationErrorPathLocalized({ - configBlockReferences: [], - data, - fields, - path: 'nested.localizedJSON', - }), - ).toBe(true) - }) - - it('resolves array row indices without consuming a field name', () => { - expect( - isValidationErrorPathLocalized({ - configBlockReferences: [], - data, - fields, - path: 'nestedArray.0.shared', - }), - ).toBe(false) - expect( - isValidationErrorPathLocalized({ - configBlockReferences: [], - data, - fields, - path: 'nestedArray.0.localizedJSON', - }), - ).toBe(true) - }) - - it('resolves blocks row indices via the row blockType', () => { - expect( - isValidationErrorPathLocalized({ - configBlockReferences: [], - data, - fields, - path: 'nestedBlocks.0.shared', - }), - ).toBe(false) - expect( - isValidationErrorPathLocalized({ - configBlockReferences: [], - data, - fields, - path: 'nestedBlocks.0.localizedJSON', - }), - ).toBe(true) - }) - - it('sees through unnamed presentational row fields', () => { - expect( - isValidationErrorPathLocalized({ - configBlockReferences: [], - data, - fields, - path: 'rowShared', - }), - ).toBe(false) - }) - - it('resolves named and unnamed tabs', () => { - expect( - isValidationErrorPathLocalized({ - configBlockReferences: [], - data, - fields, - path: 'localizedTab.value', - }), - ).toBe(true) - expect( - isValidationErrorPathLocalized({ - configBlockReferences: [], - data, - fields, - path: 'unnamedTabShared', - }), - ).toBe(false) - }) - - it('conservatively treats an unresolvable path as localized', () => { + it.each([ + ['a top-level non-localized field', 'shared', false], + ['a top-level localized field', 'localizedJSON', true], + ['a field inside a localized group', 'localizedGroup.value', true], + ['a shared field inside a group', 'nested.shared', false], + ['a localized field inside a group', 'nested.localizedJSON', true], + ['a shared field inside an array row', 'nestedArray.0.shared', false], + ['a localized field inside an array row', 'nestedArray.0.localizedJSON', true], + ['a shared field inside a block row', 'nestedBlocks.0.shared', false], + ['a localized field inside a block row', 'nestedBlocks.0.localizedJSON', true], + ['a field inside a presentational row', 'rowShared', false], + ['a field inside a localized named tab', 'localizedTab.value', true], + ['a field inside an unnamed tab', 'unnamedTabShared', false], + ['an unresolvable path', 'doesNotExist', true], + ])('should identify %s at %s as localized=%s', (_description, path, expected) => { expect( isValidationErrorPathLocalized({ configBlockReferences: [], data, fields, - path: 'doesNotExist', + path, }), - ).toBe(true) + ).toBe(expected) }) }) diff --git a/packages/payload/src/utilities/parseValidationLocale.spec.ts b/packages/payload/src/utilities/parseValidationLocale.spec.ts index 7e30567b3f0..6f298945230 100644 --- a/packages/payload/src/utilities/parseValidationLocale.spec.ts +++ b/packages/payload/src/utilities/parseValidationLocale.spec.ts @@ -7,54 +7,33 @@ import { } from './parseValidationLocale.js' describe('parseValidationLocale', () => { - it('should parse a single locale string', () => { - expect(parseValidationLocale('en')).toEqual({ locale: 'en', type: 'single' }) - }) - - it('should parse "all" as the all type', () => { - expect(parseValidationLocale('all')).toEqual({ type: 'all' }) - }) - - it('should parse an array of locale strings', () => { - expect(parseValidationLocale(['en', 'es'])).toEqual({ locales: ['en', 'es'], type: 'multiple' }) - }) - - it('should reject an empty string', () => { - expect(() => parseValidationLocale('')).toThrow(/requires a locale/i) - }) - - it('should reject an empty array', () => { - expect(() => parseValidationLocale([])).toThrow(/requires a locale/i) - }) - - it('should reject an array containing an empty string', () => { - expect(() => parseValidationLocale(['en', ''])).toThrow(/requires a locale/i) - }) - - it('should reject a mixed-type array', () => { - expect(() => parseValidationLocale(['en', 1])).toThrow(/requires a locale/i) - }) - - it('should reject undefined', () => { - expect(() => parseValidationLocale(undefined)).toThrow(/requires a locale/i) - }) - - it('should reject a non-string, non-array value', () => { - expect(() => parseValidationLocale({ locale: 'en' })).toThrow(/requires a locale/i) + it.each([ + ['a single locale', 'en', { locale: 'en', type: 'single' }], + ['all locales', 'all', { type: 'all' }], + ['multiple locales', ['en', 'es'], { locales: ['en', 'es'], type: 'multiple' }], + ])('should parse %s', (_description, input, expected) => { + expect(parseValidationLocale(input)).toEqual(expected) + }) + + it.each([ + ['an empty string', ''], + ['an empty array', []], + ['an array containing an empty string', ['en', '']], + ['a mixed-type array', ['en', 1]], + ['undefined', undefined], + ['a non-string, non-array value', { locale: 'en' }], + ])('should reject %s', (_description, input) => { + expect(() => parseValidationLocale(input)).toThrow(/requires a locale/i) }) }) describe('parseValidationLocaleSelector', () => { - it('should return "all" for the all type', () => { - expect(parseValidationLocaleSelector('all')).toBe('all') - }) - - it('should return a single locale string', () => { - expect(parseValidationLocaleSelector('en')).toBe('en') - }) - - it('should return a locale array for repeated values', () => { - expect(parseValidationLocaleSelector(['en', 'es'])).toEqual(['en', 'es']) + it.each([ + ['all', 'all', 'all'], + ['a single locale', 'en', 'en'], + ['repeated locale values', ['en', 'es'], ['en', 'es']], + ])('should return %s', (_description, input, expected) => { + expect(parseValidationLocaleSelector(input)).toEqual(expected) }) }) @@ -63,19 +42,12 @@ describe('assertValidationData', () => { expect(() => assertValidationData({ title: 'Hello' })).not.toThrow() }) - it('should reject null', () => { - expect(() => assertValidationData(null)).toThrow(/must be an object/i) - }) - - it('should reject undefined', () => { - expect(() => assertValidationData(undefined)).toThrow(/must be an object/i) - }) - - it('should reject an array', () => { - expect(() => assertValidationData([])).toThrow(/must be an object/i) - }) - - it('should reject a primitive', () => { - expect(() => assertValidationData('title')).toThrow(/must be an object/i) + it.each([ + ['null', null], + ['undefined', undefined], + ['an array', []], + ['a primitive', 'title'], + ])('should reject %s', (_description, input) => { + expect(() => assertValidationData(input)).toThrow(/must be an object/i) }) }) diff --git a/packages/payload/src/utilities/resolveValidationLocales.spec.ts b/packages/payload/src/utilities/resolveValidationLocales.spec.ts index 26d72eb396b..db1dd77bdb2 100644 --- a/packages/payload/src/utilities/resolveValidationLocales.spec.ts +++ b/packages/payload/src/utilities/resolveValidationLocales.spec.ts @@ -4,11 +4,7 @@ import type { SanitizedLocalizationConfig } from '../config/types.js' import type { PayloadRequest } from '../types/index.js' import type { TypedLocale } from '../index.js' -import { - cloneValidationRequest, - resolveValidationLocales, - runValidationLocalePasses, -} from './resolveValidationLocales.js' +import { cloneValidationRequest, resolveValidationLocales } from './resolveValidationLocales.js' function createReq(localization: false | SanitizedLocalizationConfig): PayloadRequest { return { @@ -105,93 +101,6 @@ describe('resolveValidationLocales', () => { }) }) -describe('runValidationLocalePasses - concurrency', () => { - const tenLocales = Array.from({ length: 10 }, (_, index) => `locale-${index}`) as TypedLocale[] - - it('should run locale passes concurrently by default, allowing overlap', async () => { - let activeCount = 0 - let maxObservedActiveCount = 0 - - await runValidationLocalePasses({ - locales: tenLocales, - validate: async (locale) => { - activeCount += 1 - maxObservedActiveCount = Math.max(maxObservedActiveCount, activeCount) - - await new Promise((resolve) => setTimeout(resolve, 5)) - - activeCount -= 1 - return locale - }, - }) - - expect(maxObservedActiveCount).toBeGreaterThan(1) - }) - - it('should never run more than one locale pass at a time across 10 locales when concurrency is 1', async () => { - let activeCount = 0 - let maxObservedActiveCount = 0 - - const results = await runValidationLocalePasses({ - concurrency: 1, - locales: tenLocales, - validate: async (locale) => { - activeCount += 1 - maxObservedActiveCount = Math.max(maxObservedActiveCount, activeCount) - - if (activeCount > 1) { - throw new Error(`Locale pass for "${String(locale)}" overlapped with another pass`) - } - - await new Promise((resolve) => setTimeout(resolve, 5)) - - activeCount -= 1 - return locale - }, - }) - - expect(maxObservedActiveCount).toBe(1) - expect(results).toEqual(tenLocales) - }) - - it('should preserve input locale order in the results regardless of concurrency', async () => { - const results = await runValidationLocalePasses({ - concurrency: 4, - locales: tenLocales, - validate: async (locale) => { - const delayMs = tenLocales.indexOf(locale) % 2 === 0 ? 1 : 10 - - await new Promise((resolve) => setTimeout(resolve, delayMs)) - - return locale - }, - }) - - expect(results).toEqual(tenLocales) - }) - - it('should clamp concurrency to the number of locales', async () => { - let maxObservedActiveCount = 0 - let activeCount = 0 - - await runValidationLocalePasses({ - concurrency: 100, - locales: tenLocales, - validate: async (locale) => { - activeCount += 1 - maxObservedActiveCount = Math.max(maxObservedActiveCount, activeCount) - - await new Promise((resolve) => setTimeout(resolve, 5)) - - activeCount -= 1 - return locale - }, - }) - - expect(maxObservedActiveCount).toBe(tenLocales.length) - }) -}) - describe('cloneValidationRequest', () => { it('should return an empty object for an undefined request', () => { expect(cloneValidationRequest(undefined)).toEqual({}) diff --git a/packages/payload/src/utilities/toValidationResult.spec.ts b/packages/payload/src/utilities/toValidationResult.spec.ts deleted file mode 100644 index c897afbe2eb..00000000000 --- a/packages/payload/src/utilities/toValidationResult.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { PayloadRequest } from '../types/index.js' - -import { describe, expect, it } from 'vitest' - -import { ValidationError } from '../errors/index.js' -import { toValidationResult } from './toValidationResult.js' - -describe('toValidationResult', () => { - it('maps a ValidationError to field errors tagged with the request locale', () => { - const req = { locale: 'de' } as PayloadRequest - const error = new ValidationError({ - errors: [{ label: 'Title', message: 'Title is required', path: 'title' }], - }) - - expect(toValidationResult({ error, req })).toEqual({ - errors: [{ label: 'Title', locale: 'de', message: 'Title is required', path: 'title' }], - valid: false, - }) - }) - - it('omits the locale when the request has none', () => { - const req = {} as PayloadRequest - const error = new ValidationError({ - errors: [{ message: 'Title is required', path: 'title' }], - }) - - expect(toValidationResult({ error, req })).toEqual({ - errors: [{ locale: undefined, message: 'Title is required', path: 'title' }], - valid: false, - }) - }) - - it('rethrows errors that are not a ValidationError', () => { - const req = { locale: 'en' } as PayloadRequest - const error = new Error('boom') - - expect(() => toValidationResult({ error, req })).toThrow(error) - }) -}) diff --git a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts index edf59d09082..52e05e97273 100644 --- a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts +++ b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.spec.ts @@ -29,7 +29,7 @@ const createArgs = (slug = 'posts'): AccessArgs => ({ }) describe('addCollectionAccess', () => { - it('adds tenant constraints through base access without replacing collection access', async () => { + it('should add tenant constraints through base access without replacing collection access', async () => { const documentRead = vi.fn(() => ({ published: { equals: true } })) const collection: CollectionConfig = { slug: 'posts', @@ -56,7 +56,7 @@ describe('addCollectionAccess', () => { expect(documentRead).not.toHaveBeenCalled() }) - it('returns a boolean tenant result for collection create access', async () => { + it('should return a boolean tenant result for collection create access', async () => { const collection: CollectionConfig = { slug: 'posts', fields: [] } const config = {} as Config @@ -65,7 +65,7 @@ describe('addCollectionAccess', () => { await expect(config.baseAccess?.collections?.create?.(createArgs())).resolves.toBe(true) }) - it('falls back to update access for validate when accessResultCallback is configured', async () => { + it('should fall back to update access for validate when accessResultCallback is configured', async () => { const documentUpdate = vi.fn(() => true) const accessResultCallback = vi.fn(({ accessResult }) => accessResult) const collection: CollectionConfig = { @@ -91,7 +91,7 @@ describe('addCollectionAccess', () => { ) }) - it('keeps callback wrapping when an access result override is configured', async () => { + it('should keep callback wrapping when an access result override is configured', async () => { const documentResult = { published: { equals: true } } const documentRead = vi.fn(() => documentResult) const accessResultCallback = vi.fn(({ accessResult }) => accessResult) diff --git a/packages/ui/src/elements/PublishButton/index.spec.ts b/packages/ui/src/elements/PublishButton/index.spec.ts index 7837ded3f14..b35f607b12e 100644 --- a/packages/ui/src/elements/PublishButton/index.spec.ts +++ b/packages/ui/src/elements/PublishButton/index.spec.ts @@ -8,9 +8,7 @@ import { PublishButton } from './index.js' const mocks = vi.hoisted(() => ({ primaryAction: undefined as (() => Promise) | undefined, - publishAllLocalesAction: undefined as (() => Promise) | undefined, submit: vi.fn(), - toastError: vi.fn(), })) vi.mock('../../forms/Form/context.js', () => ({ @@ -119,30 +117,16 @@ vi.mock('../../providers/Translation/index.js', () => ({ vi.mock('../Popup/index.js', () => ({ PopupList: { - Button: ({ id, onClick }: { id?: string; onClick?: () => Promise }) => { - if (id === 'publish-all-locales') { - mocks.publishAllLocalesAction = onClick - } - - return null - }, + Button: () => null, ButtonGroup: ({ children }: { children?: ReactNode }) => children, }, })) -vi.mock('sonner', () => ({ - toast: { - error: mocks.toastError, - }, -})) - describe('PublishButton', () => { beforeEach(() => { mocks.primaryAction = undefined - mocks.publishAllLocalesAction = undefined mocks.submit.mockReset() mocks.submit.mockResolvedValue(true) - mocks.toastError.mockReset() }) it('should publish only the active locale on the first render', async () => { @@ -157,16 +141,4 @@ describe('PublishButton', () => { ) expect(markup).toContain('Publish in English') }) - - it('should report a validation request failure without publishing', async () => { - vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('network unavailable')) - - renderToStaticMarkup(createElement(PublishButton)) - - expect(mocks.publishAllLocalesAction).toBeDefined() - await expect(mocks.publishAllLocalesAction!()).resolves.toBeUndefined() - - expect(mocks.toastError).toHaveBeenCalledWith('An unknown error has occurred.') - expect(mocks.submit).not.toHaveBeenCalled() - }) }) diff --git a/packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts b/packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts index 746f0ed47db..54233998f1d 100644 --- a/packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts +++ b/packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts @@ -1,19 +1,13 @@ import type { ClientField } from 'payload' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { getValidationEndpoint, projectValidationDataForSiblingLocales, - requestDocumentValidation, - validateDocumentLocales, } from './validateAllLocales.js' describe('validate all locales before publish', () => { - afterEach(() => { - vi.unstubAllGlobals() - }) - it('should construct create, update, and global validation endpoints', () => { expect( getValidationEndpoint({ @@ -100,92 +94,4 @@ describe('validate all locales before publish', () => { settings: { theme: 'dark' }, }) }) - - it('should not let valid unsaved active-locale values satisfy invalid stored sibling locales', async () => { - const request = vi.fn(async ({ body, locales: requestedLocales }) => { - if (requestedLocales.includes('de') && 'title' in body) { - return { - errors: [], - valid: true, - } - } - - return { - errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], - valid: false, - } - }) - - const result = await validateDocumentLocales({ - activeLocale: 'de', - blocksMap: {}, - data: { summary: 'Shared summary', title: 'Unsaved German title' }, - endpoint: '/api/posts/123/validate', - fields: [ - { localized: true, name: 'title', type: 'text' }, - { name: 'summary', type: 'text' }, - ], - locales: ['en', 'de', 'fr'], - request, - }) - - expect(request).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - body: { summary: 'Shared summary', title: 'Unsaved German title' }, - locales: ['de'], - }), - ) - expect(request).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - body: { summary: 'Shared summary' }, - locales: ['en', 'fr'], - }), - ) - expect(result).toEqual({ - errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], - valid: false, - }) - }) - - it('should render validation errors returned in a non-2xx Payload error response', async () => { - const fetchMock = vi.fn(async () => { - return Response.json( - { - errors: [ - { - data: { - errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], - }, - message: 'The following field is invalid: title', - name: 'ValidationError', - }, - ], - }, - { status: 400 }, - ) - }) - - vi.stubGlobal('fetch', fetchMock) - - await expect( - requestDocumentValidation({ - body: { title: 'Titre' }, - endpoint: '/api/posts/123/validate', - locales: ['en', 'fr'], - }), - ).resolves.toEqual({ - errors: [{ locale: 'fr', message: 'Title is required', path: 'title' }], - valid: false, - }) - expect(fetchMock).toHaveBeenCalledWith( - '/api/posts/123/validate?locale=en&locale=fr', - expect.objectContaining({ - body: JSON.stringify({ title: 'Titre' }), - credentials: 'include', - method: 'POST', - }), - ) - }) }) diff --git a/test/validate/components/CustomValidateAllLocalesButton/index.tsx b/test/validate/components/CustomValidateAllLocalesButton/index.tsx deleted file mode 100644 index d78a4073196..00000000000 --- a/test/validate/components/CustomValidateAllLocalesButton/index.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client' -import type { ValidationResult } from 'payload' - -import { Button, useConfig, useDocumentInfo, useForm, useLocale } from '@payloadcms/ui' -import React, { useState } from 'react' - -import { - getValidateEndpoint, - requestValidation, - stripLocalizedFields, -} from '../validationRequests.js' - -const baseClass = 'custom-validate-all-locales-button' - -export function CustomValidateAllLocalesButton() { - const { id, collectionSlug, docConfig, globalSlug } = useDocumentInfo() - const { - config: { - localization, - routes: { api }, - }, - } = useConfig() - const activeLocaleCode = useLocale()?.code - const { getData } = useForm() - - const [isValidating, setIsValidating] = useState(false) - const [result, setResult] = useState(null) - - const handleClick = async () => { - if (!activeLocaleCode) { - return - } - - setIsValidating(true) - - const endpoint = getValidateEndpoint({ id, apiRoute: api, collectionSlug, globalSlug }) - const data = getData() - const fields = docConfig?.fields ?? [] - const siblingLocaleCodes = (localization ? localization.locales : []) - .map(({ code }) => code) - .filter((code) => code !== activeLocaleCode) - - const results = [await requestValidation({ body: data, endpoint, locales: [activeLocaleCode] })] - - if (siblingLocaleCodes.length > 0) { - results.push( - await requestValidation({ - body: stripLocalizedFields({ data, fields }), - endpoint, - locales: siblingLocaleCodes, - }), - ) - } - - setResult({ - errors: results.flatMap(({ errors }) => errors), - valid: results.every(({ valid }) => valid), - }) - setIsValidating(false) - } - - return ( -
- - {result && ( -
- {result.valid - ? 'All locales valid' - : result.errors.map((error, index) => ( -

- {`${error.locale ?? ''} ${error.path}: ${error.message}`} -

- ))} -
- )} -
- ) -} diff --git a/test/validate/components/CustomValidateOtherLocalesButtons/index.tsx b/test/validate/components/CustomValidateOtherLocalesButtons/index.tsx deleted file mode 100644 index 9352b08f936..00000000000 --- a/test/validate/components/CustomValidateOtherLocalesButtons/index.tsx +++ /dev/null @@ -1,85 +0,0 @@ -'use client' -import type { ValidationResult } from 'payload' - -import { Button, useConfig, useDocumentInfo, useForm, useLocale } from '@payloadcms/ui' -import React, { useState } from 'react' - -import { - getValidateEndpoint, - requestValidation, - stripLocalizedFields, -} from '../validationRequests.js' - -const baseClass = 'custom-validate-other-locales-buttons' - -export function CustomValidateOtherLocalesButtons() { - const { id, collectionSlug, docConfig, globalSlug } = useDocumentInfo() - const { - config: { - localization, - routes: { api }, - }, - } = useConfig() - const activeLocaleCode = useLocale()?.code - const { getData } = useForm() - - const [results, setResults] = useState>({}) - const [validatingLocale, setValidatingLocale] = useState(null) - - const otherLocales = localization - ? localization.locales.filter(({ code }) => code !== activeLocaleCode) - : [] - - const handleClick = async (localeCode: string) => { - setValidatingLocale(localeCode) - - const endpoint = getValidateEndpoint({ id, apiRoute: api, collectionSlug, globalSlug }) - const fields = docConfig?.fields ?? [] - - const validationResult = await requestValidation({ - body: stripLocalizedFields({ data: getData(), fields }), - endpoint, - locales: [localeCode], - }) - - setResults((previousResults) => ({ ...previousResults, [localeCode]: validationResult })) - setValidatingLocale(null) - } - - if (!otherLocales.length) { - return null - } - - return ( -
- {otherLocales.map(({ code }) => { - const isValidating = validatingLocale === code - const result = results[code] - - return ( -
- - {result && ( -
- {result.valid - ? `${code} valid` - : result.errors.map((error, index) => ( -

{`${error.path}: ${error.message}`}

- ))} -
- )} -
- ) - })} -
- ) -} diff --git a/test/validate/components/validationRequests.ts b/test/validate/components/validationRequests.ts deleted file mode 100644 index 3fb1f692c64..00000000000 --- a/test/validate/components/validationRequests.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { ClientField, Data, ValidationResult } from 'payload' - -import { formatAdminURL } from 'payload/shared' - -/** - * A locale-scoped validate call carries one flat candidate. Localized field values - * belong only to the active locale being edited, so they must be stripped before the - * same candidate is used to validate any other locale. - */ -export function stripLocalizedFields({ - data, - fields, -}: { - data: Data - fields: ClientField[] -}): Data { - const projectedData = { ...data } - - for (const field of fields) { - if ('name' in field && 'localized' in field && field.localized) { - delete projectedData[field.name] - } - } - - return projectedData -} - -export function getValidateEndpoint({ - id, - apiRoute, - collectionSlug, - globalSlug, -}: { - apiRoute: string - collectionSlug?: string - globalSlug?: string - id?: number | string -}): string { - const path: `/${string}` = globalSlug - ? `/globals/${globalSlug}/validate` - : `/${collectionSlug}${id ? `/${id}` : ''}/validate` - - return formatAdminURL({ apiRoute, path }) -} - -export async function requestValidation({ - body, - endpoint, - locales, -}: { - body: Data - endpoint: string - locales: string[] -}): Promise { - const search = new URLSearchParams() - - for (const locale of locales) { - search.append('locale', locale) - } - - const response = await fetch(`${endpoint}?${search.toString()}`, { - body: JSON.stringify(body), - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - method: 'POST', - }) - - return (await response.json()) as ValidationResult -} diff --git a/test/validate/config.ts b/test/validate/config.ts index 6de3f926712..3d289f8f135 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -32,12 +32,10 @@ export const publishCollectionSlug = 'validation-publish-items' export const publishGlobalSlug = 'validation-publish-settings' export const writeTargetsSlug = 'validation-write-targets' export const validationUploadsSlug = 'validation-uploads' -export const validationPublishUploadsSlug = 'validation-publish-uploads' export const validationCustomButtonsCollectionSlug = 'validation-custom-buttons-items' +export const validationRequestFailureTitle = 'Reject validation request' export const validationDeniedCollectionSlug = 'validation-denied-items' -export const validationNonLocalizedCollectionSlug = 'validation-non-localized-items' export const validationUploadsDir = path.resolve(dirname, 'validation-uploads') -export const validationPublishUploadsDir = path.resolve(dirname, 'validation-publish-uploads') type HookEvent = { context: Record @@ -60,7 +58,6 @@ export const permissionOperationEvents: { operation: string source: 'entity' | 'field' }[] = [] -export const scheduledValidationEvents: string[] = [] export const isolationEvents: { candidateMarker: unknown contextMarker: unknown @@ -99,7 +96,6 @@ export function clearValidationEvents(): void { activeLocalePasses = 0 maximumActiveLocalePasses = 0 permissionOperationEvents.length = 0 - scheduledValidationEvents.length = 0 validationRuntimeIdentityEvents.length = 0 } @@ -229,9 +225,9 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re case 'jobsQueue': await req.payload.jobs.queue({ - task: 'validationWriteGuardProbe', input: {}, req, + task: 'validationWriteGuardProbe', }) break @@ -456,12 +452,6 @@ const validationCollection: CollectionConfig = { type: 'text', required: true, }, - { - name: 'status', - type: 'text', - defaultValue: 'draft', - required: true, - }, { name: 'location', type: 'point', @@ -470,6 +460,12 @@ const validationCollection: CollectionConfig = { ? true : 'Location must use the public point tuple representation', }, + { + name: 'status', + type: 'text', + defaultValue: 'draft', + required: true, + }, { name: 'writeAttempt', type: 'select', @@ -696,14 +692,6 @@ const validationGlobal: GlobalConfig = { type: 'text', required: true, }, - { - name: 'location', - type: 'point', - validate: (value: unknown) => - value === undefined || (Array.isArray(value) && value.length === 2) - ? true - : 'Location must use the public point tuple representation', - }, { name: 'permissionProbe', type: 'group', @@ -756,17 +744,6 @@ const validationGlobal: GlobalConfig = { throw new Error('global validation hook failure') } - if (req.context.throwValidationErrorHook === true) { - throw new ValidationError( - { - errors: [{ message: 'Global hook validation failure', path: 'title' }], - global: validationGlobalSlug, - req, - }, - req.t, - ) - } - return data }, ], @@ -796,10 +773,6 @@ const validationFallbackCollection: CollectionConfig = { source: 'collection', }) - if (req.operation === 'validate' && req.context.requireValidationUser === true) { - return Boolean(req.user) - } - return req.operation !== 'validate' ? true : req.payloadAPI === 'REST' || req.context.allowUpdateFallback === true @@ -889,10 +862,6 @@ const validationFallbackGlobal: GlobalConfig = { source: 'global', }) - if (req.operation === 'validate' && req.context.requireValidationUser === true) { - return Boolean(req.user) - } - return req.operation !== 'validate' ? true : req.payloadAPI === 'REST' || req.context.allowUpdateFallback === true @@ -928,7 +897,6 @@ const validationFallbackGlobal: GlobalConfig = { const validationDeniedGlobal: GlobalConfig = { slug: validationDeniedGlobalSlug, access: { - update: ({ req }) => req.user?.email !== 'revoked@example.com', validate: () => false, }, fields: [ @@ -937,21 +905,6 @@ const validationDeniedGlobal: GlobalConfig = { type: 'text', }, ], - hooks: { - beforeValidate: [ - ({ operation }) => { - if (operation === 'validate') { - scheduledValidationEvents.push(operation) - } - }, - ], - }, - versions: { - drafts: { - schedulePublish: true, - validate: false, - }, - }, } const validationWriteTargetGlobal: GlobalConfig = { @@ -1052,131 +1005,10 @@ const publishCollection: CollectionConfig = { { name: 'title', type: 'text', - access: { - validate: ({ req }) => req.context.denyPublishFieldValidation !== true, - }, - localized: true, - required: true, - }, - { - name: 'localizedGroup', - type: 'group', - fields: [ - { - name: 'value', - type: 'text', - required: true, - }, - ], - localized: true, - }, - { - name: 'localizedJSON', - type: 'json', - localized: true, - required: true, - }, - { - name: 'localizedRichText', - type: 'richText', localized: true, required: true, }, - { - type: 'tabs', - tabs: [ - { - name: 'localizedTab', - fields: [ - { - name: 'value', - type: 'text', - required: true, - }, - ], - label: 'Localized tab', - localized: true, - }, - ], - }, - { - name: 'localizedArray', - type: 'array', - fields: [ - { - name: 'value', - type: 'text', - required: true, - }, - ], - localized: true, - minRows: 1, - required: true, - }, - { - name: 'localizedBlocks', - type: 'blocks', - blocks: [ - { - slug: 'validationBlock', - fields: [ - { - name: 'value', - type: 'text', - required: true, - }, - ], - }, - ], - localized: true, - minRows: 1, - required: true, - }, - { - name: 'nested', - type: 'group', - fields: [ - { - name: 'localizedJSON', - type: 'json', - localized: true, - required: true, - }, - { - name: 'shared', - type: 'text', - required: true, - }, - ], - }, ], - hooks: { - beforeValidate: [ - ({ data, req }) => { - if (data?._status === 'published' && data?.title === 'throw scheduled validation error') { - throw new ValidationError( - { - errors: [ - { - locale: req.locale ?? undefined, - message: 'Scheduled validation hook rejected the title', - path: 'title', - }, - ], - req, - }, - req.t, - ) - } - - if (data?._status === 'published' && data?.title === 'throw transient scheduled error') { - throw new Error('transient scheduled validation error') - } - - return data - }, - ], - }, trash: true, versions: { drafts: { @@ -1198,12 +1030,6 @@ const publishGlobal: GlobalConfig = { localized: true, required: true, }, - { - name: 'localizedJSON', - type: 'json', - localized: true, - required: true, - }, ], versions: { drafts: { @@ -1215,15 +1041,8 @@ const publishGlobal: GlobalConfig = { const validationCustomButtonsCollection: CollectionConfig = { slug: validationCustomButtonsCollectionSlug, - admin: { - components: { - edit: { - beforeDocumentControls: [ - '/components/CustomValidateAllLocalesButton/index.js#CustomValidateAllLocalesButton', - '/components/CustomValidateOtherLocalesButtons/index.js#CustomValidateOtherLocalesButtons', - ], - }, - }, + access: { + validate: ({ data, req }) => Boolean(req.user) && data?.title !== validationRequestFailureTitle, }, fields: [ { @@ -1265,16 +1084,6 @@ const validationDeniedCollection: CollectionConfig = { }, } -const validationNonLocalizedCollection: CollectionConfig = { - slug: validationNonLocalizedCollectionSlug, - fields: [ - { - name: 'title', - type: 'text', - }, - ], -} - export default buildConfigWithDefaults({ config: { admin: { @@ -1293,7 +1102,6 @@ export default buildConfigWithDefaults({ publishCollection, validationCustomButtonsCollection, validationDeniedCollection, - validationNonLocalizedCollection, { slug: writeTargetsSlug, fields: [ @@ -1313,35 +1121,6 @@ export default buildConfigWithDefaults({ }, versions: false, }, - { - slug: validationPublishUploadsSlug, - access: { - validate: () => true, - }, - fields: [ - { - name: 'title', - type: 'text', - localized: true, - required: true, - }, - ], - upload: { - imageSizes: [ - { - name: 'thumbnail', - height: 64, - width: 64, - }, - ], - staticDir: validationPublishUploadsDir, - }, - versions: { - drafts: { - validate: false, - }, - }, - }, ], globals: [ validationGlobal, @@ -1357,9 +1136,9 @@ export default buildConfigWithDefaults({ tasks: [ { slug: 'validationWriteGuardProbe', + handler: () => ({ output: {} }), inputSchema: [], outputSchema: [], - handler: () => ({ output: {} }), }, ], }, diff --git a/test/validate/e2e.spec.ts b/test/validate/e2e.spec.ts index 3168004fc63..12ea2d96640 100644 --- a/test/validate/e2e.spec.ts +++ b/test/validate/e2e.spec.ts @@ -9,21 +9,21 @@ import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js' import { RESTClient } from '../__helpers/shared/rest.js' import { initPage } from '../__setup/e2e/initPage.js' -import { validationCustomButtonsCollectionSlug } from './config.js' +import { validationCustomButtonsCollectionSlug, validationRequestFailureTitle } from './config.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) -test.describe('Custom locale validation buttons', () => { +test.describe('Publish all locales', () => { const createdIDs: string[] = [] let client: RESTClient - let customButtonsURL: AdminUrlUtil + let publishAllLocalesURL: AdminUrlUtil let page: Page let serverURL: string test.beforeAll(async ({ browser }) => { ;({ serverURL } = await initPayloadE2ENoConfig({ dirname })) - customButtonsURL = new AdminUrlUtil(serverURL, validationCustomButtonsCollectionSlug) + publishAllLocalesURL = new AdminUrlUtil(serverURL, validationCustomButtonsCollectionSlug) client = new RESTClient({ defaultSlug: validationCustomButtonsCollectionSlug, serverURL, @@ -35,7 +35,7 @@ test.describe('Custom locale validation buttons', () => { }) test.afterEach(async () => { - await page.goto(customButtonsURL.admin) + await page.goto(publishAllLocalesURL.admin) for (const id of createdIDs) { await client.delete(id, { @@ -46,56 +46,6 @@ test.describe('Custom locale validation buttons', () => { createdIDs.length = 0 }) - test('should render a validate-all button and one validate button per other locale', async () => { - const id = await createDraft({}) - - await openDraft(id) - - await expect(page.locator('#custom-validate-all-locales-button')).toBeVisible() - await expect(page.locator('#custom-validate-locale-es')).toBeVisible() - await expect(page.locator('#custom-validate-locale-de')).toBeVisible() - await expect(page.locator('#custom-validate-locale-fr')).toBeVisible() - await expect(page.locator('#custom-validate-locale-en')).toHaveCount(0) - }) - - test('should update the rendered per-locale buttons when the active locale changes', async () => { - const id = await createDraft({}) - - await openDraft(id) - await expect(page.locator('#custom-validate-locale-en')).toHaveCount(0) - await expect(page.locator('#custom-validate-locale-de')).toBeVisible() - - await changeLocale(page, 'de') - await waitForFormReady(page) - - await expect(page.locator('#custom-validate-locale-en')).toBeVisible() - await expect(page.locator('#custom-validate-locale-de')).toHaveCount(0) - }) - - test('should validate a single sibling locale independently of the others', async () => { - const id = await createDraft({ germanTitle: 'Deutscher Titel' }) - - await openDraft(id) - - await page.locator('#custom-validate-locale-de').click() - await expect(page.locator('#custom-validate-locale-de-result')).toContainText('de valid') - - await page.locator('#custom-validate-locale-es').click() - await expect(page.locator('#custom-validate-locale-es-result')).toContainText('title') - }) - - test('should aggregate errors across every locale for the validate-all button', async () => { - const id = await createDraft({ germanTitle: 'Deutscher Titel' }) - - await openDraft(id) - await page.locator('#custom-validate-all-locales-button').click() - - const result = page.locator('#custom-validate-all-locales-result') - await expect(result).toContainText('es title') - await expect(result).toContainText('fr title') - await expect(result).not.toContainText('de title') - }) - test('should block publishing all locales and toast the invalid locale when a sibling locale is missing required data', async () => { const id = await createDraft({ spanishTitle: 'Título en español' }) @@ -126,13 +76,38 @@ test.describe('Custom locale validation buttons', () => { await openDraft(id) await saveDocAndAssert(page, '#publish-all-locales', 'success') + + for (const locale of ['en', 'es', 'de', 'fr']) { + await expect.poll(() => getLocaleStatus({ id, locale })).toBe('published') + } + }) + + test('should report a denied validation request without publishing', async () => { + const id = await createDraft({ + englishTitle: validationRequestFailureTitle, + frenchTitle: 'Titre en français', + germanTitle: 'Deutscher Titel', + spanishTitle: 'Título en español', + }) + + await openDraft(id) + await saveDocAndAssert(page, '#publish-all-locales', 'error', { + disableDismissAllToasts: true, + }) + + await expect(page.locator('.payload-toast-container')).toContainText( + 'An unknown error has occurred.', + ) + await expect.poll(() => getLocaleStatus({ id, locale: 'en' })).toBe('draft') }) async function createDraft({ + englishTitle = 'English title', frenchTitle, germanTitle, spanishTitle, }: { + englishTitle?: string frenchTitle?: string germanTitle?: string spanishTitle?: string @@ -143,7 +118,7 @@ test.describe('Custom locale validation buttons', () => { { _status: 'draft', summary: 'Shared summary', - title: 'English title', + title: englishTitle, }, ) const id = String(response.data.doc.id) @@ -171,8 +146,16 @@ test.describe('Custom locale validation buttons', () => { } async function openDraft(id: string): Promise { - await page.goto(customButtonsURL.edit(id)) + await page.goto(publishAllLocalesURL.edit(id)) await changeLocale(page, 'en') await waitForFormReady(page) } + + async function getLocaleStatus({ id, locale }: { id: string; locale: string }): Promise { + const { data } = await client.endpointWithAuth<{ _status: string }>( + `/api/${validationCustomButtonsCollectionSlug}/${id}?draft=true&locale=${locale}`, + ) + + return data._status + } }) diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index c0341342729..711c06b41b5 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -1,10 +1,9 @@ import type { Payload, PayloadRequest } from 'payload' -import { buildEditorState } from '@payloadcms/richtext-lexical' import { randomUUID } from 'crypto' import fs from 'fs/promises' import path from 'path' -import { createPayloadRequest, getFileByPath } from 'payload' +import { createPayloadRequest } from 'payload' import { getEntityPermissions } from 'payload/internal' import { expect } from 'vitest' @@ -35,8 +34,6 @@ import { validationFallbackCollectionSlug, validationFallbackGlobalSlug, validationGlobalSlug, - validationPublishUploadsDir, - validationPublishUploadsSlug, validationRuntimeIdentityEvents, validationUploadsDir, validationUploadsSlug, @@ -60,7 +57,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal await payload.updateGlobal({ slug: validationGlobalSlug, data: { - location: [-0.12, 51.5], summary: 'stored global summary', title: 'Stored global title', }, @@ -156,12 +152,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal overrideAccess: true, where: { id: { exists: true } }, }), - payload.delete({ - collection: validationPublishUploadsSlug, - disableTransaction: true, - overrideAccess: true, - where: { id: { exists: true } }, - }), payload.delete({ collection: writeTargetsSlug, disableTransaction: true, @@ -176,7 +166,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal }), ]) await fs.rm(validationUploadsDir, { force: true, recursive: true }) - await fs.rm(validationPublishUploadsDir, { force: true, recursive: true }) }) test.describe('collections', () => { @@ -221,47 +210,26 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal }) test('should enforce explicit collection validate access by default', async () => { - await expect( - payload.validate({ - collection: validationDeniedCollectionSlug, - data: { - title: 'Candidate title', - }, - locale: 'en', - }), - ).rejects.toMatchObject({ - status: 403, - }) - }) - - test('should let an explicit null user override an authenticated reused collection request', async () => { const req = { - user: { - id: 'authenticated-user', - collection: validationCollectionSlug, - } as never, + operation: 'delete', } satisfies Partial await expect( payload.validate({ - collection: validationFallbackCollectionSlug, - context: { - requireValidationUser: true, - }, + collection: validationCollectionSlug, data: { + summary: 'candidate summary', title: 'Candidate title', }, locale: 'en', - overrideAccess: false, req, - user: null, }), ).rejects.toMatchObject({ status: 403, }) - expect(req.user).toMatchObject({ - id: 'authenticated-user', - }) + + expect(accessEvents).toEqual(['collection']) + expect(req.operation).toBe('delete') }) test('should fall back to field update access with the validate operation', async () => { @@ -704,65 +672,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal expect(result.errors.filter((error) => error.path === 'summary')).toHaveLength(1) }) - test('should deduplicate explicit locales in deterministic order', async () => { - const result = await payload.validate({ - collection: validationCollectionSlug, - context: { - trackLocalePasses: true, - }, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: ['es', 'en', 'es'], - overrideAccess: true, - }) - - expect(result).toEqual({ - errors: [], - valid: true, - }) - expect(localePassEvents.map(({ localeAtStart }) => localeAtStart)).toEqual(['es', 'en']) - }) - - test('should reject empty, unknown, and unavailable locale selectors', async () => { - await expect( - payload.validate({ - collection: validationCollectionSlug, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: [] as never, - }), - ).rejects.toThrow('Validation requires a locale') - - await expect( - payload.validate({ - collection: validationCollectionSlug, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: ['en', 'unknown'], - } as never), - ).rejects.toThrow('unknown') - - await expect( - payload.validate({ - collection: validationCollectionSlug, - context: { - availableLocaleCodes: ['en'], - }, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: ['en', 'es'], - }), - ).rejects.toThrow('es') - }) - test('should cap concurrent locale passes at three with isolated request state', async () => { const result = await payload.validate({ collection: validationCollectionSlug, @@ -803,6 +712,7 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal const context = { allowValidation: true, isolation: { marker: 'caller' }, + trackLocalePasses: true, trackMutableIsolation: true, } const requestData = { isolation: { marker: 'caller' } } @@ -837,6 +747,8 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal errors: [], valid: true, }) + expect(getMaximumActiveLocalePasses()).toBe(1) + expect(getLocalePassRequestCount()).toBe(2) expect(isolationEvents).toEqual([ { candidateMarker: 'caller', @@ -909,28 +821,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal expect(req.operation).toBe('update') }) - test('should return a successful result for valid create data', async () => { - const req = { - operation: 'read', - } satisfies Partial - const result = await payload.validate({ - collection: validationCollectionSlug, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: 'en', - overrideAccess: true, - req, - }) - - expect(result).toEqual({ - errors: [], - valid: true, - }) - expect(req.operation).toBe('read') - }) - test('should run validation hooks in order with the validate operation and unchanged context', async () => { const result = await payload.validate({ collection: validationCollectionSlug, @@ -965,24 +855,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal ).toBe(true) }) - test('should apply defaults before validating required fields', async () => { - const result = await payload.validate({ - collection: validationCollectionSlug, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: 'en', - overrideAccess: true, - }) - - expect(result).toEqual({ - errors: [], - valid: true, - }) - expect(hookEvents.find(({ hook }) => hook === 'fieldValidate')).toBeDefined() - }) - test('should reject create simulation without data at runtime', async () => { await expect( payload.validate({ @@ -1143,58 +1015,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal expect(versionsAfter).toEqual(versionsBefore) }) - test('should validate a partial update with an unchanged stored point representation', async () => { - const stored = await payload.create({ - collection: validationCollectionSlug, - data: { - location: [-73.9857, 40.7484], - summary: 'stored summary', - title: 'Stored title', - }, - locale: 'en', - overrideAccess: true, - }) - - const result = await payload.validate({ - id: stored.id, - collection: validationCollectionSlug, - data: { - summary: 'candidate summary', - }, - locale: 'en', - overrideAccess: true, - }) - - expect(result).toEqual({ - errors: [], - valid: true, - }) - }) - - test('should execute first-class collection validation access and throw on denial', async () => { - const req = { - operation: 'delete', - } satisfies Partial - - await expect( - payload.validate({ - collection: validationCollectionSlug, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: 'en', - overrideAccess: false, - req, - }), - ).rejects.toMatchObject({ - status: 403, - }) - - expect(accessEvents).toEqual(['collection']) - expect(req.operation).toBe('delete') - }) - test('should restore the caller request operation after a collection hook throws', async () => { const req = { operation: 'create', @@ -1236,6 +1056,7 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal expect(result).toEqual({ errors: [ expect.objectContaining({ + locale: 'en', message: 'Collection hook validation failure', path: 'title', }), @@ -1286,36 +1107,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal }) }) - test('should let an explicit null user override an authenticated reused global request', async () => { - const req = { - user: { - id: 'authenticated-user', - collection: validationCollectionSlug, - } as never, - } satisfies Partial - - await expect( - payload.validateGlobal({ - slug: validationFallbackGlobalSlug, - context: { - requireValidationUser: true, - }, - data: { - title: 'Candidate title', - }, - locale: 'en', - overrideAccess: false, - req, - user: null, - }), - ).rejects.toMatchObject({ - status: 403, - }) - expect(req.user).toMatchObject({ - id: 'authenticated-user', - }) - }) - test('should fall back to global field update access with the validate operation', async () => { const excludedResult = await payload.validateGlobal({ slug: validationFallbackGlobalSlug, @@ -1405,17 +1196,22 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal }) test('should enforce explicit global validate access by default', async () => { + const req = { + operation: 'delete', + } satisfies Partial + await expect( payload.validateGlobal({ - slug: validationDeniedGlobalSlug, - data: { - title: 'Candidate title', - }, + slug: validationGlobalSlug, locale: 'en', + req, }), ).rejects.toMatchObject({ status: 403, }) + + expect(accessEvents).toEqual(['global']) + expect(req.operation).toBe('delete') }) test('should load a draft-only global only when draft validation is requested', async () => { @@ -1508,150 +1304,23 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal expect(globalValidationSourceEvents).toEqual([]) }) - test('should ignore internal projection flags passed to the public global validate API', async () => { + test('should return errors for invalid partial global data without persisting it', async () => { + const req = { + operation: 'update', + } satisfies Partial const result = await payload.validateGlobal({ slug: validationGlobalSlug, data: { - summary: 'candidate summary', - title: 'Candidate title', + title: '', }, - locale: ['en', 'es'], + locale: 'en', overrideAccess: true, - validationDataLocale: 'en', - } as never) - - expect(result).toEqual({ - errors: [], - valid: true, + req, }) - }) - - test('should isolate mutable access data and request state between global locale passes', async () => { - const candidateData = { - isolation: { marker: 'caller' }, - summary: 'candidate summary', - title: 'Candidate title', - } - const context = { - allowValidation: true, - isolation: { marker: 'caller' }, - trackMutableIsolation: true, - } - const requestData = { isolation: { marker: 'caller' } } - const query = { isolation: { marker: 'caller' } } - const routeParams = { isolation: { marker: 'caller' } } - const user = { - id: validationIsolationUserID, - collection: 'users', - isolation: { marker: 'caller' }, - } - const req = { - data: requestData, - headers: new Headers({ 'x-validation-isolation': 'caller' }), - query, - responseHeaders: new Headers({ 'x-validation-isolation': 'caller' }), - routeParams, - } satisfies Partial - - const result = await payload.validateGlobal({ + const afterValidation = await payload.findGlobal({ slug: validationGlobalSlug, - context, - data: candidateData, - locale: ['en', 'es'], - overrideAccess: false, - req, - user: user as never, - }) - - expect(result).toEqual({ - errors: [], - valid: true, - }) - expect(isolationEvents.map(({ locale, source }) => ({ locale, source }))).toEqual([ - { locale: 'en', source: 'global' }, - { locale: 'es', source: 'global' }, - ]) - expect( - isolationEvents.every( - ({ - candidateMarker, - contextMarker, - headerMarker, - queryMarker, - requestDataMarker, - responseHeaderMarker, - routeMarker, - userMarker, - }) => - candidateMarker === 'caller' && - contextMarker === 'caller' && - headerMarker === 'caller' && - queryMarker === 'caller' && - requestDataMarker === 'caller' && - responseHeaderMarker === 'caller' && - routeMarker === 'caller' && - userMarker === 'caller', - ), - ).toBe(true) - expect(candidateData.isolation.marker).toBe('caller') - expect(context.isolation.marker).toBe('caller') - expect(requestData.isolation.marker).toBe('caller') - expect(query.isolation.marker).toBe('caller') - expect(req.headers.get('x-validation-isolation')).toBe('caller') - expect(req.responseHeaders.get('x-validation-isolation')).toBe('caller') - expect(routeParams.isolation.marker).toBe('caller') - expect(user.isolation.marker).toBe('caller') - }) - - test('should validate valid partial global data without persisting it', async () => { - const req = { - operation: 'read', - } satisfies Partial - const result = await payload.validateGlobal({ - slug: validationGlobalSlug, - data: { - summary: 'candidate summary', - }, - locale: 'en', - overrideAccess: true, - req, - }) - const afterValidation = await payload.findGlobal({ - slug: validationGlobalSlug, - locale: 'en', - overrideAccess: true, - }) - - expect(result).toEqual({ - errors: [], - valid: true, - }) - expect(afterValidation).toMatchObject({ - location: [-0.12, 51.5], - summary: 'stored global summary', - title: 'Stored global title', - }) - expect(localeFilterOperationEvents).toEqual(['validate']) - expect(req.operation).toBe('read') - }) - - test('should return errors for invalid partial global data without persisting it', async () => { - const req = { - operation: 'update', - } satisfies Partial - const result = await payload.validateGlobal({ - slug: validationGlobalSlug, - data: { - title: '', - }, - locale: 'en', - overrideAccess: true, - req, - }) - const afterValidation = await payload.findGlobal({ - slug: validationGlobalSlug, - locale: 'en', - overrideAccess: true, + locale: 'en', + overrideAccess: true, }) expect(result).toMatchObject({ @@ -1667,22 +1336,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal expect(req.operation).toBe('update') }) - test('should validate partial global data with an unchanged stored point representation', async () => { - const result = await payload.validateGlobal({ - slug: validationGlobalSlug, - data: { - summary: 'candidate summary', - }, - locale: 'en', - overrideAccess: true, - }) - - expect(result).toEqual({ - errors: [], - valid: true, - }) - }) - test('should use the published global as the validation base unless draft is true', async () => { await seedPublishGlobal({ de: 'German optional', @@ -1764,26 +1417,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal expect(versionsAfter).toEqual(versionsBefore) }) - test('should execute first-class global validation access and throw on denial', async () => { - const req = { - operation: 'delete', - } satisfies Partial - - await expect( - payload.validateGlobal({ - slug: validationGlobalSlug, - locale: 'en', - overrideAccess: false, - req, - }), - ).rejects.toMatchObject({ - status: 403, - }) - - expect(accessEvents).toEqual(['global']) - expect(req.operation).toBe('delete') - }) - test('should restore the caller request operation after a global hook throws', async () => { const req = { operation: 'create', @@ -1803,27 +1436,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal expect(req.operation).toBe('create') }) - - test('should return a validation result instead of throwing when a global hook throws a ValidationError', async () => { - const result = await payload.validateGlobal({ - slug: validationGlobalSlug, - context: { - throwValidationErrorHook: true, - }, - locale: 'en', - overrideAccess: true, - }) - - expect(result).toEqual({ - errors: [ - expect.objectContaining({ - message: 'Global hook validation failure', - path: 'title', - }), - ], - valid: false, - }) - }) }) test.describe('REST API', () => { @@ -1848,27 +1460,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal }) }) - test('should use global update access when validate access is not configured', async () => { - const response = await restClient.POST( - `/globals/${validationFallbackGlobalSlug}/validate?locale=en`, - { - body: JSON.stringify({ - title: 'Candidate title', - }), - }, - ) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - errors: [], - valid: true, - }) - expect(fallbackAccessEvents).toContainEqual({ - operation: 'validate', - source: 'global', - }) - }) - test('should deny global REST validation when explicit validate access denies it', async () => { const response = await restClient.POST( `/globals/${validationDeniedGlobalSlug}/validate?locale=en`, @@ -1924,21 +1515,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal ).toEqual({ totalDocs: 0 }) }) - test('should return valid collection create validation', async () => { - const response = await restClient.POST(`/${validationCollectionSlug}/validate?locale=en`, { - body: JSON.stringify({ - summary: 'candidate summary', - title: 'Candidate title', - }), - }) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - errors: [], - valid: true, - }) - }) - test('should return 400 for missing or empty locales and malformed data', async () => { const missingLocale = await restClient.POST(`/${validationCollectionSlug}/validate`, { body: JSON.stringify({ @@ -1971,39 +1547,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal expect(malformedJSON.status).toBe(400) }) - test('should accept repeated and all locale selectors', async () => { - const repeatedLocale = await restClient.POST( - `/${validationCollectionSlug}/validate?locale=en&locale=es`, - { - body: JSON.stringify({ - summary: 'candidate summary', - title: 'Candidate title', - }), - }, - ) - expect(repeatedLocale.status).toBe(200) - await expect(repeatedLocale.json()).resolves.toEqual({ - errors: [], - valid: true, - }) - - clearValidationEvents() - - const allLocales = await restClient.POST(`/${validationCollectionSlug}/validate?locale=all`, { - body: JSON.stringify({ - summary: 'candidate summary', - title: 'Candidate title', - }), - }) - - expect(allLocales.status).toBe(200) - await expect(allLocales.json()).resolves.toEqual({ - errors: [], - valid: true, - }) - expect(localeFilterOperationEvents).toEqual(['validate']) - }) - test('should use the latest collection draft as the REST validation base', async () => { const draft = await seedPublishCollection({ de: 'German optional', @@ -2048,45 +1591,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal }) }) - test('should use the latest global draft as the REST validation base', async () => { - await seedPublishGlobal({ - de: 'German optional', - en: 'English draft', - es: 'Spanish valid', - }) - await payload.updateGlobal({ - slug: publishGlobalSlug, - data: { - _status: 'published', - title: 'English published', - }, - locale: 'en', - overrideAccess: true, - }) - await payload.updateGlobal({ - slug: publishGlobalSlug, - data: { - title: '', - }, - draft: true, - locale: 'en', - overrideAccess: true, - }) - - const response = await restClient.POST(`/globals/${publishGlobalSlug}/validate?locale=en`) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toMatchObject({ - errors: [ - { - locale: 'en', - path: 'title', - }, - ], - valid: false, - }) - }) - test('should merge by-ID validation data without persisting it', async () => { const stored = await payload.create({ collection: validationCollectionSlug, @@ -2153,23 +1657,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal expect(afterValidation.title).toBe('Stored global title') }) - test('should return valid global validation without persisting it', async () => { - const response = await restClient.POST( - `/globals/${validationGlobalSlug}/validate?locale=en`, - { - body: JSON.stringify({ - summary: 'candidate summary', - }), - }, - ) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - errors: [], - valid: true, - }) - }) - test('should deny collection REST validation when explicit validate access denies it', async () => { const response = await restClient.POST( `/${validationDeniedCollectionSlug}/validate?locale=en`, @@ -2398,24 +1885,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal ) }) - test('should return a valid result for a valid collection create candidate', async () => { - const query = `mutation { - validateValidationWriteTarget(data: { title: "GraphQL candidate" }) { - valid - errors { - path - message - } - } - }` - - const { data } = await restClient - .GRAPHQL_POST({ body: JSON.stringify({ query }) }) - .then((res) => res.json()) - - expect(data.validateValidationWriteTarget).toEqual({ errors: [], valid: true }) - }) - test('should validate a stored collection document by id without persisting the candidate', async () => { const target = await createWriteTarget() @@ -2476,77 +1945,26 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal }) }) - test('should reject an update that reuses the validation request before a row is written', async () => { - const target = await createWriteTarget() - - await expect(runWriteAttempt('update', target.id)).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) - - await expect( - payload.findByID({ - id: target.id, - collection: writeTargetsSlug, - overrideAccess: true, - }), - ).resolves.toMatchObject({ - title: 'stored target', - }) - }) - - test('should reject a bulk update that reuses the validation request before a row is written', async () => { - const target = await createWriteTarget() - - await expect(runWriteAttempt('updateMany', target.id)).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) + test.each(['delete', 'deleteMany', 'update', 'updateMany'] as const)( + 'should reject %s when it reuses the validation request before changing a row', + async (writeAttempt) => { + const target = await createWriteTarget() - await expect( - payload.findByID({ - id: target.id, - collection: writeTargetsSlug, - overrideAccess: true, - }), - ).resolves.toMatchObject({ - title: 'stored target', - }) - }) - - test('should reject a delete that reuses the validation request before a row is removed', async () => { - const target = await createWriteTarget() - - await expect(runWriteAttempt('delete', target.id)).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) - - await expect( - payload.findByID({ - id: target.id, - collection: writeTargetsSlug, - overrideAccess: true, - }), - ).resolves.toMatchObject({ - title: 'stored target', - }) - }) - - test('should reject a bulk delete that reuses the validation request before a row is removed', async () => { - const target = await createWriteTarget() - - await expect(runWriteAttempt('deleteMany', target.id)).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) + await expect(runWriteAttempt(writeAttempt, target.id)).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) - await expect( - payload.findByID({ - id: target.id, - collection: writeTargetsSlug, - overrideAccess: true, - }), - ).resolves.toMatchObject({ - title: 'stored target', - }) - }) + await expect( + payload.findByID({ + id: target.id, + collection: writeTargetsSlug, + overrideAccess: true, + }), + ).resolves.toMatchObject({ + title: 'stored target', + }) + }, + ) test('should reject a global update that reuses the validation request before data is written', async () => { await payload.updateGlobal({ @@ -2736,53 +2154,20 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal } }) - test('should reject a logout that reuses the validation request before a session is removed', async () => { - const usersBefore = await payload.count({ collection: 'users', overrideAccess: true }) + test.each(['logout', 'refresh', 'resetPassword', 'verifyEmail'] as const)( + 'should reject %s when it reuses the validation request before changing a user', + async (writeAttempt) => { + const usersBefore = await payload.count({ collection: 'users', overrideAccess: true }) - await expect(runWriteAttempt('logout')).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) - - expect(await payload.count({ collection: 'users', overrideAccess: true })).toEqual( - usersBefore, - ) - }) - - test('should reject a refresh that reuses the validation request before a session is written', async () => { - const usersBefore = await payload.count({ collection: 'users', overrideAccess: true }) - - await expect(runWriteAttempt('refresh')).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) - - expect(await payload.count({ collection: 'users', overrideAccess: true })).toEqual( - usersBefore, - ) - }) - - test('should reject a reset password that reuses the validation request before a password is written', async () => { - const usersBefore = await payload.count({ collection: 'users', overrideAccess: true }) - - await expect(runWriteAttempt('resetPassword')).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) - - expect(await payload.count({ collection: 'users', overrideAccess: true })).toEqual( - usersBefore, - ) - }) - - test('should reject a verify email that reuses the validation request before a user is written', async () => { - const usersBefore = await payload.count({ collection: 'users', overrideAccess: true }) - - await expect(runWriteAttempt('verifyEmail')).rejects.toThrow( - 'Payload writes are not allowed during validation', - ) + await expect(runWriteAttempt(writeAttempt)).rejects.toThrow( + 'Payload writes are not allowed during validation', + ) - expect(await payload.count({ collection: 'users', overrideAccess: true })).toEqual( - usersBefore, - ) - }) + expect(await payload.count({ collection: 'users', overrideAccess: true })).toEqual( + usersBefore, + ) + }, + ) }) }) @@ -2830,68 +2215,23 @@ async function runWriteAttempt( }) } -type PublishCollectionLocalizedField = - | 'localizedArray' - | 'localizedBlocks' - | 'localizedGroup' - | 'localizedJSON' - | 'localizedRichText' - | 'localizedTab' - | 'nested.localizedJSON' - -function getPublishCollectionLocaleData({ - omit = [], - title, -}: { - omit?: PublishCollectionLocalizedField[] - title: string -}): Record { - const data: Record = { - localizedArray: [{ value: `${title} array` }], - localizedBlocks: [{ blockType: 'validationBlock', value: `${title} block` }], - localizedGroup: { value: `${title} group` }, - localizedJSON: { value: `${title} JSON` }, - localizedRichText: buildEditorState({ text: `${title} rich text` }), - localizedTab: { value: `${title} tab` }, - nested: { - localizedJSON: { value: `${title} nested JSON` }, - shared: 'shared value', - }, - title, - } - - for (const field of omit) { - if (field === 'nested.localizedJSON') { - delete (data.nested as Record).localizedJSON - } else if (field === 'localizedArray' || field === 'localizedBlocks') { - data[field] = [] - } else { - delete data[field] - } - } - - return data -} - async function seedPublishCollection({ de, deletedAt, en, es, fr, - omit, }: { de: string deletedAt?: string en: string es: string fr?: string - omit?: Partial> }) { const draft = await payload.create({ collection: publishCollectionSlug, data: { - ...getPublishCollectionLocaleData({ omit: omit?.en, title: en }), + title: en, ...(deletedAt ? { deletedAt } : {}), }, draft: true, @@ -2902,7 +2242,7 @@ async function seedPublishCollection({ await payload.update({ id: draft.id, collection: publishCollectionSlug, - data: getPublishCollectionLocaleData({ omit: omit?.es, title: es }), + data: { title: es }, draft: true, locale: 'es', overrideAccess: true, @@ -2911,7 +2251,7 @@ async function seedPublishCollection({ await payload.update({ id: draft.id, collection: publishCollectionSlug, - data: getPublishCollectionLocaleData({ omit: omit?.de, title: de }), + data: { title: de }, draft: true, locale: 'de', overrideAccess: true, @@ -2921,7 +2261,7 @@ async function seedPublishCollection({ await payload.update({ id: draft.id, collection: publishCollectionSlug, - data: getPublishCollectionLocaleData({ title: fr }), + data: { title: fr }, draft: true, locale: 'fr', overrideAccess: true, @@ -2932,31 +2272,16 @@ async function seedPublishCollection({ return draft } -function getPublishGlobalLocaleData({ - includeLocalizedJSON = true, - title, -}: { - includeLocalizedJSON?: boolean - title: string -}): Record { - return { - localizedJSON: includeLocalizedJSON ? { value: `${title} JSON` } : null, - title, - } -} - async function seedPublishGlobal({ de, en, es, fr, - omitLocalizedJSON, }: { de: string en: string es: string fr?: string - omitLocalizedJSON?: Partial> }) { await payload.updateGlobal({ slug: publishGlobalSlug, @@ -2966,30 +2291,21 @@ async function seedPublishGlobal({ }) await payload.updateGlobal({ slug: publishGlobalSlug, - data: getPublishGlobalLocaleData({ - includeLocalizedJSON: !omitLocalizedJSON?.en, - title: en, - }), + data: { title: en }, draft: true, locale: 'en', overrideAccess: true, }) await payload.updateGlobal({ slug: publishGlobalSlug, - data: getPublishGlobalLocaleData({ - includeLocalizedJSON: !omitLocalizedJSON?.es, - title: es, - }), + data: { title: es }, draft: true, locale: 'es', overrideAccess: true, }) await payload.updateGlobal({ slug: publishGlobalSlug, - data: getPublishGlobalLocaleData({ - includeLocalizedJSON: !omitLocalizedJSON?.de, - title: de, - }), + data: { title: de }, draft: true, locale: 'de', overrideAccess: true, @@ -2997,9 +2313,7 @@ async function seedPublishGlobal({ if (fr !== undefined) { await payload.updateGlobal({ slug: publishGlobalSlug, - data: getPublishGlobalLocaleData({ - title: fr, - }), + data: { title: fr }, draft: true, locale: 'fr', overrideAccess: true, From 179bebcfb4a7226bc64bb71574d0454a1183470a Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 2 Oct 2026 16:29:25 +0100 Subject: [PATCH 46/47] test: replace mocked validation tests with integration coverage Written with AI --- .../resolvers/collections/validate.spec.ts | 30 -------- .../src/resolvers/globals/validate.spec.ts | 30 -------- packages/payload/src/auth/sessions.spec.ts | 31 --------- .../local/resetLoginAttempts.spec.ts | 16 ----- .../fields/hooks/beforeChange/promise.spec.ts | 69 ------------------- .../hooks/collectionBeforeChange.spec.ts | 36 ---------- test/fields/int.spec.ts | 29 ++++++++ test/hierarchy/int.spec.ts | 18 +++++ test/validate/config.ts | 10 +++ test/validate/int.spec.ts | 50 +------------- 10 files changed, 59 insertions(+), 260 deletions(-) delete mode 100644 packages/graphql/src/resolvers/collections/validate.spec.ts delete mode 100644 packages/graphql/src/resolvers/globals/validate.spec.ts delete mode 100644 packages/payload/src/auth/sessions.spec.ts delete mode 100644 packages/payload/src/auth/strategies/local/resetLoginAttempts.spec.ts delete mode 100644 packages/payload/src/fields/hooks/beforeChange/promise.spec.ts delete mode 100644 packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts diff --git a/packages/graphql/src/resolvers/collections/validate.spec.ts b/packages/graphql/src/resolvers/collections/validate.spec.ts deleted file mode 100644 index 0fa50570f7e..00000000000 --- a/packages/graphql/src/resolvers/collections/validate.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Collection, PayloadRequest } from 'payload' - -import { describe, expect, it, vi } from 'vitest' - -import { validateResolver } from './validate.js' - -const createContext = (validate: (args: { req: PayloadRequest }) => Promise) => ({ - req: { - locale: 'en', - payload: { - config: { localization: false }, - validate, - }, - } as unknown as PayloadRequest, -}) - -describe('collections validateResolver', () => { - it('should isolate transactionID from the shared GraphQL request context', async () => { - const validate = vi.fn(async ({ req }: { req: PayloadRequest }) => { - req.transactionID = 'validate-transaction' - return { errors: [], valid: true } - }) - const context = createContext(validate) - const collection = { config: { slug: 'posts' } } as unknown as Collection - - await validateResolver(collection)(null, { data: { title: 'x' } }, context) - - expect(context.req.transactionID).toBeUndefined() - }) -}) diff --git a/packages/graphql/src/resolvers/globals/validate.spec.ts b/packages/graphql/src/resolvers/globals/validate.spec.ts deleted file mode 100644 index 509eadd711d..00000000000 --- a/packages/graphql/src/resolvers/globals/validate.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { PayloadRequest, SanitizedGlobalConfig } from 'payload' - -import { describe, expect, it, vi } from 'vitest' - -import { validateResolver } from './validate.js' - -const createContext = (validateGlobal: (args: { req: PayloadRequest }) => Promise) => ({ - req: { - locale: 'en', - payload: { - config: { localization: false }, - validateGlobal, - }, - } as unknown as PayloadRequest, -}) - -describe('globals validateResolver', () => { - it('should isolate transactionID from the shared GraphQL request context', async () => { - const validateGlobal = vi.fn(async ({ req }: { req: PayloadRequest }) => { - req.transactionID = 'validate-transaction' - return { errors: [], valid: true } - }) - const context = createContext(validateGlobal) - const globalConfig = { slug: 'settings' } as unknown as SanitizedGlobalConfig - - await validateResolver(globalConfig)(null, { data: { title: 'x' } }, context) - - expect(context.req.transactionID).toBeUndefined() - }) -}) diff --git a/packages/payload/src/auth/sessions.spec.ts b/packages/payload/src/auth/sessions.spec.ts deleted file mode 100644 index a2edc374ac6..00000000000 --- a/packages/payload/src/auth/sessions.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { addSessionToUser, revokeSession } from './sessions.js' - -const validationRequest = { operation: 'validate' } as any -const collectionConfig = { auth: { useSessions: true }, slug: 'users' } as any - -describe('session helpers reject writes made through an active validation request', () => { - it('addSessionToUser should reject before writing a session', async () => { - await expect( - addSessionToUser({ - collectionConfig, - payload: {} as any, - req: validationRequest, - user: { id: '1' } as any, - }), - ).rejects.toThrow(/not allowed during validation/i) - }) - - it('revokeSession should reject before writing a session', async () => { - await expect( - revokeSession({ - collectionConfig, - payload: {} as any, - req: validationRequest, - sid: 'session-id', - user: { id: '1', sessions: [{ id: 'session-id' }] } as any, - }), - ).rejects.toThrow(/not allowed during validation/i) - }) -}) diff --git a/packages/payload/src/auth/strategies/local/resetLoginAttempts.spec.ts b/packages/payload/src/auth/strategies/local/resetLoginAttempts.spec.ts deleted file mode 100644 index 611ff8500b4..00000000000 --- a/packages/payload/src/auth/strategies/local/resetLoginAttempts.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { resetLoginAttempts } from './resetLoginAttempts.js' - -describe('resetLoginAttempts', () => { - it('should reject writes made through an active validation request', async () => { - await expect( - resetLoginAttempts({ - collection: { slug: 'users' } as any, - doc: { id: '1', loginAttempts: 3 } as any, - payload: {} as any, - req: { operation: 'validate' } as any, - }), - ).rejects.toThrow(/not allowed during validation/i) - }) -}) diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.spec.ts b/packages/payload/src/fields/hooks/beforeChange/promise.spec.ts deleted file mode 100644 index d3e93a40aca..00000000000 --- a/packages/payload/src/fields/hooks/beforeChange/promise.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import type { PayloadRequest } from '../../../types/index.js' -import type { Field } from '../../config/types.js' - -import { promise } from './promise.js' - -const req = { - i18n: {}, - locale: 'de', - payload: { - blocks: {}, - config: {}, - }, - t: (key: string) => key, - user: null, -} as PayloadRequest - -describe('beforeChange field traversal', () => { - it('should add the active locale to invalid block filter errors', async () => { - const errors = [] - - await promise({ - collection: null, - context: {}, - data: { content: [{ blockType: 'restricted' }] }, - doc: {}, - docWithLocales: {}, - errors, - field: { - blocks: [ - { - fields: [], - labels: { plural: 'Restricted', singular: 'Restricted' }, - slug: 'restricted', - }, - ], - filterOptions: [], - name: 'content', - type: 'blocks', - validate: () => 'Invalid block', - } as Field, - fieldIndex: 0, - fieldLabelPath: '', - global: null, - mergeLocaleActions: [], - operation: 'update', - overrideAccess: false, - parentIndexPath: '', - parentIsLocalized: false, - parentPath: '', - parentSchemaPath: '', - req, - siblingData: { content: [{ blockType: 'restricted' }] }, - siblingDoc: {}, - siblingDocWithLocales: {}, - skipValidation: false, - }) - - expect(errors).toEqual([ - { - label: 'Content > fields:block 1 (Restricted)', - locale: 'de', - message: 'validation:invalidBlock', - path: 'content.0.id', - }, - ]) - }) -}) diff --git a/packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts b/packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts deleted file mode 100644 index 33c16f76506..00000000000 --- a/packages/payload/src/hierarchy/hooks/collectionBeforeChange.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { hierarchyCollectionBeforeChange } from './collectionBeforeChange.js' - -describe('hierarchyCollectionBeforeChange', () => { - const hook = hierarchyCollectionBeforeChange({ parentFieldName: 'parent' }) - - it.each(['update', 'validate'] as const)( - 'should reject a self-referential parent during %s', - async (operation) => { - await expect( - hook({ - collection: { hierarchy: { parentFieldName: 'parent' }, slug: 'folders' } as any, - context: {}, - data: { id: '1', parent: '1' }, - operation, - originalDoc: { id: '1', parent: null } as any, - req: {} as any, - }), - ).rejects.toThrow(/cannot be its own parent/i) - }, - ) - - it('should allow an unrelated parent change during create', async () => { - await expect( - hook({ - collection: { hierarchy: { parentFieldName: 'parent' }, slug: 'folders' } as any, - context: {}, - data: { id: '1', parent: '2' }, - operation: 'create', - originalDoc: undefined, - req: {} as any, - }), - ).resolves.toEqual({ id: '1', parent: '2' }) - }) -}) diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index 0e24bbe1e09..0f4b1932c74 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -4691,6 +4691,35 @@ test.suite('Fields', { config: './config.ts', resetBetweenTests: false }, () => }) test.describe('blocks', () => { + test('should include the active locale in invalid block filter errors', async ({ payload }) => { + let error: undefined | ValidationError + + try { + await payload.create({ + collection: blockFieldsSlug, + data: { + blocksWithDynamicFilterOptions: [ + { + blockType: 'blockOne', + }, + ], + enabledBlocks: 'blockTwo', + }, + locale: 'de', + overrideAccess: true, + }) + } catch (caughtError) { + error = caughtError as ValidationError + } + + expect(error?.data.errors).toContainEqual( + expect.objectContaining({ + locale: 'de', + path: 'blocksWithDynamicFilterOptions.0.id', + }), + ) + }) + test('should retrieve doc with blocks', async ({ payload }) => { const blockFields = await payload.find({ collection: 'block-fields', diff --git a/test/hierarchy/int.spec.ts b/test/hierarchy/int.spec.ts index cd0704371a6..7bfea5aedfc 100644 --- a/test/hierarchy/int.spec.ts +++ b/test/hierarchy/int.spec.ts @@ -267,6 +267,24 @@ test.suite('Hierarchy', { config: './config.ts', resetBetweenTests: false }, () ).rejects.toThrow('Document cannot be its own parent') }) + test('should prevent self-referential parent during validation', async ({ payload }) => { + const page = await payload.create({ + collection: 'organizations', + data: { parent: null, title: 'Test Page' }, + overrideAccess: true, + }) + + await expect( + payload.validate({ + id: page.id, + collection: 'organizations', + data: { parent: page.id }, + locale: 'en', + overrideAccess: true, + }), + ).rejects.toThrow('Document cannot be its own parent') + }) + test('should prevent circular reference with direct child', async ({ payload }) => { const parentPage = await payload.create({ collection: 'organizations', diff --git a/test/validate/config.ts b/test/validate/config.ts index 3d289f8f135..efd5d43c2f7 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -314,6 +314,15 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re }) break + case 'unlock': + await req.payload.unlock({ + collection: 'users', + data: { email: 'validation-isolation@example.com' }, + overrideAccess: true, + req, + }) + break + case 'upload': { const fileData = Buffer.from('must not be uploaded') @@ -483,6 +492,7 @@ const validationCollection: CollectionConfig = { 'update', 'updateGlobal', 'updateMany', + 'unlock', 'upload', 'verifyEmail', 'version', diff --git a/test/validate/int.spec.ts b/test/validate/int.spec.ts index 711c06b41b5..04c76a2429a 100644 --- a/test/validate/int.spec.ts +++ b/test/validate/int.spec.ts @@ -585,53 +585,6 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal }) }) - test('should ignore internal projection flags passed to the public collection validate API', async () => { - const stored = await payload.create({ - collection: validationCollectionSlug, - data: { - summary: 'stored summary', - title: 'Stored title', - }, - locale: 'en', - overrideAccess: true, - }) - - const result = await payload.validate({ - id: stored.id, - collection: validationCollectionSlug, - data: { - summary: 'candidate summary', - title: 'Candidate title', - }, - locale: ['en', 'es'], - overrideAccess: true, - validationDataLocale: 'en', - } as never) - - expect(result).toEqual({ - errors: [], - valid: true, - }) - }) - - test('should ignore internal trash-source flags passed to the public collection validate API', async () => { - const stored = await seedPublishCollection({ - de: 'German optional', - deletedAt: new Date().toISOString(), - en: 'English draft', - es: 'Spanish valid', - }) - - await expect( - payload.validate({ - id: stored.id, - collection: publishCollectionSlug, - locale: 'en', - validationTrash: true, - } as never), - ).rejects.toThrow(/not found/i) - }) - test('should resolve all to every available locale through locale filtering', async () => { const result = await payload.validate({ collection: validationCollectionSlug, @@ -2154,7 +2107,7 @@ test.suite('validate Local API', { config: './config.ts', resetBetweenTests: fal } }) - test.each(['logout', 'refresh', 'resetPassword', 'verifyEmail'] as const)( + test.each(['logout', 'refresh', 'resetPassword', 'unlock', 'verifyEmail'] as const)( 'should reject %s when it reuses the validation request before changing a user', async (writeAttempt) => { const usersBefore = await payload.count({ collection: 'users', overrideAccess: true }) @@ -2197,6 +2150,7 @@ async function runWriteAttempt( | 'update' | 'updateGlobal' | 'updateMany' + | 'unlock' | 'upload' | 'verifyEmail' | 'version', From 6bf95f4723421e8845865f21a606db469c337593 Mon Sep 17 00:00:00 2001 From: Paul Popus Date: Fri, 2 Oct 2026 17:30:01 +0100 Subject: [PATCH 47/47] chore: address publish all locales review findings Written with AI --- .../ui/src/elements/PublishButton/index.tsx | 21 +- .../PublishButton/validateAllLocales.spec.ts | 35 +++ .../PublishButton/validateAllLocales.ts | 214 ++++++++++++------ .../utilities/traverseForLocalizedFields.ts | 66 +----- test/validate/config.ts | 69 +++++- test/validate/e2e.spec.ts | 26 ++- 6 files changed, 293 insertions(+), 138 deletions(-) diff --git a/packages/ui/src/elements/PublishButton/index.tsx b/packages/ui/src/elements/PublishButton/index.tsx index 5a70a43ebf0..802628d7c38 100644 --- a/packages/ui/src/elements/PublishButton/index.tsx +++ b/packages/ui/src/elements/PublishButton/index.tsx @@ -17,10 +17,13 @@ import { useEditDepth } from '../../providers/EditDepth/index.js' import { useLocale } from '../../providers/Locale/index.js' import { useOperation } from '../../providers/Operation/index.js' import { useTranslation } from '../../providers/Translation/index.js' -import { traverseForLocalizedFields } from '../../utilities/traverseForLocalizedFields.js' import { PopupList } from '../Popup/index.js' import { FieldErrorsToast } from '../Toasts/fieldErrors.js' -import { getValidationEndpoint, validateDocumentLocales } from './validateAllLocales.js' +import { + hasLocalizedFields as fieldsContainLocalizedData, + getValidationEndpoint, + validateDocumentLocales, +} from './validateAllLocales.js' import './index.css' export function PublishButton({ @@ -77,7 +80,11 @@ export function PublishButton({ const hasLocalizedFields = React.useMemo( () => Boolean( - entityConfig?.fields && traverseForLocalizedFields(entityConfig.fields, { blocksMap }), + entityConfig?.fields && + fieldsContainLocalizedData({ + blocksMap, + fields: entityConfig.fields, + }), ), [blocksMap, entityConfig?.fields], ) @@ -175,7 +182,12 @@ export function PublishButton({ if (!validation.valid) { const introMessage = t('error:followingFieldsInvalid', { count: validation.errors.length }) const fieldList = validation.errors - .map((error) => `${error.locale ? `[${error.locale}] ` : ''}${error.label ?? error.path}`) + .map( + (error) => + `${error.locale ? `[${error.locale}] ` : ''}${ + error.label ? getTranslation(error.label, i18n) : error.path + }`, + ) .join(', ') toast.error() @@ -223,6 +235,7 @@ export function PublishButton({ hasLocalizedFields, id, localization, + i18n, setHasPublishedDoc, submit, setUnpublishedVersionCount, diff --git a/packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts b/packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts index 54233998f1d..593894d1728 100644 --- a/packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts +++ b/packages/ui/src/elements/PublishButton/validateAllLocales.spec.ts @@ -4,7 +4,9 @@ import { describe, expect, it } from 'vitest' import { getValidationEndpoint, + hasLocalizedFields, projectValidationDataForSiblingLocales, + validateDocumentLocales, } from './validateAllLocales.js' describe('validate all locales before publish', () => { @@ -94,4 +96,37 @@ describe('validate all locales before publish', () => { settings: { theme: 'dark' }, }) }) + + it('should reject validation when no locales are selected', async () => { + await expect( + validateDocumentLocales({ + activeLocale: 'en', + blocksMap: {}, + data: {}, + endpoint: '/api/posts/validate', + fields: [], + locales: [], + }), + ).rejects.toThrow('Document validation requires at least one locale.') + }) + + it('should find localized fields in referenced blocks', () => { + expect( + hasLocalizedFields({ + blocksMap: { + hero: { + fields: [{ localized: true, name: 'heading', type: 'text' }], + slug: 'hero', + }, + }, + fields: [ + { + blocks: ['hero'], + name: 'layout', + type: 'blocks', + }, + ] as ClientField[], + }), + ).toBe(true) + }) }) diff --git a/packages/ui/src/elements/PublishButton/validateAllLocales.ts b/packages/ui/src/elements/PublishButton/validateAllLocales.ts index 3e5504d3d7c..5aeab6ed915 100644 --- a/packages/ui/src/elements/PublishButton/validateAllLocales.ts +++ b/packages/ui/src/elements/PublishButton/validateAllLocales.ts @@ -12,13 +12,16 @@ import { formatAdminURL, tabHasName, } from 'payload/shared' +import * as qs from 'qs-esm' -export type DocumentValidationRequest = (args: { +import { requests } from '../../utilities/api.js' + +type DocumentValidationRequestArgs = { body: Data endpoint: string locales: string[] signal?: AbortSignal -}) => Promise +} type ValidationTarget = { apiRoute: string @@ -59,16 +62,32 @@ export function projectValidationDataForSiblingLocales({ }): Data { const projectedData = cloneValidationData(data) - removeLocalizedData({ + processLocalizedFields({ blocksMap, data: projectedData, fields, parentIsLocalized: false, + visitedBlockSlugs: new Set(), }) return projectedData } +export function hasLocalizedFields({ + blocksMap, + fields, +}: { + blocksMap: Record + fields: ClientField[] +}): boolean { + return processLocalizedFields({ + blocksMap, + fields, + parentIsLocalized: false, + visitedBlockSlugs: new Set(), + }) +} + export async function validateDocumentLocales({ activeLocale, blocksMap, @@ -76,7 +95,6 @@ export async function validateDocumentLocales({ endpoint, fields, locales, - request = requestDocumentValidation, signal, }: { activeLocale: string @@ -85,15 +103,18 @@ export async function validateDocumentLocales({ endpoint: string fields: ClientField[] locales: string[] - request?: DocumentValidationRequest signal?: AbortSignal }): Promise { const selectedLocales = locales.filter((locale, index) => locales.indexOf(locale) === index) const validationResults: ValidationResult[] = [] + if (selectedLocales.length === 0) { + throw new Error('Document validation requires at least one locale.') + } + if (selectedLocales.includes(activeLocale)) { validationResults.push( - await request({ + await requestDocumentValidation({ body: data, endpoint, locales: [activeLocale], @@ -106,7 +127,7 @@ export async function validateDocumentLocales({ if (siblingLocales.length > 0) { validationResults.push( - await request({ + await requestDocumentValidation({ body: projectValidationDataForSiblingLocales({ blocksMap, data, @@ -132,23 +153,27 @@ export async function requestDocumentValidation({ endpoint, locales, signal, -}: Parameters[0]): Promise { - const search = new URLSearchParams() - - for (const locale of locales) { - search.append('locale', locale) - } - - const response = await fetch(`${endpoint}?${search.toString()}`, { +}: DocumentValidationRequestArgs): Promise { + const query = qs.stringify( + { locale: locales }, + { + addQueryPrefix: true, + arrayFormat: 'repeat', + }, + ) + const response = await requests.post(`${endpoint}${query}`, { body: JSON.stringify(body), - credentials: 'include', headers: { 'Content-Type': 'application/json', }, - method: 'POST', signal, }) const responseData = (await response.json()) as unknown + + if (!response.ok) { + throw new Error(getResponseErrorMessage(responseData) || response.statusText) + } + const result = parseValidationResult(responseData) if (result) { @@ -235,37 +260,56 @@ function cloneValidationData(value: T): T { return value } -function removeLocalizedData({ +function processLocalizedFields({ blocksMap, data, fields, parentIsLocalized, + visitedBlockSlugs, }: { blocksMap: Record - data: Data + data?: Data fields: ClientField[] parentIsLocalized: boolean -}): void { + visitedBlockSlugs: Set +}): boolean { + let hasLocalizedField = false + for (const field of fields) { if (fieldAffectsData(field)) { if (parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized })) { - delete data[field.name] + hasLocalizedField = true + + if (data) { + delete data[field.name] + } + continue } - const fieldValue = data[field.name] + const fieldValue = data?.[field.name] switch (field.type) { case 'array': { - if (Array.isArray(fieldValue)) { + if (data === undefined) { + hasLocalizedField = + processLocalizedFields({ + blocksMap, + fields: field.fields, + parentIsLocalized: false, + visitedBlockSlugs, + }) || hasLocalizedField + } else if (Array.isArray(fieldValue)) { for (const row of fieldValue) { if (isObject(row)) { - removeLocalizedData({ - blocksMap, - data: row, - fields: field.fields, - parentIsLocalized: false, - }) + hasLocalizedField = + processLocalizedFields({ + blocksMap, + data: row, + fields: field.fields, + parentIsLocalized: false, + visitedBlockSlugs, + }) || hasLocalizedField } } } @@ -273,7 +317,29 @@ function removeLocalizedData({ } case 'blocks': { - if (Array.isArray(fieldValue)) { + if (data === undefined) { + for (const blockOrSlug of field.blocks) { + if (typeof blockOrSlug === 'string' && visitedBlockSlugs.has(blockOrSlug)) { + continue + } + + const block = typeof blockOrSlug === 'string' ? blocksMap[blockOrSlug] : blockOrSlug + + if (typeof blockOrSlug === 'string') { + visitedBlockSlugs.add(blockOrSlug) + } + + if (block) { + hasLocalizedField = + processLocalizedFields({ + blocksMap, + fields: block.fields, + parentIsLocalized: false, + visitedBlockSlugs, + }) || hasLocalizedField + } + } + } else if (Array.isArray(fieldValue)) { for (const row of fieldValue) { if (!isObject(row) || typeof row.blockType !== 'string') { continue @@ -285,12 +351,14 @@ function removeLocalizedData({ const block = typeof blockOrSlug === 'string' ? blocksMap[blockOrSlug] : blockOrSlug if (block) { - removeLocalizedData({ - blocksMap, - data: row, - fields: block.fields, - parentIsLocalized: false, - }) + hasLocalizedField = + processLocalizedFields({ + blocksMap, + data: row, + fields: block.fields, + parentIsLocalized: false, + visitedBlockSlugs, + }) || hasLocalizedField } } } @@ -298,13 +366,15 @@ function removeLocalizedData({ } case 'group': { - if (isObject(fieldValue)) { - removeLocalizedData({ - blocksMap, - data: fieldValue, - fields: field.fields, - parentIsLocalized: false, - }) + if (data === undefined || isObject(fieldValue)) { + hasLocalizedField = + processLocalizedFields({ + blocksMap, + data: isObject(fieldValue) ? fieldValue : undefined, + fields: field.fields, + parentIsLocalized: false, + visitedBlockSlugs, + }) || hasLocalizedField } break } @@ -317,35 +387,49 @@ function removeLocalizedData({ const isLocalized = parentIsLocalized || fieldShouldBeLocalized({ field, parentIsLocalized }) - removeLocalizedData({ - blocksMap, - data, - fields: field.fields, - parentIsLocalized: isLocalized, - }) + hasLocalizedField = isLocalized || hasLocalizedField + hasLocalizedField = + processLocalizedFields({ + blocksMap, + data, + fields: field.fields, + parentIsLocalized: isLocalized, + visitedBlockSlugs, + }) || hasLocalizedField break } case 'tabs': { for (const tab of field.tabs) { + const isLocalized = + parentIsLocalized || fieldShouldBeLocalized({ field: tab, parentIsLocalized }) + + hasLocalizedField = isLocalized || hasLocalizedField + if (tabHasName(tab)) { - if (parentIsLocalized || fieldShouldBeLocalized({ field: tab, parentIsLocalized })) { - delete data[tab.name] - } else if (isObject(data[tab.name])) { - removeLocalizedData({ - blocksMap, - data: data[tab.name], - fields: tab.fields, - parentIsLocalized: false, - }) + if (isLocalized) { + if (data) { + delete data[tab.name] + } + } else if (data === undefined || isObject(data[tab.name])) { + hasLocalizedField = + processLocalizedFields({ + blocksMap, + data: data && isObject(data[tab.name]) ? data[tab.name] : undefined, + fields: tab.fields, + parentIsLocalized: false, + visitedBlockSlugs, + }) || hasLocalizedField } } else { - removeLocalizedData({ - blocksMap, - data, - fields: tab.fields, - parentIsLocalized, - }) + hasLocalizedField = + processLocalizedFields({ + blocksMap, + data, + fields: tab.fields, + parentIsLocalized: isLocalized, + visitedBlockSlugs, + }) || hasLocalizedField } } break @@ -353,6 +437,8 @@ function removeLocalizedData({ } } } + + return hasLocalizedField } function isObject(value: unknown): value is Data { diff --git a/packages/ui/src/utilities/traverseForLocalizedFields.ts b/packages/ui/src/utilities/traverseForLocalizedFields.ts index b0053b680cd..49ec87dd69c 100644 --- a/packages/ui/src/utilities/traverseForLocalizedFields.ts +++ b/packages/ui/src/utilities/traverseForLocalizedFields.ts @@ -1,29 +1,6 @@ -import type { ClientBlock, ClientField, Field } from 'payload' +import type { ClientField, Field } from 'payload' -type TraverseForLocalizedFieldsOptions = { - blocksMap?: Record -} - -export const traverseForLocalizedFields = ( - fields: ClientField[] | Field[], - { blocksMap }: TraverseForLocalizedFieldsOptions = {}, -): boolean => { - return traverseFields({ - blocksMap, - fields, - visitedBlockSlugs: new Set(), - }) -} - -const traverseFields = ({ - blocksMap, - fields, - visitedBlockSlugs, -}: { - blocksMap?: Record - fields: ClientField[] | Field[] - visitedBlockSlugs: Set -}): boolean => { +export const traverseForLocalizedFields = (fields: ClientField[] | Field[]): boolean => { for (const field of fields) { if ('localized' in field && field.localized) { return true @@ -34,39 +11,18 @@ const traverseFields = ({ case 'collapsible': case 'group': case 'row': - if ( - field.fields && - traverseFields({ - blocksMap, - fields: field.fields, - visitedBlockSlugs, - }) - ) { + if (field.fields && traverseForLocalizedFields(field.fields)) { return true } break case 'blocks': if (field.blocks) { - for (const blockOrSlug of field.blocks) { - if (typeof blockOrSlug === 'string' && visitedBlockSlugs.has(blockOrSlug)) { + for (const block of field.blocks) { + if (typeof block === 'string') { continue } - - const block = typeof blockOrSlug === 'string' ? blocksMap?.[blockOrSlug] : blockOrSlug - - if (typeof blockOrSlug === 'string') { - visitedBlockSlugs.add(blockOrSlug) - } - - if ( - block?.fields && - traverseFields({ - blocksMap, - fields: block.fields, - visitedBlockSlugs, - }) - ) { + if (block.fields && traverseForLocalizedFields(block.fields)) { return true } } @@ -79,15 +35,7 @@ const traverseFields = ({ if ('localized' in tab && tab.localized) { return true } - if ( - 'fields' in tab && - tab.fields && - traverseFields({ - blocksMap, - fields: tab.fields, - visitedBlockSlugs, - }) - ) { + if ('fields' in tab && tab.fields && traverseForLocalizedFields(tab.fields)) { return true } } diff --git a/test/validate/config.ts b/test/validate/config.ts index efd5d43c2f7..96fbd9adb9a 100644 --- a/test/validate/config.ts +++ b/test/validate/config.ts @@ -34,6 +34,7 @@ export const writeTargetsSlug = 'validation-write-targets' export const validationUploadsSlug = 'validation-uploads' export const validationCustomButtonsCollectionSlug = 'validation-custom-buttons-items' export const validationRequestFailureTitle = 'Reject validation request' +export const validationTranslatedLabelTitle = 'Return translated validation label' export const validationDeniedCollectionSlug = 'validation-denied-items' export const validationUploadsDir = path.resolve(dirname, 'validation-uploads') @@ -278,6 +279,15 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re }) break + case 'unlock': + await req.payload.unlock({ + collection: 'users', + data: { email: 'validation-isolation@example.com' }, + overrideAccess: true, + req, + }) + break + case 'update': await req.payload.update({ id: targetID!, @@ -314,15 +324,6 @@ const runWriteAttempt: CollectionBeforeChangeHook = async ({ data, operation, re }) break - case 'unlock': - await req.payload.unlock({ - collection: 'users', - data: { email: 'validation-isolation@example.com' }, - overrideAccess: true, - req, - }) - break - case 'upload': { const fileData = Buffer.from('must not be uploaded') @@ -1052,7 +1053,29 @@ const publishGlobal: GlobalConfig = { const validationCustomButtonsCollection: CollectionConfig = { slug: validationCustomButtonsCollectionSlug, access: { - validate: ({ data, req }) => Boolean(req.user) && data?.title !== validationRequestFailureTitle, + validate: ({ data, req }) => { + if (!req.user) { + return false + } + + if (req.method === 'POST' && data?.title === validationRequestFailureTitle) { + throw new ValidationError( + { + errors: [ + { + label: { en: 'Request failure title' }, + message: 'The validation request failed.', + path: 'title', + }, + ], + req, + }, + req.t, + ) + } + + return true + }, }, fields: [ { @@ -1067,6 +1090,32 @@ const validationCustomButtonsCollection: CollectionConfig = { required: true, }, ], + hooks: { + beforeValidate: [ + ({ data, operation, req }) => { + if (operation === 'validate' && data?.title === validationTranslatedLabelTitle) { + throw new ValidationError( + { + errors: [ + { + label: { + de: 'Übersetzter Titel', + en: 'Translated title', + }, + message: 'The translated title is invalid.', + path: 'title', + }, + ], + req, + }, + req.t, + ) + } + + return data + }, + ], + }, versions: { drafts: { validate: false, diff --git a/test/validate/e2e.spec.ts b/test/validate/e2e.spec.ts index 12ea2d96640..a4c84aaa002 100644 --- a/test/validate/e2e.spec.ts +++ b/test/validate/e2e.spec.ts @@ -9,7 +9,11 @@ import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js' import { RESTClient } from '../__helpers/shared/rest.js' import { initPage } from '../__setup/e2e/initPage.js' -import { validationCustomButtonsCollectionSlug, validationRequestFailureTitle } from './config.js' +import { + validationCustomButtonsCollectionSlug, + validationRequestFailureTitle, + validationTranslatedLabelTitle, +} from './config.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -101,6 +105,26 @@ test.describe('Publish all locales', () => { await expect.poll(() => getLocaleStatus({ id, locale: 'en' })).toBe('draft') }) + test('should render a translated validation label', async () => { + const id = await createDraft({ + englishTitle: validationTranslatedLabelTitle, + frenchTitle: 'Titre en français', + germanTitle: 'Deutscher Titel', + spanishTitle: 'Título en español', + }) + + await openDraft(id) + await saveDocAndAssert(page, '#publish-all-locales', 'error', { + disableDismissAllToasts: true, + }) + + const toast = page.locator('.payload-toast-container') + + await expect(toast).toContainText('Translated title') + await expect(toast).not.toContainText('[object Object]') + await expect.poll(() => getLocaleStatus({ id, locale: 'en' })).toBe('draft') + }) + async function createDraft({ englishTitle = 'English title', frenchTitle,