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
2 changes: 1 addition & 1 deletion docs/component-adapter/component-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
60 changes: 41 additions & 19 deletions src/components/Base/Base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -36,17 +36,15 @@ export interface BaseComponentInterface<TResourceKey extends keyof Resources = k

type SubmitHandler<T> = (data: T) => Promise<void>

export const BaseComponent = <TResourceKey extends keyof Resources = keyof Resources>({
export const BaseComponentProvider = <TResourceKey extends keyof Resources = keyof Resources>({
children,
FallbackComponent = InternalError,
LoaderComponent: LoadingIndicatorFromProps,
onEvent,
onEvent = () => {},
}: BaseComponentInterface<TResourceKey>) => {
const [error, setError] = useState<KnownErrors | null>(null)
const [fieldErrors, setFieldErrors] = useState<EntityErrorObject[] | null>(null)
const throwError = useAsyncError()
const { t } = useTranslation()
const Components = useComponentContext()

const { LoadingIndicator: LoadingIndicatorFromContext } = useLoadingIndicator()

Expand Down Expand Up @@ -90,6 +88,7 @@ export const BaseComponent = <TResourceKey extends keyof Resources = keyof Resou
return (
<BaseContext.Provider
value={{
error,
fieldErrors,
setError: setErrorWithFieldsClear,
onEvent,
Expand All @@ -107,23 +106,46 @@ export const BaseComponent = <TResourceKey extends keyof Resources = keyof Resou
onEvent(componentEvents.ERROR, err)
}}
>
{(error || fieldErrors) && (
<Components.Alert label={t('status.errorEncountered')} status="error">
{fieldErrors && <Components.UnorderedList items={renderErrorList(fieldErrors)} />}
{error && error instanceof APIError && (
<Components.Text>{error.message}</Components.Text>
)}
{error && error instanceof SDKValidationError && (
<Components.Text as="pre">{error.pretty()}</Components.Text>
)}
</Components.Alert>
)}
<Suspense fallback={<LoaderComponent />}>
<FadeIn>{children}</FadeIn>
</Suspense>
<Suspense fallback={<LoaderComponent />}>{children}</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</BaseContext.Provider>
)
}

export const BaseUIComponent = ({ children }: { children: ReactNode }) => {
const { error, fieldErrors } = useBase()
const { t } = useTranslation()
const Components = useComponentContext()

return (
<FadeIn>
{(error || fieldErrors) && (
<Components.Alert label={t('status.errorEncountered')} status="error">
{fieldErrors && <Components.UnorderedList items={renderErrorList(fieldErrors)} />}
{error && error instanceof APIError && <Components.Text>{error.message}</Components.Text>}
{error && error instanceof SDKValidationError && (
<Components.Text as="pre">{error.pretty()}</Components.Text>
)}
</Components.Alert>
)}
{children}
</FadeIn>
)
}

// 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 = <TResourceKey extends keyof Resources = keyof Resources>({
children,
...props
}: BaseComponentInterface<TResourceKey>) => {
return (
<BaseComponentProvider {...props}>
<BaseUIComponent>{children}</BaseUIComponent>
</BaseComponentProvider>
)
}
1 change: 1 addition & 0 deletions src/components/Base/useBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type OnEventType<K, T> = (type: K, data?: T) => void
export type KnownErrors = APIError | SDKValidationError | UnprocessableEntityErrorObject

