From 5b4846e5a0c5b33f0a9c6246124d9fbda79f1426 Mon Sep 17 00:00:00 2001 From: Steve Jensen Date: Thu, 6 Nov 2025 16:24:02 -0700 Subject: [PATCH 1/2] feat: ui flexibility prototype version 1 --- src/components/Base/Base.tsx | 60 ++++-- src/components/Base/useBase.tsx | 1 + src/components/index.ts | 1 + .../ContractorAddressForm.test.tsx | 202 ++++++++++++++++++ .../ContractorAddressForm.tsx | 149 +++++++++++++ .../ContractorAddressFormProvider.tsx | 84 ++++++++ .../prototypes/ContractorAddressForm/index.ts | 1 + .../useContractorAddressForm.ts | 51 +++++ src/components/prototypes/index.ts | 1 + 9 files changed, 531 insertions(+), 19 deletions(-) create mode 100644 src/components/prototypes/ContractorAddressForm/ContractorAddressForm.test.tsx create mode 100644 src/components/prototypes/ContractorAddressForm/ContractorAddressForm.tsx create mode 100644 src/components/prototypes/ContractorAddressForm/ContractorAddressFormProvider.tsx create mode 100644 src/components/prototypes/ContractorAddressForm/index.ts create mode 100644 src/components/prototypes/ContractorAddressForm/useContractorAddressForm.ts create mode 100644 src/components/prototypes/index.ts diff --git a/src/components/Base/Base.tsx b/src/components/Base/Base.tsx index 64d4ed12..7435dc6a 100644 --- a/src/components/Base/Base.tsx +++ b/src/components/Base/Base.tsx @@ -9,7 +9,7 @@ import { UnprocessableEntityErrorObject } from '@gusto/embedded-api/models/error import { QueryErrorResetBoundary } from '@tanstack/react-query' import type { EntityErrorObject } from '@gusto/embedded-api/models/components/entityerrorobject' import { FadeIn } from '../Common/FadeIn/FadeIn' -import { BaseContext, type KnownErrors, type OnEventType } from './useBase' +import { BaseContext, useBase, type KnownErrors, type OnEventType } from './useBase' import { componentEvents, type EventType } from '@/shared/constants' import { InternalError } from '@/components/Common' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' @@ -36,17 +36,15 @@ export interface BaseComponentInterface = (data: T) => Promise -export const BaseComponent = ({ +export const BaseComponentProvider = ({ children, FallbackComponent = InternalError, LoaderComponent: LoadingIndicatorFromProps, - onEvent, + onEvent = () => {}, }: BaseComponentInterface) => { const [error, setError] = useState(null) const [fieldErrors, setFieldErrors] = useState(null) const throwError = useAsyncError() - const { t } = useTranslation() - const Components = useComponentContext() const { LoadingIndicator: LoadingIndicatorFromContext } = useLoadingIndicator() @@ -90,6 +88,7 @@ export const BaseComponent = - {(error || fieldErrors) && ( - - {fieldErrors && } - {error && error instanceof APIError && ( - {error.message} - )} - {error && error instanceof SDKValidationError && ( - {error.pretty()} - )} - - )} - }> - {children} - + }>{children} )} ) } + +export const BaseUIComponent = ({ children }: { children: ReactNode }) => { + const { error, fieldErrors } = useBase() + const { t } = useTranslation() + const Components = useComponentContext() + + return ( + + {(error || fieldErrors) && ( + + {fieldErrors && } + {error && error instanceof APIError && {error.message}} + {error && error instanceof SDKValidationError && ( + {error.pretty()} + )} + + )} + {children} + + ) +} + +// Existing BaseComponent composes BaseComponentProvider and BaseUIComponent +// These were separated to allow the provider functionality to be used without +// importing the UI components. BaseUIComponent can be used for our codebase +// UI implementations for rendering the errors in a single place. +export const BaseComponent = ({ + children, + ...props +}: BaseComponentInterface) => { + return ( + + {children} + + ) +} diff --git a/src/components/Base/useBase.tsx b/src/components/Base/useBase.tsx index 3b640149..64962c8c 100644 --- a/src/components/Base/useBase.tsx +++ b/src/components/Base/useBase.tsx @@ -11,6 +11,7 @@ export type OnEventType = (type: K, data?: T) => void export type KnownErrors = APIError | SDKValidationError | UnprocessableEntityErrorObject interface BaseContextProps { + error: KnownErrors | null fieldErrors: Array | null setError: (err: KnownErrors | null) => void onEvent: OnEventType diff --git a/src/components/index.ts b/src/components/index.ts index 65177cfb..d73b9901 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,3 +3,4 @@ export * as Company from './Company' export * as Contractor from './Contractor' export * as Employee from './Employee' export * as Payroll from './Payroll' +export * as Prototypes from './prototypes' diff --git a/src/components/prototypes/ContractorAddressForm/ContractorAddressForm.test.tsx b/src/components/prototypes/ContractorAddressForm/ContractorAddressForm.test.tsx new file mode 100644 index 00000000..e826de8e --- /dev/null +++ b/src/components/prototypes/ContractorAddressForm/ContractorAddressForm.test.tsx @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import Address from './ContractorAddressForm' +import { server } from '@/test/mocks/server' +import { + handleGetContractor, + handleGetContractorAddress, + handleUpdateContractorAddress, +} from '@/test/mocks/apis/contractor_address' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { contractorEvents } from '@/shared/constants' +import { renderWithProviders } from '@/test-utils/renderWithProviders' + +describe('Contractor/Address', () => { + beforeEach(() => { + setupApiTestMocks() + }) + + describe('when API has minimal address details', () => { + const exampleUpdatedAddress = { + version: 'contractor-address-version-updated', + country: 'USA', + street_1: '123 Main St', + street_2: 'Apt 4B', + city: 'Denver', + state: 'CO', + zip: '80202', + } + + beforeEach(() => { + server.use( + handleGetContractorAddress(() => + HttpResponse.json({ + version: 'contractor-address-version', + street_1: null, + street_2: null, + city: null, + state: null, + zip: null, + country: 'USA', + }), + ), + handleUpdateContractorAddress(() => HttpResponse.json(exampleUpdatedAddress)), + ) + }) + + it('should allow submitting the form', async () => { + const user = userEvent.setup() + const mockOnEvent = vi.fn() + + renderWithProviders(
) + + await screen.findByText('Home address') + + await user.type(screen.getByLabelText('Street 1'), '123 Main St') + await user.type(screen.getByLabelText(/Street 2/i), 'Apt 4B') + await user.type(screen.getByLabelText('City'), 'Denver') + + const stateControl = screen.getByRole('button', { + name: /Select state.../i, + expanded: false, + }) + await user.click(stateControl) + const coloradoOption = screen.getByRole('option', { + name: /Colorado/i, + }) + await user.click(coloradoOption) + + await user.type(screen.getByLabelText('Zip'), '80202') + + const continueButton = screen.getByRole('button', { + name: /Continue/i, + }) + await user.click(continueButton) + + expect(mockOnEvent).toHaveBeenNthCalledWith( + 1, + contractorEvents.CONTRACTOR_ADDRESS_UPDATED, + expect.objectContaining({ + version: exampleUpdatedAddress.version, + street1: exampleUpdatedAddress.street_1, + street2: exampleUpdatedAddress.street_2, + city: exampleUpdatedAddress.city, + state: exampleUpdatedAddress.state, + zip: exampleUpdatedAddress.zip, + country: exampleUpdatedAddress.country, + }), + ) + + expect(mockOnEvent).toHaveBeenNthCalledWith(2, contractorEvents.CONTRACTOR_ADDRESS_DONE) + }) + + it('should allow setting default values', async () => { + renderWithProviders( +
{}} + defaultValues={{ + street1: '999 Default St', + street2: 'Apt 123', + city: 'Default City', + state: 'CO', + zip: '80202', + }} + />, + ) + + await screen.findByText('Home address') + + expect(screen.getByLabelText('Street 1')).toHaveValue('999 Default St') + expect(screen.getByLabelText(/Street 2/i)).toHaveValue('Apt 123') + expect(screen.getByLabelText('City')).toHaveValue('Default City') + expect( + screen.getByRole('button', { + name: /Colorado/i, + expanded: false, + }), + ).toBeInTheDocument() + expect(screen.getByLabelText('Zip')).toHaveValue('80202') + }) + }) + + describe('when API has full address details', () => { + beforeEach(() => { + server.use( + handleGetContractorAddress(() => + HttpResponse.json({ + version: 'contractor-address-version', + street_1: '999 Kiera Stravenue', + street_2: 'Suite 541', + city: 'San Francisco', + state: 'CA', + zip: '94107', + country: 'USA', + }), + ), + ) + }) + + it('should defer to values from API over default values', async () => { + renderWithProviders( +
{}} + defaultValues={{ + street1: '999 Default St', + street2: 'Apt 123', + city: 'Default City', + state: 'CO', + zip: '80202', + }} + />, + ) + + await screen.findByText('Home address') + + expect(screen.getByLabelText('Street 1')).toHaveValue('999 Kiera Stravenue') + expect(screen.getByLabelText(/Street 2/i)).toHaveValue('Suite 541') + expect(screen.getByLabelText('City')).toHaveValue('San Francisco') + expect( + screen.getByRole('button', { + name: /California/i, + expanded: false, + }), + ).toBeInTheDocument() + expect(screen.getByLabelText('Zip')).toHaveValue('94107') + }) + }) + + describe('contractor type in heading', () => { + it('should show individual text when contractorType is Individual', async () => { + renderWithProviders(
{}} />) + + expect(await screen.findByText('Home address')).toBeInTheDocument() + expect( + screen.getByText("Contractor's home mailing address, within the United States."), + ).toBeInTheDocument() + }) + + it('should show business text when contractorType is Business', async () => { + server.use( + handleGetContractor(() => { + return HttpResponse.json({ + uuid: 'contractor_id', + type: 'Business', + is_active: true, + file_new_hire_report: false, + }) + }), + ) + + renderWithProviders(
{}} />) + + expect(await screen.findByText('Business address')).toBeInTheDocument() + expect( + screen.getByText("Contractor's business address, within the United States."), + ).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/prototypes/ContractorAddressForm/ContractorAddressForm.tsx b/src/components/prototypes/ContractorAddressForm/ContractorAddressForm.tsx new file mode 100644 index 00000000..2da81e10 --- /dev/null +++ b/src/components/prototypes/ContractorAddressForm/ContractorAddressForm.tsx @@ -0,0 +1,149 @@ +import type { ReactNode } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { useTranslation } from 'react-i18next' +import { ContractorAddressFormProvider } from './ContractorAddressFormProvider' +import { useContractorAddressForm, ContractorAddressFormSchema } from './useContractorAddressForm' +import type { ContractorAddressFormValues } from './useContractorAddressForm' +import { Form as HtmlForm } from '@/components/Common/Form/Form' +import { useI18n, useComponentDictionary } from '@/i18n' +import { Flex, TextInputField, SelectField, Grid } from '@/components/Common' +import { ActionsLayout } from '@/components/Common/ActionsLayout/ActionsLayout' +import type { BaseComponentInterface } from '@/components/Base' +import { BaseUIComponent } from '@/components/Base/Base' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { contractorEvents, STATES_ABBR } from '@/shared/constants' +import { useBase } from '@/components/Base/useBase' + +export interface ContractorAddressProps extends BaseComponentInterface<'Contractor.Address'> { + contractorId: string + defaultValues?: ContractorAddressFormValues + children?: ReactNode + className?: string +} + +function ContractorAddress({ defaultValues, ...props }: ContractorAddressProps) { + return ( + + + + + + ) +} + +function Root({ defaultValues, children, className, dictionary }: ContractorAddressProps) { + useComponentDictionary('Contractor.Address', dictionary) + useI18n('Contractor.Address') + + const { + contractor, + defaultValues: formDefaultValues, + isUpdating, + onSubmit, + } = useContractorAddressForm() + const contractorType = contractor?.type + + const Components = useComponentContext() + const { onEvent } = useBase() + + const { t } = useTranslation('Contractor.Address') + + const composedDefaultValues = { + street1: formDefaultValues.street1 || defaultValues?.street1, + street2: formDefaultValues.street2 || defaultValues?.street2, + city: formDefaultValues.city || defaultValues?.city, + state: formDefaultValues.state || defaultValues?.state, + zip: formDefaultValues.zip || defaultValues?.zip, + } + + const formMethods = useForm({ + resolver: zodResolver(ContractorAddressFormSchema), + defaultValues: composedDefaultValues, + }) + + const handleSubmit = async (values: ContractorAddressFormValues) => { + const { updatedContractorAddressResponse } = await onSubmit(values) + + if (updatedContractorAddressResponse) { + onEvent( + contractorEvents.CONTRACTOR_ADDRESS_UPDATED, + updatedContractorAddressResponse.contractorAddress, + ) + onEvent(contractorEvents.CONTRACTOR_ADDRESS_DONE) + } + } + + return ( +
+ + + + {children ? ( + children + ) : ( + <> +
+ + {contractorType === 'Business' + ? t('businessAddressTitle') + : t('homeAddressTitle')} + + + {contractorType === 'Business' + ? t('businessAddressDescription') + : t('homeAddressDescription')} + +
+ + + + + + ({ + label: t(`statesHash.${stateAbbr}`, { + ns: 'common', + defaultValue: stateAbbr, + }), + value: stateAbbr, + }))} + label={t('state')} + placeholder={t('statePlaceholder')} + errorMessage={t('validations.state')} + isRequired + /> + + + + + + {t('submit')} + + + + )} +
+
+
+
+ ) +} + +export default ContractorAddress diff --git a/src/components/prototypes/ContractorAddressForm/ContractorAddressFormProvider.tsx b/src/components/prototypes/ContractorAddressForm/ContractorAddressFormProvider.tsx new file mode 100644 index 00000000..d3e4be03 --- /dev/null +++ b/src/components/prototypes/ContractorAddressForm/ContractorAddressFormProvider.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from 'react' +import { useContractorsGetSuspense } from '@gusto/embedded-api/react-query/contractorsGet' +import { useContractorsGetAddressSuspense } from '@gusto/embedded-api/react-query/contractorsGetAddress' +import { useContractorsUpdateAddressMutation } from '@gusto/embedded-api/react-query/contractorsUpdateAddress' +import type { PutV1ContractorsContractorUuidAddressResponse } from '@gusto/embedded-api/models/operations/putv1contractorscontractoruuidaddress' +import type { ContractorAddressFormValues } from './useContractorAddressForm' +import { ContractorAddressFormPropsProvider } from './useContractorAddressForm' +import type { BaseComponentInterface } from '@/components/Base/Base' +import { BaseComponentProvider } from '@/components/Base/Base' +import { useBase } from '@/components/Base/useBase' + +export interface ComposedContractorAddressFormProviderProps extends BaseComponentInterface { + contractorId: string + children?: ReactNode +} + +function ComposedContractorAddressFormProvider(props: ComposedContractorAddressFormProviderProps) { + return ( + + {props.children} + + ) +} + +function ContractorAddressFormProvider({ + contractorId, + children, +}: ComposedContractorAddressFormProviderProps) { + const { baseSubmitHandler } = useBase() + + const { data: contractorData } = useContractorsGetSuspense({ contractorUuid: contractorId }) + const { data: addressData } = useContractorsGetAddressSuspense({ contractorUuid: contractorId }) + + const { mutateAsync: updateAddress, isPending: isUpdatingAddressPending } = + useContractorsUpdateAddressMutation() + + const address = addressData.contractorAddress + + const formDefaultValues = { + street1: address?.street1 || '', + street2: address?.street2 || '', + city: address?.city || '', + state: address?.state || '', + zip: address?.zip || '', + } + + const onSubmit = async (data: ContractorAddressFormValues) => { + let response: PutV1ContractorsContractorUuidAddressResponse | undefined + + await baseSubmitHandler(data, async payload => { + response = await updateAddress({ + request: { + contractorUuid: contractorId, + requestBody: { + version: address?.version as string, + street1: payload.street1, + street2: payload.street2, + city: payload.city, + state: payload.state, + zip: payload.zip, + }, + }, + }) + }) + + return { updatedContractorAddressResponse: response } + } + + return ( + + {children} + + ) +} + +export { ComposedContractorAddressFormProvider as ContractorAddressFormProvider } diff --git a/src/components/prototypes/ContractorAddressForm/index.ts b/src/components/prototypes/ContractorAddressForm/index.ts new file mode 100644 index 00000000..13e6da06 --- /dev/null +++ b/src/components/prototypes/ContractorAddressForm/index.ts @@ -0,0 +1 @@ +export { default as ContractorAddressForm } from './ContractorAddressForm' diff --git a/src/components/prototypes/ContractorAddressForm/useContractorAddressForm.ts b/src/components/prototypes/ContractorAddressForm/useContractorAddressForm.ts new file mode 100644 index 00000000..07b3ddb9 --- /dev/null +++ b/src/components/prototypes/ContractorAddressForm/useContractorAddressForm.ts @@ -0,0 +1,51 @@ +import type { Contractor, ContractorType } from '@gusto/embedded-api/models/components/contractor' +import type { ContractorAddress } from '@gusto/embedded-api/models/components/contractoraddress' +import { z } from 'zod' +import type { PutV1ContractorsContractorUuidAddressResponse } from '@gusto/embedded-api/models/operations/putv1contractorscontractoruuidaddress' +import { createCompoundContext } from '@/components/Base' + +// TODO: +// It's annoying to have to import so much from this file but we need it +// currently to avoid circular dependencies and to keep hot reloading. Look +// into a way to improve the ergnomics of the dev UX here. + +export const ContractorAddressFormValidationError = { + STREET1_REQUIRED: 'STREET1_REQUIRED', + CITY_REQUIRED: 'CITY_REQUIRED', + STATE_REQUIRED: 'STATE_REQUIRED', + ZIP_REQUIRED: 'ZIP_REQUIRED', +} as const + +export const ContractorAddressFormSchema = z.object({ + street1: z + .string({ required_error: ContractorAddressFormValidationError.STREET1_REQUIRED }) + .min(1, ContractorAddressFormValidationError.STREET1_REQUIRED), + street2: z.string().optional(), + city: z + .string({ required_error: ContractorAddressFormValidationError.CITY_REQUIRED }) + .min(1, ContractorAddressFormValidationError.CITY_REQUIRED), + state: z + .string({ required_error: ContractorAddressFormValidationError.STATE_REQUIRED }) + .min(1, ContractorAddressFormValidationError.STATE_REQUIRED), + zip: z + .string({ required_error: ContractorAddressFormValidationError.ZIP_REQUIRED }) + .min(1, ContractorAddressFormValidationError.ZIP_REQUIRED), +}) + +export type ContractorAddressFormValues = z.infer + +export interface ContractorAddressFormContextType { + contractor?: Contractor + contractorType?: ContractorType + address?: ContractorAddress + isUpdating: boolean + onSubmit: (data: ContractorAddressFormValues) => Promise<{ + updatedContractorAddressResponse: PutV1ContractorsContractorUuidAddressResponse | undefined + }> + defaultValues: ContractorAddressFormValues +} + +const [useContractorAddressForm, ContractorAddressFormPropsProvider] = + createCompoundContext('ContractorAddressContext') + +export { useContractorAddressForm, ContractorAddressFormPropsProvider } diff --git a/src/components/prototypes/index.ts b/src/components/prototypes/index.ts new file mode 100644 index 00000000..31c4b93b --- /dev/null +++ b/src/components/prototypes/index.ts @@ -0,0 +1 @@ +export { ContractorAddressForm } from './ContractorAddressForm' From b334388f739547786dab149096e86f01356cd23a Mon Sep 17 00:00:00 2001 From: Steve Jensen Date: Fri, 7 Nov 2025 16:44:46 -0700 Subject: [PATCH 2/2] feat: ui flexibility prototype version 2 --- docs/component-adapter/component-inventory.md | 2 +- package-lock.json | 68 ++--- package.json | 4 +- .../Fields/CheckboxField/CheckboxField.tsx | 13 +- .../CheckboxGroupField/CheckboxGroupField.tsx | 15 +- .../Fields/ComboBoxField/ComboBoxField.tsx | 15 +- .../DatePickerField/DatePickerField.tsx | 9 +- .../NumberInputField/NumberInputField.tsx | 17 +- .../RadioGroupField/RadioGroupField.tsx | 15 +- .../Common/Fields/SelectField/SelectField.tsx | 22 +- .../Common/Fields/SwitchField/SwitchField.tsx | 12 +- .../Fields/TextInputField/TextInputField.tsx | 23 +- src/components/Common/Fields/helpers/index.ts | 1 + .../helpers/processDescription.test.tsx | 68 +++++ .../Fields/helpers/processDescription.ts | 13 + .../Common/Fields/hooks/useField.test.tsx | 178 ------------- .../Common/Fields/hooks/useField.ts | 23 +- .../useStringifyGenericFieldValue.test.tsx | 36 --- .../hooks/useStringifyGenericFieldValue.ts | 9 +- src/components/Common/UI/Select/Select.tsx | 1 - .../Common/UI/Select/SelectTypes.ts | 2 +- .../ContractorAddressForm.tsx | 240 ++++++++++-------- .../ContractorAddressFormFields.tsx | 168 ++++++++++++ .../ContractorAddressFormProvider.tsx | 96 +++++-- .../useContractorAddressForm.ts | 22 +- 25 files changed, 644 insertions(+), 428 deletions(-) create mode 100644 src/components/Common/Fields/helpers/index.ts create mode 100644 src/components/Common/Fields/helpers/processDescription.test.tsx create mode 100644 src/components/Common/Fields/helpers/processDescription.ts create mode 100644 src/components/prototypes/ContractorAddressForm/ContractorAddressFormFields.tsx diff --git a/docs/component-adapter/component-inventory.md b/docs/component-adapter/component-inventory.md index d8d1ce67..59882f98 100644 --- a/docs/component-adapter/component-inventory.md +++ b/docs/component-adapter/component-inventory.md @@ -475,7 +475,7 @@ The props for this component are defined in [BaseListProps](#baselistprops). | --------------------------- | -------------------------------- | -------- | ---------------------------------------------------------------------- | | **isDisabled** | `boolean` | No | Disables the select and prevents interaction | | **isInvalid** | `boolean` | No | Indicates that the field has an error | -| **label** | `string` | Yes | Label text for the select field | +| **label** | `React.ReactNode` | Yes | Label text for the select field | | **onChange** | `(value: string) => void` | No | Callback when selection changes | | **onBlur** | `() => void` | No | Handler for blur events | | **options** | [SelectOption](#selectoption)[] | Yes | Array of options to display in the select dropdown | diff --git a/package-lock.json b/package-lock.json index 29a7f73b..5c613f64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,8 +77,8 @@ }, "peerDependencies": { "@tanstack/react-query": "^5", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", + "react": "19.0.0", + "react-dom": "19.0.0", "typescript": "^5.8.3" } }, @@ -1584,13 +1584,13 @@ } }, "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz", - "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==", + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "2.2.7", - "@formatjs/intl-localematcher": "0.6.1", + "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } @@ -1605,30 +1605,30 @@ } }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz", - "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", - "@formatjs/icu-skeleton-parser": "1.8.14", + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.14", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz", - "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==", + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz", - "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", "license": "MIT", "dependencies": { "tslib": "^2.8.0" @@ -11346,14 +11346,14 @@ } }, "node_modules/intl-messageformat": { - "version": "10.7.16", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", - "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==", + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", "license": "BSD-3-Clause", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.4", + "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.2", + "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, @@ -15825,9 +15825,9 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "license": "MIT", "peer": true, "engines": { @@ -15971,16 +15971,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "license": "MIT", "peer": true, "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.25.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.0.0" } }, "node_modules/react-error-boundary": { @@ -17126,9 +17126,9 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", "license": "MIT", "peer": true }, diff --git a/package.json b/package.json index c70442d2..5e018ae4 100644 --- a/package.json +++ b/package.json @@ -109,8 +109,8 @@ }, "peerDependencies": { "@tanstack/react-query": "^5", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", + "react": "19.0.0", + "react-dom": "19.0.0", "typescript": "^5.8.3" }, "dependencies": { diff --git a/src/components/Common/Fields/CheckboxField/CheckboxField.tsx b/src/components/Common/Fields/CheckboxField/CheckboxField.tsx index 5883ace3..f86b767e 100644 --- a/src/components/Common/Fields/CheckboxField/CheckboxField.tsx +++ b/src/components/Common/Fields/CheckboxField/CheckboxField.tsx @@ -1,10 +1,14 @@ +import { useMemo } from 'react' import { useField, type UseFieldProps } from '@/components/Common/Fields/hooks/useField' import type { CheckboxProps } from '@/components/Common/UI/Checkbox/CheckboxTypes' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { processDescription } from '@/components/Common/Fields/helpers/processDescription' export interface CheckboxFieldProps extends Omit, - UseFieldProps {} + UseFieldProps { + description?: React.ReactNode +} export const CheckboxField: React.FC = ({ rules, @@ -28,10 +32,13 @@ export const CheckboxField: React.FC = ({ isRequired, onChange, transform, - description, onBlur, inputRef, }) - return + const processedDescription = useMemo(() => processDescription(description), [description]) + + return ( + + ) } diff --git a/src/components/Common/Fields/CheckboxGroupField/CheckboxGroupField.tsx b/src/components/Common/Fields/CheckboxGroupField/CheckboxGroupField.tsx index 302b359c..9662b172 100644 --- a/src/components/Common/Fields/CheckboxGroupField/CheckboxGroupField.tsx +++ b/src/components/Common/Fields/CheckboxGroupField/CheckboxGroupField.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { useField, type UseFieldProps } from '@/components/Common/Fields/hooks/useField' import type { CheckboxGroupProps } from '@/components/Common/UI/CheckboxGroup/CheckboxGroupTypes' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' @@ -6,6 +7,7 @@ import { useStringifyGenericFieldValueArray, type OptionWithGenericValue, } from '@/components/Common/Fields/hooks/useStringifyGenericFieldValue' +import { processDescription } from '@/components/Common/Fields/helpers/processDescription' type GenericCheckboxGroupOption = OptionWithGenericValue @@ -14,6 +16,7 @@ export interface CheckboxGroupFieldProps UseFieldProps { options: GenericCheckboxGroupOption[] convertValueToString?: (value: TValue) => string + description?: React.ReactNode } export const CheckboxGroupField = ({ @@ -40,7 +43,6 @@ export const CheckboxGroupField = ({ isRequired, onChange: onChangeFromProps, transform, - description, onBlur, inputRef, }) @@ -52,5 +54,14 @@ export const CheckboxGroupField = ({ convertValueToString, }) - return + const processedDescription = useMemo(() => processDescription(description), [description]) + + return ( + + ) } diff --git a/src/components/Common/Fields/ComboBoxField/ComboBoxField.tsx b/src/components/Common/Fields/ComboBoxField/ComboBoxField.tsx index 1c0dff94..5a74ea02 100644 --- a/src/components/Common/Fields/ComboBoxField/ComboBoxField.tsx +++ b/src/components/Common/Fields/ComboBoxField/ComboBoxField.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { useField, type UseFieldProps } from '@/components/Common/Fields/hooks/useField' import type { ComboBoxProps, ComboBoxOption } from '@/components/Common/UI/ComboBox/ComboBoxTypes' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' @@ -5,6 +6,7 @@ import { useStringifyGenericFieldValue, type OptionWithGenericValue, } from '@/components/Common/Fields/hooks/useStringifyGenericFieldValue' +import { processDescription } from '@/components/Common/Fields/helpers/processDescription' type GenericComboBoxOption = OptionWithGenericValue @@ -13,6 +15,7 @@ export interface ComboBoxFieldProps UseFieldProps { options: GenericComboBoxOption[] convertValueToString?: (value: TValue) => string + description?: React.ReactNode } export const ComboBoxField = ({ @@ -39,7 +42,6 @@ export const ComboBoxField = ({ isRequired, onChange: onChangeFromProps, transform, - description, onBlur, inputRef, }) @@ -51,5 +53,14 @@ export const ComboBoxField = ({ convertValueToString, }) - return + const processedDescription = useMemo(() => processDescription(description), [description]) + + return ( + + ) } diff --git a/src/components/Common/Fields/DatePickerField/DatePickerField.tsx b/src/components/Common/Fields/DatePickerField/DatePickerField.tsx index 0f561554..54463d8f 100644 --- a/src/components/Common/Fields/DatePickerField/DatePickerField.tsx +++ b/src/components/Common/Fields/DatePickerField/DatePickerField.tsx @@ -3,10 +3,13 @@ import { useField, type UseFieldProps } from '@/components/Common/Fields/hooks/u import type { DatePickerProps } from '@/components/Common/UI/DatePicker/DatePickerTypes' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' import { normalizeDateToLocal } from '@/helpers/dateFormatting' +import { processDescription } from '@/components/Common/Fields/helpers/processDescription' interface DatePickerFieldProps extends Omit, - UseFieldProps {} + UseFieldProps { + description?: React.ReactNode +} export const DatePickerField: React.FC = ({ rules, @@ -30,7 +33,6 @@ export const DatePickerField: React.FC = ({ isRequired, onChange, transform, - description, onBlur, inputRef, }) @@ -48,11 +50,14 @@ export const DatePickerField: React.FC = ({ [fieldProps], ) + const processedDescription = React.useMemo(() => processDescription(description), [description]) + return ( ) } diff --git a/src/components/Common/Fields/NumberInputField/NumberInputField.tsx b/src/components/Common/Fields/NumberInputField/NumberInputField.tsx index 8cbbb6f8..d0339f93 100644 --- a/src/components/Common/Fields/NumberInputField/NumberInputField.tsx +++ b/src/components/Common/Fields/NumberInputField/NumberInputField.tsx @@ -1,10 +1,14 @@ +import { useMemo } from 'react' import { useField, type UseFieldProps } from '@/components/Common/Fields/hooks/useField' import type { NumberInputProps } from '@/components/Common/UI/NumberInput/NumberInputTypes' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { processDescription } from '@/components/Common/Fields/helpers/processDescription' export interface NumberInputFieldProps extends Omit, - UseFieldProps {} + UseFieldProps { + description?: React.ReactNode +} export const NumberInputField: React.FC = ({ rules: providedRules, @@ -38,10 +42,17 @@ export const NumberInputField: React.FC = ({ isRequired, onChange, transform, - description, onBlur, inputRef, }) - return + const processedDescription = useMemo(() => processDescription(description), [description]) + + return ( + + ) } diff --git a/src/components/Common/Fields/RadioGroupField/RadioGroupField.tsx b/src/components/Common/Fields/RadioGroupField/RadioGroupField.tsx index 6a476be9..73cc752b 100644 --- a/src/components/Common/Fields/RadioGroupField/RadioGroupField.tsx +++ b/src/components/Common/Fields/RadioGroupField/RadioGroupField.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { useField, type UseFieldProps } from '@/components/Common/Fields/hooks/useField' import type { RadioGroupProps, @@ -8,6 +9,7 @@ import { useStringifyGenericFieldValue, type OptionWithGenericValue, } from '@/components/Common/Fields/hooks/useStringifyGenericFieldValue' +import { processDescription } from '@/components/Common/Fields/helpers/processDescription' type GenericRadioGroupOption = OptionWithGenericValue @@ -16,6 +18,7 @@ export interface RadioGroupFieldProps UseFieldProps { options: GenericRadioGroupOption[] convertValueToString?: (value: TValue) => string + description?: React.ReactNode } export const RadioGroupField = ({ @@ -42,7 +45,6 @@ export const RadioGroupField = ({ isRequired, onChange: onChangeFromProps, transform, - description, onBlur, inputRef, }) @@ -54,5 +56,14 @@ export const RadioGroupField = ({ convertValueToString, }) - return + const processedDescription = useMemo(() => processDescription(description), [description]) + + return ( + + ) } diff --git a/src/components/Common/Fields/SelectField/SelectField.tsx b/src/components/Common/Fields/SelectField/SelectField.tsx index 03079893..d14134e3 100644 --- a/src/components/Common/Fields/SelectField/SelectField.tsx +++ b/src/components/Common/Fields/SelectField/SelectField.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react' +import type { UseFieldReturn } from '@/components/Common/Fields/hooks/useField' import { useField, type UseFieldProps } from '@/components/Common/Fields/hooks/useField' import type { SelectOption, SelectProps } from '@/components/Common/UI/Select/SelectTypes' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' @@ -5,14 +7,17 @@ import { useStringifyGenericFieldValue, type OptionWithGenericValue, } from '@/components/Common/Fields/hooks/useStringifyGenericFieldValue' +import { processDescription } from '@/components/Common/Fields/helpers/processDescription' type GenericSelectOption = OptionWithGenericValue -export interface SelectFieldProps +export interface SelectFieldProps extends Omit, UseFieldProps { options: GenericSelectOption[] convertValueToString?: (value: TValue) => string + description?: React.ReactNode + renderInput?: (props: UseFieldReturn & { options: SelectProps['options'] }) => React.ReactNode } export const SelectField = ({ @@ -28,6 +33,7 @@ export const SelectField = ({ description, onBlur, inputRef, + renderInput, ...selectProps }: SelectFieldProps) => { const Components = useComponentContext() @@ -39,7 +45,6 @@ export const SelectField = ({ isRequired, onChange: onChangeFromProps, transform, - description, onBlur, inputRef, }) @@ -51,5 +56,16 @@ export const SelectField = ({ convertValueToString, }) - return + const processedDescription = useMemo(() => processDescription(description), [description]) + + return renderInput ? ( + renderInput({ ...fieldProps, ...stringFieldProps }) + ) : ( + + ) } diff --git a/src/components/Common/Fields/SwitchField/SwitchField.tsx b/src/components/Common/Fields/SwitchField/SwitchField.tsx index c06ea963..674e539a 100644 --- a/src/components/Common/Fields/SwitchField/SwitchField.tsx +++ b/src/components/Common/Fields/SwitchField/SwitchField.tsx @@ -1,9 +1,14 @@ +import { useMemo } from 'react' import { useField, type UseFieldProps } from '@/components/Common/Fields/hooks/useField' import type { SwitchProps } from '@/components/Common/UI/Switch/SwitchTypes' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { processDescription } from '@/components/Common/Fields/helpers/processDescription' + export interface SwitchFieldProps extends Omit, - UseFieldProps {} + UseFieldProps { + description?: React.ReactNode +} export const SwitchField: React.FC = ({ rules, @@ -27,10 +32,11 @@ export const SwitchField: React.FC = ({ isRequired, onChange, transform, - description, onBlur, inputRef, }) - return + const processedDescription = useMemo(() => processDescription(description), [description]) + + return } diff --git a/src/components/Common/Fields/TextInputField/TextInputField.tsx b/src/components/Common/Fields/TextInputField/TextInputField.tsx index 6fcd200f..421b1855 100644 --- a/src/components/Common/Fields/TextInputField/TextInputField.tsx +++ b/src/components/Common/Fields/TextInputField/TextInputField.tsx @@ -1,10 +1,19 @@ -import { useField, type UseFieldProps } from '@/components/Common/Fields/hooks/useField' +import { useMemo } from 'react' +import { + useField, + type UseFieldProps, + type UseFieldReturn, +} from '@/components/Common/Fields/hooks/useField' import type { TextInputProps } from '@/components/Common/UI/TextInput/TextInputTypes' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { processDescription } from '@/components/Common/Fields/helpers/processDescription' export interface TextInputFieldProps extends Omit, - UseFieldProps {} + UseFieldProps { + renderInput?: (props: UseFieldReturn) => React.ReactNode + description?: React.ReactNode +} export const TextInputField: React.FC = ({ rules, @@ -17,6 +26,7 @@ export const TextInputField: React.FC = ({ description, onBlur, inputRef, + renderInput, ...textInputProps }: TextInputFieldProps) => { const Components = useComponentContext() @@ -28,10 +38,15 @@ export const TextInputField: React.FC = ({ isRequired, onChange, transform, - description, onBlur, inputRef, }) - return + const processedDescription = useMemo(() => processDescription(description), [description]) + + return renderInput ? ( + renderInput(fieldProps) + ) : ( + + ) } diff --git a/src/components/Common/Fields/helpers/index.ts b/src/components/Common/Fields/helpers/index.ts new file mode 100644 index 00000000..60eb5cf2 --- /dev/null +++ b/src/components/Common/Fields/helpers/index.ts @@ -0,0 +1 @@ +export { processDescription } from './processDescription' diff --git a/src/components/Common/Fields/helpers/processDescription.test.tsx b/src/components/Common/Fields/helpers/processDescription.test.tsx new file mode 100644 index 00000000..ae7dfff3 --- /dev/null +++ b/src/components/Common/Fields/helpers/processDescription.test.tsx @@ -0,0 +1,68 @@ +import { describe, test, expect } from 'vitest' +import React from 'react' +import { processDescription } from './processDescription' + +describe('processDescription', () => { + test('should return non-string descriptions as-is', () => { + const jsxElement = JSX element + const result = processDescription(jsxElement) + + expect(result).toBe(jsxElement) + }) + + test('should return null/undefined descriptions as-is', () => { + expect(processDescription(null)).toBeNull() + expect(processDescription(undefined)).toBeUndefined() + }) + + test('should process plain text strings as React elements with sanitized content', () => { + const plainText = 'Plain text description' + const result = processDescription(plainText) + + const element = result as React.ReactElement<{ + dangerouslySetInnerHTML: { __html: string } + }> + expect(React.isValidElement(element)).toBe(true) + expect(element.props.dangerouslySetInnerHTML.__html).toBe(plainText) + }) + + test('should process HTML strings and preserve safe HTML tags', () => { + const result = processDescription( + 'Text with bold and link', + ) + + const element = result as React.ReactElement<{ + dangerouslySetInnerHTML: { __html: string } + }> + expect(React.isValidElement(element)).toBe(true) + expect(element.props.dangerouslySetInnerHTML.__html).toBe( + 'Text with bold and link', + ) + }) + + test('should sanitize dangerous HTML content and remove script tags', () => { + const result = processDescription('Safe text more text') + + const element = result as React.ReactElement<{ + dangerouslySetInnerHTML: { __html: string } + }> + expect(React.isValidElement(element)).toBe(true) + expect(element.props.dangerouslySetInnerHTML.__html).toBe('Safe text more text') + expect(element.props.dangerouslySetInnerHTML.__html).not.toContain(' more text', - }), - { - wrapper: FormWrapper, - }, - ) - - const element = result.current.description as React.ReactElement<{ - dangerouslySetInnerHTML: { __html: string } - }> - expect(React.isValidElement(element)).toBe(true) - expect(element.props.dangerouslySetInnerHTML.__html).toBe('Safe text more text') - expect(element.props.dangerouslySetInnerHTML.__html).not.toContain('