Skip to content
Draft
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
13 changes: 13 additions & 0 deletions example/src/Onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,19 @@ const OnboardingWithProps = ({
render={OnBoardingRender}
employmentId={employmentId}
externalId={externalId}
options={{
jsfModify: {
basic_information: {
fields: {
tax_servicing_countries: {
presentation: {
enableCustomTaxServicingComponent: true,
},
},
},
},
},
}}
/>
</RemoteFlows>
);
Expand Down
218 changes: 218 additions & 0 deletions src/flows/Onboarding/components/TaxServicingCountries.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { useFormFields } from '@/src/context';
import { $TSFixMe, Components, JSFField } from '@/src/types/remoteFlows';
import { useFormContext } from 'react-hook-form';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/src/components/ui/form';
import { MultiSelect } from '@/src/components/ui/multi-select';
import { useState, useCallback, useMemo } from 'react';

type TaxCountriesFieldProps = JSFField & {
placeholder?: string;
options?: Array<{ value: string; label: string }>;
className?: string;
onChange?: (value: $TSFixMe) => void;
component?: Components['multi-select'];
$meta?: {
regions?: Record<string, string[]>;
subregions?: Record<string, string[]>;
};
};

// Constants for tax servicing countries logic
const GLOBAL_OPTION = { label: 'Global', value: 'GLOBAL', type: 'global' };
const AREA_TYPE = {
REGIONS: 'regions',
SUBREGIONS: 'subregions',
COUNTRIES: 'countries',
};

export function TaxServicingCountriesField({
label,
name,
options,
defaultValue,
description,
onChange,
component,
$meta,
...rest
}: TaxCountriesFieldProps) {
const { control } = useFormContext();
const { components } = useFormFields();
const [selected, setSelected] = useState<$TSFixMe[]>();

// Enhanced options with regions/subregions/countries
const enhancedOptions = useMemo(() => {
const { regions = {}, subregions = {} } = $meta || {};

const regionsOptions = [
GLOBAL_OPTION,
...Object.keys(regions).map((region) => ({
label: region,
value: region,
type: AREA_TYPE.REGIONS,
category: 'Regions',
})),
];

const subregionsOptions = Object.keys(subregions).map((subregion) => ({
label: subregion,
value: subregion,
type: AREA_TYPE.SUBREGIONS,
category: 'Subregions',
}));

const countriesOptions =
options?.map((option) => ({
...option,
type: AREA_TYPE.COUNTRIES,
category: 'Countries',
})) || [];

return [...regionsOptions, ...subregionsOptions, ...countriesOptions];
}, [options, $meta]);

// Enhanced change handler with regions/subregions logic
const handleChange = useCallback(
(rawValues: $TSFixMe[]) => {
const { regions = {}, subregions = {} } = $meta || {};

if (!rawValues?.length) {
onChange?.([]);
setSelected([]);
return;
}

// Check if Global is being selected
const isGlobalSelected = rawValues.some(
(option) => option.value === GLOBAL_OPTION.value,
);

// If Global is selected, clear all other selections and only keep Global
if (isGlobalSelected) {
// If Global was already selected and user is trying to add more options, ignore the new selections
if (selected?.some((option) => option.value === GLOBAL_OPTION.value)) {
return; // Don't change anything if Global is already selected
}

// If Global is being selected for the first time, clear everything else
onChange?.([GLOBAL_OPTION.value]);
setSelected([GLOBAL_OPTION]);
return;
}

// If Global is currently selected and user is trying to select other options, ignore them
if (selected?.some((option) => option.value === GLOBAL_OPTION.value)) {
return; // Don't allow other selections when Global is selected
}

// Track countries to prevent duplicates when multiple overlapping areas are selected
const existingCountries = new Set();
const updatedSelected = rawValues.flatMap((option) => {
// If the selection is a country, add it directly
if (option.type === AREA_TYPE.COUNTRIES) {
existingCountries.add(option.value);
return [option];
}

// For regions/subregions, look up their countries
const lookupLabel = option.originalLabel || option.label;
const areaCountries =
option.type === AREA_TYPE.REGIONS
? regions[lookupLabel] || []
: subregions[lookupLabel] || [];

// Convert each country name into a country option object
return areaCountries
.filter((country) => !existingCountries.has(country))
.map((country) => {
existingCountries.add(country);
return {
label: country,
value: country,
type: AREA_TYPE.COUNTRIES,
category: 'Countries',
};
});
});

// Extract just the country values for the form
const countryValues = updatedSelected
.filter((option) => option.type === AREA_TYPE.COUNTRIES)
.map((option) => option.value);

onChange?.(countryValues);
setSelected(updatedSelected);
},
[$meta, onChange, selected], // Added selected to dependencies
);

return (
<FormField
defaultValue={defaultValue}
control={control}
name={name}
render={({ field, fieldState }) => {
const CustomSelectField = component || components?.['multi-select'];
if (CustomSelectField) {
const customSelectFieldProps = {
label,
name,
options: enhancedOptions,
defaultValue,
description,
onChange: handleChange,
$meta,
...rest,
};
return (
<CustomSelectField
field={{
...field,
onChange: (value: $TSFixMe) => {
field.onChange(value);
handleChange(value);
},
}}
fieldState={fieldState}
fieldData={customSelectFieldProps}
/>
);
}

const selectedOptions =
selected ||
enhancedOptions.filter((option) =>
field.value?.includes(option.value),
);
Copy link

Choose a reason for hiding this comment

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

Local state ignores external form value changes

Medium Severity

The selected state is initialized as undefined and only falls back to field.value while undefined. After any user interaction, handleChange calls setSelected, setting it to an array. From then on, selectedOptions always uses the local selected state (since even [] is truthy), ignoring any external changes to field.value. This means form.reset() or programmatic value changes won't update the UI - users will see stale selections.

Additional Locations (1)

Fix in Cursor Fix in Web


return (
<FormItem
data-field={name}
className={`RemoteFlows__TaxCountriesField__Item__${name}`}
>
<FormLabel className='RemoteFlows__TaxCountriesField__Label'>
{label}
</FormLabel>
<FormControl>
<MultiSelect
options={enhancedOptions}
selected={selectedOptions}
onChange={handleChange}
{...rest}
/>
Copy link

Choose a reason for hiding this comment

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

Form state not updated when using default MultiSelect

High Severity

The default MultiSelect path passes handleChange directly as the onChange handler, but handleChange never calls field.onChange from react-hook-form. This means the form's internal state is never updated when selections change. The UI will visually show the correct selections via local state, but form submission will contain stale or initial values because the form field value was never synchronized.

Fix in Cursor Fix in Web

</FormControl>
{description && <FormDescription>{description}</FormDescription>}
{fieldState.error && <FormMessage />}
</FormItem>
);
}}
/>
);
}
51 changes: 48 additions & 3 deletions src/flows/Onboarding/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { FlowOptions, JSFModify, JSONSchemaFormType } from '@/src/flows/types';
import { AnnualGrossSalary } from '@/src/flows/Onboarding/components/AnnualGrossSalary';
import { $TSFixMe, JSFField, JSFFieldset, Meta } from '@/src/types/remoteFlows';
import { EquityPriceDetails } from '@/src/flows/Onboarding/components/EquityPriceDetails';
import { TaxServicingCountriesField } from '@/src/flows/Onboarding/components/TaxServicingCountries';

type OnboardingHookProps = OnboardingFlowParams;

Expand Down Expand Up @@ -277,13 +278,57 @@ export const useOnboarding = ({
Boolean(employmentId)),
);

const taxServicingCountriesField =
options?.jsfModify?.basic_information?.fields?.tax_servicing_countries;
const taxServicingCountriesFieldPresentation =
taxServicingCountriesField &&
typeof taxServicingCountriesField === 'object' &&
'presentation' in taxServicingCountriesField
? (
taxServicingCountriesField as {
presentation?: {
enableCustomTaxServicingComponent?: boolean;
};
}
).presentation
: undefined;

const customBasicInformationFields = useMemo(() => {
const enableCustomTaxServicingComponent =
taxServicingCountriesFieldPresentation?.enableCustomTaxServicingComponent !==
false;
Copy link

Choose a reason for hiding this comment

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

Custom component enabled by default instead of opt-in

Medium Severity

The condition !== false causes enableCustomTaxServicingComponent to default to true when not specified, making the custom component opt-out rather than opt-in. The example code explicitly sets enableCustomTaxServicingComponent: true, which would be redundant if that were the default—suggesting opt-in behavior was intended. This means all users of the basic_information form get the custom TaxServicingCountriesField component even without requesting it, potentially causing unexpected behavior changes.

Fix in Cursor Fix in Web


return {
fields: {
tax_servicing_countries: {
...taxServicingCountriesField,
presentation: {
...taxServicingCountriesFieldPresentation,
enableCustomTaxServicingComponent,
...(enableCustomTaxServicingComponent && {
Component: (props: JSFField) => {
return <TaxServicingCountriesField {...props} />;
},
}),
},
},
},
};
}, [taxServicingCountriesField, taxServicingCountriesFieldPresentation]);

const {
data: basicInformationForm,
isLoading: isLoadingBasicInformationForm,
} = useJSONSchema({
form: 'employment_basic_information',
options: {
jsfModify: options?.jsfModify?.basic_information,
jsfModify: {
...options?.jsfModify?.basic_information,
fields: {
...options?.jsfModify?.basic_information?.fields,
...customBasicInformationFields.fields,
},
},
queryOptions: {
enabled: isBasicInformationDetailsEnabled,
},
Expand Down Expand Up @@ -312,7 +357,7 @@ export const useOnboarding = ({
const equityCompensationField =
options?.jsfModify?.contract_details?.fields?.equity_compensation;

const customFields = useMemo(
const customContractDetailsFields = useMemo(
() => ({
fields: {
annual_gross_salary: {
Expand Down Expand Up @@ -380,7 +425,7 @@ export const useOnboarding = ({
...options?.jsfModify?.contract_details,
fields: {
...options?.jsfModify?.contract_details?.fields,
...customFields.fields,
...customContractDetailsFields.fields,
},
},
queryOptions: {
Expand Down
Loading