Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/account-settings-custom-fields-3074.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": patch
---

Fix Account Settings failing to save for customers when a merchant-defined required custom customer field exists. BigCommerce revalidates required custom fields on every `updateCustomer` call, so Account Settings now renders and resubmits those fields (mirroring the existing Register/Address form-field support) instead of only supporting first name, last name, email, and company.
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { parseWithZod } from '@conform-to/zod';
import { revalidateTag } from 'next/cache';
import { getTranslations } from 'next-intl/server';

import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema';
import { updateAccountSchema } from '@/vibes/soul/sections/account-settings/schema';
import { UpdateAccountAction } from '@/vibes/soul/sections/account-settings/update-account-form';
import { State } from '@/vibes/soul/sections/account-settings/update-account-form';
import { getSessionCustomerAccessToken } from '~/auth';
import { client } from '~/client';
import { graphql } from '~/client/graphql';
import { graphql, VariablesOf } from '~/client/graphql';
import { TAGS } from '~/client/tags';

const UpdateCustomerMutation = graphql(`
Expand All @@ -19,6 +20,8 @@ const UpdateCustomerMutation = graphql(`
customer {
firstName
lastName
email
company
}
errors {
__typename
Expand All @@ -43,11 +46,81 @@ const UpdateCustomerMutation = graphql(`
}
`);

export const updateCustomer: UpdateAccountAction = async (prevState, formData) => {
type FormFieldsInput = NonNullable<
VariablesOf<typeof UpdateCustomerMutation>['input']['formFields']
>;

/*
* BigCommerce re-validates every required custom customer field on every updateCustomer call,
* even ones already satisfied by a prior save (see issue #3074) - so any required custom field
* rendered in the form must always be resent here, not just the ones the user actually changed.
*/
function buildFormFieldsInput(
fields: Array<Field | FieldGroup<Field>>,
value: Record<string, unknown>,
): FormFieldsInput {
const flatFields = fields.flatMap((field) => (Array.isArray(field) ? field : [field]));

return {
checkboxes: flatFields
.filter((field) => field.type === 'checkbox-group')
.filter((field) => Boolean(value[field.name]))
.map((field) => {
const rawValue = value[field.name];
const rawValues = Array.isArray(rawValue) ? rawValue : [rawValue];

return {
fieldEntityId: Number(field.id),
fieldValueEntityIds: rawValues.map(Number),
};
}),
multipleChoices: flatFields
.filter((field) => ['radio-group', 'button-radio-group', 'select'].includes(field.type))
.filter((field) => Boolean(value[field.name]))
.map((field) => ({
fieldEntityId: Number(field.id),
fieldValueEntityId: Number(value[field.name]),
})),
numbers: flatFields
.filter((field) => field.type === 'number')
.filter((field) => Boolean(value[field.name]))
.map((field) => ({
fieldEntityId: Number(field.id),
number: Number(value[field.name]),
})),
dates: flatFields
.filter((field) => field.type === 'date')
.filter((field) => Boolean(value[field.name]))
.map((field) => ({
fieldEntityId: Number(field.id),
date: new Date(String(value[field.name])).toISOString(),
})),
multilineTexts: flatFields
.filter((field) => field.type === 'textarea')
.filter((field) => Boolean(value[field.name]))
.map((field) => ({
fieldEntityId: Number(field.id),
multilineText: String(value[field.name]),
})),
texts: flatFields
.filter((field) => field.type === 'text' || field.type === 'email')
.filter((field) => Boolean(value[field.name]))
.map((field) => ({
fieldEntityId: Number(field.id),
text: String(value[field.name]),
})),
};
}

export async function updateCustomer(
{ fields }: { fields: Array<Field | FieldGroup<Field>> },
prevState: Awaited<State>,
formData: FormData,
): Promise<State> {
const t = await getTranslations('Account.Settings');
const customerAccessToken = await getSessionCustomerAccessToken();

const submission = parseWithZod(formData, { schema: updateAccountSchema });
const submission = parseWithZod(formData, { schema: updateAccountSchema(fields) });

if (submission.status !== 'success') {
return {
Expand All @@ -56,12 +129,20 @@ export const updateCustomer: UpdateAccountAction = async (prevState, formData) =
};
}

const { firstName, lastName, email, company, ...customFieldValues } = submission.value;

try {
const response = await client.fetch({
document: UpdateCustomerMutation,
customerAccessToken,
variables: {
input: submission.value,
input: {
firstName,
lastName,
email,
company,
formFields: buildFormFieldsInput(fields, customFieldValues),
},
},
fetchOptions: { cache: 'no-store' },
});
Expand Down Expand Up @@ -107,4 +188,4 @@ export const updateCustomer: UpdateAccountAction = async (prevState, formData) =
lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }),
};
}
};
}
11 changes: 9 additions & 2 deletions core/app/[locale]/(default)/account/settings/page-data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { getSessionCustomerAccessToken } from '~/auth';
import { client } from '~/client';
import { graphql, VariablesOf } from '~/client/graphql';
import { TAGS } from '~/client/tags';
import { FormFieldsFragment } from '~/data-transformers/form-field-transformer/fragment';
import {
FormFieldsFragment,
FormFieldValuesFragment,
} from '~/data-transformers/form-field-transformer/fragment';

