fix(ui): issues with prevent leave and autosave when the form is submitted but invalid (#11233)
Fixes https://github.com/payloadcms/payload/issues/11224 Fixes https://github.com/payloadcms/payload/issues/10492 This PR fixes a few weird behaviours when `validate: true` is set on drafts: - when autosave is on and you submit an invalid form it would get stuck in an infinite loop - PreventLeave would not trigger for submitted but invalid forms leading to potential data loss Changes: - Adds e2e tests for the above scenarios - Adds a new `isValid` flag on the `Form` context provider to signal globally if the form is in a valid or invalid state - Components like Autosave will manage this internally since it manages its own submission flow as well - Adds PreventLeave to Autosave too for when form is invalid meaning data hasn't been actually saved so we want to prevent the user accidentally losing data by reloading or closing the page The following tests have been added 
This commit is contained in:
@@ -22,6 +22,7 @@ import { useTranslation } from '../../providers/Translation/index.js'
|
||||
import './index.scss'
|
||||
import { formatTimeToNow } from '../../utilities/formatDate.js'
|
||||
import { reduceFieldsToValuesWithValidation } from '../../utilities/reduceFieldsToValuesWithValidation.js'
|
||||
import { LeaveWithoutSaving } from '../LeaveWithoutSaving/index.js'
|
||||
|
||||
const baseClass = 'autosave'
|
||||
// The minimum time the saving state should be shown
|
||||
@@ -55,25 +56,36 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
|
||||
const isProcessingRef = useRef(false)
|
||||
|
||||
const { reportUpdate } = useDocumentEvents()
|
||||
const { dispatchFields, setSubmitted } = useForm()
|
||||
const submitted = useFormSubmitted()
|
||||
const versionsConfig = docConfig?.versions
|
||||
const { dispatchFields, isValid, setIsValid, setSubmitted } = useForm()
|
||||
|
||||
const [fields] = useAllFormFields()
|
||||
const modified = useFormModified()
|
||||
const submitted = useFormSubmitted()
|
||||
|
||||
const { code: locale } = useLocale()
|
||||
const { i18n, t } = useTranslation()
|
||||
|
||||
const versionsConfig = docConfig?.versions
|
||||
let interval = versionDefaults.autosaveInterval
|
||||
|
||||
if (versionsConfig.drafts && versionsConfig.drafts.autosave) {
|
||||
interval = versionsConfig.drafts.autosave.interval
|
||||
}
|
||||
|
||||
const validateOnDraft = Boolean(
|
||||
docConfig?.versions?.drafts && docConfig?.versions?.drafts.validate,
|
||||
)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const debouncedFields = useDebounce(fields, interval)
|
||||
const fieldRef = useRef(fields)
|
||||
const modifiedRef = useRef(modified)
|
||||
const localeRef = useRef(locale)
|
||||
/**
|
||||
* Track the validation internally so Autosave can determine when to run queue processing again
|
||||
* Helps us prevent infinite loops when the queue is processing and the form is invalid
|
||||
*/
|
||||
const isValidRef = useRef(isValid)
|
||||
const debouncedRef = useRef(debouncedFields)
|
||||
|
||||
debouncedRef.current = debouncedFields
|
||||
@@ -97,6 +109,14 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidRef.current) {
|
||||
// Clear queue so we don't end up in an infinite loop
|
||||
queueRef.current = []
|
||||
// Reset internal validation state so queue processing can run again
|
||||
isValidRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
isProcessingRef.current = true
|
||||
const latestAction = queueRef.current[queueRef.current.length - 1]
|
||||
queueRef.current = []
|
||||
@@ -149,7 +169,7 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
|
||||
const skipSubmission =
|
||||
submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate
|
||||
|
||||
if (!skipSubmission) {
|
||||
if (!skipSubmission && isValidRef.current) {
|
||||
await fetch(url, {
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include',
|
||||
@@ -222,8 +242,11 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
|
||||
toast.error(err.message || i18n.t('error:unknown'))
|
||||
})
|
||||
|
||||
// Set valid to false internally so the queue doesn't process
|
||||
isValidRef.current = false
|
||||
setIsValid(false)
|
||||
setSubmitted(true)
|
||||
setSaving(false)
|
||||
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -232,11 +255,15 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
|
||||
|
||||
// Manually update the data since this function doesn't fire the `submit` function from useForm
|
||||
if (document) {
|
||||
setIsValid(true)
|
||||
|
||||
// Reset internal state allowing the queue to process
|
||||
isValidRef.current = true
|
||||
updateSavedDocumentData(document)
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
.finally(() => {
|
||||
// If request was faster than minimum animation time, animate the difference
|
||||
if (endTimestamp - startTimestamp < minimumAnimationTime) {
|
||||
autosaveTimeout = setTimeout(
|
||||
@@ -282,6 +309,7 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
{validateOnDraft && !isValid && <LeaveWithoutSaving />}
|
||||
{saving && t('general:saving')}
|
||||
{!saving && Boolean(lastUpdateTime) && (
|
||||
<React.Fragment>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
|
||||
import { useFormModified } from '../../forms/Form/index.js'
|
||||
import { useForm, useFormModified } from '../../forms/Form/index.js'
|
||||
import { useAuth } from '../../providers/Auth/index.js'
|
||||
import { useTranslation } from '../../providers/Translation/index.js'
|
||||
import { Button } from '../Button/index.js'
|
||||
@@ -59,11 +59,12 @@ const Component: React.FC<{
|
||||
export const LeaveWithoutSaving: React.FC = () => {
|
||||
const { closeModal } = useModal()
|
||||
const modified = useFormModified()
|
||||
const { isValid } = useForm()
|
||||
const { user } = useAuth()
|
||||
const [show, setShow] = React.useState(false)
|
||||
const [hasAccepted, setHasAccepted] = React.useState(false)
|
||||
|
||||
const prevent = Boolean(modified && user)
|
||||
const prevent = Boolean((modified || !isValid) && user)
|
||||
|
||||
const onPrevent = useCallback(() => {
|
||||
setShow(true)
|
||||
|
||||
@@ -96,6 +96,11 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
const [disabled, setDisabled] = useState(disabledFromProps || false)
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
const [modified, setModified] = useState(false)
|
||||
/**
|
||||
* Tracks wether the form state passes validation.
|
||||
* For example the state could be submitted but invalid as field errors have been returned.
|
||||
*/
|
||||
const [isValid, setIsValid] = useState(true)
|
||||
const [initializing, setInitializing] = useState(initializingFromProps)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
@@ -177,6 +182,8 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
dispatchFields({ type: 'REPLACE_STATE', state: validatedFieldState })
|
||||
}
|
||||
|
||||
setIsValid(isValid)
|
||||
|
||||
return isValid
|
||||
}, [collectionSlug, config, dispatchFields, id, operation, t, user, documentForm])
|
||||
|
||||
@@ -257,6 +264,8 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
([, field]) => field.valid !== false,
|
||||
)
|
||||
|
||||
setIsValid(isValid)
|
||||
|
||||
if (!isValid) {
|
||||
setProcessing(false)
|
||||
setSubmitted(true)
|
||||
@@ -268,6 +277,7 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
const isValid =
|
||||
skipValidation || disableValidationOnSubmit ? true : await contextRef.current.validateForm()
|
||||
|
||||
setIsValid(isValid)
|
||||
// If not valid, prevent submission
|
||||
if (!isValid) {
|
||||
errorToast(t('error:correctInvalidFields'))
|
||||
@@ -408,6 +418,8 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
[[], []],
|
||||
)
|
||||
|
||||
setIsValid(false)
|
||||
|
||||
dispatchFields({
|
||||
type: 'ADD_SERVER_ERRORS',
|
||||
errors: fieldErrors,
|
||||
@@ -615,6 +627,7 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
contextRef.current.setModified = setModified
|
||||
contextRef.current.setProcessing = setProcessing
|
||||
contextRef.current.setSubmitted = setSubmitted
|
||||
contextRef.current.setIsValid = setIsValid
|
||||
contextRef.current.disabled = disabled
|
||||
contextRef.current.setDisabled = setDisabled
|
||||
contextRef.current.formRef = formRef
|
||||
@@ -626,6 +639,7 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
contextRef.current.replaceFieldRow = replaceFieldRow
|
||||
contextRef.current.uuid = uuid
|
||||
contextRef.current.initializing = initializing
|
||||
contextRef.current.isValid = isValid
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
|
||||
@@ -39,14 +39,17 @@ export const initContextState: Context = {
|
||||
getFields: (): FormState => ({}),
|
||||
getSiblingData,
|
||||
initializing: undefined,
|
||||
isValid: true,
|
||||
removeFieldRow: () => undefined,
|
||||
replaceFieldRow: () => undefined,
|
||||
replaceState: () => undefined,
|
||||
reset,
|
||||
setDisabled: () => undefined,
|
||||
setIsValid: () => undefined,
|
||||
setModified,
|
||||
setProcessing,
|
||||
setSubmitted,
|
||||
submit,
|
||||
|
||||
validateForm,
|
||||
}
|
||||
|
||||
@@ -227,6 +227,11 @@ export type Context = {
|
||||
getFields: GetFields
|
||||
getSiblingData: GetSiblingData
|
||||
initializing: boolean
|
||||
/**
|
||||
* Tracks wether the form state passes validation.
|
||||
* For example the state could be submitted but invalid as field errors have been returned.
|
||||
*/
|
||||
isValid: boolean
|
||||
removeFieldRow: ({ path, rowIndex }: { path: string; rowIndex: number }) => void
|
||||
replaceFieldRow: ({
|
||||
blockType,
|
||||
@@ -244,6 +249,7 @@ export type Context = {
|
||||
replaceState: (state: FormState) => void
|
||||
reset: Reset
|
||||
setDisabled: (disabled: boolean) => void
|
||||
setIsValid: (processing: boolean) => void
|
||||
setModified: SetModified
|
||||
setProcessing: SetProcessing
|
||||
setSubmitted: SetSubmitted
|
||||
|
||||
@@ -4,20 +4,32 @@ import { expect } from '@playwright/test'
|
||||
import { wait } from 'payload/shared'
|
||||
import { POLL_TOPASS_TIMEOUT } from 'playwright.config.js'
|
||||
|
||||
export async function waitForAutoSaveToRunAndComplete(page: Page) {
|
||||
export async function waitForAutoSaveToRunAndComplete(
|
||||
page: Page,
|
||||
expectation: 'error' | 'success' = 'success',
|
||||
) {
|
||||
await expect(async () => {
|
||||
await expect(page.locator('.autosave:has-text("Saving...")')).toBeVisible()
|
||||
}).toPass({
|
||||
timeout: POLL_TOPASS_TIMEOUT,
|
||||
intervals: [50],
|
||||
})
|
||||
|
||||
await wait(500)
|
||||
|
||||
await expect(async () => {
|
||||
await expect(
|
||||
page.locator('.autosave:has-text("Last saved less than a minute ago")'),
|
||||
).toBeVisible()
|
||||
}).toPass({
|
||||
timeout: POLL_TOPASS_TIMEOUT,
|
||||
})
|
||||
if (expectation === 'success') {
|
||||
await expect(async () => {
|
||||
await expect(
|
||||
page.locator('.autosave:has-text("Last saved less than a minute ago")'),
|
||||
).toBeVisible()
|
||||
}).toPass({
|
||||
timeout: POLL_TOPASS_TIMEOUT,
|
||||
})
|
||||
} else {
|
||||
await expect(async () => {
|
||||
await expect(page.locator('.payload-toast-container .toast-error')).toBeVisible()
|
||||
}).toPass({
|
||||
timeout: POLL_TOPASS_TIMEOUT,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
33
test/versions/collections/AutosaveWithValidate.ts
Normal file
33
test/versions/collections/AutosaveWithValidate.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { autosaveWithValidateCollectionSlug } from '../slugs.js'
|
||||
|
||||
const AutosaveWithValidatePosts: CollectionConfig = {
|
||||
slug: autosaveWithValidateCollectionSlug,
|
||||
labels: {
|
||||
singular: 'Autosave with Validate Post',
|
||||
plural: 'Autosave with Validate Posts',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'subtitle', 'createdAt', '_status'],
|
||||
},
|
||||
versions: {
|
||||
maxPerDoc: 35,
|
||||
drafts: {
|
||||
validate: true,
|
||||
autosave: {
|
||||
interval: 250,
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default AutosaveWithValidatePosts
|
||||
21
test/versions/collections/DraftsWithValidate.ts
Normal file
21
test/versions/collections/DraftsWithValidate.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { draftWithValidateCollectionSlug } from '../slugs.js'
|
||||
|
||||
const DraftsWithValidate: CollectionConfig = {
|
||||
slug: draftWithValidateCollectionSlug,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
versions: {
|
||||
drafts: {
|
||||
validate: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default DraftsWithValidate
|
||||
@@ -4,11 +4,13 @@ const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
||||
import AutosavePosts from './collections/Autosave.js'
|
||||
import AutosaveWithValidate from './collections/AutosaveWithValidate.js'
|
||||
import CustomIDs from './collections/CustomIDs.js'
|
||||
import { Diff } from './collections/Diff.js'
|
||||
import DisablePublish from './collections/DisablePublish.js'
|
||||
import DraftPosts from './collections/Drafts.js'
|
||||
import DraftWithMax from './collections/DraftsWithMax.js'
|
||||
import DraftsWithValidate from './collections/DraftsWithValidate.js'
|
||||
import LocalizedPosts from './collections/Localized.js'
|
||||
import { Media } from './collections/Media.js'
|
||||
import Posts from './collections/Posts.js'
|
||||
@@ -32,8 +34,10 @@ export default buildConfigWithDefaults({
|
||||
DisablePublish,
|
||||
Posts,
|
||||
AutosavePosts,
|
||||
AutosaveWithValidate,
|
||||
DraftPosts,
|
||||
DraftWithMax,
|
||||
DraftsWithValidate,
|
||||
LocalizedPosts,
|
||||
VersionPosts,
|
||||
CustomIDs,
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* - specify locales to show
|
||||
*/
|
||||
|
||||
import type { BrowserContext, Page } from '@playwright/test'
|
||||
import type { BrowserContext, Dialog, Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
import path from 'path'
|
||||
@@ -51,6 +51,7 @@ import { titleToDelete } from './shared.js'
|
||||
import {
|
||||
autosaveCollectionSlug,
|
||||
autoSaveGlobalSlug,
|
||||
autosaveWithValidateCollectionSlug,
|
||||
customIDSlug,
|
||||
diffCollectionSlug,
|
||||
disablePublishGlobalSlug,
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
draftGlobalSlug,
|
||||
draftWithMaxCollectionSlug,
|
||||
draftWithMaxGlobalSlug,
|
||||
draftWithValidateCollectionSlug,
|
||||
localizedCollectionSlug,
|
||||
localizedGlobalSlug,
|
||||
postCollectionSlug,
|
||||
@@ -79,6 +81,8 @@ describe('Versions', () => {
|
||||
let url: AdminUrlUtil
|
||||
let serverURL: string
|
||||
let autosaveURL: AdminUrlUtil
|
||||
let autosaveWithValidateURL: AdminUrlUtil
|
||||
let draftWithValidateURL: AdminUrlUtil
|
||||
let disablePublishURL: AdminUrlUtil
|
||||
let customIDURL: AdminUrlUtil
|
||||
let postURL: AdminUrlUtil
|
||||
@@ -115,6 +119,7 @@ describe('Versions', () => {
|
||||
beforeAll(() => {
|
||||
url = new AdminUrlUtil(serverURL, draftCollectionSlug)
|
||||
autosaveURL = new AdminUrlUtil(serverURL, autosaveCollectionSlug)
|
||||
autosaveWithValidateURL = new AdminUrlUtil(serverURL, autosaveWithValidateCollectionSlug)
|
||||
disablePublishURL = new AdminUrlUtil(serverURL, disablePublishSlug)
|
||||
customIDURL = new AdminUrlUtil(serverURL, customIDSlug)
|
||||
postURL = new AdminUrlUtil(serverURL, postCollectionSlug)
|
||||
@@ -812,6 +817,232 @@ describe('Versions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Collections with draft validation', () => {
|
||||
beforeAll(() => {
|
||||
autosaveWithValidateURL = new AdminUrlUtil(serverURL, autosaveWithValidateCollectionSlug)
|
||||
draftWithValidateURL = new AdminUrlUtil(serverURL, draftWithValidateCollectionSlug)
|
||||
})
|
||||
|
||||
test('- can save', async () => {
|
||||
await page.goto(draftWithValidateURL.create)
|
||||
|
||||
const titleField = page.locator('#field-title')
|
||||
await titleField.fill('Initial')
|
||||
await saveDocAndAssert(page, '#action-save-draft')
|
||||
|
||||
await expect(titleField).toBeEnabled()
|
||||
await titleField.fill('New title')
|
||||
await saveDocAndAssert(page, '#action-save-draft')
|
||||
|
||||
await page.reload()
|
||||
|
||||
// Ensure its saved
|
||||
await expect(page.locator('#field-title')).toHaveValue('New title')
|
||||
})
|
||||
|
||||
test('- can safely trigger validation errors and then continue editing', async () => {
|
||||
await page.goto(draftWithValidateURL.create)
|
||||
|
||||
const titleField = page.locator('#field-title')
|
||||
await titleField.fill('Initial')
|
||||
await saveDocAndAssert(page, '#action-save-draft')
|
||||
await page.reload()
|
||||
|
||||
await expect(titleField).toBeEnabled()
|
||||
await titleField.fill('')
|
||||
await saveDocAndAssert(page, '#action-save-draft', 'error')
|
||||
|
||||
await titleField.fill('New title')
|
||||
|
||||
await saveDocAndAssert(page, '#action-save-draft')
|
||||
|
||||
await page.reload()
|
||||
|
||||
// Ensure its saved
|
||||
await expect(page.locator('#field-title')).toHaveValue('New title')
|
||||
})
|
||||
|
||||
test('- shows a prevent leave alert when form is submitted but invalid', async () => {
|
||||
await page.goto(draftWithValidateURL.create)
|
||||
|
||||
// Flag to check against if window alert has been displayed and dismissed since we can only check via events
|
||||
let alertDisplayed = false
|
||||
|
||||
async function dismissAlert(dialog: Dialog) {
|
||||
alertDisplayed = true
|
||||
|
||||
await dialog.dismiss()
|
||||
}
|
||||
|
||||
async function acceptAlert(dialog: Dialog) {
|
||||
await dialog.accept()
|
||||
}
|
||||
|
||||
const titleField = page.locator('#field-title')
|
||||
await titleField.fill('Initial')
|
||||
await saveDocAndAssert(page, '#action-save-draft')
|
||||
|
||||
// Remove required data, then let autosave trigger
|
||||
await expect(titleField).toBeEnabled()
|
||||
await titleField.fill('')
|
||||
await saveDocAndAssert(page, '#action-save-draft', 'error')
|
||||
|
||||
// Expect the prevent leave and then dismiss it
|
||||
page.on('dialog', dismissAlert)
|
||||
await expect(async () => {
|
||||
await page.reload({ timeout: 500 }) // custom short timeout since we want this to fail
|
||||
}).not.toPass({
|
||||
timeout: POLL_TOPASS_TIMEOUT,
|
||||
})
|
||||
|
||||
await expect(() => {
|
||||
expect(alertDisplayed).toEqual(true)
|
||||
}).toPass({
|
||||
timeout: POLL_TOPASS_TIMEOUT,
|
||||
})
|
||||
|
||||
// Remove event listener and reset our flag
|
||||
page.removeListener('dialog', dismissAlert)
|
||||
|
||||
await expect(page.locator('#field-title')).toHaveValue('')
|
||||
|
||||
// Now has updated data
|
||||
await titleField.fill('New title')
|
||||
await saveDocAndAssert(page, '#action-save-draft')
|
||||
await expect(page.locator('#field-title')).toHaveValue('New title')
|
||||
|
||||
await page.reload()
|
||||
|
||||
page.on('dialog', acceptAlert)
|
||||
|
||||
// Ensure data is saved
|
||||
await expect(page.locator('#field-title')).toHaveValue('New title')
|
||||
|
||||
// Fill with invalid data again, then reload and accept the warning, should contain previous data
|
||||
await titleField.fill('')
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect(titleField).toBeEnabled()
|
||||
|
||||
// Contains previous data
|
||||
await expect(page.locator('#field-title')).toHaveValue('New title')
|
||||
|
||||
// Remove listener
|
||||
page.removeListener('dialog', acceptAlert)
|
||||
})
|
||||
|
||||
test('- with autosave - can save', async () => {
|
||||
await page.goto(autosaveWithValidateURL.create)
|
||||
|
||||
const titleField = page.locator('#field-title')
|
||||
await titleField.fill('Initial')
|
||||
await saveDocAndAssert(page, '#action-save-draft')
|
||||
|
||||
await expect(titleField).toBeEnabled()
|
||||
await titleField.fill('New title')
|
||||
await waitForAutoSaveToRunAndComplete(page)
|
||||
|
||||
await page.reload()
|
||||
|
||||
// Ensure its saved
|
||||
await expect(page.locator('#field-title')).toHaveValue('New title')
|
||||
})
|
||||
|
||||
test('- with autosave - can safely trigger validation errors and then continue editing', async () => {
|
||||
// This test has to make sure we don't enter an infinite loop when draft.validate is on and we have autosave enabled
|
||||
await page.goto(autosaveWithValidateURL.create)
|
||||
|
||||
const titleField = page.locator('#field-title')
|
||||
await titleField.fill('Initial')
|
||||
await saveDocAndAssert(page, '#action-save-draft')
|
||||
await page.reload()
|
||||
|
||||
await expect(titleField).toBeEnabled()
|
||||
await titleField.fill('')
|
||||
await waitForAutoSaveToRunAndComplete(page, 'error')
|
||||
|
||||
await titleField.fill('New title')
|
||||
|
||||
await waitForAutoSaveToRunAndComplete(page)
|
||||
|
||||
await page.reload()
|
||||
|
||||
// Ensure its saved
|
||||
await expect(page.locator('#field-title')).toHaveValue('New title')
|
||||
})
|
||||
|
||||
test('- with autosave - shows a prevent leave alert when form is submitted but invalid', async () => {
|
||||
await page.goto(autosaveWithValidateURL.create)
|
||||
|
||||
// Flag to check against if window alert has been displayed and dismissed since we can only check via events
|
||||
let alertDisplayed = false
|
||||
|
||||
async function dismissAlert(dialog: Dialog) {
|
||||
alertDisplayed = true
|
||||
|
||||
await dialog.dismiss()
|
||||
}
|
||||
|
||||
async function acceptAlert(dialog: Dialog) {
|
||||
await dialog.accept()
|
||||
}
|
||||
|
||||
const titleField = page.locator('#field-title')
|
||||
await titleField.fill('Initial')
|
||||
await saveDocAndAssert(page, '#action-save-draft')
|
||||
|
||||
// Remove required data, then let autosave trigger
|
||||
await expect(titleField).toBeEnabled()
|
||||
await titleField.fill('')
|
||||
await waitForAutoSaveToRunAndComplete(page, 'error')
|
||||
|
||||
// Expect the prevent leave and then dismiss it
|
||||
page.on('dialog', dismissAlert)
|
||||
await expect(async () => {
|
||||
await page.reload({ timeout: 500 }) // custom short timeout since we want this to fail
|
||||
}).not.toPass({
|
||||
timeout: POLL_TOPASS_TIMEOUT,
|
||||
})
|
||||
|
||||
await expect(() => {
|
||||
expect(alertDisplayed).toEqual(true)
|
||||
}).toPass({
|
||||
timeout: POLL_TOPASS_TIMEOUT,
|
||||
})
|
||||
|
||||
// Remove event listener and reset our flag
|
||||
page.removeListener('dialog', dismissAlert)
|
||||
|
||||
await expect(page.locator('#field-title')).toHaveValue('')
|
||||
|
||||
// Now has updated data
|
||||
await titleField.fill('New title')
|
||||
await waitForAutoSaveToRunAndComplete(page)
|
||||
await expect(page.locator('#field-title')).toHaveValue('New title')
|
||||
|
||||
await page.reload()
|
||||
|
||||
page.on('dialog', acceptAlert)
|
||||
|
||||
// Ensure data is saved
|
||||
await expect(page.locator('#field-title')).toHaveValue('New title')
|
||||
|
||||
// Fill with invalid data again, then reload and accept the warning, should contain previous data
|
||||
await titleField.fill('')
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect(titleField).toBeEnabled()
|
||||
|
||||
// Contains previous data
|
||||
await expect(page.locator('#field-title')).toHaveValue('New title')
|
||||
|
||||
// Remove listener
|
||||
page.removeListener('dialog', acceptAlert)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Globals - publish individual locale', () => {
|
||||
beforeAll(() => {
|
||||
url = new AdminUrlUtil(serverURL, localizedGlobalSlug)
|
||||
|
||||
@@ -69,8 +69,10 @@ export interface Config {
|
||||
'disable-publish': DisablePublish;
|
||||
posts: Post;
|
||||
'autosave-posts': AutosavePost;
|
||||
'autosave-with-validate-posts': AutosaveWithValidatePost;
|
||||
'draft-posts': DraftPost;
|
||||
'draft-with-max-posts': DraftWithMaxPost;
|
||||
'draft-with-validate-posts': DraftWithValidatePost;
|
||||
'localized-posts': LocalizedPost;
|
||||
'version-posts': VersionPost;
|
||||
'custom-ids': CustomId;
|
||||
@@ -87,8 +89,10 @@ export interface Config {
|
||||
'disable-publish': DisablePublishSelect<false> | DisablePublishSelect<true>;
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
'autosave-posts': AutosavePostsSelect<false> | AutosavePostsSelect<true>;
|
||||
'autosave-with-validate-posts': AutosaveWithValidatePostsSelect<false> | AutosaveWithValidatePostsSelect<true>;
|
||||
'draft-posts': DraftPostsSelect<false> | DraftPostsSelect<true>;
|
||||
'draft-with-max-posts': DraftWithMaxPostsSelect<false> | DraftWithMaxPostsSelect<true>;
|
||||
'draft-with-validate-posts': DraftWithValidatePostsSelect<false> | DraftWithValidatePostsSelect<true>;
|
||||
'localized-posts': LocalizedPostsSelect<false> | LocalizedPostsSelect<true>;
|
||||
'version-posts': VersionPostsSelect<false> | VersionPostsSelect<true>;
|
||||
'custom-ids': CustomIdsSelect<false> | CustomIdsSelect<true>;
|
||||
@@ -221,6 +225,17 @@ export interface DraftPost {
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "autosave-with-validate-posts".
|
||||
*/
|
||||
export interface AutosaveWithValidatePost {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "draft-with-max-posts".
|
||||
@@ -245,6 +260,17 @@ export interface DraftWithMaxPost {
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "draft-with-validate-posts".
|
||||
*/
|
||||
export interface DraftWithValidatePost {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "localized-posts".
|
||||
@@ -497,6 +523,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'autosave-posts';
|
||||
value: string | AutosavePost;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'autosave-with-validate-posts';
|
||||
value: string | AutosaveWithValidatePost;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'draft-posts';
|
||||
value: string | DraftPost;
|
||||
@@ -505,6 +535,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'draft-with-max-posts';
|
||||
value: string | DraftWithMaxPost;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'draft-with-validate-posts';
|
||||
value: string | DraftWithValidatePost;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'localized-posts';
|
||||
value: string | LocalizedPost;
|
||||
@@ -607,6 +641,16 @@ export interface AutosavePostsSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "autosave-with-validate-posts_select".
|
||||
*/
|
||||
export interface AutosaveWithValidatePostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "draft-posts_select".
|
||||
@@ -660,6 +704,16 @@ export interface DraftWithMaxPostsSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "draft-with-validate-posts_select".
|
||||
*/
|
||||
export interface DraftWithValidatePostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "localized-posts_select".
|
||||
|
||||
@@ -7,7 +7,12 @@ import type { DraftPost } from './payload-types.js'
|
||||
import { devUser } from '../credentials.js'
|
||||
import { executePromises } from '../helpers/executePromises.js'
|
||||
import { titleToDelete } from './shared.js'
|
||||
import { diffCollectionSlug, draftCollectionSlug, mediaCollectionSlug } from './slugs.js'
|
||||
import {
|
||||
autosaveWithValidateCollectionSlug,
|
||||
diffCollectionSlug,
|
||||
draftCollectionSlug,
|
||||
mediaCollectionSlug,
|
||||
} from './slugs.js'
|
||||
import { textToLexicalJSON } from './textToLexicalJSON.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
@@ -120,6 +125,13 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
|
||||
draft: true,
|
||||
})
|
||||
|
||||
await _payload.create({
|
||||
collection: autosaveWithValidateCollectionSlug,
|
||||
data: {
|
||||
title: 'Initial seeded title',
|
||||
},
|
||||
})
|
||||
|
||||
const diffDoc = await _payload.create({
|
||||
collection: diffCollectionSlug,
|
||||
locale: 'en',
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
export const autosaveCollectionSlug = 'autosave-posts'
|
||||
|
||||
export const autosaveWithValidateCollectionSlug = 'autosave-with-validate-posts'
|
||||
|
||||
export const customIDSlug = 'custom-ids'
|
||||
|
||||
export const draftCollectionSlug = 'draft-posts'
|
||||
|
||||
export const draftWithValidateCollectionSlug = 'draft-with-validate-posts'
|
||||
export const draftWithMaxCollectionSlug = 'draft-with-max-posts'
|
||||
|
||||
export const postCollectionSlug = 'posts'
|
||||
|
||||
Reference in New Issue
Block a user