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