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
![image](https://github.com/user-attachments/assets/db208aa4-6ed6-4287-b200-59575cd3c9d0)
This commit is contained in:
Paul
2025-02-18 19:12:41 +00:00
committed by GitHub
parent ede7bd7b4b
commit 06debf5e14
13 changed files with 441 additions and 18 deletions

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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