Skip to content

Commit 3ea1d39

Browse files
authored
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.
1 parent b44aade commit 3ea1d39

File tree

9 files changed

+116
-43
lines changed

9 files changed

+116
-43
lines changed

packages/ui/src/fields/Text/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import { useConfig } from '../../providers/Config/index.js'
1212
import { useLocale } from '../../providers/Locale/index.js'
1313
import { mergeFieldStyles } from '../mergeFieldStyles.js'
1414
import { isFieldRTL } from '../shared/index.js'
15-
import './index.scss'
1615
import { TextInput } from './Input.js'
16+
import './index.scss'
1717

1818
export { TextInput, TextInputProps }
1919

packages/ui/src/forms/Form/index.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -334,10 +334,11 @@ export const Form: React.FC<FormProps> = (props) => {
334334
if (typeof onSuccess === 'function') {
335335
const newFormState = await onSuccess(json)
336336
if (newFormState) {
337-
const { newState: mergedFormState } = mergeServerFormState(
338-
contextRef.current.fields || {},
339-
newFormState,
340-
)
337+
const { newState: mergedFormState } = mergeServerFormState({
338+
acceptValues: true,
339+
existingState: contextRef.current.fields || {},
340+
incomingState: newFormState,
341+
})
341342

342343
dispatchFields({
343344
type: 'REPLACE_STATE',
@@ -666,10 +667,10 @@ export const Form: React.FC<FormProps> = (props) => {
666667
return
667668
}
668669

669-
const { changed, newState } = mergeServerFormState(
670-
contextRef.current.fields || {},
671-
revalidatedFormState,
672-
)
670+
const { changed, newState } = mergeServerFormState({
671+
existingState: contextRef.current.fields || {},
672+
incomingState: revalidatedFormState,
673+
})
673674

674675
if (changed) {
675676
dispatchFields({

packages/ui/src/forms/Form/mergeServerFormState.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,11 @@ import { type FormState } from 'payload'
44

55
import { mergeErrorPaths } from './mergeErrorPaths.js'
66

7-
const serverPropsToAccept = [
8-
'passesCondition',
9-
'valid',
10-
'errorMessage',
11-
'rows',
12-
'customComponents',
13-
'requiresRender',
14-
]
7+
type Args = {
8+
acceptValues?: boolean
9+
existingState: FormState
10+
incomingState: FormState
11+
}
1512

1613
/**
1714
* Merges certain properties from the server state into the client state. These do not include values,
@@ -20,10 +17,24 @@ const serverPropsToAccept = [
2017
* We want to use this to update the error state, and other properties that are not user input, as the error state
2118
* is the thing we want to keep in sync with the server (where it's calculated) on the client.
2219
*/
23-
export const mergeServerFormState = (
24-
existingState: FormState,
25-
incomingState: FormState,
26-
): { changed: boolean; newState: FormState } => {
20+
export const mergeServerFormState = ({
21+
acceptValues,
22+
existingState,
23+
incomingState,
24+
}: Args): { changed: boolean; newState: FormState } => {
25+
const serverPropsToAccept = [
26+
'passesCondition',
27+
'valid',
28+
'errorMessage',
29+
'rows',
30+
'customComponents',
31+
'requiresRender',
32+
]
33+
34+
if (acceptValues) {
35+
serverPropsToAccept.push('value')
36+
}
37+
2738
let changed = false
2839

2940
const newState = {}

packages/ui/src/forms/useField/index.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,8 @@ export const useField = <TValue,>(options: Options): FieldType<TValue> => {
3838
const { id, collectionSlug } = useDocumentInfo()
3939
const operation = useOperation()
4040

41-
const { dispatchField, field } = useFormFields(([fields, dispatch]) => ({
42-
dispatchField: dispatch,
43-
field: (fields && fields?.[path]) || null,
44-
}))
41+
const dispatchField = useFormFields(([_, dispatch]) => dispatch)
42+
const field = useFormFields(([fields]) => (fields && fields?.[path]) || null)
4543

4644
const { t } = useTranslation()
4745
const { config } = useConfig()

packages/ui/src/views/Edit/index.tsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -291,32 +291,32 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
291291
}
292292
},
293293
[
294-
adminRoute,
294+
reportUpdate,
295+
id,
296+
entitySlug,
297+
user,
295298
collectionSlug,
299+
userSlug,
300+
incrementVersionCount,
301+
updateSavedDocumentData,
302+
onSaveFromContext,
303+
isEditing,
296304
depth,
297-
docPermissions,
298-
entitySlug,
299305
getDocPermissions,
300-
getDocPreferences,
301-
getFormState,
302306
globalSlug,
303-
id,
304-
incrementVersionCount,
305-
isEditing,
306-
isLockingEnabled,
307-
locale,
308-
onSaveFromContext,
309-
operation,
307+
autosaveEnabled,
310308
refreshCookieAsync,
311-
reportUpdate,
312-
resetUploadEdits,
309+
adminRoute,
310+
locale,
313311
router,
312+
resetUploadEdits,
313+
getDocPreferences,
314+
getFormState,
315+
docPermissions,
316+
operation,
314317
schemaPathSegments,
318+
isLockingEnabled,
315319
setDocumentIsLocked,
316-
updateSavedDocumentData,
317-
user,
318-
userSlug,
319-
autosaveEnabled,
320320
],
321321
)
322322

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
export const BeforeChangeHooks: CollectionConfig = {
4+
slug: 'before-change-hooks',
5+
hooks: {
6+
beforeChange: [
7+
({ data }) => {
8+
data.title = 'hi from hook'
9+
10+
return data
11+
},
12+
],
13+
},
14+
fields: [
15+
{
16+
name: 'title',
17+
label: 'Title',
18+
type: 'text',
19+
required: true,
20+
},
21+
],
22+
}

test/hooks/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { APIError } from 'payload'
88

99
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
1010
import { AfterOperationCollection } from './collections/AfterOperation/index.js'
11+
import { BeforeChangeHooks } from './collections/BeforeChange/index.js'
1112
import { BeforeValidateCollection } from './collections/BeforeValidate/index.js'
1213
import ChainingHooks from './collections/ChainingHooks/index.js'
1314
import ContextHooks from './collections/ContextHooks/index.js'
@@ -25,6 +26,7 @@ export const HooksConfig: Promise<SanitizedConfig> = buildConfigWithDefaults({
2526
},
2627
},
2728
collections: [
29+
BeforeChangeHooks,
2830
BeforeValidateCollection,
2931
AfterOperationCollection,
3032
ContextHooks,

test/hooks/e2e.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ let payload: PayloadTestSDK<Config>
2323

2424
describe('Hooks', () => {
2525
let url: AdminUrlUtil
26+
let beforeChangeURL: AdminUrlUtil
2627
let page: Page
2728
let serverURL: string
2829

@@ -31,6 +32,7 @@ describe('Hooks', () => {
3132
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
3233

3334
url = new AdminUrlUtil(serverURL, 'before-validate')
35+
beforeChangeURL = new AdminUrlUtil(serverURL, 'before-change-hooks')
3436

3537
const context = await browser.newContext()
3638
page = await context.newPage()
@@ -58,6 +60,18 @@ describe('Hooks', () => {
5860

5961
await expect(page.locator('#field-title')).toHaveValue('reset in beforeValidate')
6062
})
63+
64+
test('should reflect changes made in beforeChange collection hooks within ui after save', async () => {
65+
await page.goto(beforeChangeURL.create)
66+
await page.locator('#field-title').fill('should replace value with before change response')
67+
await saveDocAndAssert(page)
68+
69+
await expect(page.locator('#field-title')).toHaveValue('hi from hook')
70+
await page.locator('#field-title').fill('helllooooooooo')
71+
await saveDocAndAssert(page)
72+
73+
await expect(page.locator('#field-title')).toHaveValue('hi from hook')
74+
})
6175
})
6276

6377
async function clearAllDocs(): Promise<void> {

test/hooks/payload-types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface Config {
1111
'hooks-users': HooksUserAuthOperations;
1212
};
1313
collections: {
14+
'before-change-hooks': BeforeChangeHook;
1415
'before-validate': BeforeValidate;
1516
afterOperation: AfterOperation;
1617
'context-hooks': ContextHook;
@@ -27,6 +28,7 @@ export interface Config {
2728
};
2829
collectionsJoins: {};
2930
collectionsSelect: {
31+
'before-change-hooks': BeforeChangeHooksSelect<false> | BeforeChangeHooksSelect<true>;
3032
'before-validate': BeforeValidateSelect<false> | BeforeValidateSelect<true>;
3133
afterOperation: AfterOperationSelect<false> | AfterOperationSelect<true>;
3234
'context-hooks': ContextHooksSelect<false> | ContextHooksSelect<true>;
@@ -77,6 +79,16 @@ export interface HooksUserAuthOperations {
7779
password: string;
7880
};
7981
}
82+
/**
83+
* This interface was referenced by `Config`'s JSON-Schema
84+
* via the `definition` "before-change-hooks".
85+
*/
86+
export interface BeforeChangeHook {
87+
id: string;
88+
title: string;
89+
updatedAt: string;
90+
createdAt: string;
91+
}
8092
/**
8193
* This interface was referenced by `Config`'s JSON-Schema
8294
* via the `definition` "before-validate".
@@ -231,6 +243,10 @@ export interface DataHook {
231243
export interface PayloadLockedDocument {
232244
id: string;
233245
document?:
246+
| ({
247+
relationTo: 'before-change-hooks';
248+
value: string | BeforeChangeHook;
249+
} | null)
234250
| ({
235251
relationTo: 'before-validate';
236252
value: string | BeforeValidate;
@@ -313,6 +329,15 @@ export interface PayloadMigration {
313329
updatedAt: string;
314330
createdAt: string;
315331
}
332+
/**
333+
* This interface was referenced by `Config`'s JSON-Schema
334+
* via the `definition` "before-change-hooks_select".
335+
*/
336+
export interface BeforeChangeHooksSelect<T extends boolean = true> {
337+
title?: T;
338+
updatedAt?: T;
339+
createdAt?: T;
340+
}
316341
/**
317342
* This interface was referenced by `Config`'s JSON-Schema
318343
* via the `definition` "before-validate_select".

0 commit comments

Comments
 (0)