diff --git a/.vscode/launch.json b/.vscode/launch.json index a029e7adda..08e3e1b773 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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}", diff --git a/docs/versions/drafts.mdx b/docs/versions/drafts.mdx index 30fae559bd..07a579de6b 100644 --- a/docs/versions/drafts.mdx +++ b/docs/versions/drafts.mdx @@ -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 diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index 9b8ab13494..da20d5dad2 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -151,8 +151,10 @@ export const Document: React.FC = 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: {}, diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 2b93538fb4..3d5c3ee562 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -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) } } diff --git a/packages/payload/src/collections/config/schema.ts b/packages/payload/src/collections/config/schema.ts index 709ab0a7b6..5ade372c99 100644 --- a/packages/payload/src/collections/config/schema.ts +++ b/packages/payload/src/collections/config/schema.ts @@ -221,6 +221,7 @@ const collectionSchema = joi.object().keys({ interval: joi.number(), }), ), + validate: joi.boolean(), }), joi.boolean(), ), diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index a0c3dbe9d7..714f135579 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -165,14 +165,6 @@ export const createOperation = async = ({ } = 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 = ({ const [saving, setSaving] = useState(false) const [lastSaved, setLastSaved] = useState() - 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 = ({ }) 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(() => { diff --git a/packages/ui/src/elements/DocumentControls/index.tsx b/packages/ui/src/elements/DocumentControls/index.tsx index 855deef75d..8ebff63738 100644 --- a/packages/ui/src/elements/DocumentControls/index.tsx +++ b/packages/ui/src/elements/DocumentControls/index.tsx @@ -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 (
@@ -103,7 +106,8 @@ export const DocumentControls: React.FC<{ )} {((collectionConfig?.versions?.drafts && - collectionConfig?.versions?.drafts?.autosave) || + collectionConfig?.versions?.drafts?.autosave && + !unsavedDraftWithValidations) || (globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave)) && hasSavePermission && (
  • @@ -168,6 +172,7 @@ export const DocumentControls: React.FC<{ {((collectionConfig?.versions?.drafts && !collectionConfig?.versions?.drafts?.autosave) || + unsavedDraftWithValidations || (globalConfig?.versions?.drafts && !globalConfig?.versions?.drafts?.autosave)) && ( diff --git a/test/field-error-states/collections/ValidateDraftsOff/index.ts b/test/field-error-states/collections/ValidateDraftsOff/index.ts new file mode 100644 index 0000000000..88e52e0b61 --- /dev/null +++ b/test/field-error-states/collections/ValidateDraftsOff/index.ts @@ -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, + }, +} diff --git a/test/field-error-states/collections/ValidateDraftsOn/index.ts b/test/field-error-states/collections/ValidateDraftsOn/index.ts new file mode 100644 index 0000000000..75e24766be --- /dev/null +++ b/test/field-error-states/collections/ValidateDraftsOn/index.ts @@ -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, + }, + }, +} diff --git a/test/field-error-states/collections/ValidateDraftsOnAutosave/index.ts b/test/field-error-states/collections/ValidateDraftsOnAutosave/index.ts new file mode 100644 index 0000000000..54b3bf5ff2 --- /dev/null +++ b/test/field-error-states/collections/ValidateDraftsOnAutosave/index.ts @@ -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, + }, + }, +} diff --git a/test/field-error-states/config.ts b/test/field-error-states/config.ts index d404b67b0c..6bfdec80f6 100644 --- a/test/field-error-states/config.ts +++ b/test/field-error-states/config.ts @@ -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', diff --git a/test/field-error-states/e2e.spec.ts b/test/field-error-states/e2e.spec.ts index 92c691c574..2f99ecd0d6 100644 --- a/test/field-error-states/e2e.spec.ts +++ b/test/field-error-states/e2e.spec.ts @@ -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') + }) + }) }) diff --git a/test/field-error-states/payload-types.ts b/test/field-error-states/payload-types.ts index b4cd54e7a7..64f06ebd9e 100644 --- a/test/field-error-states/payload-types.ts +++ b/test/field-error-states/payload-types.ts @@ -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 diff --git a/test/field-error-states/shared.ts b/test/field-error-states/shared.ts new file mode 100644 index 0000000000..533f92b83b --- /dev/null +++ b/test/field-error-states/shared.ts @@ -0,0 +1,5 @@ +export const slugs = { + validateDraftsOn: 'validate-drafts-on', + validateDraftsOnAutosave: 'validate-drafts-on-autosave', + validateDraftsOff: 'validate-drafts-off', +} diff --git a/test/helpers.ts b/test/helpers.ts index feb24b2f0a..a3d05878aa 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -180,11 +180,20 @@ export async function saveDocHotkeyAndAssert(page: Page): Promise { await expect(page.locator('.Toastify')).toContainText('successfully') } -export async function saveDocAndAssert(page: Page, selector = '#action-save'): Promise { +export async function saveDocAndAssert( + page: Page, + selector = '#action-save', + expectation: 'error' | 'success' = 'success', +): Promise { 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 {