Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions packages/ui/src/forms/Form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
import { errorMessages } from './errorMessages.js'
import { fieldReducer } from './fieldReducer.js'
import { initContextState } from './initContextState.js'
import { isOnlyErrorStateChange } from './isOnlyErrorStateChange.js'

const baseClass = 'form'

Expand Down Expand Up @@ -165,6 +166,7 @@ export const Form: React.FC<FormProps> = (props) => {
const contextRef = useRef({} as FormContextType)
const abortResetFormRef = useRef<AbortController>(null)
const isFirstRenderRef = useRef(true)
const serverErrorsJustAddedRef = useRef(false)

const fieldsReducer = useReducer(fieldReducer, {}, () => initialState)

Expand Down Expand Up @@ -402,12 +404,6 @@ export const Form: React.FC<FormProps> = (props) => {
res = await action(formData)
}

if (!modifiedWhileProcessingRef.current) {
setModified(false)
} else {
modifiedWhileProcessingRef.current = false
}

setDisabled(false)

if (typeof handleResponse === 'function') {
Expand All @@ -426,6 +422,13 @@ export const Form: React.FC<FormProps> = (props) => {
}

if (res.status < 400) {
// Only reset modified state on successful submission
if (!modifiedWhileProcessingRef.current) {
setModified(false)
} else {
modifiedWhileProcessingRef.current = false
}

if (typeof onSuccess === 'function') {
const newFormState = await onSuccess(json, {
context,
Expand Down Expand Up @@ -507,6 +510,9 @@ export const Form: React.FC<FormProps> = (props) => {
errors: fieldErrors,
})

// Prevent the immediate onChange from clearing these server errors
serverErrorsJustAddedRef.current = true

nonFieldErrors.forEach((err) => {
errorToast(<FieldErrorsToast errorMessage={err.message || t('error:unknown')} />)
})
Expand Down Expand Up @@ -849,7 +855,23 @@ export const Form: React.FC<FormProps> = (props) => {

useDebouncedEffect(
() => {
if ((isFirstRenderRef.current || !dequal(formState, prevFormState.current)) && modified) {
const formStateChanged = isFirstRenderRef.current || !dequal(formState, prevFormState.current)

// Skip onChange immediately after server errors are added to prevent clearing them
// But if the form state has changed due to user edits (not just error state), allow onChange to proceed
if (serverErrorsJustAddedRef.current && formStateChanged) {
if (isOnlyErrorStateChange(prevFormState.current, formState)) {
// Only error state changed, skip this onChange
serverErrorsJustAddedRef.current = false
prevFormState.current = formState
return
} else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like an unnecessary else block considering L865 and L870 do the same thing.

Wouldn't this simplified code work the same?

if (serverErrorsJustAddedRef.current && formStateChanged) {
  serverErrorsJustAddedRef.current = false
  if (isOnlyErrorStateChange(prevFormState.current, formState)) {
    // Only error state changed, skip this onChange
    prevFormState.current = formState
    return
  }
}

// User made actual changes, clear the flag and allow onChange
serverErrorsJustAddedRef.current = false
}
}

if (formStateChanged && modified) {
executeOnChange(submitted)
}

Expand Down
26 changes: 26 additions & 0 deletions packages/ui/src/forms/Form/isOnlyErrorStateChange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { FormState } from 'payload'

import { dequal } from 'dequal/lite'

/**
* Determines if the form state change is only due to error messages being added/removed,
* without any actual field value changes from user interaction.
*
* This is useful for detecting when server validation errors are added to the form state
* versus when a user actually modifies field values.
*/
export const isOnlyErrorStateChange = (prev: FormState, current: FormState): boolean => {
const prevWithoutErrors = Object.entries(prev).reduce((acc, [path, field]) => {
const { errorMessage, valid, ...rest } = field
acc[path] = rest
return acc
}, {} as FormState)

const currentWithoutErrors = Object.entries(current).reduce((acc, [path, field]) => {
const { errorMessage, valid, ...rest } = field
acc[path] = rest
return acc
}, {} as FormState)

return dequal(prevWithoutErrors, currentWithoutErrors)
}
30 changes: 30 additions & 0 deletions test/field-error-states/collections/Users/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { type CollectionConfig, ValidationError } from 'payload'

export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [],
hooks: {
beforeValidate: [
(data) => {
const { password } = data.data || {}
// Only validate password length on update operations (when changing password)
// Skip validation on create to allow test suite initialization with "test" password
if (data.operation === 'update' && password && password.length < 8) {
throw new ValidationError({
collection: 'users',
errors: [
{
message: 'Password must be at least 8 characters long',
path: 'password',
},
],
})
}
},
],
},
}
2 changes: 2 additions & 0 deletions test/field-error-states/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ErrorFieldsCollection } from './collections/ErrorFields/index.js'
import { PrevValue } from './collections/PrevValue/index.js'
import { PrevValueRelation } from './collections/PrevValueRelation/index.js'
import Uploads from './collections/Upload/index.js'
import { Users } from './collections/Users/index.js'
import { ValidateDraftsOff } from './collections/ValidateDraftsOff/index.js'
import { ValidateDraftsOn } from './collections/ValidateDraftsOn/index.js'
import { ValidateDraftsOnAndAutosave } from './collections/ValidateDraftsOnAutosave/index.js'
Expand All @@ -20,6 +21,7 @@ export default buildConfigWithDefaults({
},
},
collections: [
Users,
ErrorFieldsCollection,
Uploads,
ValidateDraftsOn,
Expand Down
100 changes: 97 additions & 3 deletions test/field-error-states/e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { Page } from '@playwright/test'
import type { Payload } from 'payload'

import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
import { addArrayRow, removeArrayRow } from 'helpers/e2e/fields/array/index.js'
import path from 'path'
import { formatAdminURL, wait } from 'payload/shared'
import { formatAdminURL, wait } from 'payload/shared'
import { fileURLToPath } from 'url'

import {
Expand All @@ -17,24 +18,27 @@ import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { collectionSlugs } from './shared.js'

const { beforeAll, describe } = test
const { afterAll, beforeAll, describe } = test
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

describe('Field Error States', () => {
let serverURL: string
let page: Page
let usersURL: AdminUrlUtil
let validateDraftsOff: AdminUrlUtil
let validateDraftsOn: AdminUrlUtil
let validateDraftsOnAutosave: AdminUrlUtil
let prevValue: AdminUrlUtil
let prevValueRelation: AdminUrlUtil
let errorFieldsURL: AdminUrlUtil
let adminRoute: string
let payload: Payload

beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ serverURL } = await initPayloadE2ENoConfig({ dirname }))
;({ serverURL, payload } = await initPayloadE2ENoConfig({ dirname }))
usersURL = new AdminUrlUtil(serverURL, collectionSlugs.users!)
validateDraftsOff = new AdminUrlUtil(serverURL, collectionSlugs.validateDraftsOff!)
validateDraftsOn = new AdminUrlUtil(serverURL, collectionSlugs.validateDraftsOn!)
validateDraftsOnAutosave = new AdminUrlUtil(
Expand Down Expand Up @@ -219,4 +223,94 @@ describe('Field Error States', () => {
await saveDocAndAssert(page, '#action-save')
})
})

describe('password change validation', () => {
const testUserEmail = 'test@example.com'
let testUserId: string

beforeAll(async () => {
// Clean up any existing test user from previous runs
const existingUsers = await payload.find({
collection: 'users',
where: {
email: {
equals: testUserEmail,
},
},
})

if (existingUsers.docs.length > 0 && existingUsers.docs[0]?.id) {
await payload.delete({
collection: 'users',
id: existingUsers.docs[0].id,
})
}

// Create test user
const { id } = await payload.create({
collection: 'users',
data: {
email: testUserEmail,
password: 'test',
},
})

testUserId = id
})

afterAll(async () => {
// Clean up test user
if (testUserId) {
await payload.delete({
collection: 'users',
id: testUserId,
})
}
})

test('should keep password fields visible when validation fails', async () => {
await page.goto(usersURL.edit(testUserId))

const emailField = page.locator('#field-email')
await expect(emailField).toBeVisible()
await expect(emailField).toBeEnabled()

const changePasswordButton = page.locator('#change-password')
await expect(changePasswordButton).toBeVisible()
await expect(changePasswordButton).toBeEnabled()
await changePasswordButton.click()

const passwordField = page.locator('#field-password')
const confirmPasswordField = page.locator('#field-confirm-password')

await expect(passwordField).toBeVisible()
await expect(passwordField).toBeEnabled()
await expect(confirmPasswordField).toBeVisible()
await expect(confirmPasswordField).toBeEnabled()

// Password fields not registered in form state soon enough without this wait even if visible
await wait(500)

await passwordField.fill('hello')
await confirmPasswordField.fill('hello')

await saveDocAndAssert(page, '#action-save', 'error')

const passwordError = page.locator('.field-type.password .field-error')
await expect(passwordError).toBeVisible()
await expect(passwordError).toContainText('Password must be at least 8 characters long')

// Wait for the debounced onChange to run (250ms debounce + some buffer)
// Without the fix, onChange would clear the error during this time
await wait(500)

// Verify error tooltip is still visible after onChange would have run
await expect(passwordError).toBeVisible()
await expect(passwordError).toContainText('Password must be at least 8 characters long')

await expect(passwordField).toBeVisible()
await expect(confirmPasswordField).toBeVisible()
await expect(page.locator('#cancel-change-password')).toBeVisible()
})
})
})
Loading