feat(ui): form state queues (#11579)
Implements a form state task queue. This will prevent onChange handlers within the form component from processing unnecessarily often, sometimes long after the user has stopped making changes. This leads to a potentially huge number of network requests if those changes were made slower than the debounce rate. This is especially noticeable on slow networks. Does so through a new `useQueue` hook. This hook maintains a stack of events that need processing but only processes the final event to arrive. Every time a new event is pushed to the stack, the currently running process is aborted (if any), and that event becomes the next in the queue. This results in a shocking reduction in the time it takes between final change to form state and the final network response, from ~1.5 minutes to ~3 seconds (depending on the scenario, see below). This likely fixes a number of existing open issues. I will link those issues here once they are identified and verifiably fixed. Before: I'm typing slowly here to ensure my changes aren't debounce by the form. There are a total of 60 characters typed, triggering 58 network requests and taking around 1.5 minutes to complete after the final change was made. https://github.com/user-attachments/assets/49ba0790-a8f8-4390-8421-87453ff8b650 After: Here there are a total of 69 characters typed, triggering 11 network requests and taking only about 3 seconds to complete after the final change was made. https://github.com/user-attachments/assets/447f8303-0957-41bd-bb2d-9e1151ed9ec3
This commit is contained in:
1
.github/workflows/main.yml
vendored
1
.github/workflows/main.yml
vendored
@@ -308,6 +308,7 @@ jobs:
|
||||
- fields__collections__Text
|
||||
- fields__collections__UI
|
||||
- fields__collections__Upload
|
||||
- form-state
|
||||
- live-preview
|
||||
- localization
|
||||
- locked-documents
|
||||
|
||||
@@ -12,7 +12,7 @@ export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
|
||||
return null
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
|
||||
const uploadDoc = uploadNode.value as FileData & TypeWithID
|
||||
|
||||
const url = uploadDoc.url
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
reduceFieldsToValues,
|
||||
wait,
|
||||
} from 'payload/shared'
|
||||
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
import { FieldErrorsToast } from '../../elements/Toasts/fieldErrors.js'
|
||||
import { useDebouncedEffect } from '../../hooks/useDebouncedEffect.js'
|
||||
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
|
||||
import { useQueues } from '../../hooks/useQueues.js'
|
||||
import { useThrottledEffect } from '../../hooks/useThrottledEffect.js'
|
||||
import { useAuth } from '../../providers/Auth/index.js'
|
||||
import { useConfig } from '../../providers/Config/index.js'
|
||||
@@ -91,6 +92,7 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
const { i18n, t } = useTranslation()
|
||||
const { refreshCookie, user } = useAuth()
|
||||
const operation = useOperation()
|
||||
const { queueTask } = useQueues()
|
||||
|
||||
const { getFormState } = useServerFunctions()
|
||||
const { startRouteTransition } = useRouteTransition()
|
||||
@@ -101,6 +103,7 @@ 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.
|
||||
@@ -114,17 +117,16 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const contextRef = useRef({} as FormContextType)
|
||||
const abortResetFormRef = useRef<AbortController>(null)
|
||||
const isFirstRenderRef = useRef(true)
|
||||
|
||||
const fieldsReducer = useReducer(fieldReducer, {}, () => initialState)
|
||||
|
||||
/**
|
||||
* `fields` is the current, up-to-date state/data of all fields in the form. It can be modified by using dispatchFields,
|
||||
* which calls the fieldReducer, which then updates the state.
|
||||
*/
|
||||
const [fields, dispatchFields] = fieldsReducer
|
||||
|
||||
contextRef.current.fields = fields
|
||||
|
||||
const prevFields = useRef(fields)
|
||||
|
||||
const validateForm = useCallback(async () => {
|
||||
const validatedFieldState = {}
|
||||
let isValid = true
|
||||
@@ -718,11 +720,15 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
|
||||
const classes = [className, baseClass].filter(Boolean).join(' ')
|
||||
|
||||
const executeOnChange = useEffectEvent(async (submitted: boolean) => {
|
||||
const executeOnChange = useEffectEvent(async (submitted: boolean, signal: AbortSignal) => {
|
||||
if (Array.isArray(onChange)) {
|
||||
let revalidatedFormState: FormState = contextRef.current.fields
|
||||
|
||||
for (const onChangeFn of onChange) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
// Edit view default onChange is in packages/ui/src/views/Edit/index.tsx. This onChange usually sends a form state request
|
||||
revalidatedFormState = await onChangeFn({
|
||||
formState: deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields),
|
||||
@@ -739,7 +745,9 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
incomingState: revalidatedFormState,
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
if (changed && !signal.aborted) {
|
||||
prevFields.current = newState
|
||||
|
||||
dispatchFields({
|
||||
type: 'REPLACE_STATE',
|
||||
optimize: false,
|
||||
@@ -749,29 +757,16 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
const prevFields = useRef(contextRef.current.fields)
|
||||
const isFirstRenderRef = useRef(true)
|
||||
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
if (isFirstRenderRef.current || !dequal(contextRef.current.fields, prevFields.current)) {
|
||||
if (modified) {
|
||||
void executeOnChange(submitted)
|
||||
}
|
||||
if ((isFirstRenderRef.current || !dequal(fields, prevFields.current)) && modified) {
|
||||
queueTask(async (signal) => executeOnChange(submitted, signal))
|
||||
}
|
||||
|
||||
prevFields.current = fields
|
||||
isFirstRenderRef.current = false
|
||||
prevFields.current = contextRef.current.fields
|
||||
},
|
||||
/*
|
||||
Make sure we trigger this whenever modified changes (not just when `fields` changes),
|
||||
otherwise we will miss merging server form state for the first form update/onChange.
|
||||
|
||||
Here's why:
|
||||
`fields` updates before `modified`, because setModified is in a setTimeout.
|
||||
So on the first change, modified is false, so we don't trigger the effect even though we should.
|
||||
**/
|
||||
[modified, submitted, contextRef.current.fields],
|
||||
[modified, submitted, fields, queueTask],
|
||||
250,
|
||||
)
|
||||
|
||||
|
||||
48
packages/ui/src/hooks/useQueues.ts
Normal file
48
packages/ui/src/hooks/useQueues.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useRef } from 'react'
|
||||
|
||||
export function useQueues(): {
|
||||
queueTask: (fn: (signal: AbortSignal) => Promise<void>) => void
|
||||
} {
|
||||
const runningTaskRef = useRef<null | Promise<void>>(null)
|
||||
const queuedTask = useRef<((signal: AbortSignal) => Promise<void>) | null>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const queueTask = (fn: (signal: AbortSignal) => Promise<void>) => {
|
||||
// Overwrite the queued task every time a new one arrives
|
||||
queuedTask.current = fn
|
||||
|
||||
// If a task is already running, abort it and return
|
||||
if (runningTaskRef.current !== null) {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const executeTask = async () => {
|
||||
while (queuedTask.current) {
|
||||
const taskToRun = queuedTask.current
|
||||
queuedTask.current = null // Reset latest task before running
|
||||
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
try {
|
||||
runningTaskRef.current = taskToRun(controller.signal)
|
||||
await runningTaskRef.current // Wait for the task to complete
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Error in queued function:', err) // eslint-disable-line no-console
|
||||
}
|
||||
} finally {
|
||||
runningTaskRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void executeTask()
|
||||
}
|
||||
|
||||
return { queueTask }
|
||||
}
|
||||
@@ -263,25 +263,6 @@ export const Posts: CollectionConfig = {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validateUsingEvent',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description:
|
||||
'This field should only validate on submit. Try typing "Not allowed" and submitting the form.',
|
||||
},
|
||||
validate: (value, { event }) => {
|
||||
if (event === 'onChange') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (value === 'Not allowed') {
|
||||
return 'This field has been validated only on submit'
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
],
|
||||
labels: {
|
||||
plural: slugPluralLabel,
|
||||
|
||||
@@ -174,36 +174,12 @@ describe('Document View', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('form state', () => {
|
||||
test('collection — should re-enable fields after save', async () => {
|
||||
await page.goto(postsUrl.create)
|
||||
await page.locator('#field-title').fill(title)
|
||||
await saveDocAndAssert(page)
|
||||
await expect(page.locator('#field-title')).toBeEnabled()
|
||||
})
|
||||
|
||||
test('global — should re-enable fields after save', async () => {
|
||||
await page.goto(globalURL.global(globalSlug))
|
||||
await page.locator('#field-title').fill(title)
|
||||
await saveDocAndAssert(page)
|
||||
await expect(page.locator('#field-title')).toBeEnabled()
|
||||
})
|
||||
|
||||
test('should thread proper event argument to validation functions', async () => {
|
||||
await page.goto(postsUrl.create)
|
||||
await page.locator('#field-title').fill(title)
|
||||
await page.locator('#field-validateUsingEvent').fill('Not allowed')
|
||||
await saveDocAndAssert(page, '#action-save', 'error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('document titles', () => {
|
||||
test('collection — should render fallback titles when creating new', async () => {
|
||||
await page.goto(postsUrl.create)
|
||||
await checkPageTitle(page, '[Untitled]')
|
||||
await checkBreadcrumb(page, 'Create New')
|
||||
await saveDocAndAssert(page)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test('collection — should render `useAsTitle` field', async () => {
|
||||
@@ -213,7 +189,6 @@ describe('Document View', () => {
|
||||
await wait(500)
|
||||
await checkPageTitle(page, title)
|
||||
await checkBreadcrumb(page, title)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test('collection — should render `id` as `useAsTitle` fallback', async () => {
|
||||
|
||||
@@ -65,7 +65,12 @@ export const testEslintConfig = [
|
||||
'playwright/expect-expect': [
|
||||
'error',
|
||||
{
|
||||
assertFunctionNames: ['assertToastErrors', 'saveDocAndAssert', 'runFilterOptionsTest'],
|
||||
assertFunctionNames: [
|
||||
'assertToastErrors',
|
||||
'saveDocAndAssert',
|
||||
'runFilterOptionsTest',
|
||||
'assertNetworkRequests',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
|
||||
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
|
||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||
import { trackNetworkRequests } from '../helpers/e2e/trackNetworkRequests.js'
|
||||
import { assertNetworkRequests } from '../helpers/e2e/assertNetworkRequests.js'
|
||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||
import {
|
||||
@@ -176,7 +176,7 @@ describe('Relationship Field', () => {
|
||||
await expect(options).toHaveCount(2) // two docs
|
||||
await options.nth(0).click()
|
||||
await expect(field).toContainText(relationOneDoc.id)
|
||||
await trackNetworkRequests(page, `/api/${relationOneSlug}`, async () => {
|
||||
await assertNetworkRequests(page, `/api/${relationOneSlug}`, async () => {
|
||||
await saveDocAndAssert(page)
|
||||
await wait(200)
|
||||
})
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('Array', () => {
|
||||
await expect(page.locator('#field-customArrayField__0__text')).toBeVisible()
|
||||
})
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
|
||||
test('should bypass min rows validation when no rows present and field is not required', async () => {
|
||||
await page.goto(url.create)
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../../../../../helpers.js'
|
||||
import { AdminUrlUtil } from '../../../../../helpers/adminUrlUtil.js'
|
||||
import { assertToastErrors } from '../../../../../helpers/assertToastErrors.js'
|
||||
import { trackNetworkRequests } from '../../../../../helpers/e2e/trackNetworkRequests.js'
|
||||
import { assertNetworkRequests } from '../../../../../helpers/e2e/assertNetworkRequests.js'
|
||||
import { initPayloadE2ENoConfig } from '../../../../../helpers/initPayloadE2ENoConfig.js'
|
||||
import { reInitializeDB } from '../../../../../helpers/reInitializeDB.js'
|
||||
import { RESTClient } from '../../../../../helpers/rest.js'
|
||||
@@ -400,11 +400,12 @@ describe('lexicalBlocks', () => {
|
||||
await dependsOnBlockData.locator('.rs__control').click()
|
||||
|
||||
// Fill and wait for form state to come back
|
||||
await trackNetworkRequests(page, '/admin/collections/lexical-fields', async () => {
|
||||
await assertNetworkRequests(page, '/admin/collections/lexical-fields', async () => {
|
||||
await topLevelDocTextField.fill('invalid')
|
||||
})
|
||||
|
||||
// Ensure block form state is updated and comes back (=> filter options are updated)
|
||||
await trackNetworkRequests(
|
||||
await assertNetworkRequests(
|
||||
page,
|
||||
'/admin/collections/lexical-fields',
|
||||
async () => {
|
||||
@@ -442,7 +443,7 @@ describe('lexicalBlocks', () => {
|
||||
topLevelDocTextField,
|
||||
} = await setupFilterOptionsTests()
|
||||
|
||||
await trackNetworkRequests(
|
||||
await assertNetworkRequests(
|
||||
page,
|
||||
'/admin/collections/lexical-fields',
|
||||
async () => {
|
||||
@@ -478,7 +479,7 @@ describe('lexicalBlocks', () => {
|
||||
topLevelDocTextField,
|
||||
} = await setupFilterOptionsTests()
|
||||
|
||||
await trackNetworkRequests(
|
||||
await assertNetworkRequests(
|
||||
page,
|
||||
'/admin/collections/lexical-fields',
|
||||
async () => {
|
||||
@@ -571,19 +572,21 @@ describe('lexicalBlocks', () => {
|
||||
await topLevelDocTextField.fill('invalid')
|
||||
|
||||
await saveDocAndAssert(page, '#action-save', 'error')
|
||||
|
||||
await assertToastErrors({
|
||||
page,
|
||||
errors: ['Lexical With Blocks', 'Lexical With Blocks → Group → Text Depends On Doc Data'],
|
||||
})
|
||||
|
||||
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
|
||||
|
||||
await trackNetworkRequests(
|
||||
await assertNetworkRequests(
|
||||
page,
|
||||
'/admin/collections/lexical-fields',
|
||||
async () => {
|
||||
await topLevelDocTextField.fill('Rich Text') // Default value
|
||||
},
|
||||
{ allowedNumberOfRequests: 2 },
|
||||
{ allowedNumberOfRequests: 1 },
|
||||
)
|
||||
|
||||
await saveDocAndAssert(page)
|
||||
@@ -604,13 +607,13 @@ describe('lexicalBlocks', () => {
|
||||
})
|
||||
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
|
||||
|
||||
await trackNetworkRequests(
|
||||
await assertNetworkRequests(
|
||||
page,
|
||||
'/admin/collections/lexical-fields',
|
||||
async () => {
|
||||
await blockGroupTextField.fill('')
|
||||
},
|
||||
{ allowedNumberOfRequests: 3 },
|
||||
{ allowedNumberOfRequests: 2 },
|
||||
)
|
||||
|
||||
await saveDocAndAssert(page)
|
||||
@@ -628,13 +631,13 @@ describe('lexicalBlocks', () => {
|
||||
})
|
||||
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
|
||||
|
||||
await trackNetworkRequests(
|
||||
await assertNetworkRequests(
|
||||
page,
|
||||
'/admin/collections/lexical-fields',
|
||||
async () => {
|
||||
await blockTextField.fill('')
|
||||
},
|
||||
{ allowedNumberOfRequests: 3 },
|
||||
{ allowedNumberOfRequests: 2 },
|
||||
)
|
||||
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
@@ -111,7 +111,6 @@ describe('Number', () => {
|
||||
test('should bypass min rows validation when no rows present and field is not required', async () => {
|
||||
await page.goto(url.create)
|
||||
await saveDocAndAssert(page)
|
||||
expect(true).toBe(true) // the above fn contains the assertion
|
||||
})
|
||||
|
||||
test('should fail min rows validation when rows are present', async () => {
|
||||
|
||||
2
test/form-state/.gitignore
vendored
Normal file
2
test/form-state/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/media
|
||||
/media-gif
|
||||
59
test/form-state/collections/Posts/index.ts
Normal file
59
test/form-state/collections/Posts/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const postsSlug = 'posts'
|
||||
|
||||
export const PostsCollection: CollectionConfig = {
|
||||
slug: postsSlug,
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'validateUsingEvent',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description:
|
||||
'This field should only validate on submit. Try typing "Not allowed" and submitting the form.',
|
||||
},
|
||||
validate: (value, { event }) => {
|
||||
if (event === 'onChange') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (value === 'Not allowed') {
|
||||
return 'This field has been validated only on submit'
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'blocks',
|
||||
type: 'blocks',
|
||||
blocks: [
|
||||
{
|
||||
slug: 'text',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'number',
|
||||
fields: [
|
||||
{
|
||||
name: 'number',
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
37
test/form-state/config.ts
Normal file
37
test/form-state/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'path'
|
||||
|
||||
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
|
||||
import { devUser } from '../credentials.js'
|
||||
import { PostsCollection, postsSlug } from './collections/Posts/index.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfigWithDefaults({
|
||||
collections: [PostsCollection],
|
||||
admin: {
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
onInit: async (payload) => {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: devUser.email,
|
||||
password: devUser.password,
|
||||
},
|
||||
})
|
||||
|
||||
await payload.create({
|
||||
collection: postsSlug,
|
||||
data: {
|
||||
title: 'example post',
|
||||
},
|
||||
})
|
||||
},
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
})
|
||||
126
test/form-state/e2e.spec.ts
Normal file
126
test/form-state/e2e.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { BrowserContext, Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { addBlock } from 'helpers/e2e/addBlock.js'
|
||||
import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js'
|
||||
import * as path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import {
|
||||
ensureCompilationIsDone,
|
||||
initPageConsoleErrorCatch,
|
||||
saveDocAndAssert,
|
||||
throttleTest,
|
||||
} from '../helpers.js'
|
||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
const title = 'Title'
|
||||
let context: BrowserContext
|
||||
|
||||
test.describe('Form State', () => {
|
||||
let page: Page
|
||||
let postsUrl: AdminUrlUtil
|
||||
|
||||
test.beforeAll(async ({ browser }, testInfo) => {
|
||||
testInfo.setTimeout(TEST_TIMEOUT_LONG)
|
||||
|
||||
const { payload, serverURL } = await initPayloadE2ENoConfig({ dirname })
|
||||
postsUrl = new AdminUrlUtil(serverURL, 'posts')
|
||||
|
||||
context = await browser.newContext()
|
||||
page = await context.newPage()
|
||||
initPageConsoleErrorCatch(page)
|
||||
await ensureCompilationIsDone({ page, serverURL })
|
||||
})
|
||||
|
||||
test('collection — should re-enable fields after save', async () => {
|
||||
await page.goto(postsUrl.create)
|
||||
await page.locator('#field-title').fill(title)
|
||||
await saveDocAndAssert(page)
|
||||
await expect(page.locator('#field-title')).toBeEnabled()
|
||||
})
|
||||
|
||||
test('should thread proper event argument to validation functions', async () => {
|
||||
await page.goto(postsUrl.create)
|
||||
await page.locator('#field-title').fill(title)
|
||||
await page.locator('#field-validateUsingEvent').fill('Not allowed')
|
||||
await saveDocAndAssert(page, '#action-save', 'error')
|
||||
})
|
||||
|
||||
test('should fire a single network request for onChange events when manipulating blocks', async () => {
|
||||
await page.goto(postsUrl.create)
|
||||
|
||||
await assertNetworkRequests(
|
||||
page,
|
||||
postsUrl.create,
|
||||
async () => {
|
||||
await addBlock({
|
||||
page,
|
||||
blockLabel: 'Text',
|
||||
fieldName: 'blocks',
|
||||
})
|
||||
},
|
||||
{
|
||||
allowedNumberOfRequests: 1,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('should debounce onChange events', async () => {
|
||||
await page.goto(postsUrl.create)
|
||||
const field = page.locator('#field-title')
|
||||
|
||||
await assertNetworkRequests(
|
||||
page,
|
||||
postsUrl.create,
|
||||
async () => {
|
||||
// Need to type _faster_ than the debounce rate (250ms)
|
||||
await field.pressSequentially('Some text to type', { delay: 50 })
|
||||
},
|
||||
{
|
||||
allowedNumberOfRequests: 1,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('should queue onChange functions', async () => {
|
||||
await page.goto(postsUrl.create)
|
||||
const field = page.locator('#field-title')
|
||||
await field.fill('Test')
|
||||
|
||||
// only throttle test after initial load to avoid timeouts
|
||||
const cdpSession = await throttleTest({
|
||||
page,
|
||||
context,
|
||||
delay: 'Slow 3G',
|
||||
})
|
||||
|
||||
await assertNetworkRequests(
|
||||
page,
|
||||
postsUrl.create,
|
||||
async () => {
|
||||
await field.fill('')
|
||||
// Need to type into a _slower_ than the debounce rate (250ms), but _faster_ than the network request
|
||||
await field.pressSequentially('Some text to type', { delay: 300 })
|
||||
},
|
||||
{
|
||||
allowedNumberOfRequests: 1,
|
||||
timeout: 10000, // watch network for 10 seconds to allow requests to build up
|
||||
},
|
||||
)
|
||||
|
||||
await cdpSession.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
latency: 0,
|
||||
downloadThroughput: -1,
|
||||
uploadThroughput: -1,
|
||||
})
|
||||
|
||||
await cdpSession.detach()
|
||||
})
|
||||
})
|
||||
19
test/form-state/eslint.config.js
Normal file
19
test/form-state/eslint.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { rootParserOptions } from '../../eslint.config.js'
|
||||
import { testEslintConfig } from '../eslint.config.js'
|
||||
|
||||
/** @typedef {import('eslint').Linter.Config} Config */
|
||||
|
||||
/** @type {Config[]} */
|
||||
export const index = [
|
||||
...testEslintConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
...rootParserOptions,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default index
|
||||
47
test/form-state/int.spec.ts
Normal file
47
test/form-state/int.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
|
||||
|
||||
import { devUser } from '../credentials.js'
|
||||
import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
||||
import { postsSlug } from './collections/Posts/index.js'
|
||||
|
||||
let payload: Payload
|
||||
let token: string
|
||||
let restClient: NextRESTClient
|
||||
|
||||
const { email, password } = devUser
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
describe('Form State', () => {
|
||||
// --__--__--__--__--__--__--__--__--__
|
||||
// Boilerplate test setup/teardown
|
||||
// --__--__--__--__--__--__--__--__--__
|
||||
beforeAll(async () => {
|
||||
const initialized = await initPayloadInt(dirname)
|
||||
;({ payload, restClient } = initialized)
|
||||
|
||||
const data = await restClient
|
||||
.POST('/users/login', {
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
|
||||
token = data.token
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (typeof payload.db.destroy === 'function') {
|
||||
await payload.db.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it.todo('should execute form state endpoint')
|
||||
})
|
||||
306
test/form-state/payload-types.ts
Normal file
306
test/form-state/payload-types.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by Payload.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported timezones in IANA format.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supportedTimezones".
|
||||
*/
|
||||
export type SupportedTimezones =
|
||||
| 'Pacific/Midway'
|
||||
| 'Pacific/Niue'
|
||||
| 'Pacific/Honolulu'
|
||||
| 'Pacific/Rarotonga'
|
||||
| 'America/Anchorage'
|
||||
| 'Pacific/Gambier'
|
||||
| 'America/Los_Angeles'
|
||||
| 'America/Tijuana'
|
||||
| 'America/Denver'
|
||||
| 'America/Phoenix'
|
||||
| 'America/Chicago'
|
||||
| 'America/Guatemala'
|
||||
| 'America/New_York'
|
||||
| 'America/Bogota'
|
||||
| 'America/Caracas'
|
||||
| 'America/Santiago'
|
||||
| 'America/Buenos_Aires'
|
||||
| 'America/Sao_Paulo'
|
||||
| 'Atlantic/South_Georgia'
|
||||
| 'Atlantic/Azores'
|
||||
| 'Atlantic/Cape_Verde'
|
||||
| 'Europe/London'
|
||||
| 'Europe/Berlin'
|
||||
| 'Africa/Lagos'
|
||||
| 'Europe/Athens'
|
||||
| 'Africa/Cairo'
|
||||
| 'Europe/Moscow'
|
||||
| 'Asia/Riyadh'
|
||||
| 'Asia/Dubai'
|
||||
| 'Asia/Baku'
|
||||
| 'Asia/Karachi'
|
||||
| 'Asia/Tashkent'
|
||||
| 'Asia/Calcutta'
|
||||
| 'Asia/Dhaka'
|
||||
| 'Asia/Almaty'
|
||||
| 'Asia/Jakarta'
|
||||
| 'Asia/Bangkok'
|
||||
| 'Asia/Shanghai'
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
| 'Pacific/Auckland'
|
||||
| 'Pacific/Fiji';
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
posts: Post;
|
||||
users: User;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: string;
|
||||
};
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: null;
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
};
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
workflows: unknown;
|
||||
};
|
||||
}
|
||||
export interface UserAuthOperations {
|
||||
forgotPassword: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
login: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
registerFirstUser: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
unlock: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts".
|
||||
*/
|
||||
export interface Post {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
/**
|
||||
* This field should only validate on submit. Try typing "Not allowed" and submitting the form.
|
||||
*/
|
||||
validateUsingEvent?: string | null;
|
||||
blocks?:
|
||||
| (
|
||||
| {
|
||||
text?: string | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'text';
|
||||
}
|
||||
| {
|
||||
number?: number | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'number';
|
||||
}
|
||||
)[]
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
resetPasswordToken?: string | null;
|
||||
resetPasswordExpiration?: string | null;
|
||||
salt?: string | null;
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: string;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: string | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: string;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts_select".
|
||||
*/
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
validateUsingEvent?: T;
|
||||
blocks?:
|
||||
| T
|
||||
| {
|
||||
text?:
|
||||
| T
|
||||
| {
|
||||
text?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
number?:
|
||||
| T
|
||||
| {
|
||||
number?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
*/
|
||||
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
|
||||
document?: T;
|
||||
globalSlug?: T;
|
||||
user?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences_select".
|
||||
*/
|
||||
export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
key?: T;
|
||||
value?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations_select".
|
||||
*/
|
||||
export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
batch?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
*/
|
||||
export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
// @ts-ignore
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
1902
test/form-state/schema.graphql
Normal file
1902
test/form-state/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
13
test/form-state/tsconfig.eslint.json
Normal file
13
test/form-state/tsconfig.eslint.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
// extend your base config to share compilerOptions, etc
|
||||
//"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// ensure that nobody can accidentally use this config for a build
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
// whatever paths you intend to lint
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx"
|
||||
]
|
||||
}
|
||||
3
test/form-state/tsconfig.json
Normal file
3
test/form-state/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../tsconfig.json"
|
||||
}
|
||||
9
test/form-state/types.d.ts
vendored
Normal file
9
test/form-state/types.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { RequestContext as OriginalRequestContext } from 'payload'
|
||||
|
||||
declare module 'payload' {
|
||||
// Create a new interface that merges your additional fields with the original one
|
||||
export interface RequestContext extends OriginalRequestContext {
|
||||
myObject?: string
|
||||
// ...
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,37 @@ import type { Page, Request } from '@playwright/test'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
// Allows you to test the number of network requests triggered by an action
|
||||
// This can be used to ensure various actions do not trigger unnecessary requests
|
||||
// For example, an effect within a component might fetch data multiple times unnecessarily
|
||||
export const trackNetworkRequests = async (
|
||||
/**
|
||||
* Counts the number of network requests every `interval` milliseconds until `timeout` is reached.
|
||||
* Useful to ensure unexpected network requests are not triggered by an action.
|
||||
* For example, an effect within a component might fetch data multiple times unnecessarily.
|
||||
* @param page The Playwright page
|
||||
* @param url The URL to match in the network requests
|
||||
* @param action The action to perform
|
||||
* @param options Options
|
||||
* @param options.allowedNumberOfRequests The number of requests that are allowed to be made, defaults to 1
|
||||
* @param options.beforePoll A function to run before polling the network requests
|
||||
* @param options.interval The interval in milliseconds to poll the network requests, defaults to 1000
|
||||
* @param options.timeout The timeout in milliseconds to poll the network requests, defaults to 5000
|
||||
* @returns The matched network requests
|
||||
*/
|
||||
export const assertNetworkRequests = async (
|
||||
page: Page,
|
||||
url: string,
|
||||
action: () => Promise<any>,
|
||||
options?: {
|
||||
{
|
||||
beforePoll,
|
||||
allowedNumberOfRequests = 1,
|
||||
timeout = 5000,
|
||||
interval = 1000,
|
||||
}: {
|
||||
allowedNumberOfRequests?: number
|
||||
beforePoll?: () => Promise<any> | void
|
||||
interval?: number
|
||||
timeout?: number
|
||||
},
|
||||
} = {},
|
||||
): Promise<Array<Request>> => {
|
||||
const { beforePoll, allowedNumberOfRequests = 1, timeout = 5000, interval = 1000 } = options || {}
|
||||
|
||||
const matchedRequests = []
|
||||
const matchedRequests: Request[] = []
|
||||
|
||||
// begin tracking network requests
|
||||
page.on('request', (request) => {
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
selectTableRow,
|
||||
} from '../helpers.js'
|
||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||
import { trackNetworkRequests } from '../helpers/e2e/trackNetworkRequests.js'
|
||||
import { assertNetworkRequests } from '../helpers/e2e/assertNetworkRequests.js'
|
||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||
import { reInitializeDB } from '../helpers/reInitializeDB.js'
|
||||
import { waitForAutoSaveToRunAndComplete } from '../helpers/waitForAutoSaveToRunAndComplete.js'
|
||||
@@ -416,7 +416,7 @@ describe('Versions', () => {
|
||||
|
||||
await page.goto(postURL.edit(postID))
|
||||
|
||||
await trackNetworkRequests(
|
||||
await assertNetworkRequests(
|
||||
page,
|
||||
`${serverURL}/admin/collections/${postCollectionSlug}/${postID}`,
|
||||
async () => {
|
||||
@@ -1224,6 +1224,7 @@ describe('Versions', () => {
|
||||
|
||||
test('should render diff', async () => {
|
||||
await navigateToVersionDiff()
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test('should render diff for nested fields', async () => {
|
||||
|
||||
Reference in New Issue
Block a user