interface BaseContextProps {
error: KnownErrors | null
fieldErrors: Array<EntityErrorObject> | null
setError: (err: KnownErrors | null) => void
onEvent: OnEventType<EventType, unknown>
Expand Down
1 change: 0 additions & 1 deletion src/components/Common/UI/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ export const Select = ({
{...props}
>
<AriaSelect
aria-label={label}
isDisabled={isDisabled}
isInvalid={isInvalid}
onSelectionChange={key => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Common/UI/Select/SelectTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface SelectProps
/**
* Label text for the select field
*/
label: string
label: React.ReactNode
/**
* Callback when selection changes
*/
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Original file line number Diff line number Diff line change
@@ -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(<Address contractorId="contractor_id" onEvent={mockOnEvent} />)

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', {

Check failure on line 61 in src/components/prototypes/ContractorAddressForm/ContractorAddressForm.test.tsx

View workflow job for this annotation

GitHub Actions / build

src/components/prototypes/ContractorAddressForm/ContractorAddressForm.test.tsx > Contractor/Address > when API has minimal address details > should allow submitting the form

TestingLibraryElementError: Unable to find an accessible element with the role "button" and name `/Select state.../i` Here are the accessible roles: article: Name "": <article class="GSDK" data-testid="GSDK" /> -------------------------------------------------- banner: Name "": <header /> -------------------------------------------------- heading: Name "Home address": <h2 class="_root_6f170c _h2_6f170c _textAlign-undefined_6f170c" /> -------------------------------------------------- paragraph: Name "": <p class="_root_173295 _md_173295" /> -------------------------------------------------- textbox: Name "Street 1": <input aria-describedby="" aria-invalid="false" class="react-aria-Input" data-rac="" id="input-_r_0_" name="street1" type="text" value="123 Main St" /> Name "Street 2 (optional)": <input aria-describedby="" aria-invalid="false" class="react-aria-Input" data-rac="" id="input-_r_3_" name="street2" type="text" value="Apt 4B" /> Name "City": <input aria-describedby="" aria-invalid="false" class="react-aria-Input" data-focused="true" data-hovered="true" data-rac="" id="input-_r_6_" name="city" type="text" value="Denver" /> Name "Zip": <input aria-describedby="" aria-invalid="false" class="react-aria-Input" data-rac="" id="input-_r_m_" name="zip" type="text" value="" /> -------------------------------------------------- button: Name "Select an item": <button aria-describedby="" aria-expanded="false" aria-haspopup="listbox" aria-labelledby="react-aria-_r_j_" class="react-aria-Button" data-rac="" data-react-aria-pressable="true" id="input-_r_9_" tabindex="0" type="button" /> Name "Continue": <button class="_root_fb3795 react-aria-Button" data-rac="" data-react-aria-pressable="true" data-variant="primary" id="react-aria-_r_p_" tabindex="0" type="submit" /> -------------------------------------------------- Ignored nodes: comments, script, style <body> <div> <article class="GSDK" data-testid="GSDK" > <div lang="en-US" > <div class="_fade_8c1d2d _fadeIn_8c1d2d" > <section> <form class="_form_bd794c" > <div class="_flexContainer_1b3eeb" > <div class="_flex_1b3eeb" style="--g-flex-direction-base: column; --g-justify-content-base: normal; --g-align-items-base: flex-start; --g-gap-base: 2rem;" > <header> <h2 class="_root_6f170c _h2_6f170c _textAlign-undefined_6f170c" > Home address </h2> <p class="_root_173295 _md_173295" > Contractor's home mailing address, within the United States. </p> </header> <div class="_gridContainer_bc76ad" > <div class="_grid_bc76ad" style="--g-gap-base: 1.25rem; --g-grid-template-columns-base: 1fr; --g-grid-template-columns-small: 1fr 1fr;" > <div class="_root_3871f5" > <div class="_labelAndDescription_3871f5 _withVisibleLabel_3871f5" > <label class="_root_b15cb7" for="input-_r_0_" > Street 1 </label> </div> <div
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(
<Address
contractorId="contractor_id"
onEvent={() => {}}
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(
<Address
contractorId="contractor_id"
onEvent={() => {}}
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(<Address contractorId="contractor_id" onEvent={() => {}} />)

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(<Address contractorId="contractor_id" onEvent={() => {}} />)

expect(await screen.findByText('Business address')).toBeInTheDocument()
expect(
screen.getByText("Contractor's business address, within the United States."),
).toBeInTheDocument()
})
})
})
Loading
Loading