Skip to content

Commit 83dcdaf

Browse files
committed
feat: ui flexibility prototype version 1
1 parent 11bc79c commit 83dcdaf

File tree

9 files changed

+531
-19
lines changed

9 files changed

+531
-19
lines changed

src/components/Base/Base.tsx

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { UnprocessableEntityErrorObject } from '@gusto/embedded-api/models/error
99
import { QueryErrorResetBoundary } from '@tanstack/react-query'
1010
import type { EntityErrorObject } from '@gusto/embedded-api/models/components/entityerrorobject'
1111
import { FadeIn } from '../Common/FadeIn/FadeIn'
12-
import { BaseContext, type KnownErrors, type OnEventType } from './useBase'
12+
import { BaseContext, useBase, type KnownErrors, type OnEventType } from './useBase'
1313
import { componentEvents, type EventType } from '@/shared/constants'
1414
import { InternalError } from '@/components/Common'
1515
import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
@@ -36,17 +36,15 @@ export interface BaseComponentInterface<TResourceKey extends keyof Resources = k
3636

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

39-
export const BaseComponent = <TResourceKey extends keyof Resources = keyof Resources>({
39+
export const BaseComponentProvider = <TResourceKey extends keyof Resources = keyof Resources>({
4040
children,
4141
FallbackComponent = InternalError,
4242
LoaderComponent: LoadingIndicatorFromProps,
43-
onEvent,
43+
onEvent = () => {},
4444
}: BaseComponentInterface<TResourceKey>) => {
4545
const [error, setError] = useState<KnownErrors | null>(null)
4646
const [fieldErrors, setFieldErrors] = useState<EntityErrorObject[] | null>(null)
4747
const throwError = useAsyncError()
48-
const { t } = useTranslation()
49-
const Components = useComponentContext()
5048

5149
const { LoadingIndicator: LoadingIndicatorFromContext } = useLoadingIndicator()
5250

@@ -90,6 +88,7 @@ export const BaseComponent = <TResourceKey extends keyof Resources = keyof Resou
9088
return (
9189
<BaseContext.Provider
9290
value={{
91+
error,
9392
fieldErrors,
9493
setError: setErrorWithFieldsClear,
9594
onEvent,
@@ -107,23 +106,46 @@ export const BaseComponent = <TResourceKey extends keyof Resources = keyof Resou
107106
onEvent(componentEvents.ERROR, err)
108107
}}
109108
>
110-
{(error || fieldErrors) && (
111-
<Components.Alert label={t('status.errorEncountered')} status="error">
112-
{fieldErrors && <Components.UnorderedList items={renderErrorList(fieldErrors)} />}
113-
{error && error instanceof APIError && (
114-
<Components.Text>{error.message}</Components.Text>
115-
)}
116-
{error && error instanceof SDKValidationError && (
117-
<Components.Text as="pre">{error.pretty()}</Components.Text>
118-
)}
119-
</Components.Alert>
120-
)}
121-
<Suspense fallback={<LoaderComponent />}>
122-
<FadeIn>{children}</FadeIn>
123-
</Suspense>
109+
<Suspense fallback={<LoaderComponent />}>{children}</Suspense>
124110
</ErrorBoundary>
125111
)}
126112
</QueryErrorResetBoundary>
127113
</BaseContext.Provider>
128114
)
129115
}
116+
117+
export const BaseUIComponent = ({ children }: { children: ReactNode }) => {
118+
const { error, fieldErrors } = useBase()
119+
const { t } = useTranslation()
120+
const Components = useComponentContext()
121+
122+
return (
123+
<FadeIn>
124+
{(error || fieldErrors) && (
125+
<Components.Alert label={t('status.errorEncountered')} status="error">
126+
{fieldErrors && <Components.UnorderedList items={renderErrorList(fieldErrors)} />}
127+
{error && error instanceof APIError && <Components.Text>{error.message}</Components.Text>}
128+
{error && error instanceof SDKValidationError && (
129+
<Components.Text as="pre">{error.pretty()}</Components.Text>
130+
)}
131+
</Components.Alert>
132+
)}
133+
{children}
134+
</FadeIn>
135+
)
136+
}
137+
138+
// Existing BaseComponent composes BaseComponentProvider and BaseUIComponent
139+
// These were separated to allow the provider functionality to be used without
140+
// importing the UI components. BaseUIComponent can be used for our codebase
141+
// UI implementations for rendering the errors in a single place.
142+
export const BaseComponent = <TResourceKey extends keyof Resources = keyof Resources>({
143+
children,
144+
...props
145+
}: BaseComponentInterface<TResourceKey>) => {
146+
return (
147+
<BaseComponentProvider {...props}>
148+
<BaseUIComponent>{children}</BaseUIComponent>
149+
</BaseComponentProvider>
150+
)
151+
}

