diff --git a/docs/component-adapter/component-inventory.md b/docs/component-adapter/component-inventory.md index c6554d62..8fcc4944 100644 --- a/docs/component-adapter/component-inventory.md +++ b/docs/component-adapter/component-inventory.md @@ -463,7 +463,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/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/Common/UI/Select/Select.tsx b/src/components/Common/UI/Select/Select.tsx index 47bfc458..c8f91e0e 100644 --- a/src/components/Common/UI/Select/Select.tsx +++ b/src/components/Common/UI/Select/Select.tsx @@ -64,7 +64,6 @@ export const Select = ({ {...props} > { diff --git a/src/components/Common/UI/Select/SelectTypes.ts b/src/components/Common/UI/Select/SelectTypes.ts index 7951ca8f..43f12d0d 100644 --- a/src/components/Common/UI/Select/SelectTypes.ts +++ b/src/components/Common/UI/Select/SelectTypes.ts @@ -26,7 +26,7 @@ export interface SelectProps /** * Label text for the select field */ - label: string + label: React.ReactNode /** * Callback when selection changes */ 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..3bc6af03 --- /dev/null +++ b/src/components/prototypes/ContractorAddressForm/ContractorAddressForm.tsx @@ -0,0 +1,121 @@ +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { ContractorAddressFormProvider } from './ContractorAddressFormProvider' +import { useContractorAddressForm } from './useContractorAddressForm' +import type { ContractorAddressFormDefaultValues } from './useContractorAddressForm' +import { Form as HtmlForm } from '@/components/Common/Form/Form' +import { useI18n, useComponentDictionary } from '@/i18n' +import { Flex, 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 } from '@/shared/constants' +import { useBase } from '@/components/Base/useBase' + +export interface ContractorAddressFormProps extends BaseComponentInterface<'Contractor.Address'> { + contractorId: string + defaultValues?: ContractorAddressFormDefaultValues + children?: ReactNode + className?: string +} + +function ContractorAddressForm(props: ContractorAddressFormProps) { + return ( + + + + + + ) +} + +function Root({ children, className, dictionary }: ContractorAddressFormProps) { + useComponentDictionary('Contractor.Address', dictionary) + useI18n('Contractor.Address') + + const { Fields, contractor, isUpdating, onSubmit } = useContractorAddressForm() + const contractorType = contractor?.type + + const Components = useComponentContext() + const { onEvent } = useBase() + + const { t } = useTranslation('Contractor.Address') + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + + const { updatedContractorAddressResponse } = await onSubmit() + + 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')} + +
+ + + + + + + + + + + + {t('submit')} + + + + )} +
+
+
+ ) +} + +export default ContractorAddressForm diff --git a/src/components/prototypes/ContractorAddressForm/ContractorAddressFormFields.tsx b/src/components/prototypes/ContractorAddressForm/ContractorAddressFormFields.tsx new file mode 100644 index 00000000..2fe5efc1 --- /dev/null +++ b/src/components/prototypes/ContractorAddressForm/ContractorAddressFormFields.tsx @@ -0,0 +1,107 @@ +import { SelectField } from '@/components/Common' +import { STATES_ABBR } from '@/shared/constants' +import { TextInputField } from '@/components/Common' + +interface FieldProps { + label: React.ReactNode + description?: React.ReactNode + placeholder?: string +} + +interface FieldPropsWithValidations extends FieldProps { + validationMessages: Record +} + +const RequiredValidationKey = 'required' as const + +export type Street1FieldProps = FieldPropsWithValidations + +export function Street1({ + label, + description, + placeholder, + validationMessages, +}: Street1FieldProps) { + return ( + + ) +} + +export type Street2FieldProps = FieldProps + +export function Street2({ label, description, placeholder }: Street2FieldProps) { + return ( + + ) +} + +export type CityFieldProps = FieldPropsWithValidations + +export function City({ + label, + description, + placeholder, + validationMessages, +}: FieldPropsWithValidations) { + return ( + + ) +} + +export type StateFieldProps = FieldPropsWithValidations + +export function State({ label, description, placeholder, validationMessages }: StateFieldProps) { + return ( + ({ + label: stateAbbr, + value: stateAbbr, + }))} + /> + ) +} + +export type ZipFieldProps = FieldPropsWithValidations + +export function Zip({ + label, + description, + placeholder, + validationMessages, +}: FieldPropsWithValidations) { + return ( + + ) +} diff --git a/src/components/prototypes/ContractorAddressForm/ContractorAddressFormProvider.tsx b/src/components/prototypes/ContractorAddressForm/ContractorAddressFormProvider.tsx new file mode 100644 index 00000000..8bfc9b9e --- /dev/null +++ b/src/components/prototypes/ContractorAddressForm/ContractorAddressFormProvider.tsx @@ -0,0 +1,128 @@ +import { useMemo } from 'react' +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 { FormProvider, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import type { + ContractorAddressFormDefaultValues, + ContractorAddressFormValues, +} from './useContractorAddressForm' +import { + ContractorAddressFormPropsProvider, + ContractorAddressFormSchema, +} from './useContractorAddressForm' +import { Street1, Street2, City, State, Zip } from './ContractorAddressFormFields' +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 + defaultValues?: ContractorAddressFormDefaultValues +} + +function ComposedContractorAddressFormProvider(props: ComposedContractorAddressFormProviderProps) { + return ( + + {props.children} + + ) +} + +function ContractorAddressFormProvider({ + contractorId, + children, + defaultValues, +}: 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 || defaultValues?.street1 || '', + street2: address?.street2 || defaultValues?.street2 || '', + city: address?.city || defaultValues?.city || '', + state: address?.state || defaultValues?.state || '', + zip: address?.zip || defaultValues?.zip || '', + } + + const formMethods = useForm({ + resolver: zodResolver(ContractorAddressFormSchema), + defaultValues: formDefaultValues, + }) + + // TODO: This is not super elegant, we need to return the API responses from the onSubmit + // so that we can use those in the onEvent for the parent component but hook form does not + // allow doing that through the submit handler. This provides a workaround. + const onSubmit = async () => { + return new Promise<{ + updatedContractorAddressResponse: PutV1ContractorsContractorUuidAddressResponse | undefined + }>((resolve, reject) => { + formMethods + .handleSubmit( + 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, + }, + }, + }) + }) + + resolve({ updatedContractorAddressResponse: response }) + }, + () => { + resolve({ updatedContractorAddressResponse: undefined }) + }, + )() + .catch(reject) + }) + } + + const Fields = useMemo( + () => ({ + Street1, + Street2, + City, + State, + Zip, + }), + [], + ) + + 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..8c3cf818 --- /dev/null +++ b/src/components/prototypes/ContractorAddressForm/useContractorAddressForm.ts @@ -0,0 +1,69 @@ +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 type { + Street1FieldProps, + Street2FieldProps, + CityFieldProps, + StateFieldProps, + ZipFieldProps, +} from './ContractorAddressFormFields' +import { createCompoundContext } from '@/components/Base' +import type { RequireAtLeastOne } from '@/types/Helpers' + +// 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 type ContractorAddressFormDefaultValues = RequireAtLeastOne< + Pick +> + +export interface ContractorAddressFormContextType { + contractor?: Contractor + contractorType?: ContractorType + address?: ContractorAddress + isUpdating: boolean + onSubmit: () => Promise<{ + updatedContractorAddressResponse: PutV1ContractorsContractorUuidAddressResponse | undefined + }> + Fields: { + Street1: React.ComponentType + Street2: React.ComponentType + City: React.ComponentType + State: React.ComponentType + Zip: React.ComponentType + } +} + +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'