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__Text
|
||||||
- fields__collections__UI
|
- fields__collections__UI
|
||||||
- fields__collections__Upload
|
- fields__collections__Upload
|
||||||
|
- form-state
|
||||||
- live-preview
|
- live-preview
|
||||||
- localization
|
- localization
|
||||||
- locked-documents
|
- locked-documents
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
||||||
const uploadDoc = uploadNode.value as FileData & TypeWithID
|
const uploadDoc = uploadNode.value as FileData & TypeWithID
|
||||||
|
|
||||||
const url = uploadDoc.url
|
const url = uploadDoc.url
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
reduceFieldsToValues,
|
reduceFieldsToValues,
|
||||||
wait,
|
wait,
|
||||||
} from 'payload/shared'
|
} 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 { toast } from 'sonner'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
import { FieldErrorsToast } from '../../elements/Toasts/fieldErrors.js'
|
import { FieldErrorsToast } from '../../elements/Toasts/fieldErrors.js'
|
||||||
import { useDebouncedEffect } from '../../hooks/useDebouncedEffect.js'
|
import { useDebouncedEffect } from '../../hooks/useDebouncedEffect.js'
|
||||||
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
|
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
|
||||||
|
import { useQueues } from '../../hooks/useQueues.js'
|
||||||
import { useThrottledEffect } from '../../hooks/useThrottledEffect.js'
|
import { useThrottledEffect } from '../../hooks/useThrottledEffect.js'
|
||||||
import { useAuth } from '../../providers/Auth/index.js'
|
import { useAuth } from '../../providers/Auth/index.js'
|
||||||
import { useConfig } from '../../providers/Config/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 { i18n, t } = useTranslation()
|
||||||
const { refreshCookie, user } = useAuth()
|
const { refreshCookie, user } = useAuth()
|
||||||
const operation = useOperation()
|
const operation = useOperation()
|
||||||
|
const { queueTask } = useQueues()
|
||||||
|
|
||||||
const { getFormState } = useServerFunctions()
|
const { getFormState } = useServerFunctions()
|
||||||
const { startRouteTransition } = useRouteTransition()
|
const { startRouteTransition } = useRouteTransition()
|
||||||
@@ -101,6 +103,7 @@ export const Form: React.FC<FormProps> = (props) => {
|
|||||||
const [disabled, setDisabled] = useState(disabledFromProps || false)
|
const [disabled, setDisabled] = useState(disabledFromProps || false)
|
||||||
const [isMounted, setIsMounted] = useState(false)
|
const [isMounted, setIsMounted] = useState(false)
|
||||||
const [modified, setModified] = useState(false)
|
const [modified, setModified] = useState(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks wether the form state passes validation.
|
* Tracks wether the form state passes validation.
|
||||||
* For example the state could be submitted but invalid as field errors have been returned.
|
* 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 formRef = useRef<HTMLFormElement>(null)
|
||||||
const contextRef = useRef({} as FormContextType)
|
const contextRef = useRef({} as FormContextType)
|
||||||
const abortResetFormRef = useRef<AbortController>(null)
|
const abortResetFormRef = useRef<AbortController>(null)
|
||||||
|
const isFirstRenderRef = useRef(true)
|
||||||
|
|
||||||
const fieldsReducer = useReducer(fieldReducer, {}, () => initialState)
|
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
|
const [fields, dispatchFields] = fieldsReducer
|
||||||
|
|
||||||
contextRef.current.fields = fields
|
contextRef.current.fields = fields
|
||||||
|
|
||||||
|
const prevFields = useRef(fields)
|
||||||
|
|
||||||
const validateForm = useCallback(async () => {
|
const validateForm = useCallback(async () => {
|
||||||
const validatedFieldState = {}
|
const validatedFieldState = {}
|
||||||
let isValid = true
|
let isValid = true
|
||||||
@@ -718,11 +720,15 @@ export const Form: React.FC<FormProps> = (props) => {
|
|||||||
|
|
||||||
const classes = [className, baseClass].filter(Boolean).join(' ')
|
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)) {
|
if (Array.isArray(onChange)) {
|
||||||
let revalidatedFormState: FormState = contextRef.current.fields
|
let revalidatedFormState: FormState = contextRef.current.fields
|
||||||
|
|
||||||
for (const onChangeFn of onChange) {
|
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
|
// Edit view default onChange is in packages/ui/src/views/Edit/index.tsx. This onChange usually sends a form state request
|
||||||
revalidatedFormState = await onChangeFn({
|
revalidatedFormState = await onChangeFn({
|
||||||
formState: deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields),
|
formState: deepCopyObjectSimpleWithoutReactComponents(contextRef.current.fields),
|
||||||
@@ -739,7 +745,9 @@ export const Form: React.FC<FormProps> = (props) => {
|
|||||||
incomingState: revalidatedFormState,
|
incomingState: revalidatedFormState,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (changed) {
|
if (changed && !signal.aborted) {
|
||||||
|
prevFields.current = newState
|
||||||
|
|
||||||
dispatchFields({
|
dispatchFields({
|
||||||
type: 'REPLACE_STATE',
|
type: 'REPLACE_STATE',
|
||||||
optimize: false,
|
optimize: false,
|
||||||
@@ -749,29 +757,16 @@ export const Form: React.FC<FormProps> = (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const prevFields = useRef(contextRef.current.fields)
|
|
||||||
const isFirstRenderRef = useRef(true)
|
|
||||||
|
|
||||||
useDebouncedEffect(
|
useDebouncedEffect(
|
||||||
() => {
|
() => {
|
||||||
if (isFirstRenderRef.current || !dequal(contextRef.current.fields, prevFields.current)) {
|
if ((isFirstRenderRef.current || !dequal(fields, prevFields.current)) && modified) {
|
||||||
if (modified) {
|
queueTask(async (signal) => executeOnChange(submitted, signal))
|
||||||
void executeOnChange(submitted)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prevFields.current = fields
|
||||||
isFirstRenderRef.current = false
|
isFirstRenderRef.current = false
|
||||||
prevFields.current = contextRef.current.fields
|
|
||||||
},
|
},
|
||||||
/*
|
[modified, submitted, fields, queueTask],
|
||||||
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],
|
|
||||||
250,
|
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',
|
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: {
|
labels: {
|
||||||
plural: slugPluralLabel,
|
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', () => {
|
describe('document titles', () => {
|
||||||
test('collection — should render fallback titles when creating new', async () => {
|
test('collection — should render fallback titles when creating new', async () => {
|
||||||
await page.goto(postsUrl.create)
|
await page.goto(postsUrl.create)
|
||||||
await checkPageTitle(page, '[Untitled]')
|
await checkPageTitle(page, '[Untitled]')
|
||||||
await checkBreadcrumb(page, 'Create New')
|
await checkBreadcrumb(page, 'Create New')
|
||||||
await saveDocAndAssert(page)
|
await saveDocAndAssert(page)
|
||||||
expect(true).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('collection — should render `useAsTitle` field', async () => {
|
test('collection — should render `useAsTitle` field', async () => {
|
||||||
@@ -213,7 +189,6 @@ describe('Document View', () => {
|
|||||||
await wait(500)
|
await wait(500)
|
||||||
await checkPageTitle(page, title)
|
await checkPageTitle(page, title)
|
||||||
await checkBreadcrumb(page, title)
|
await checkBreadcrumb(page, title)
|
||||||
expect(true).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('collection — should render `id` as `useAsTitle` fallback', async () => {
|
test('collection — should render `id` as `useAsTitle` fallback', async () => {
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ export const testEslintConfig = [
|
|||||||
'playwright/expect-expect': [
|
'playwright/expect-expect': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
assertFunctionNames: ['assertToastErrors', 'saveDocAndAssert', 'runFilterOptionsTest'],
|
assertFunctionNames: [
|
||||||
|
'assertToastErrors',
|
||||||
|
'saveDocAndAssert',
|
||||||
|
'runFilterOptionsTest',
|
||||||
|
'assertNetworkRequests',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import type {
|
|||||||
|
|
||||||
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
|
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.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 { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||||
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||||
import {
|
import {
|
||||||
@@ -176,7 +176,7 @@ describe('Relationship Field', () => {
|
|||||||
await expect(options).toHaveCount(2) // two docs
|
await expect(options).toHaveCount(2) // two docs
|
||||||
await options.nth(0).click()
|
await options.nth(0).click()
|
||||||
await expect(field).toContainText(relationOneDoc.id)
|
await expect(field).toContainText(relationOneDoc.id)
|
||||||
await trackNetworkRequests(page, `/api/${relationOneSlug}`, async () => {
|
await assertNetworkRequests(page, `/api/${relationOneSlug}`, async () => {
|
||||||
await saveDocAndAssert(page)
|
await saveDocAndAssert(page)
|
||||||
await wait(200)
|
await wait(200)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ describe('Array', () => {
|
|||||||
await expect(page.locator('#field-customArrayField__0__text')).toBeVisible()
|
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 () => {
|
test('should bypass min rows validation when no rows present and field is not required', async () => {
|
||||||
await page.goto(url.create)
|
await page.goto(url.create)
|
||||||
await saveDocAndAssert(page)
|
await saveDocAndAssert(page)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from '../../../../../helpers.js'
|
} from '../../../../../helpers.js'
|
||||||
import { AdminUrlUtil } from '../../../../../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../../../../../helpers/adminUrlUtil.js'
|
||||||
import { assertToastErrors } from '../../../../../helpers/assertToastErrors.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 { initPayloadE2ENoConfig } from '../../../../../helpers/initPayloadE2ENoConfig.js'
|
||||||
import { reInitializeDB } from '../../../../../helpers/reInitializeDB.js'
|
import { reInitializeDB } from '../../../../../helpers/reInitializeDB.js'
|
||||||
import { RESTClient } from '../../../../../helpers/rest.js'
|
import { RESTClient } from '../../../../../helpers/rest.js'
|
||||||
@@ -400,11 +400,12 @@ describe('lexicalBlocks', () => {
|
|||||||
await dependsOnBlockData.locator('.rs__control').click()
|
await dependsOnBlockData.locator('.rs__control').click()
|
||||||
|
|
||||||
// Fill and wait for form state to come back
|
// 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')
|
await topLevelDocTextField.fill('invalid')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Ensure block form state is updated and comes back (=> filter options are updated)
|
// Ensure block form state is updated and comes back (=> filter options are updated)
|
||||||
await trackNetworkRequests(
|
await assertNetworkRequests(
|
||||||
page,
|
page,
|
||||||
'/admin/collections/lexical-fields',
|
'/admin/collections/lexical-fields',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -442,7 +443,7 @@ describe('lexicalBlocks', () => {
|
|||||||
topLevelDocTextField,
|
topLevelDocTextField,
|
||||||
} = await setupFilterOptionsTests()
|
} = await setupFilterOptionsTests()
|
||||||
|
|
||||||
await trackNetworkRequests(
|
await assertNetworkRequests(
|
||||||
page,
|
page,
|
||||||
'/admin/collections/lexical-fields',
|
'/admin/collections/lexical-fields',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -478,7 +479,7 @@ describe('lexicalBlocks', () => {
|
|||||||
topLevelDocTextField,
|
topLevelDocTextField,
|
||||||
} = await setupFilterOptionsTests()
|
} = await setupFilterOptionsTests()
|
||||||
|
|
||||||
await trackNetworkRequests(
|
await assertNetworkRequests(
|
||||||
page,
|
page,
|
||||||
'/admin/collections/lexical-fields',
|
'/admin/collections/lexical-fields',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -571,19 +572,21 @@ describe('lexicalBlocks', () => {
|
|||||||
await topLevelDocTextField.fill('invalid')
|
await topLevelDocTextField.fill('invalid')
|
||||||
|
|
||||||
await saveDocAndAssert(page, '#action-save', 'error')
|
await saveDocAndAssert(page, '#action-save', 'error')
|
||||||
|
|
||||||
await assertToastErrors({
|
await assertToastErrors({
|
||||||
page,
|
page,
|
||||||
errors: ['Lexical With Blocks', 'Lexical With Blocks → Group → Text Depends On Doc Data'],
|
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 expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
|
||||||
|
|
||||||
await trackNetworkRequests(
|
await assertNetworkRequests(
|
||||||
page,
|
page,
|
||||||
'/admin/collections/lexical-fields',
|
'/admin/collections/lexical-fields',
|
||||||
async () => {
|
async () => {
|
||||||
await topLevelDocTextField.fill('Rich Text') // Default value
|
await topLevelDocTextField.fill('Rich Text') // Default value
|
||||||
},
|
},
|
||||||
{ allowedNumberOfRequests: 2 },
|
{ allowedNumberOfRequests: 1 },
|
||||||
)
|
)
|
||||||
|
|
||||||
await saveDocAndAssert(page)
|
await saveDocAndAssert(page)
|
||||||
@@ -604,13 +607,13 @@ describe('lexicalBlocks', () => {
|
|||||||
})
|
})
|
||||||
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
|
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
|
||||||
|
|
||||||
await trackNetworkRequests(
|
await assertNetworkRequests(
|
||||||
page,
|
page,
|
||||||
'/admin/collections/lexical-fields',
|
'/admin/collections/lexical-fields',
|
||||||
async () => {
|
async () => {
|
||||||
await blockGroupTextField.fill('')
|
await blockGroupTextField.fill('')
|
||||||
},
|
},
|
||||||
{ allowedNumberOfRequests: 3 },
|
{ allowedNumberOfRequests: 2 },
|
||||||
)
|
)
|
||||||
|
|
||||||
await saveDocAndAssert(page)
|
await saveDocAndAssert(page)
|
||||||
@@ -628,13 +631,13 @@ describe('lexicalBlocks', () => {
|
|||||||
})
|
})
|
||||||
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
|
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
|
||||||
|
|
||||||
await trackNetworkRequests(
|
await assertNetworkRequests(
|
||||||
page,
|
page,
|
||||||
'/admin/collections/lexical-fields',
|
'/admin/collections/lexical-fields',
|
||||||
async () => {
|
async () => {
|
||||||
await blockTextField.fill('')
|
await blockTextField.fill('')
|
||||||
},
|
},
|
||||||
{ allowedNumberOfRequests: 3 },
|
{ allowedNumberOfRequests: 2 },
|
||||||
)
|
)
|
||||||
|
|
||||||
await saveDocAndAssert(page)
|
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 () => {
|
test('should bypass min rows validation when no rows present and field is not required', async () => {
|
||||||
await page.goto(url.create)
|
await page.goto(url.create)
|
||||||
await saveDocAndAssert(page)
|
await saveDocAndAssert(page)
|
||||||
expect(true).toBe(true) // the above fn contains the assertion
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should fail min rows validation when rows are present', async () => {
|
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'
|
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
|
* Counts the number of network requests every `interval` milliseconds until `timeout` is reached.
|
||||||
// For example, an effect within a component might fetch data multiple times unnecessarily
|
* Useful to ensure unexpected network requests are not triggered by an action.
|
||||||
export const trackNetworkRequests = async (
|
* 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,
|
page: Page,
|
||||||
url: string,
|
url: string,
|
||||||
action: () => Promise<any>,
|
action: () => Promise<any>,
|
||||||
options?: {
|
{
|
||||||
|
beforePoll,
|
||||||
|
allowedNumberOfRequests = 1,
|
||||||
|
timeout = 5000,
|
||||||
|
interval = 1000,
|
||||||
|
}: {
|
||||||
allowedNumberOfRequests?: number
|
allowedNumberOfRequests?: number
|
||||||
beforePoll?: () => Promise<any> | void
|
beforePoll?: () => Promise<any> | void
|
||||||
interval?: number
|
interval?: number
|
||||||
timeout?: number
|
timeout?: number
|
||||||
},
|
} = {},
|
||||||
): Promise<Array<Request>> => {
|
): Promise<Array<Request>> => {
|
||||||
const { beforePoll, allowedNumberOfRequests = 1, timeout = 5000, interval = 1000 } = options || {}
|
const matchedRequests: Request[] = []
|
||||||
|
|
||||||
const matchedRequests = []
|
|
||||||
|
|
||||||
// begin tracking network requests
|
// begin tracking network requests
|
||||||
page.on('request', (request) => {
|
page.on('request', (request) => {
|
||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
selectTableRow,
|
selectTableRow,
|
||||||
} from '../helpers.js'
|
} from '../helpers.js'
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.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 { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||||
import { reInitializeDB } from '../helpers/reInitializeDB.js'
|
import { reInitializeDB } from '../helpers/reInitializeDB.js'
|
||||||
import { waitForAutoSaveToRunAndComplete } from '../helpers/waitForAutoSaveToRunAndComplete.js'
|
import { waitForAutoSaveToRunAndComplete } from '../helpers/waitForAutoSaveToRunAndComplete.js'
|
||||||
@@ -416,7 +416,7 @@ describe('Versions', () => {
|
|||||||
|
|
||||||
await page.goto(postURL.edit(postID))
|
await page.goto(postURL.edit(postID))
|
||||||
|
|
||||||
await trackNetworkRequests(
|
await assertNetworkRequests(
|
||||||
page,
|
page,
|
||||||
`${serverURL}/admin/collections/${postCollectionSlug}/${postID}`,
|
`${serverURL}/admin/collections/${postCollectionSlug}/${postID}`,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -1224,6 +1224,7 @@ describe('Versions', () => {
|
|||||||
|
|
||||||
test('should render diff', async () => {
|
test('should render diff', async () => {
|
||||||
await navigateToVersionDiff()
|
await navigateToVersionDiff()
|
||||||
|
expect(true).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should render diff for nested fields', async () => {
|
test('should render diff for nested fields', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user