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:
Jarrod Flesch
2024-06-07 15:22:03 -04:00
committed by GitHub
parent 8ec836737e
commit 52c81ad525
20 changed files with 228 additions and 30 deletions

7
.vscode/launch.json vendored
View File

@@ -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}",

View File

@@ -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

View File

@@ -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: {},

View File

@@ -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)
}
}

View File

@@ -221,6 +221,7 @@ const collectionSchema = joi.object().keys({
interval: joi.number(),
}),
),
validate: joi.boolean(),
}),
joi.boolean(),
),

View File

@@ -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,
})
// /////////////////////////////////////

View File

@@ -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

View File

@@ -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',
})
// /////////////////////////////////////

View File

@@ -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',
})
// /////////////////////////////////////

View File

@@ -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 = {

View File

@@ -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(() => {

View File

@@ -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} />

View File

@@ -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,
},
}

View File

@@ -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,
},
},
}

View File

@@ -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,
},
},
}

View File

@@ -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',

View File

@@ -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')
})
})
})

View File

@@ -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

View File

@@ -0,0 +1,5 @@
export const slugs = {
validateDraftsOn: 'validate-drafts-on',
validateDraftsOnAutosave: 'validate-drafts-on-autosave',
validateDraftsOff: 'validate-drafts-off',
}

View File

@@ -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> {