feat: adds draft validation option (#6677)
## Description
Allows draft validation to be enabled at the config level.
You can enable this by:
```ts
// ...collectionConfig
versions: {
drafts: {
validate: true // defaults to false
}
}
```
This commit is contained in:
7
.vscode/launch.json
vendored
7
.vscode/launch.json
vendored
@@ -111,6 +111,13 @@
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
"command": "node --no-deprecation test/dev.js field-error-states",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"name": "Run Dev Field Error States",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
"command": "pnpm run test:int live-preview",
|
||||
"cwd": "${workspaceFolder}",
|
||||
|
||||
@@ -22,6 +22,7 @@ Collections and Globals both support the same options for configuring drafts. Yo
|
||||
| Draft Option | Description |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `autosave` | Enable `autosave` to automatically save progress while documents are edited. To enable, set to `true` or pass an object with [options](/docs/versions/autosave). |
|
||||
| `validate` | Set `validate` to `true` to validate draft documents when saved. Default is `false`. |
|
||||
|
||||
## Database changes
|
||||
|
||||
|
||||
@@ -151,8 +151,10 @@ export const Document: React.FC<AdminViewProps> = async ({
|
||||
hasSavePermission &&
|
||||
((collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
|
||||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave))
|
||||
const validateDraftData =
|
||||
collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.validate
|
||||
|
||||
if (shouldAutosave && !id && collectionSlug) {
|
||||
if (shouldAutosave && !validateDraftData && !id && collectionSlug) {
|
||||
const doc = await payload.create({
|
||||
collection: collectionSlug,
|
||||
data: {},
|
||||
|
||||
@@ -84,6 +84,7 @@ export const sanitizeCollection = async (
|
||||
if (sanitized.versions.drafts === true) {
|
||||
sanitized.versions.drafts = {
|
||||
autosave: false,
|
||||
validate: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +94,10 @@ export const sanitizeCollection = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitized.versions.drafts.validate === undefined) {
|
||||
sanitized.versions.drafts.validate = false
|
||||
}
|
||||
|
||||
sanitized.fields = mergeBaseFields(sanitized.fields, baseVersionFields)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ const collectionSchema = joi.object().keys({
|
||||
interval: joi.number(),
|
||||
}),
|
||||
),
|
||||
validate: joi.boolean(),
|
||||
}),
|
||||
joi.boolean(),
|
||||
),
|
||||
|
||||
@@ -165,14 +165,6 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
|
||||
Promise.resolve(),
|
||||
)
|
||||
|
||||
// /////////////////////////////////////
|
||||
// Write files to local storage
|
||||
// /////////////////////////////////////
|
||||
|
||||
// if (!collectionConfig.upload.disableLocalStorage) {
|
||||
// await uploadFiles(payload, filesToUpload, req.t)
|
||||
// }
|
||||
|
||||
// /////////////////////////////////////
|
||||
// beforeChange - Collection
|
||||
// /////////////////////////////////////
|
||||
@@ -203,7 +195,10 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
|
||||
global: null,
|
||||
operation: 'create',
|
||||
req,
|
||||
skipValidation: shouldSaveDraft,
|
||||
skipValidation:
|
||||
shouldSaveDraft &&
|
||||
collectionConfig.versions.drafts &&
|
||||
!collectionConfig.versions.drafts.validate,
|
||||
})
|
||||
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -205,7 +205,10 @@ export const duplicateOperation = async <TSlug extends keyof GeneratedTypes['col
|
||||
global: null,
|
||||
operation,
|
||||
req,
|
||||
skipValidation: shouldSaveDraft,
|
||||
skipValidation:
|
||||
shouldSaveDraft &&
|
||||
collectionConfig.versions.drafts &&
|
||||
!collectionConfig.versions.drafts.validate,
|
||||
})
|
||||
|
||||
// set req.locale back to the original locale
|
||||
|
||||
@@ -270,7 +270,10 @@ export const updateOperation = async <TSlug extends keyof GeneratedTypes['collec
|
||||
operation: 'update',
|
||||
req,
|
||||
skipValidation:
|
||||
Boolean(collectionConfig.versions?.drafts) && data._status !== 'published',
|
||||
shouldSaveDraft &&
|
||||
collectionConfig.versions.drafts &&
|
||||
!collectionConfig.versions.drafts.validate &&
|
||||
data._status !== 'published',
|
||||
})
|
||||
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -242,7 +242,11 @@ export const updateByIDOperation = async <TSlug extends keyof GeneratedTypes['co
|
||||
global: null,
|
||||
operation: 'update',
|
||||
req,
|
||||
skipValidation: Boolean(collectionConfig.versions?.drafts) && data._status !== 'published',
|
||||
skipValidation:
|
||||
shouldSaveDraft &&
|
||||
collectionConfig.versions.drafts &&
|
||||
!collectionConfig.versions.drafts.validate &&
|
||||
data._status !== 'published',
|
||||
})
|
||||
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -4,10 +4,12 @@ export type Autosave = {
|
||||
|
||||
export type IncomingDrafts = {
|
||||
autosave?: Autosave | boolean
|
||||
validate?: boolean
|
||||
}
|
||||
|
||||
export type SanitizedDrafts = {
|
||||
autosave: Autosave | false
|
||||
validate: boolean
|
||||
}
|
||||
|
||||
export type IncomingCollectionVersions = {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload/types'
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import { useAllFormFields, useFormModified } from '../../forms/Form/context.js'
|
||||
import { useDebounce } from '../../hooks/useDebounce.js'
|
||||
import { useAllFormFields, useForm, useFormModified } from '../../forms/Form/context.js'
|
||||
import { useConfig } from '../../providers/Config/index.js'
|
||||
import { useDocumentEvents } from '../../providers/DocumentEvents/index.js'
|
||||
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
|
||||
@@ -36,6 +36,7 @@ export const Autosave: React.FC<Props> = ({
|
||||
} = useConfig()
|
||||
const { docConfig, getVersions, versions } = useDocumentInfo()
|
||||
const { reportUpdate } = useDocumentEvents()
|
||||
const { dispatchFields, setSubmitted } = useForm()
|
||||
const versionsConfig = docConfig?.versions
|
||||
|
||||
const [fields] = useAllFormFields()
|
||||
@@ -49,7 +50,6 @@ export const Autosave: React.FC<Props> = ({
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [lastSaved, setLastSaved] = useState<number>()
|
||||
const debouncedFields = useDebounce(fields, interval)
|
||||
const fieldRef = useRef(fields)
|
||||
const modifiedRef = useRef(modified)
|
||||
const localeRef = useRef(locale)
|
||||
@@ -117,26 +117,77 @@ export const Autosave: React.FC<Props> = ({
|
||||
})
|
||||
void getVersions()
|
||||
}
|
||||
|
||||
if (
|
||||
versionsConfig?.drafts &&
|
||||
versionsConfig?.drafts?.validate &&
|
||||
res.status === 400
|
||||
) {
|
||||
const json = await res.json()
|
||||
if (Array.isArray(json.errors)) {
|
||||
const [fieldErrors, nonFieldErrors] = json.errors.reduce(
|
||||
([fieldErrs, nonFieldErrs], err) => {
|
||||
const newFieldErrs = []
|
||||
const newNonFieldErrs = []
|
||||
|
||||
if (err?.message) {
|
||||
newNonFieldErrs.push(err)
|
||||
}
|
||||
|
||||
if (Array.isArray(err?.data)) {
|
||||
err.data.forEach((dataError) => {
|
||||
if (dataError?.field) {
|
||||
newFieldErrs.push(dataError)
|
||||
} else {
|
||||
newNonFieldErrs.push(dataError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
[...fieldErrs, ...newFieldErrs],
|
||||
[...nonFieldErrs, ...newNonFieldErrs],
|
||||
]
|
||||
},
|
||||
[[], []],
|
||||
)
|
||||
|
||||
dispatchFields({
|
||||
type: 'ADD_SERVER_ERRORS',
|
||||
errors: fieldErrors,
|
||||
})
|
||||
|
||||
nonFieldErrors.forEach((err) => {
|
||||
toast.error(err.message || i18n.t('error:unknown'))
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
setSubmitted(true)
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
}, 1000)
|
||||
}, interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void autosave()
|
||||
}, [
|
||||
i18n,
|
||||
debouncedFields,
|
||||
modified,
|
||||
serverURL,
|
||||
api,
|
||||
collection,
|
||||
globalDoc,
|
||||
reportUpdate,
|
||||
id,
|
||||
dispatchFields,
|
||||
getVersions,
|
||||
globalDoc,
|
||||
i18n,
|
||||
id,
|
||||
interval,
|
||||
modified,
|
||||
reportUpdate,
|
||||
serverURL,
|
||||
setSubmitted,
|
||||
versionsConfig?.drafts,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -74,6 +74,9 @@ export const DocumentControls: React.FC<{
|
||||
collectionConfig && id && !disableActions && (hasCreatePermission || hasDeletePermission),
|
||||
)
|
||||
|
||||
const unsavedDraftWithValidations =
|
||||
!id && collectionConfig?.versions?.drafts && collectionConfig.versions?.drafts.validate
|
||||
|
||||
return (
|
||||
<Gutter className={baseClass}>
|
||||
<div className={`${baseClass}__wrapper`}>
|
||||
@@ -103,7 +106,8 @@ export const DocumentControls: React.FC<{
|
||||
</li>
|
||||
)}
|
||||
{((collectionConfig?.versions?.drafts &&
|
||||
collectionConfig?.versions?.drafts?.autosave) ||
|
||||
collectionConfig?.versions?.drafts?.autosave &&
|
||||
!unsavedDraftWithValidations) ||
|
||||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave)) &&
|
||||
hasSavePermission && (
|
||||
<li className={`${baseClass}__list-item`}>
|
||||
@@ -168,6 +172,7 @@ export const DocumentControls: React.FC<{
|
||||
<React.Fragment>
|
||||
{((collectionConfig?.versions?.drafts &&
|
||||
!collectionConfig?.versions?.drafts?.autosave) ||
|
||||
unsavedDraftWithValidations ||
|
||||
(globalConfig?.versions?.drafts &&
|
||||
!globalConfig?.versions?.drafts?.autosave)) && (
|
||||
<SaveDraftButton CustomComponent={componentMap.SaveDraftButton} />
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
|
||||
import { slugs } from '../../shared.js'
|
||||
import { ValidateDraftsOn } from '../ValidateDraftsOn/index.js'
|
||||
|
||||
export const ValidateDraftsOff: CollectionConfig = {
|
||||
...ValidateDraftsOn,
|
||||
slug: slugs.validateDraftsOff,
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
|
||||
import { slugs } from '../../shared.js'
|
||||
|
||||
export const ValidateDraftsOn: CollectionConfig = {
|
||||
slug: slugs.validateDraftsOn,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: true,
|
||||
validate: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
|
||||
import { slugs } from '../../shared.js'
|
||||
import { ValidateDraftsOn } from '../ValidateDraftsOn/index.js'
|
||||
|
||||
export const ValidateDraftsOnAndAutosave: CollectionConfig = {
|
||||
...ValidateDraftsOn,
|
||||
slug: slugs.validateDraftsOnAutosave,
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: true,
|
||||
validate: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -2,9 +2,18 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
||||
import { devUser } from '../credentials.js'
|
||||
import { ErrorFieldsCollection } from './collections/ErrorFields/index.js'
|
||||
import Uploads from './collections/Upload/index.js'
|
||||
import { ValidateDraftsOff } from './collections/ValidateDraftsOff/index.js'
|
||||
import { ValidateDraftsOn } from './collections/ValidateDraftsOn/index.js'
|
||||
import { ValidateDraftsOnAndAutosave } from './collections/ValidateDraftsOnAutosave/index.js'
|
||||
|
||||
export default buildConfigWithDefaults({
|
||||
collections: [ErrorFieldsCollection, Uploads],
|
||||
collections: [
|
||||
ErrorFieldsCollection,
|
||||
Uploads,
|
||||
ValidateDraftsOn,
|
||||
ValidateDraftsOff,
|
||||
ValidateDraftsOnAndAutosave,
|
||||
],
|
||||
onInit: async (payload) => {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { ensureAutoLoginAndCompilationIsDone, initPageConsoleErrorCatch } from '../helpers.js'
|
||||
import {
|
||||
ensureAutoLoginAndCompilationIsDone,
|
||||
initPageConsoleErrorCatch,
|
||||
saveDocAndAssert,
|
||||
} from '../helpers.js'
|
||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||
import { slugs } from './shared.js'
|
||||
|
||||
const { beforeAll, describe } = test
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
@@ -15,10 +21,16 @@ const dirname = path.dirname(filename)
|
||||
describe('field error states', () => {
|
||||
let serverURL: string
|
||||
let page: Page
|
||||
let validateDraftsOff: AdminUrlUtil
|
||||
let validateDraftsOn: AdminUrlUtil
|
||||
let validateDraftsOnAutosave: AdminUrlUtil
|
||||
|
||||
beforeAll(async ({ browser }, testInfo) => {
|
||||
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
||||
;({ serverURL } = await initPayloadE2ENoConfig({ dirname }))
|
||||
validateDraftsOff = new AdminUrlUtil(serverURL, slugs.validateDraftsOff)
|
||||
validateDraftsOn = new AdminUrlUtil(serverURL, slugs.validateDraftsOn)
|
||||
validateDraftsOnAutosave = new AdminUrlUtil(serverURL, slugs.validateDraftsOnAutosave)
|
||||
const context = await browser.newContext()
|
||||
page = await context.newPage()
|
||||
initPageConsoleErrorCatch(page)
|
||||
@@ -57,4 +69,27 @@ describe('field error states', () => {
|
||||
)
|
||||
expect(errorPill).toBeNull()
|
||||
})
|
||||
|
||||
describe('draft validations', () => {
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test('should not validate drafts by default', async () => {
|
||||
await page.goto(validateDraftsOff.create)
|
||||
await saveDocAndAssert(page, '#action-save-draft')
|
||||
})
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test('should validate drafts when enabled', async () => {
|
||||
await page.goto(validateDraftsOn.create)
|
||||
await saveDocAndAssert(page, '#action-save-draft', 'error')
|
||||
})
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test('should show validation errors when validate and autosave are enabled', async () => {
|
||||
await page.goto(validateDraftsOnAutosave.create)
|
||||
await page.locator('#field-title').fill('valid')
|
||||
await saveDocAndAssert(page)
|
||||
await page.locator('#field-title').fill('')
|
||||
await saveDocAndAssert(page, '#action-save', 'error')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Config {
|
||||
collections: {
|
||||
'error-fields': ErrorField;
|
||||
uploads: Upload;
|
||||
'validate-drafts': ValidateDraft;
|
||||
users: User;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
@@ -255,6 +256,19 @@ export interface Upload {
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "validate-drafts".
|
||||
*/
|
||||
export interface ValidateDraft {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
|
||||
5
test/field-error-states/shared.ts
Normal file
5
test/field-error-states/shared.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const slugs = {
|
||||
validateDraftsOn: 'validate-drafts-on',
|
||||
validateDraftsOnAutosave: 'validate-drafts-on-autosave',
|
||||
validateDraftsOff: 'validate-drafts-off',
|
||||
}
|
||||
@@ -180,11 +180,20 @@ export async function saveDocHotkeyAndAssert(page: Page): Promise<void> {
|
||||
await expect(page.locator('.Toastify')).toContainText('successfully')
|
||||
}
|
||||
|
||||
export async function saveDocAndAssert(page: Page, selector = '#action-save'): Promise<void> {
|
||||
export async function saveDocAndAssert(
|
||||
page: Page,
|
||||
selector = '#action-save',
|
||||
expectation: 'error' | 'success' = 'success',
|
||||
): Promise<void> {
|
||||
await wait(500) // TODO: Fix this
|
||||
await page.click(selector, { delay: 100 })
|
||||
await expect(page.locator('.Toastify')).toContainText('successfully')
|
||||
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
|
||||
|
||||
if (expectation === 'success') {
|
||||
await expect(page.locator('.Toastify')).toContainText('successfully')
|
||||
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
|
||||
} else {
|
||||
await expect(page.locator('.Toastify .Toastify__toast--error')).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
export async function openNav(page: Page): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user