@@ -4,6 +4,7 @@ import type { FormState } from 'payload'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { addBlock } from 'helpers/e2e/addBlock.js'
|
||||
import { assertElementStaysVisible } from 'helpers/e2e/assertElementStaysVisible.js'
|
||||
import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js'
|
||||
import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js'
|
||||
import * as path from 'path'
|
||||
@@ -154,7 +155,7 @@ test.describe('Form State', () => {
|
||||
|
||||
// The `array` itself SHOULD have a `lastRenderedPath` because it was rendered on initial load
|
||||
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
|
||||
action: await page.locator('#field-array .array-field__add-row').click(),
|
||||
action: async () => await page.locator('#field-array .array-field__add-row').click(),
|
||||
url: postsUrl.create,
|
||||
expect: (body) =>
|
||||
Boolean(
|
||||
@@ -172,7 +173,7 @@ test.describe('Form State', () => {
|
||||
// The `array` itself SHOULD still have a `lastRenderedPath`
|
||||
// The rich text field in the first row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the first request
|
||||
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
|
||||
action: await page.locator('#field-array .array-field__add-row').click(),
|
||||
action: async () => await page.locator('#field-array .array-field__add-row').click(),
|
||||
url: postsUrl.create,
|
||||
expect: (body) =>
|
||||
Boolean(
|
||||
@@ -193,7 +194,7 @@ test.describe('Form State', () => {
|
||||
// The rich text field in the first row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the first request
|
||||
// The rich text field in the second row SHOULD ALSO have a `lastRenderedPath` bc it was rendered in the second request
|
||||
await assertRequestBody<{ args: { formState: FormState } }[]>(page, {
|
||||
action: await page.locator('#field-array .array-field__add-row').click(),
|
||||
action: async () => await page.locator('#field-array .array-field__add-row').click(),
|
||||
url: postsUrl.create,
|
||||
expect: (body) =>
|
||||
Boolean(
|
||||
@@ -241,6 +242,77 @@ test.describe('Form State', () => {
|
||||
await cdpSession.detach()
|
||||
})
|
||||
|
||||
test('optimistic rows should not disappear between pending network requests', 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',
|
||||
})
|
||||
|
||||
let requestCount = 0
|
||||
|
||||
// increment the response count for form state requests
|
||||
page.on('request', (request) => {
|
||||
if (request.url() === postsUrl.create && request.method() === 'POST') {
|
||||
requestCount++
|
||||
}
|
||||
})
|
||||
|
||||
// Add the first row and expect an optimistic loading state
|
||||
await page.locator('#field-array .array-field__add-row').click()
|
||||
await expect(page.locator('#field-array #array-row-0')).toBeVisible()
|
||||
// use waitForSelector because the shimmer effect is not always visible
|
||||
// eslint-disable-next-line playwright/no-wait-for-selector
|
||||
await page.waitForSelector('#field-array #array-row-0 .shimmer-effect')
|
||||
|
||||
// Wait for the first request to be sent
|
||||
await page.waitForRequest((request) => request.url() === postsUrl.create)
|
||||
|
||||
// Before the first request comes back, add the second row and expect an optimistic loading state
|
||||
await page.locator('#field-array .array-field__add-row').click()
|
||||
await expect(page.locator('#field-array #array-row-1')).toBeVisible()
|
||||
// use waitForSelector because the shimmer effect is not always visible
|
||||
// eslint-disable-next-line playwright/no-wait-for-selector
|
||||
await page.waitForSelector('#field-array #array-row-0 .shimmer-effect')
|
||||
|
||||
// At this point there should have been a single request sent for the first row
|
||||
expect(requestCount).toBe(1)
|
||||
|
||||
// Wait for the first request to finish
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url() === postsUrl.create &&
|
||||
response.status() === 200 &&
|
||||
response.headers()['content-type'] === 'text/x-component',
|
||||
)
|
||||
|
||||
// block the second request from executing to ensure the form remains in stale state
|
||||
await page.route(postsUrl.create, async (route) => {
|
||||
if (route.request().method() === 'POST' && route.request().url() === postsUrl.create) {
|
||||
await route.abort()
|
||||
return
|
||||
}
|
||||
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await assertElementStaysVisible(page, '#field-array #array-row-1')
|
||||
|
||||
await cdpSession.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
latency: 0,
|
||||
downloadThroughput: -1,
|
||||
uploadThroughput: -1,
|
||||
})
|
||||
|
||||
await cdpSession.detach()
|
||||
})
|
||||
|
||||
test('should not cause nested custom components to disappear when adding a row then editing a field', async () => {
|
||||
await page.goto(postsUrl.create)
|
||||
const field = page.locator('#field-title')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Payload, User } from 'payload'
|
||||
import type { FormState, Payload, User } from 'payload'
|
||||
|
||||
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
|
||||
import path from 'path'
|
||||
@@ -10,6 +10,8 @@ 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'
|
||||
// eslint-disable-next-line payload/no-relative-monorepo-imports
|
||||
import { mergeServerFormState } from '../../packages/ui/src/forms/Form/mergeServerFormState.js'
|
||||
|
||||
let payload: Payload
|
||||
let token: string
|
||||
@@ -217,4 +219,53 @@ describe('Form State', () => {
|
||||
expect(stateWithTitle?.['array.1.richText']).toHaveProperty('customComponents')
|
||||
expect(stateWithTitle?.['array.1.richText']?.customComponents?.Field).toBeDefined()
|
||||
})
|
||||
|
||||
it('should merge array rows without losing current state', () => {
|
||||
const currentState: FormState = {
|
||||
array: {
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
isLoading: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const incomingState: FormState = {
|
||||
array: {
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
lastRenderedPath: 'array.0.text',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const { newState } = mergeServerFormState({
|
||||
existingState: currentState,
|
||||
incomingState,
|
||||
})
|
||||
|
||||
expect(newState).toStrictEqual({
|
||||
array: {
|
||||
passesCondition: true,
|
||||
valid: true,
|
||||
rows: [
|
||||
{
|
||||
id: '1',
|
||||
lastRenderedPath: 'array.0.text',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
isLoading: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
26
test/helpers/e2e/assertElementStaysVisible.ts
Normal file
26
test/helpers/e2e/assertElementStaysVisible.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Page } from 'playwright'
|
||||
|
||||
export async function assertElementStaysVisible(
|
||||
page: Page,
|
||||
selector: string,
|
||||
durationMs: number = 3000,
|
||||
pollIntervalMs: number = 250,
|
||||
): Promise<void> {
|
||||
const start = Date.now()
|
||||
|
||||
// Ensure it appears at least once first
|
||||
await page.waitForSelector(selector, { state: 'visible' })
|
||||
|
||||
// Start polling to confirm it stays visible
|
||||
while (Date.now() - start < durationMs) {
|
||||
const isVisible = await page.isVisible(selector)
|
||||
|
||||
if (!isVisible) {
|
||||
throw new Error(`Element "${selector}" disappeared during the visibility duration.`)
|
||||
}
|
||||
|
||||
await new Promise((res) => setTimeout(res, pollIntervalMs))
|
||||
}
|
||||
|
||||
console.log(`Element "${selector}" remained visible for ${durationMs}ms.`)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { expect } from '@playwright/test'
|
||||
export const assertRequestBody = async <T>(
|
||||
page: Page,
|
||||
options: {
|
||||
action: Promise<void> | void
|
||||
action: () => Promise<void> | void
|
||||
expect?: (requestBody: T) => boolean | Promise<boolean>
|
||||
requestMethod?: string
|
||||
url: string
|
||||
@@ -34,7 +34,7 @@ export const assertRequestBody = async <T>(
|
||||
(request.method() === options.requestMethod || 'POST'),
|
||||
),
|
||||
),
|
||||
await options.action,
|
||||
await options.action(),
|
||||
])
|
||||
|
||||
const requestBody = request.postData()
|
||||
|
||||
Reference in New Issue
Block a user