Skip to content

Commit 42ae441

Browse files
jorgemoyajamesqquick
authored andcommitted
refactor: decouple fields from state in DynamicForms (#2823)
* refactor: decouple fields from state in DynamicForms * fix: remove options from fields * fix: pass passwordComplexity as an arg * fix: remove fields from return state in contact form * chore: add changeset
1 parent 408f4d7 commit 42ae441

11 files changed

Lines changed: 237 additions & 72 deletions

File tree

.changeset/quiet-heads-heal.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
---
2+
"@bigcommerce/catalyst-core": patch
3+
---
4+
5+
Refactor DynamicForm actions to decouple fields and passwordComplexity from state, passing them as separate arguments instead. This reduces state payload size by removing fields from state objects and stripping options from fields before passing them to actions (options are only needed for rendering, not processing). All form actions now accept a `DynamicFormActionArgs` object as the first parameter containing fields and optional passwordComplexity, followed by the previous state and formData.
6+
7+
## Migration steps
8+
9+
### Step 1: Changes to DynamicForm component
10+
11+
The `DynamicForm` component and related utilities have been updated to support the new action signature pattern:
12+
13+
**`core/vibes/soul/form/dynamic-form/index.tsx`**:
14+
- Added `DynamicFormActionArgs<F>` interface that contains `fields` and optional `passwordComplexity`
15+
- Updated `DynamicFormAction<F>` type to accept `DynamicFormActionArgs<F>` as the first parameter
16+
- Removed `fields` and `passwordComplexity` from the `State` interface
17+
- Added automatic removal of `options` from fields before passing to actions (options are only needed for rendering)
18+
- Updated action binding to use `action.bind(null, { fields: fieldsWithoutOptions, passwordComplexity })`
19+
20+
**`core/vibes/soul/form/dynamic-form/utils.ts`** (new file):
21+
- Added `removeOptionsFromFields()` utility function that strips the `options` property from field definitions before passing them to actions, reducing the state payload size
22+
23+
```diff
24+
+ export interface DynamicFormActionArgs<F extends Field> {
25+
+ fields: Array<F | FieldGroup<F>>;
26+
+ passwordComplexity?: PasswordComplexitySettings | null;
27+
+ }
28+
+
29+
+ type Action<F extends Field, S, P> = (
30+
+ args: DynamicFormActionArgs<F>,
31+
+ state: Awaited<S>,
32+
+ payload: P,
33+
+ ) => S | Promise<S>;
34+
+
35+
interface State {
36+
lastResult: SubmissionResult | null;
37+
- fields: Array<F | FieldGroup<F>>;
38+
- passwordComplexity?: PasswordComplexitySettings | null;
39+
}
40+
```
41+
42+
### Step 2: Update DynamicForm action signatures
43+
44+
All form actions that use `DynamicForm` must be updated to accept `DynamicFormActionArgs<F>` as the first parameter instead of including fields in the state.
45+
46+
Update your form action function signature:
47+
48+
```diff
49+
+ import { DynamicFormActionArgs } from '@/vibes/soul/form/dynamic-form';
50+
import { Field, FieldGroup, schema } from '@/vibes/soul/form/dynamic-form/schema';
51+
52+
- export async function myFormAction<F extends Field>(
53+
- prevState: {
54+
- lastResult: SubmissionResult | null;
55+
- fields: Array<F | FieldGroup<F>>;
56+
- passwordComplexity?: PasswordComplexitySettings | null;
57+
- },
58+
- formData: FormData,
59+
- ) {
60+
+ export async function myFormAction<F extends Field>(
61+
+ { fields, passwordComplexity }: DynamicFormActionArgs<F>,
62+
+ _prevState: {
63+
+ lastResult: SubmissionResult | null;
64+
+ },
65+
+ formData: FormData,
66+
+ ) {
67+
```
68+
69+
### Step 2: Remove fields and passwordComplexity from state interfaces
70+
71+
Update state interfaces to remove fields and passwordComplexity properties:
72+
73+
```diff
74+
interface State {
75+
lastResult: SubmissionResult | null;
76+
- fields: Array<Field | FieldGroup<Field>>;
77+
- passwordComplexity?: PasswordComplexitySettings | null;
78+
}
79+
```
80+
81+
### Step 3: Update action implementations
82+
83+
Remove references to `prevState.fields` and `prevState.passwordComplexity` in action implementations:
84+
85+
```diff
86+
const submission = parseWithZod(formData, {
87+
- schema: schema(prevState.fields, prevState.passwordComplexity),
88+
+ schema: schema(fields, passwordComplexity),
89+
});
90+
91+
if (submission.status !== 'success') {
92+
return {
93+
lastResult: submission.reply(),
94+
- fields: prevState.fields,
95+
- passwordComplexity: prevState.passwordComplexity,
96+
};
97+
}
98+
```
99+
100+
### Step 4: Update action calls in components
101+
102+
For actions used with `AddressListSection`, update the action signature to accept fields as the first parameter:
103+
104+
```diff
105+
- export async function addressAction(
106+
- prevState: Awaited<State>,
107+
- formData: FormData,
108+
- ): Promise<State> {
109+
+ export async function addressAction(
110+
+ fields: Array<Field | FieldGroup<Field>>,
111+
+ prevState: Awaited<State>,
112+
+ formData: FormData,
113+
+ ): Promise<State> {
114+
```
115+
116+
### Step 5: Update DynamicForm usage
117+
118+
No changes needed to `DynamicForm` component usage. The component automatically handles binding fields and passwordComplexity to actions. The `DynamicForm` component now:
119+
- Automatically removes options from fields before passing them to actions (reducing payload size)
120+
- Binds fields and passwordComplexity to the action using `action.bind()`
121+
- Maintains the same props interface, so existing usage continues to work
122+
123+
### Affected files
124+
125+
The following files were updated in this refactor:
126+
- `core/vibes/soul/form/dynamic-form/index.tsx` - Added `DynamicFormActionArgs` type and updated action binding
127+
- `core/vibes/soul/form/dynamic-form/utils.ts` - Added `removeOptionsFromFields` utility function
128+
- `core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts`
129+
- `core/app/[locale]/(default)/account/addresses/_actions/address-action.ts`
130+
- `core/app/[locale]/(default)/account/addresses/_actions/create-address.ts`
131+
- `core/app/[locale]/(default)/account/addresses/_actions/update-address.ts`
132+
- `core/app/[locale]/(default)/account/addresses/_actions/delete-address.ts`
133+
- `core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx`
134+
- `core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts`
135+
- `core/vibes/soul/sections/address-list-section/index.tsx`

core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { parseWithZod } from '@conform-to/zod';
66
import { getLocale, getTranslations } from 'next-intl/server';
77
import { z } from 'zod';
88

9+
import { DynamicFormActionArgs } from '@/vibes/soul/form/dynamic-form';
910
import { Field, FieldGroup, schema } from '@/vibes/soul/form/dynamic-form/schema';
1011
import { signIn } from '~/auth';
1112
import { client } from '~/client';
@@ -335,10 +336,9 @@ function parseRegisterCustomerInput(
335336
}
336337

337338
export async function registerCustomer<F extends Field>(
338-
prevState: {
339+
{ fields, passwordComplexity }: DynamicFormActionArgs<F>,
340+
_prevState: {
339341
lastResult: SubmissionResult | null;
340-
fields: Array<F | FieldGroup<F>>;
341-
passwordComplexity?: Parameters<typeof schema>[1];
342342
},
343343
formData: FormData,
344344
) {
@@ -347,19 +347,17 @@ export async function registerCustomer<F extends Field>(
347347
const cartId = await getCartId();
348348

349349
const submission = parseWithZod(formData, {
350-
schema: schema(prevState.fields, prevState.passwordComplexity),
350+
schema: schema(fields, passwordComplexity),
351351
});
352352

353353
if (submission.status !== 'success') {
354354
return {
355355
lastResult: submission.reply(),
356-
fields: prevState.fields,
357-
passwordComplexity: prevState.passwordComplexity,
358356
};
359357
}
360358

361359
try {
362-
const input = parseRegisterCustomerInput(submission.value, prevState.fields);
360+
const input = parseRegisterCustomerInput(submission.value, fields);
363361
const response = await client.fetch({
364362
document: RegisterCustomerMutation,
365363
variables: {
@@ -375,8 +373,6 @@ export async function registerCustomer<F extends Field>(
375373
lastResult: submission.reply({
376374
formErrors: response.data.customer.registerCustomer.errors.map((error) => error.message),
377375
}),
378-
fields: prevState.fields,
379-
passwordComplexity: prevState.passwordComplexity,
380376
};
381377
}
382378

@@ -397,23 +393,17 @@ export async function registerCustomer<F extends Field>(
397393
lastResult: submission.reply({
398394
formErrors: error.errors.map(({ message }) => message),
399395
}),
400-
fields: prevState.fields,
401-
passwordComplexity: prevState.passwordComplexity,
402396
};
403397
}
404398

405399
if (error instanceof Error) {
406400
return {
407401
lastResult: submission.reply({ formErrors: [error.message] }),
408-
fields: prevState.fields,
409-
passwordComplexity: prevState.passwordComplexity,
410402
};
411403
}
412404

413405
return {
414406
lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }),
415-
fields: prevState.fields,
416-
passwordComplexity: prevState.passwordComplexity,
417407
};
418408
}
419409

core/app/[locale]/(default)/account/addresses/_actions/address-action.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,24 @@ export interface State {
1111
addresses: Address[];
1212
lastResult: SubmissionResult | null;
1313
defaultAddress?: DefaultAddressConfiguration;
14-
fields: Array<Field | FieldGroup<Field>>;
1514
}
1615

17-
export async function addressAction(prevState: Awaited<State>, formData: FormData): Promise<State> {
16+
export async function addressAction(
17+
fields: Array<Field | FieldGroup<Field>>,
18+
prevState: Awaited<State>,
19+
formData: FormData,
20+
): Promise<State> {
1821
'use server';
1922

2023
const intent = formData.get('intent');
2124

2225
switch (intent) {
2326
case 'create': {
24-
return await createAddress(prevState, formData);
27+
return await createAddress(fields, prevState, formData);
2528
}
2629

2730
case 'update': {
28-
return await updateAddress(prevState, formData);
31+
return await updateAddress(fields, prevState, formData);
2932
}
3033

3134
case 'delete': {

core/app/[locale]/(default)/account/addresses/_actions/create-address.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,11 @@ function parseAddAddressInput(
196196
return inputSchema.parse(mappedInput);
197197
}
198198

199-
export async function createAddress(prevState: Awaited<State>, formData: FormData): Promise<State> {
199+
export async function createAddress(
200+
fields: Array<Field | FieldGroup<Field>>,
201+
prevState: Awaited<State>,
202+
formData: FormData,
203+
): Promise<State> {
200204
const t = await getTranslations('Account.Addresses');
201205
const customerAccessToken = await getSessionCustomerAccessToken();
202206

@@ -210,7 +214,7 @@ export async function createAddress(prevState: Awaited<State>, formData: FormDat
210214
}
211215

212216
try {
213-
const input = parseAddAddressInput(submission.value, prevState.fields);
217+
const input = parseAddAddressInput(submission.value, fields);
214218

215219
const response = await client.fetch({
216220
document: AddCustomerAddressMutation,
@@ -242,7 +246,6 @@ export async function createAddress(prevState: Awaited<State>, formData: FormDat
242246
],
243247
lastResult: submission.reply({ resetForm: true }),
244248
defaultAddress: prevState.defaultAddress,
245-
fields: prevState.fields,
246249
};
247250
} catch (error) {
248251
// eslint-disable-next-line no-console

core/app/[locale]/(default)/account/addresses/_actions/delete-address.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ export async function deleteAddress(prevState: Awaited<State>, formData: FormDat
8686
),
8787
lastResult: submission.reply({ resetForm: true }),
8888
defaultAddress: prevState.defaultAddress,
89-
fields: prevState.fields,
9089
};
9190
} catch (error) {
9291
// eslint-disable-next-line no-console

core/app/[locale]/(default)/account/addresses/_actions/update-address.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,11 @@ function parseUpdateAddressInput(
209209
return inputSchema.parse(mappedInput);
210210
}
211211

212-
export async function updateAddress(prevState: Awaited<State>, formData: FormData): Promise<State> {
212+
export async function updateAddress(
213+
fields: Array<Field | FieldGroup<Field>>,
214+
prevState: Awaited<State>,
215+
formData: FormData,
216+
): Promise<State> {
213217
const t = await getTranslations('Account.Addresses');
214218
const customerAccessToken = await getSessionCustomerAccessToken();
215219

@@ -223,7 +227,7 @@ export async function updateAddress(prevState: Awaited<State>, formData: FormDat
223227
}
224228

225229
try {
226-
const input = parseUpdateAddressInput(submission.value, prevState.fields);
230+
const input = parseUpdateAddressInput(submission.value, fields);
227231

228232
const response = await client.fetch({
229233
document: UpdateCustomerAddressMutation,
@@ -251,7 +255,6 @@ export async function updateAddress(prevState: Awaited<State>, formData: FormDat
251255
),
252256
lastResult: submission.reply({ resetForm: true }),
253257
defaultAddress: prevState.defaultAddress,
254-
fields: prevState.fields,
255258
};
256259
} catch (error) {
257260
// eslint-disable-next-line no-console

core/app/[locale]/(default)/gift-certificates/purchase/_actions/add-to-cart.tsx

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { getFormatter, getTranslations } from 'next-intl/server';
77
import { ReactNode } from 'react';
88
import { z } from 'zod';
99

10-
import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema';
10+
import { DynamicFormActionArgs } from '@/vibes/soul/form/dynamic-form';
11+
import { Field } from '@/vibes/soul/form/dynamic-form/schema';
1112
import { client } from '~/client';
1213
import { graphql, ResultOf } from '~/client/graphql';
1314
import { ExistingResultType } from '~/client/util';
@@ -19,7 +20,6 @@ import { getPreferredCurrencyCode } from '~/lib/currency';
1920
import { GiftCertificateSettingsFragment } from '../fragment';
2021

2122
interface State {
22-
fields: Array<Field | FieldGroup<Field>>;
2323
lastResult: SubmissionResult | null;
2424
successMessage?: ReactNode;
2525
}
@@ -100,8 +100,9 @@ const schema = (
100100
});
101101
};
102102

103-
export async function addGiftCertificateToCart(
104-
prevState: State,
103+
export async function addGiftCertificateToCart<F extends Field>(
104+
_args: DynamicFormActionArgs<F>,
105+
_prevState: State,
105106
formData: FormData,
106107
): Promise<State> {
107108
const t = await getTranslations('GiftCertificates.Purchase');
@@ -117,7 +118,7 @@ export async function addGiftCertificateToCart(
117118
});
118119

119120
if (submission.status !== 'success') {
120-
return { lastResult: submission.reply(), fields: prevState.fields };
121+
return { lastResult: submission.reply() };
121122
}
122123

123124
const amountFormatted = format.number(submission.value.amount, {
@@ -148,7 +149,6 @@ export async function addGiftCertificateToCart(
148149

149150
return {
150151
lastResult: submission.reply(),
151-
fields: prevState.fields,
152152
successMessage: t.rich('successMessage', {
153153
cartLink: (chunks) => (
154154
<Link className="underline" href="/cart" prefetch="viewport" prefetchKind="full">
@@ -168,27 +168,23 @@ export async function addGiftCertificateToCart(
168168
return message;
169169
}),
170170
}),
171-
fields: prevState.fields,
172171
};
173172
}
174173

175174
if (error instanceof MissingCartError) {
176175
return {
177176
lastResult: submission.reply({ formErrors: [t('missingCart')] }),
178-
fields: prevState.fields,
179177
};
180178
}
181179

182180
if (error instanceof Error) {
183181
return {
184182
lastResult: submission.reply({ formErrors: [error.message] }),
185-
fields: prevState.fields,
186183
};
187184
}
188185

189186
return {
190187
lastResult: submission.reply({ formErrors: [t('unknownError')] }),
191-
fields: prevState.fields,
192188
};
193189
}
194190
}

0 commit comments

Comments
 (0)