src/components/Base/useBase.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type OnEventType<K, T> = (type: K, data?: T) => void
1111
export type KnownErrors = APIError | SDKValidationError | UnprocessableEntityErrorObject
1212

1313
interface BaseContextProps {
14+
error: KnownErrors | null
1415
fieldErrors: Array<EntityErrorObject> | null
1516
setError: (err: KnownErrors | null) => void
1617
onEvent: OnEventType<EventType, unknown>

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * as Company from './Company'
33
export * as Contractor from './Contractor'
44
export * as Employee from './Employee'
55
export * as Payroll from './Payroll'
6+
export * as Prototypes from './prototypes'
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { screen } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import { HttpResponse } from 'msw'
5+
import Address from './ContractorAddressForm'
6+
import { server } from '@/test/mocks/server'
7+
import {
8+
handleGetContractor,
9+
handleGetContractorAddress,
10+
handleUpdateContractorAddress,
11+
} from '@/test/mocks/apis/contractor_address'
12+
import { setupApiTestMocks } from '@/test/mocks/apiServer'
13+
import { contractorEvents } from '@/shared/constants'
14+
import { renderWithProviders } from '@/test-utils/renderWithProviders'
15+
16+
describe('Contractor/Address', () => {
17+
beforeEach(() => {
18+
setupApiTestMocks()
19+
})
20+
21+
describe('when API has minimal address details', () => {
22+
const exampleUpdatedAddress = {
23+
version: 'contractor-address-version-updated',
24+
country: 'USA',
25+
street_1: '123 Main St',
26+
street_2: 'Apt 4B',
27+
city: 'Denver',
28+
state: 'CO',
29+
zip: '80202',
30+
}
31+
32+
beforeEach(() => {
33+
server.use(
34+
handleGetContractorAddress(() =>
35+
HttpResponse.json({
36+
version: 'contractor-address-version',
37+
street_1: null,
38+
street_2: null,
39+
city: null,
40+
state: null,
41+
zip: null,
42+
country: 'USA',
43+
}),
44+
),
45+
handleUpdateContractorAddress(() => HttpResponse.json(exampleUpdatedAddress)),
46+
)
47+
})
48+
49+
it('should allow submitting the form', async () => {
50+
const user = userEvent.setup()
51+
const mockOnEvent = vi.fn()
52+
53+
renderWithProviders(<Address contractorId="contractor_id" onEvent={mockOnEvent} />)
54+
55+
await screen.findByText('Home address')
56+
57+
await user.type(screen.getByLabelText('Street 1'), '123 Main St')
58+
await user.type(screen.getByLabelText(/Street 2/i), 'Apt 4B')
59+
await user.type(screen.getByLabelText('City'), 'Denver')
60+
61+
const stateControl = screen.getByRole('button', {
62+
name: /Select state.../i,
63+
expanded: false,
64+
})
65+
await user.click(stateControl)
66+
const coloradoOption = screen.getByRole('option', {
67+
name: /Colorado/i,
68+
})
69+
await user.click(coloradoOption)
70+
71+
await user.type(screen.getByLabelText('Zip'), '80202')
72+
73+
const continueButton = screen.getByRole('button', {
74+
name: /Continue/i,
75+
})
76+
await user.click(continueButton)
77+
78+
expect(mockOnEvent).toHaveBeenNthCalledWith(
79+
1,
80+
contractorEvents.CONTRACTOR_ADDRESS_UPDATED,
81+
expect.objectContaining({
82+
version: exampleUpdatedAddress.version,
83+
street1: exampleUpdatedAddress.street_1,
84+
street2: exampleUpdatedAddress.street_2,
85+
city: exampleUpdatedAddress.city,
86+
state: exampleUpdatedAddress.state,
87+
zip: exampleUpdatedAddress.zip,
88+
country: exampleUpdatedAddress.country,
89+
}),
90+
)
91+
92+
expect(mockOnEvent).toHaveBeenNthCalledWith(2, contractorEvents.CONTRACTOR_ADDRESS_DONE)
93+
})
94+
95+
it('should allow setting default values', async () => {
96+
renderWithProviders(
97+
<Address
98+
contractorId="contractor_id"
99+
onEvent={() => {}}
100+
defaultValues={{
101+
street1: '999 Default St',
102+
street2: 'Apt 123',
103+
city: 'Default City',
104+
state: 'CO',
105+
zip: '80202',
106+
}}
107+
/>,
108+
)
109+
110+
await screen.findByText('Home address')
111+
112+
expect(screen.getByLabelText('Street 1')).toHaveValue('999 Default St')
113+
expect(screen.getByLabelText(/Street 2/i)).toHaveValue('Apt 123')
114+
expect(screen.getByLabelText('City')).toHaveValue('Default City')
115+
expect(
116+
screen.getByRole('button', {
117+
name: /Colorado/i,
118+
expanded: false,
119+
}),
120+
).toBeInTheDocument()
121+
expect(screen.getByLabelText('Zip')).toHaveValue('80202')
122+
})
123+
})
124+
125+
describe('when API has full address details', () => {
126+
beforeEach(() => {
127+
server.use(
128+
handleGetContractorAddress(() =>
129+
HttpResponse.json({
130+
version: 'contractor-address-version',
131+
street_1: '999 Kiera Stravenue',
132+
street_2: 'Suite 541',
133+
city: 'San Francisco',
134+
state: 'CA',
135+
zip: '94107',
136+
country: 'USA',
137+
}),
138+
),
139+
)
140+
})
141+
142+
it('should defer to values from API over default values', async () => {
143+
renderWithProviders(
144+
<Address
145+
contractorId="contractor_id"
146+
onEvent={() => {}}
147+
defaultValues={{
148+
street1: '999 Default St',
149+
street2: 'Apt 123',
150+
city: 'Default City',
151+
state: 'CO',
152+
zip: '80202',
153+
}}
154+
/>,
155+
)
156+
157+
await screen.findByText('Home address')
158+
159+
expect(screen.getByLabelText('Street 1')).toHaveValue('999 Kiera Stravenue')
160+
expect(screen.getByLabelText(/Street 2/i)).toHaveValue('Suite 541')
161+
expect(screen.getByLabelText('City')).toHaveValue('San Francisco')
162+
expect(
163+
screen.getByRole('button', {
164+
name: /California/i,
165+
expanded: false,
166+
}),
167+
).toBeInTheDocument()
168+
expect(screen.getByLabelText('Zip')).toHaveValue('94107')
169+
})
170+
})
171+
172+
describe('contractor type in heading', () => {
173+
it('should show individual text when contractorType is Individual', async () => {
174+
renderWithProviders(<Address contractorId="contractor_id" onEvent={() => {}} />)
175+
176+
expect(await screen.findByText('Home address')).toBeInTheDocument()
177+
expect(
178+
screen.getByText("Contractor's home mailing address, within the United States."),
179+
).toBeInTheDocument()
180+
})
181+
182+
it('should show business text when contractorType is Business', async () => {
183+
server.use(
184+
handleGetContractor(() => {
185+
return HttpResponse.json({
186+
uuid: 'contractor_id',
187+
type: 'Business',
188+
is_active: true,
189+
file_new_hire_report: false,
190+
})
191+
}),
192+
)
193+
194+
renderWithProviders(<Address contractorId="contractor_id" onEvent={() => {}} />)
195+
196+
expect(await screen.findByText('Business address')).toBeInTheDocument()
197+
expect(
198+
screen.getByText("Contractor's business address, within the United States."),
199+
).toBeInTheDocument()
200+
})
201+
})
202+
})

0 commit comments

Comments
 (0)