From 330e0728983603b7dd53bb3c1214fa20acaa7138 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 10 Oct 2025 13:01:35 +0200 Subject: [PATCH 1/2] feat(tax-servicing-countries) - create custom component --- .../components/TaxServicingCountries.tsx | 218 ++++++++++++++++++ src/flows/Onboarding/hooks.tsx | 32 ++- 2 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 src/flows/Onboarding/components/TaxServicingCountries.tsx diff --git a/src/flows/Onboarding/components/TaxServicingCountries.tsx b/src/flows/Onboarding/components/TaxServicingCountries.tsx new file mode 100644 index 00000000..2afbd179 --- /dev/null +++ b/src/flows/Onboarding/components/TaxServicingCountries.tsx @@ -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; + subregions?: Record; + }; +}; + +// 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 ( + { + const CustomSelectField = component || components?.['multi-select']; + if (CustomSelectField) { + const customSelectFieldProps = { + label, + name, + options: enhancedOptions, + defaultValue, + description, + onChange: handleChange, + $meta, + ...rest, + }; + return ( + { + field.onChange(value); + handleChange(value); + }, + }} + fieldState={fieldState} + fieldData={customSelectFieldProps} + /> + ); + } + + const selectedOptions = + selected || + enhancedOptions.filter((option) => + field.value?.includes(option.value), + ); + + return ( + + + {label} + + + + + {description && {description}} + {fieldState.error && } + + ); + }} + /> + ); +} diff --git a/src/flows/Onboarding/hooks.tsx b/src/flows/Onboarding/hooks.tsx index ec1e0424..9dfc37bd 100644 --- a/src/flows/Onboarding/hooks.tsx +++ b/src/flows/Onboarding/hooks.tsx @@ -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; @@ -277,13 +278,38 @@ export const useOnboarding = ({ Boolean(employmentId)), ); + const taxServicingCountriesField = + options?.jsfModify?.basic_information?.fields?.tax_servicing_countries; + + const customBasicInformationFields = useMemo( + () => ({ + fields: { + tax_servicing_countries: { + ...taxServicingCountriesField, + presentation: { + Component: (props: JSFField) => { + return ; + }, + }, + }, + }, + }), + [taxServicingCountriesField], + ); + 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, }, @@ -312,7 +338,7 @@ export const useOnboarding = ({ const equityCompensationField = options?.jsfModify?.contract_details?.fields?.equity_compensation; - const customFields = useMemo( + const customContractDetailsFields = useMemo( () => ({ fields: { annual_gross_salary: { @@ -380,7 +406,7 @@ export const useOnboarding = ({ ...options?.jsfModify?.contract_details, fields: { ...options?.jsfModify?.contract_details?.fields, - ...customFields.fields, + ...customContractDetailsFields.fields, }, }, queryOptions: { From 81c63cc10dd5840e1d182e779950c2d9616aaabf Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 10 Oct 2025 13:15:30 +0200 Subject: [PATCH 2/2] adapt progressively --- example/src/Onboarding.tsx | 13 +++++++++++++ src/flows/Onboarding/hooks.tsx | 35 ++++++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/example/src/Onboarding.tsx b/example/src/Onboarding.tsx index cb4eae92..be6da590 100644 --- a/example/src/Onboarding.tsx +++ b/example/src/Onboarding.tsx @@ -270,6 +270,19 @@ const OnboardingWithProps = ({ render={OnBoardingRender} employmentId={employmentId} externalId={externalId} + options={{ + jsfModify: { + basic_information: { + fields: { + tax_servicing_countries: { + presentation: { + enableCustomTaxServicingComponent: true, + }, + }, + }, + }, + }, + }} /> ); diff --git a/src/flows/Onboarding/hooks.tsx b/src/flows/Onboarding/hooks.tsx index 9dfc37bd..3a7f1a2f 100644 --- a/src/flows/Onboarding/hooks.tsx +++ b/src/flows/Onboarding/hooks.tsx @@ -280,22 +280,41 @@ export const useOnboarding = ({ 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 customBasicInformationFields = useMemo(() => { + const enableCustomTaxServicingComponent = + taxServicingCountriesFieldPresentation?.enableCustomTaxServicingComponent !== + false; + + return { fields: { tax_servicing_countries: { ...taxServicingCountriesField, presentation: { - Component: (props: JSFField) => { - return ; - }, + ...taxServicingCountriesFieldPresentation, + enableCustomTaxServicingComponent, + ...(enableCustomTaxServicingComponent && { + Component: (props: JSFField) => { + return ; + }, + }), }, }, }, - }), - [taxServicingCountriesField], - ); + }; + }, [taxServicingCountriesField, taxServicingCountriesFieldPresentation]); const { data: basicInformationForm,