fix(ui): properly reflects hook changes in ui (#10268)

Fixes #9882 and #9691

In 2.0, we would accept data coming back from an update operation and
then reflect those changes in UI.

However, in 3.0, we did not do that anymore - meaning you could change a
document with hooks in `beforeChange` or `afterChange`, but then not see
the changes made on the server.

This PR updates the way that `mergeServerFormState` works, and adds a
property to optionally allow values from server form state - which can
then be used in the `onSuccess` form handler which may need to define
new field values.
This commit is contained in:
James Mikrut
2025-01-03 14:55:52 -05:00
committed by GitHub
parent b44aadee65
commit 3ea1d393fd
9 changed files with 117 additions and 44 deletions

View File

@@ -12,8 +12,8 @@ import { useConfig } from '../../providers/Config/index.js'
import { useLocale } from '../../providers/Locale/index.js' import { useLocale } from '../../providers/Locale/index.js'
import { mergeFieldStyles } from '../mergeFieldStyles.js' import { mergeFieldStyles } from '../mergeFieldStyles.js'
import { isFieldRTL } from '../shared/index.js' import { isFieldRTL } from '../shared/index.js'
import './index.scss'
import { TextInput } from './Input.js' import { TextInput } from './Input.js'
import './index.scss'
export { TextInput, TextInputProps } export { TextInput, TextInputProps }

View File

@@ -334,10 +334,11 @@ export const Form: React.FC<FormProps> = (props) => {
if (typeof onSuccess === 'function') { if (typeof onSuccess === 'function') {
const newFormState = await onSuccess(json) const newFormState = await onSuccess(json)
if (newFormState) { if (newFormState) {
const { newState: mergedFormState } = mergeServerFormState( const { newState: mergedFormState } = mergeServerFormState({
contextRef.current.fields || {}, acceptValues: true,
newFormState, existingState: contextRef.current.fields || {},
) incomingState: newFormState,
})
dispatchFields({ dispatchFields({
type: 'REPLACE_STATE', type: 'REPLACE_STATE',
@@ -666,10 +667,10 @@ export const Form: React.FC<FormProps> = (props) => {
return return
} }
const { changed, newState } = mergeServerFormState( const { changed, newState } = mergeServerFormState({
contextRef.current.fields || {}, existingState: contextRef.current.fields || {},
revalidatedFormState, incomingState: revalidatedFormState,
) })
if (changed) { if (changed) {
dispatchFields({ dispatchFields({

View File

@@ -4,14 +4,11 @@ import { type FormState } from 'payload'
import { mergeErrorPaths } from './mergeErrorPaths.js' import { mergeErrorPaths } from './mergeErrorPaths.js'
const serverPropsToAccept = [ type Args = {
'passesCondition', acceptValues?: boolean
'valid', existingState: FormState
'errorMessage', incomingState: FormState
'rows', }
'customComponents',
'requiresRender',
]
/** /**
* Merges certain properties from the server state into the client state. These do not include values, * Merges certain properties from the server state into the client state. These do not include values,
@@ -20,10 +17,24 @@ const serverPropsToAccept = [
* We want to use this to update the error state, and other properties that are not user input, as the error state * We want to use this to update the error state, and other properties that are not user input, as the error state
* is the thing we want to keep in sync with the server (where it's calculated) on the client. * is the thing we want to keep in sync with the server (where it's calculated) on the client.
*/ */
export const mergeServerFormState = ( export const mergeServerFormState = ({
existingState: FormState, acceptValues,
incomingState: FormState, existingState,
): { changed: boolean; newState: FormState } => { incomingState,
}: Args): { changed: boolean; newState: FormState } => {
const serverPropsToAccept = [
'passesCondition',
'valid',
'errorMessage',
'rows',
'customComponents',
'requiresRender',
]
if (acceptValues) {
serverPropsToAccept.push('value')
}
let changed = false let changed = false
const newState = {} const newState = {}

View File

@@ -38,10 +38,8 @@ export const useField = <TValue,>(options: Options): FieldType<TValue> => {
const { id, collectionSlug } = useDocumentInfo() const { id, collectionSlug } = useDocumentInfo()
const operation = useOperation() const operation = useOperation()
const { dispatchField, field } = useFormFields(([fields, dispatch]) => ({ const dispatchField = useFormFields(([_, dispatch]) => dispatch)
dispatchField: dispatch, const field = useFormFields(([fields]) => (fields && fields?.[path]) || null)
field: (fields && fields?.[path]) || null,
}))
const { t } = useTranslation() const { t } = useTranslation()
const { config } = useConfig() const { config } = useConfig()

View File

@@ -291,32 +291,32 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
} }
}, },
[ [
adminRoute, reportUpdate,
collectionSlug, id,
depth,
docPermissions,
entitySlug, entitySlug,
user,
collectionSlug,
userSlug,
incrementVersionCount,
updateSavedDocumentData,
onSaveFromContext,
isEditing,
depth,
getDocPermissions, getDocPermissions,
globalSlug,
autosaveEnabled,
refreshCookieAsync,
adminRoute,
locale,
router,
resetUploadEdits,
getDocPreferences, getDocPreferences,
getFormState, getFormState,
globalSlug, docPermissions,
id,
incrementVersionCount,
isEditing,
isLockingEnabled,
locale,
onSaveFromContext,
operation, operation,
refreshCookieAsync,
reportUpdate,
resetUploadEdits,
router,
schemaPathSegments, schemaPathSegments,
isLockingEnabled,
setDocumentIsLocked, setDocumentIsLocked,
updateSavedDocumentData,
user,
userSlug,
autosaveEnabled,
], ],
) )

View File

@@ -0,0 +1,22 @@
import type { CollectionConfig } from 'payload'
export const BeforeChangeHooks: CollectionConfig = {
slug: 'before-change-hooks',
hooks: {
beforeChange: [
({ data }) => {
data.title = 'hi from hook'
return data
},
],
},
fields: [
{
name: 'title',
label: 'Title',
type: 'text',
required: true,
},
],
}

View File

@@ -8,6 +8,7 @@ import { APIError } from 'payload'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { AfterOperationCollection } from './collections/AfterOperation/index.js' import { AfterOperationCollection } from './collections/AfterOperation/index.js'
import { BeforeChangeHooks } from './collections/BeforeChange/index.js'
import { BeforeValidateCollection } from './collections/BeforeValidate/index.js' import { BeforeValidateCollection } from './collections/BeforeValidate/index.js'
import ChainingHooks from './collections/ChainingHooks/index.js' import ChainingHooks from './collections/ChainingHooks/index.js'
import ContextHooks from './collections/ContextHooks/index.js' import ContextHooks from './collections/ContextHooks/index.js'
@@ -25,6 +26,7 @@ export const HooksConfig: Promise<SanitizedConfig> = buildConfigWithDefaults({
}, },
}, },
collections: [ collections: [
BeforeChangeHooks,
BeforeValidateCollection, BeforeValidateCollection,
AfterOperationCollection, AfterOperationCollection,
ContextHooks, ContextHooks,

View File

@@ -23,6 +23,7 @@ let payload: PayloadTestSDK<Config>
describe('Hooks', () => { describe('Hooks', () => {
let url: AdminUrlUtil let url: AdminUrlUtil
let beforeChangeURL: AdminUrlUtil
let page: Page let page: Page
let serverURL: string let serverURL: string
@@ -31,6 +32,7 @@ describe('Hooks', () => {
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname })) ;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
url = new AdminUrlUtil(serverURL, 'before-validate') url = new AdminUrlUtil(serverURL, 'before-validate')
beforeChangeURL = new AdminUrlUtil(serverURL, 'before-change-hooks')
const context = await browser.newContext() const context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
@@ -58,6 +60,18 @@ describe('Hooks', () => {
await expect(page.locator('#field-title')).toHaveValue('reset in beforeValidate') await expect(page.locator('#field-title')).toHaveValue('reset in beforeValidate')
}) })
test('should reflect changes made in beforeChange collection hooks within ui after save', async () => {
await page.goto(beforeChangeURL.create)
await page.locator('#field-title').fill('should replace value with before change response')
await saveDocAndAssert(page)
await expect(page.locator('#field-title')).toHaveValue('hi from hook')
await page.locator('#field-title').fill('helllooooooooo')
await saveDocAndAssert(page)
await expect(page.locator('#field-title')).toHaveValue('hi from hook')
})
}) })
async function clearAllDocs(): Promise<void> { async function clearAllDocs(): Promise<void> {

View File

@@ -11,6 +11,7 @@ export interface Config {
'hooks-users': HooksUserAuthOperations; 'hooks-users': HooksUserAuthOperations;
}; };
collections: { collections: {
'before-change-hooks': BeforeChangeHook;
'before-validate': BeforeValidate; 'before-validate': BeforeValidate;
afterOperation: AfterOperation; afterOperation: AfterOperation;
'context-hooks': ContextHook; 'context-hooks': ContextHook;
@@ -27,6 +28,7 @@ export interface Config {
}; };
collectionsJoins: {}; collectionsJoins: {};
collectionsSelect: { collectionsSelect: {
'before-change-hooks': BeforeChangeHooksSelect<false> | BeforeChangeHooksSelect<true>;
'before-validate': BeforeValidateSelect<false> | BeforeValidateSelect<true>; 'before-validate': BeforeValidateSelect<false> | BeforeValidateSelect<true>;
afterOperation: AfterOperationSelect<false> | AfterOperationSelect<true>; afterOperation: AfterOperationSelect<false> | AfterOperationSelect<true>;
'context-hooks': ContextHooksSelect<false> | ContextHooksSelect<true>; 'context-hooks': ContextHooksSelect<false> | ContextHooksSelect<true>;
@@ -77,6 +79,16 @@ export interface HooksUserAuthOperations {
password: string; password: string;
}; };
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "before-change-hooks".
*/
export interface BeforeChangeHook {
id: string;
title: string;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "before-validate". * via the `definition` "before-validate".
@@ -231,6 +243,10 @@ export interface DataHook {
export interface PayloadLockedDocument { export interface PayloadLockedDocument {
id: string; id: string;
document?: document?:
| ({
relationTo: 'before-change-hooks';
value: string | BeforeChangeHook;
} | null)
| ({ | ({
relationTo: 'before-validate'; relationTo: 'before-validate';
value: string | BeforeValidate; value: string | BeforeValidate;
@@ -313,6 +329,15 @@ export interface PayloadMigration {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "before-change-hooks_select".
*/
export interface BeforeChangeHooksSelect<T extends boolean = true> {
title?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "before-validate_select". * via the `definition` "before-validate_select".