diff --git a/.vscode/launch.json b/.vscode/launch.json index daf057e6be..690ba0046f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -54,6 +54,13 @@ "request": "launch", "type": "node-terminal" }, + { + "command": "pnpm run dev field-error-states", + "cwd": "${workspaceFolder}", + "name": "Run Dev Field Error States", + "request": "launch", + "type": "node-terminal" + }, { "command": "pnpm run dev uploads", "cwd": "${workspaceFolder}", diff --git a/docs/versions/drafts.mdx b/docs/versions/drafts.mdx index 5b850ff6a1..007f043a44 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/payload/src/admin/components/elements/Autosave/index.tsx b/packages/payload/src/admin/components/elements/Autosave/index.tsx index aca441650c..6d1cc8e53f 100644 --- a/packages/payload/src/admin/components/elements/Autosave/index.tsx +++ b/packages/payload/src/admin/components/elements/Autosave/index.tsx @@ -7,8 +7,14 @@ import type { Props } from './types' import useDebounce from '../../../hooks/useDebounce' import { formatTimeToNow } from '../../../utilities/formatDate' -import { useAllFormFields, useFormModified } from '../../forms/Form/context' +import { + useAllFormFields, + useForm, + useFormModified, + useFormSubmitted, +} from '../../forms/Form/context' import reduceFieldsToValues from '../../forms/Form/reduceFieldsToValues' +import { reduceFieldsToValuesWithValidation } from '../../forms/Form/reduceFieldsToValuesWithValidation' import { useConfig } from '../../utilities/Config' import { useDocumentInfo } from '../../utilities/DocumentInfo' import { useLocale } from '../../utilities/Locale' @@ -24,10 +30,15 @@ const Autosave: React.FC = ({ id, collection, global, publishedDocUpdated const [fields] = useAllFormFields() const modified = useFormModified() const { code: locale } = useLocale() - const { replace } = useHistory() + const submitted = useFormSubmitted() + const { dispatchFields, setSubmitted } = useForm() + const history = useHistory() const { i18n, t } = useTranslation('version') let interval = 800 + const validateDrafts = + (collection?.versions.drafts && collection.versions?.drafts?.validate) || + (global?.versions.drafts && global.versions?.drafts?.validate) if (collection?.versions.drafts && collection.versions?.drafts?.autosave) interval = collection.versions.drafts.autosave.interval if (global?.versions.drafts && global.versions?.drafts?.autosave) @@ -66,7 +77,7 @@ const Autosave: React.FC = ({ id, collection, global, publishedDocUpdated if (res.status === 201) { const json = await res.json() - replace(`${admin}/collections/${collection.slug}/${json.doc.id}`, { + history.replace(`${admin}/collections/${collection.slug}/${json.doc.id}`, { state: { data: json.doc, }, @@ -74,19 +85,22 @@ const Autosave: React.FC = ({ id, collection, global, publishedDocUpdated } else { toast.error(t('error:autosaving')) } - }, [i18n, serverURL, api, collection, locale, replace, admin, t]) + }, [serverURL, api, collection?.slug, locale, i18n.language, history, admin, t]) useEffect(() => { // If no ID, but this is used for a collection doc, // Immediately save it and set lastSaved if (!id && collection) { - createCollectionDoc() + void createCollectionDoc() } }, [id, collection, createCollectionDoc]) // When debounced fields change, autosave useEffect(() => { + const abortController = new AbortController() + let autosaveTimeout = undefined + const autosave = async () => { if (modified) { setSaving(true) @@ -105,8 +119,82 @@ const Autosave: React.FC = ({ id, collection, global, publishedDocUpdated } if (url) { - setTimeout(async () => { + autosaveTimeout = setTimeout(async () => { if (modifiedRef.current) { + const { data, valid } = { + ...reduceFieldsToValuesWithValidation(fieldRef.current, true), + } + data._status = 'draft' + const skipSubmission = submitted && !valid && validateDrafts + + if (!skipSubmission) { + const res = await fetch(url, { + body: JSON.stringify(data), + credentials: 'include', + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/json', + }, + method, + signal: abortController.signal, + }) + + if (res.status === 200) { + const newDate = new Date() + setLastSaved(newDate.getTime()) + void getVersions() + } + + if (validateDrafts && 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], + ] + }, + [[], []], + ) + + fieldErrors.forEach((err) => { + dispatchFields({ + type: 'UPDATE', + errorMessage: err.message, + path: err.field, + valid: false, + }) + }) + + nonFieldErrors.forEach((err) => { + toast.error(err.message || i18n.t('error:unknown')) + }) + + setSubmitted(true) + setSaving(false) + return + } + } + } + const body = { ...reduceFieldsToValues(fieldRef.current, true), _status: 'draft', @@ -124,7 +212,7 @@ const Autosave: React.FC = ({ id, collection, global, publishedDocUpdated if (res.status === 200) { setLastSaved(new Date().getTime()) - getVersions() + void getVersions() } } @@ -134,7 +222,13 @@ const Autosave: React.FC = ({ id, collection, global, publishedDocUpdated } } - autosave() + void autosave() + + return () => { + clearTimeout(autosaveTimeout) + if (abortController.signal) abortController.abort() + setSaving(false) + } }, [ i18n, debouncedFields, @@ -147,6 +241,10 @@ const Autosave: React.FC = ({ id, collection, global, publishedDocUpdated getVersions, localeRef, modifiedRef, + submitted, + validateDrafts, + setSubmitted, + dispatchFields, ]) useEffect(() => { diff --git a/packages/payload/src/admin/components/elements/DocumentControls/index.tsx b/packages/payload/src/admin/components/elements/DocumentControls/index.tsx index 305dd5e252..12fcbb1db7 100644 --- a/packages/payload/src/admin/components/elements/DocumentControls/index.tsx +++ b/packages/payload/src/admin/components/elements/DocumentControls/index.tsx @@ -48,13 +48,18 @@ export const DocumentControls: React.FC<{ permissions, } = props - const { publishedDoc } = useDocumentInfo() + const { slug, publishedDoc } = useDocumentInfo() const { admin: { dateFormat }, + collections, + globals, routes: { admin: adminRoute }, } = useConfig() + const collectionConfig = collections.find((coll) => coll.slug === slug) + const globalConfig = globals.find((global) => global.slug === slug) + const { i18n, t } = useTranslation('general') const hasCreatePermission = 'create' in permissions && permissions.create?.permission @@ -70,6 +75,9 @@ export const DocumentControls: React.FC<{ return typeof label === 'string' ? label : getTranslation(label, i18n) } + const unsavedDraftWithValidations = + !id && collectionConfig?.versions?.drafts && collectionConfig.versions?.drafts.validate + return (
@@ -93,8 +101,10 @@ export const DocumentControls: React.FC<{ )} - {((collection?.versions?.drafts && collection?.versions?.drafts?.autosave) || - (global?.versions?.drafts && global?.versions?.drafts?.autosave)) && + {((collectionConfig?.versions?.drafts && + collectionConfig?.versions?.drafts?.autosave && + !unsavedDraftWithValidations) || + (globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave)) && hasSavePermission && (
  • {collection?.versions?.drafts || global?.versions?.drafts ? ( - {((collection?.versions?.drafts && !collection?.versions?.drafts?.autosave) || - (global?.versions?.drafts && !global?.versions?.drafts?.autosave)) && ( + {((collectionConfig?.versions?.drafts && + !collectionConfig?.versions?.drafts?.autosave) || + unsavedDraftWithValidations || + (globalConfig?.versions?.drafts && + !globalConfig?.versions?.drafts?.autosave)) && ( = ({ CustomComponent }) => { const canSaveDraft = modified + const validateDrafts = + (collection?.versions.drafts && collection.versions?.drafts?.validate) || + (global?.versions.drafts && global.versions?.drafts?.validate) + const saveDraft = useCallback(async () => { const search = `?locale=${locale}&depth=0&fallback-locale=null&draft=true` let action @@ -96,9 +100,9 @@ export const SaveDraft: React.FC = ({ CustomComponent }) => { overrides: { _status: 'draft', }, - skipValidation: true, + skipValidation: !validateDrafts, }) - }, [submit, collection, global, serverURL, api, locale, id]) + }, [submit, collection, global, serverURL, api, locale, id, validateDrafts]) return ( = (props) => { if (!stateMatches) { dispatchFields({ + type: 'UPDATE', path, rows: newRows, - type: 'UPDATE', }) } }) @@ -204,7 +204,7 @@ const Form: React.FC = (props) => { await Promise.all(validationPromises) if (!isDeepEqual(contextRef.current.fields, validatedFieldState)) { - dispatchFields({ state: validatedFieldState, type: 'REPLACE_STATE' }) + dispatchFields({ type: 'REPLACE_STATE', state: validatedFieldState }) } return isValid @@ -299,8 +299,8 @@ const Form: React.FC = (props) => { destination.state = { status: [ { - message: json.message, type: 'success', + message: json.message, }, ], } @@ -481,11 +481,11 @@ const Form: React.FC = (props) => { }) dispatchFields({ + type: 'ADD_ROW', blockType: data?.blockType, path, rowIndex, subFieldState, - type: 'ADD_ROW', }) } }, @@ -494,7 +494,7 @@ const Form: React.FC = (props) => { const removeFieldRow: Context['removeFieldRow'] = useCallback( ({ path, rowIndex }) => { - dispatchFields({ path, rowIndex, type: 'REMOVE_ROW' }) + dispatchFields({ type: 'REMOVE_ROW', path, rowIndex }) }, [dispatchFields], ) @@ -520,11 +520,11 @@ const Form: React.FC = (props) => { user, }) dispatchFields({ + type: 'REPLACE_ROW', blockType: data?.blockType, path, rowIndex, subFieldState, - type: 'REPLACE_ROW', }) } }, @@ -589,7 +589,7 @@ const Form: React.FC = (props) => { }) contextRef.current = { ...initContextState } as FormContextType setModified(false) - dispatchFields({ state, type: 'REPLACE_STATE' }) + dispatchFields({ type: 'REPLACE_STATE', state }) }, [id, user, operation, locale, t, dispatchFields, getDocPreferences, config], ) @@ -598,7 +598,7 @@ const Form: React.FC = (props) => { (state: Fields) => { contextRef.current = { ...initContextState } as FormContextType setModified(false) - dispatchFields({ state, type: 'REPLACE_STATE' }) + dispatchFields({ type: 'REPLACE_STATE', state }) }, [dispatchFields], ) @@ -630,7 +630,7 @@ const Form: React.FC = (props) => { useEffect(() => { if (initialState) { contextRef.current = { ...initContextState } as FormContextType - dispatchFields({ state: initialState, type: 'REPLACE_STATE' }) + dispatchFields({ type: 'REPLACE_STATE', state: initialState }) } }, [initialState, dispatchFields]) @@ -639,7 +639,7 @@ const Form: React.FC = (props) => { contextRef.current = { ...initContextState } as FormContextType const builtState = buildInitialState(initialData) setFormattedInitialData(builtState) - dispatchFields({ state: builtState, type: 'REPLACE_STATE' }) + dispatchFields({ type: 'REPLACE_STATE', state: builtState }) } }, [initialData, dispatchFields]) diff --git a/packages/payload/src/admin/components/forms/Form/reduceFieldsToValuesWithValidation.ts b/packages/payload/src/admin/components/forms/Form/reduceFieldsToValuesWithValidation.ts new file mode 100644 index 0000000000..f72dca257a --- /dev/null +++ b/packages/payload/src/admin/components/forms/Form/reduceFieldsToValuesWithValidation.ts @@ -0,0 +1,42 @@ +import flatleyImport from 'flatley' + +import type { Data, Fields } from './types' +const { unflatten: flatleyUnflatten } = flatleyImport + +type ReturnType = { + data: Data + valid: boolean +} + +/** + * Reduce flattened form fields (Fields) to just map to the respective values instead of the full FormField object + * + * @param unflatten This also unflattens the data if `unflatten` is true. The unflattened data should match the original data structure + * @param ignoreDisableFormData - if true, will include fields that have `disableFormData` set to true, for example, blocks or arrays fields. + * + */ +export const reduceFieldsToValuesWithValidation = ( + fields: Fields, + unflatten?: boolean, + ignoreDisableFormData?: boolean, +): ReturnType => { + const state: ReturnType = { + data: {}, + valid: true, + } + + if (!fields) return state + + Object.keys(fields).forEach((key) => { + if (ignoreDisableFormData === true || !fields[key]?.disableFormData) { + state.data[key] = fields[key]?.value + if (!fields[key].valid) state.valid = false + } + }) + + if (unflatten) { + state.data = flatleyUnflatten(state.data, { safe: true }) + } + + return state +} diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 44834281aa..735e3f6cc9 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -46,12 +46,12 @@ const sanitizeCollection = ( if (!hasUpdatedAt) { sanitized.fields.push({ name: 'updatedAt', + type: 'date', admin: { disableBulkEdit: true, hidden: true, }, label: translations['general:updatedAt'], - type: 'date', }) } if (!hasCreatedAt) { @@ -62,9 +62,9 @@ const sanitizeCollection = ( hidden: true, }, // The default sort for list view is createdAt. Thus, enabling indexing by default, is a major performance improvement, especially for large or a large amount of collections. + type: 'date', index: true, label: translations['general:createdAt'], - type: 'date', }) } } @@ -82,6 +82,7 @@ const sanitizeCollection = ( if (sanitized.versions.drafts === true) { sanitized.versions.drafts = { autosave: false, + validate: false, } } @@ -91,6 +92,10 @@ const sanitizeCollection = ( } } + 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 499c30c2b3..a5fb5cc930 100644 --- a/packages/payload/src/collections/config/schema.ts +++ b/packages/payload/src/collections/config/schema.ts @@ -228,6 +228,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 613ad54828..83d98d7a7e 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -204,7 +204,10 @@ async function create( global: null, operation: 'create', req, - skipValidation: shouldSaveDraft, + skipValidation: + shouldSaveDraft && + collectionConfig.versions.drafts && + !collectionConfig.versions.drafts.validate, }) // ///////////////////////////////////// diff --git a/packages/payload/src/collections/operations/update.ts b/packages/payload/src/collections/operations/update.ts index 74ffd9624f..3520442a70 100644 --- a/packages/payload/src/collections/operations/update.ts +++ b/packages/payload/src/collections/operations/update.ts @@ -271,7 +271,10 @@ async function update( operation: 'update', req, skipValidation: - Boolean(collectionConfig.versions?.drafts) && data._status !== 'published', + shouldSaveDraft && + collectionConfig.versions.drafts && + !collectionConfig.versions.drafts.validate && + data._status !== 'published', }) // ///////////////////////////////////// diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index 2153df9315..f9ef418b54 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -244,7 +244,11 @@ async function updateByID( 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', }) // ///////////////////////////////////// diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 02f49d038a..b39c3ba9b0 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -42,6 +42,7 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => { if (sanitizedGlobal.versions.drafts === true) { sanitizedGlobal.versions.drafts = { autosave: false, + validate: false, } } @@ -51,6 +52,10 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => { } } + if (sanitizedGlobal.versions.drafts.validate === undefined) { + sanitizedGlobal.versions.drafts.validate = false + } + sanitizedGlobal.fields = mergeBaseFields(sanitizedGlobal.fields, baseVersionFields) } } @@ -72,23 +77,23 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => { if (!hasUpdatedAt) { sanitizedGlobal.fields.push({ name: 'updatedAt', + type: 'date', admin: { disableBulkEdit: true, hidden: true, }, label: translations['general:updatedAt'], - type: 'date', }) } if (!hasCreatedAt) { sanitizedGlobal.fields.push({ name: 'createdAt', + type: 'date', admin: { disableBulkEdit: true, hidden: true, }, label: translations['general:createdAt'], - type: 'date', }) } diff --git a/packages/payload/src/globals/config/schema.ts b/packages/payload/src/globals/config/schema.ts index 6eb09f4b23..f337a89fb8 100644 --- a/packages/payload/src/globals/config/schema.ts +++ b/packages/payload/src/globals/config/schema.ts @@ -79,6 +79,7 @@ const globalSchema = joi interval: joi.number(), }), ), + validate: joi.boolean(), }), joi.boolean(), ), diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 1fae11106e..52918f1e7b 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -168,7 +168,8 @@ async function update( global: globalConfig, operation: 'update', req, - skipValidation: shouldSaveDraft, + skipValidation: + shouldSaveDraft && globalConfig.versions.drafts && !globalConfig.versions.drafts.validate, }) // ///////////////////////////////////// diff --git a/packages/payload/src/versions/types.ts b/packages/payload/src/versions/types.ts index 462227db25..a6e1504207 100644 --- a/packages/payload/src/versions/types.ts +++ b/packages/payload/src/versions/types.ts @@ -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 = { diff --git a/test/_community/collections/Posts/index.ts b/test/_community/collections/Posts/index.ts index a2fd60466d..b7f67c0457 100644 --- a/test/_community/collections/Posts/index.ts +++ b/test/_community/collections/Posts/index.ts @@ -5,11 +5,16 @@ import { mediaSlug } from '../Media' export const postsSlug = 'posts' export const PostsCollection: CollectionConfig = { + defaultSort: 'title', fields: [ { name: 'text', type: 'text', }, + { + name: 'title', + type: 'text', + }, { name: 'associatedMedia', access: { diff --git a/test/_community/config.ts b/test/_community/config.ts index f3b424c3e9..aef47551ca 100644 --- a/test/_community/config.ts +++ b/test/_community/config.ts @@ -32,6 +32,23 @@ export default buildConfigWithDefaults({ collection: postsSlug, data: { text: 'example post', + title: 'title1', + }, + }) + + await payload.create({ + collection: postsSlug, + data: { + text: 'example post', + title: 'title3', + }, + }) + + await payload.create({ + collection: postsSlug, + data: { + text: 'example post', + title: 'title2', }, }) }, 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..d7be003da9 --- /dev/null +++ b/test/field-error-states/collections/ValidateDraftsOff/index.ts @@ -0,0 +1,12 @@ +import type { CollectionConfig } from '../../../../packages/payload/src/collections/config/types' + +import { slugs } from '../../shared' +import { ValidateDraftsOn } from '../ValidateDraftsOn' + +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..8772204275 --- /dev/null +++ b/test/field-error-states/collections/ValidateDraftsOn/index.ts @@ -0,0 +1,19 @@ +import type { CollectionConfig } from '../../../../packages/payload/src/collections/config/types' + +import { slugs } from '../../shared' + +export const ValidateDraftsOn: CollectionConfig = { + slug: slugs.validateDraftsOn, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + ], + versions: { + drafts: { + 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..a461c89845 --- /dev/null +++ b/test/field-error-states/collections/ValidateDraftsOnAutosave/index.ts @@ -0,0 +1,15 @@ +import type { CollectionConfig } from '../../../../packages/payload/src/collections/config/types' + +import { slugs } from '../../shared' +import { ValidateDraftsOn } from '../ValidateDraftsOn' + +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 627f712ac9..dc533cf7a9 100644 --- a/test/field-error-states/config.ts +++ b/test/field-error-states/config.ts @@ -2,9 +2,18 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults' import { devUser } from '../credentials' import { ErrorFieldsCollection } from './collections/ErrorFields' import Uploads from './collections/Upload' +import { ValidateDraftsOff } from './collections/ValidateDraftsOff' +import { ValidateDraftsOn } from './collections/ValidateDraftsOn' +import { ValidateDraftsOnAndAutosave } from './collections/ValidateDraftsOnAutosave' export default buildConfigWithDefaults({ - collections: [ErrorFieldsCollection, Uploads], + collections: [ + ErrorFieldsCollection, + Uploads, + ValidateDraftsOn, + ValidateDraftsOff, + ValidateDraftsOnAndAutosave, + ], graphQL: { schemaOutputFile: './test/field-error-states/schema.graphql', }, diff --git a/test/field-error-states/e2e.spec.ts b/test/field-error-states/e2e.spec.ts index 7f71a706ee..95cdd7195a 100644 --- a/test/field-error-states/e2e.spec.ts +++ b/test/field-error-states/e2e.spec.ts @@ -2,17 +2,26 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' -import { initPageConsoleErrorCatch } from '../helpers' +import { initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers' +import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { initPayloadE2E } from '../helpers/configHelpers' +import { slugs } from './shared' const { beforeAll, describe } = test +let validateDraftsOff: AdminUrlUtil +let validateDraftsOn: AdminUrlUtil +let validateDraftsOnAutosave: AdminUrlUtil + describe('field error states', () => { let serverURL: string let page: Page beforeAll(async ({ browser }) => { ;({ serverURL } = await initPayloadE2E(__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) @@ -49,4 +58,31 @@ 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 page.locator('#field-title').fill('temp') + await page.locator('#field-title').fill('') + 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 page.locator('#field-title').fill('temp') + await page.locator('#field-title').fill('') + 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/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/globals/e2e.spec.ts b/test/globals/e2e.spec.ts deleted file mode 100644 index fb84e91840..0000000000 --- a/test/globals/e2e.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Page } from '@playwright/test' - -import { expect, test } from '@playwright/test' - -import { initPageConsoleErrorCatch } from '../helpers' -import { AdminUrlUtil } from '../helpers/adminUrlUtil' -import { initPayloadE2E } from '../helpers/configHelpers' - -const { beforeAll, describe } = test - -describe('Globals', () => { - let page: Page - let url: AdminUrlUtil - - beforeAll(async ({ browser }) => { - const { serverURL } = await initPayloadE2E(__dirname) - url = new AdminUrlUtil(serverURL, 'media') - - const context = await browser.newContext() - page = await context.newPage() - initPageConsoleErrorCatch(page) - }) - - test('can edit media from field', async () => { - await page.goto(url.create) - - // const textCell = page.locator('.row-1 .cell-text') - }) -}) diff --git a/test/helpers.ts b/test/helpers.ts index 50df07f4c8..e67ba02a33 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -51,10 +51,18 @@ 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 page.click(selector, { delay: 100 }) - await expect(page.locator('.Toastify')).toContainText('successfully') - expect(page.url()).not.toContain('create') + if (expectation === 'success') { + await expect(page.locator('.Toastify')).toContainText('successfully') + expect(page.url()).not.toContain('create') + } else { + await expect(page.locator('.Toastify .Toastify__toast--error')).toBeVisible() + } } export async function openNav(page: Page): Promise { diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index 4b2c764186..ae47ccc149 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -359,18 +359,18 @@ describe('versions', () => { await page.goto(autosaveURL.create) await page.locator('#field-title').fill(title) await page.locator('#field-description').fill(description) - await wait(500) // wait for autosave + await wait(1000) // wait for autosave await changeLocale(page, spanishLocale) await page.locator('#field-title').fill(spanishTitle) - await wait(500) // wait for autosave + await wait(1000) // wait for autosave await changeLocale(page, locale) await page.locator('#field-description').fill(newDescription) - await wait(500) // wait for autosave + await wait(1000) // wait for autosave await changeLocale(page, spanishLocale) - await wait(500) // wait for autosave + await wait(1000) // wait for autosave await page.reload() await expect(page.locator('#field-title')).toHaveValue(spanishTitle)