const AccountSettingsQuery = graphql(
`
Expand All @@ -21,6 +24,9 @@ const AccountSettingsQuery = graphql(
lastName
company
isSubscribedToNewsletter
formFields {
...FormFieldValuesFragment
}
}
site {
settings {
Expand Down Expand Up @@ -50,7 +56,7 @@ const AccountSettingsQuery = graphql(
}
}
`,
[FormFieldsFragment],
[FormFieldsFragment, FormFieldValuesFragment],
);

type Variables = VariablesOf<typeof AccountSettingsQuery>;
Expand Down Expand Up @@ -96,6 +102,7 @@ export const getAccountSettingsQuery = cache(async ({ address, customer }: Props
return {
addressFields,
customerFields,
customerFormFieldValues: customerInfo.formFields,
customerInfo,
newsletterSettings,
passwordComplexitySettings,
Expand Down
54 changes: 53 additions & 1 deletion core/app/[locale]/(default)/account/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,49 @@ import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getTranslations, setRequestLocale } from 'next-intl/server';

import { Field } from '@/vibes/soul/form/dynamic-form/schema';
import { AccountSettingsSection } from '@/vibes/soul/sections/account-settings';
import { formFieldTransformer } from '~/data-transformers/form-field-transformer';
import {
ACCOUNT_SETTINGS_FIELDS_TO_EXCLUDE,
mapFormFieldValueToName,
} from '~/data-transformers/form-field-transformer/utils';
import { exists } from '~/lib/utils';

import { changePassword } from './_actions/change-password';
import { updateCustomer } from './_actions/update-customer';
import { updateNewsletterSubscription } from './_actions/update-newsletter-subscription';
import { getAccountSettingsQuery } from './page-data';

function withDefaultValue(field: Field, value: unknown): Field {
if (field.type === 'checkbox-group') {
return { ...field, defaultValue: Array.isArray(value) ? value.map(String) : undefined };
}

if (typeof value !== 'string') {
return field;
}

switch (field.type) {
case 'radio-group':
case 'select':
case 'swatch-radio-group':
case 'card-radio-group':
case 'button-radio-group':
case 'checkbox':
case 'number':
case 'text':
case 'textarea':
case 'date':
case 'email':
case 'hidden':
return { ...field, defaultValue: value };

default:
return field;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just checking, do we want default to return the field?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah we do as we need to map the form submission with some field and we need the field regardless of the default value.

}
}

interface Props {
params: Promise<{ locale: string }>;
}
Expand Down Expand Up @@ -47,6 +83,21 @@ export default async function Settings({ params }: Props) {
},
);

const customFieldValues = accountSettings.customerFormFieldValues.reduce<Record<string, unknown>>(
(acc, field) => ({ ...acc, ...mapFormFieldValueToName(field) }),
{},
);

const updateAccountCustomFields = accountSettings.customerFields
.filter((field) => !ACCOUNT_SETTINGS_FIELDS_TO_EXCLUDE.includes(field.entityId))
.map(formFieldTransformer)
.filter(exists)
.map((field) => withDefaultValue(field, customFieldValues[field.name]));

const updateCustomerWithFields = updateCustomer.bind(null, {
fields: updateAccountCustomFields,
});

return (
<AccountSettingsSection
account={accountSettings.customerInfo}
Expand All @@ -63,7 +114,8 @@ export default async function Settings({ params }: Props) {
newsletterSubscriptionTitle={t('NewsletterSubscription.title')}
passwordComplexitySettings={accountSettings.passwordComplexitySettings}
title={t('title')}
updateAccountAction={updateCustomer}
updateAccountAction={updateCustomerWithFields}
updateAccountCustomFields={updateAccountCustomFields}
updateAccountSubmitLabel={t('cta')}
updateNewsletterSubscriptionAction={updateNewsletterSubscriptionActionWithCustomerInfo}
/>
Expand Down
14 changes: 14 additions & 0 deletions core/data-transformers/form-field-transformer/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ export enum FieldNameToFieldId {

export const CUSTOMER_FIELDS_TO_EXCLUDE = [FieldNameToFieldId.currentPassword];

/* Account Settings only manages the fields below directly; anything else returned by
site.settings.formFields.customer is a genuinely merchant-defined custom field. */
export const ACCOUNT_SETTINGS_FIELDS_TO_EXCLUDE = [
FieldNameToFieldId.email,
FieldNameToFieldId.password,
FieldNameToFieldId.confirmPassword,
FieldNameToFieldId.currentPassword,
FieldNameToFieldId.firstName,
FieldNameToFieldId.lastName,
FieldNameToFieldId.company,
FieldNameToFieldId.phone,
FieldNameToFieldId.exclusiveOffers,
];

export const REGISTER_CUSTOMER_FORM_LAYOUT = [
[FieldNameToFieldId.firstName, FieldNameToFieldId.lastName],
FieldNameToFieldId.email,
Expand Down
2 changes: 1 addition & 1 deletion core/vibes/soul/form/dynamic-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ function SubmitButton({
);
}

function DynamicFormField({
export function DynamicFormField({
field,
formField,
}: {
Expand Down
25 changes: 15 additions & 10 deletions core/vibes/soul/form/dynamic-form/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,31 +328,36 @@ function getFieldSchema(
return fieldSchema;
}

export function schema(
export function getFieldsShape(
fields: Array<Field | FieldGroup<Field>>,
passwordComplexity?: PasswordComplexitySettings | null,
errorTranslations?: FormErrorTranslationMap,
) {
): SchemaRawShape {
const shape: SchemaRawShape = {};
let passwordFieldName: string | undefined;
let confirmPasswordFieldName: string | undefined;

fields.forEach((field) => {
if (Array.isArray(field)) {
field.forEach((f) => {
shape[f.name] = getFieldSchema(f, passwordComplexity, errorTranslations);

if (f.type === 'password') passwordFieldName = f.name;
if (f.type === 'confirm-password') confirmPasswordFieldName = f.name;
});
} else {
shape[field.name] = getFieldSchema(field, passwordComplexity, errorTranslations);

if (field.type === 'password') passwordFieldName = field.name;
if (field.type === 'confirm-password') confirmPasswordFieldName = field.name;
}
});

return shape;
}

export function schema(
fields: Array<Field | FieldGroup<Field>>,
passwordComplexity?: PasswordComplexitySettings | null,
errorTranslations?: FormErrorTranslationMap,
) {
const shape = getFieldsShape(fields, passwordComplexity, errorTranslations);
const flatFields = fields.flatMap((field) => (Array.isArray(field) ? field : [field]));
const passwordFieldName = flatFields.find((f) => f.type === 'password')?.name;
const confirmPasswordFieldName = flatFields.find((f) => f.type === 'confirm-password')?.name;

return z.object(shape).superRefine((data, ctx) => {
if (
passwordFieldName != null &&
Expand Down
9 changes: 8 additions & 1 deletion core/vibes/soul/sections/account-settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { PasswordComplexitySettings } from '@/vibes/soul/form/dynamic-form/schema';
import {
Field,
FieldGroup,
PasswordComplexitySettings,
} from '@/vibes/soul/form/dynamic-form/schema';

import { ChangePasswordAction, ChangePasswordForm } from './change-password-form';
import {
Expand All @@ -11,6 +15,7 @@ export interface AccountSettingsSectionProps {
title?: string;
account: Account;
updateAccountAction: UpdateAccountAction;
updateAccountCustomFields?: Array<Field | FieldGroup<Field>>;
updateAccountSubmitLabel?: string;
changePasswordTitle?: string;
changePasswordAction: ChangePasswordAction;
Expand Down Expand Up @@ -45,6 +50,7 @@ export function AccountSettingsSection({
title = 'Account Settings',
account,
updateAccountAction,
updateAccountCustomFields,
updateAccountSubmitLabel,
changePasswordTitle = 'Change Password',
changePasswordAction,
Expand Down Expand Up @@ -73,6 +79,7 @@ export function AccountSettingsSection({
<UpdateAccountForm
account={account}
action={updateAccountAction}
customFields={updateAccountCustomFields}
submitLabel={updateAccountSubmitLabel}
/>
</div>
Expand Down
Loading
Loading