From 1a3b96d3a0ef99ae14a6a20bb7d74c793c1e2b05 Mon Sep 17 00:00:00 2001 From: Steve Jensen Date: Tue, 2 Jun 2026 12:25:00 -0600 Subject: [PATCH 1/3] docs: document EmployeeManagement.HomeAddress standalone block Adds the partner-facing entry for the newly migrated HomeAddress block, mirroring the Profile section: a block-level overview with props/events, plus a "compose from card + edit form directly" subsection covering HomeAddressCard and HomeAddressEditForm. Co-authored-by: Cursor --- .../employee-management.md | 113 ++++++++++ .../management/HomeAddressEditForm.test.tsx | 198 ++++++++++++++++++ .../management/HomeAddressEditForm.tsx | 76 +++++++ 3 files changed, 387 insertions(+) create mode 100644 src/components/Employee/HomeAddress/management/HomeAddressEditForm.test.tsx create mode 100644 src/components/Employee/HomeAddress/management/HomeAddressEditForm.tsx diff --git a/docs/workflows-overview/employee-management/employee-management.md b/docs/workflows-overview/employee-management/employee-management.md index 295f6c110..ebfe2e88f 100644 --- a/docs/workflows-overview/employee-management/employee-management.md +++ b/docs/workflows-overview/employee-management/employee-management.md @@ -33,6 +33,8 @@ Employee management components can be used to compose your own workflow, or can - [EmployeeManagement.DashboardFlow](#employeemanagementdashboardflow) - [EmployeeManagement.Profile](#employeemanagementprofile) - [Composing from EmployeeManagement.ProfileCard and EmployeeManagement.ProfileEditForm directly](#composing-from-employeemanagementprofilecard-and-employeemanagementprofileeditform-directly) +- [EmployeeManagement.HomeAddress](#employeemanagementhomeaddress) + - [Composing from EmployeeManagement.HomeAddressCard and EmployeeManagement.HomeAddressEditForm directly](#composing-from-employeemanagementhomeaddresscard-and-employeemanagementhomeaddresseditform-directly) ### EmployeeManagement.DashboardFlow @@ -207,3 +209,114 @@ function MyBasicDetailsPanel({ employeeId }) { | ------------------------------------------ | --------------------------------------------------- | ------------------------- | | EMPLOYEE_PROFILE_MANAGEMENT_UPDATED | Fired after the edit form is successfully submitted | Updated `Employee` entity | | EMPLOYEE_PROFILE_MANAGEMENT_EDIT_CANCELLED | Fired when the user clicks Cancel on the edit form | None | + +### EmployeeManagement.HomeAddress + +A self-contained block for viewing and managing an employee's home address — the same "Home address" experience the dashboard surfaces, but as a drop-in component that doesn't require the surrounding dashboard chrome. Renders a read-only card showing the employee's current home address. Clicking the card's "Manage" CTA swaps the card view for an inline manage screen that surfaces the current address, the address history, and forms for editing the current address, adding a new one, or deleting a non-active one. Clicking Back returns to the card view; creates, updates, and deletes happen in place on the manage screen and do not navigate away. + +```jsx +import { EmployeeManagement } from '@gusto/embedded-react-sdk' + +function MyComponent() { + return ( + {}} + /> + ) +} +``` + +#### Props + +| Name | Type | Description | +| ------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| employeeId Required | string | The associated employee identifier. | +| onEvent Required | function | See events table for available events. | +| dictionary | object | Optional translations for component text. Keys are namespaced under `Employee.HomeAddress.Management` — see the source JSON for the set. | +| FallbackComponent | React.ComponentType | Optional custom error fallback component used by the internal `BaseBoundaries` wrapper. | + +#### Events + +| Event type | Description | Data | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------- | +| EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED | Fired when the user clicks the "Manage" CTA on the card; the block swaps to the manage screen | { employeeId: string } | +| EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_CANCELLED | Fired when the user clicks Back on the manage screen; the block returns to the card view | None | +| EMPLOYEE_HOME_ADDRESS_MANAGEMENT_CREATED | Fired after a new home address is successfully created from the manage screen | Created `EmployeeAddress` entity | +| EMPLOYEE_HOME_ADDRESS_MANAGEMENT_UPDATED | Fired after an existing home address is successfully updated from the manage screen | Updated `EmployeeAddress` entity | +| EMPLOYEE_HOME_ADDRESS_MANAGEMENT_DELETED | Fired after a non-active home address is successfully deleted from the manage screen | Deleted `EmployeeAddress` entity | + +#### Composing from EmployeeManagement.HomeAddressCard and EmployeeManagement.HomeAddressEditForm directly + +`EmployeeManagement.HomeAddress` above is the recommended entry point for the home-address experience — it bundles the card, the manage screen, and the swap between them as a single drop-in. The card and edit form are also exported individually for cases where that orchestration is the wrong fit — for example, when the manage screen needs to render in a modal or drawer, when the card needs to appear read-only with no manage affordance, or when the swap is driven by a router. Using them directly means owning the swap and any cross-component state yourself. + +`EmployeeManagement.HomeAddressCard` renders the read-only home-address card and emits a single event when its "Manage" CTA is clicked. `EmployeeManagement.HomeAddressEditForm` renders the corresponding manage screen and emits events on create, update, delete, and cancel. Each piece's `onEvent` receives the event type as its first argument and any associated payload as its second — branch on the event type to drive the swap (and any of your own behavior, e.g. surfacing a success message after a save). The per-piece events tables below list every event each piece emits. + +```jsx +import { useState } from 'react' +import { componentEvents, EmployeeManagement } from '@gusto/embedded-react-sdk' + +function MyHomeAddressPanel({ employeeId }) { + const [isEditing, setIsEditing] = useState(false) + + if (isEditing) { + return ( + { + if (eventType === componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_CANCELLED) { + setIsEditing(false) + } + }} + /> + ) + } + + return ( + { + if (eventType === componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED) { + setIsEditing(true) + } + }} + /> + ) +} +``` + +##### EmployeeManagement.HomeAddressCard + +**Props** + +| Name | Type | Description | +| ------------------- | -------- | -------------------------------------- | +| employeeId Required | string | The associated employee identifier. | +| onEvent Required | function | See events table for available events. | + +**Events** + +| Event type | Description | Data | +| ----------------------------------------------- | ------------------------------------------------------- | ---------------------- | +| EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED | Fired when the user clicks the "Manage" CTA on the card | { employeeId: string } | + +##### EmployeeManagement.HomeAddressEditForm + +**Props** + +| Name | Type | Description | +| ------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| employeeId Required | string | The associated employee identifier. | +| onEvent Required | function | See events table for available events. | +| className | string | Optional class applied to the form's root section element. | +| dictionary | object | Optional translations for component text. Keys are namespaced under `Employee.HomeAddress.Management` — see the source JSON for the set. | +| FallbackComponent | React.ComponentType | Optional custom error fallback component used by the internal `BaseBoundaries` wrapper. | + +**Events** + +| Event type | Description | Data | +| ----------------------------------------------- | ------------------------------------------------------------- | -------------------------------- | +| EMPLOYEE_HOME_ADDRESS_MANAGEMENT_CREATED | Fired after a new home address is successfully created | Created `EmployeeAddress` entity | +| EMPLOYEE_HOME_ADDRESS_MANAGEMENT_UPDATED | Fired after an existing home address is successfully updated | Updated `EmployeeAddress` entity | +| EMPLOYEE_HOME_ADDRESS_MANAGEMENT_DELETED | Fired after a non-active home address is successfully deleted | Deleted `EmployeeAddress` entity | +| EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_CANCELLED | Fired when the user clicks Back on the manage screen | None | diff --git a/src/components/Employee/HomeAddress/management/HomeAddressEditForm.test.tsx b/src/components/Employee/HomeAddress/management/HomeAddressEditForm.test.tsx new file mode 100644 index 000000000..78e5b00ef --- /dev/null +++ b/src/components/Employee/HomeAddress/management/HomeAddressEditForm.test.tsx @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse, http, type HttpResponseResolver } from 'msw' +import { HomeAddressEditForm } from './HomeAddressEditForm' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { server } from '@/test/mocks/server' +import { API_BASE_URL } from '@/test/constants' + +describe('HomeAddressEditForm', () => { + const onEvent = vi.fn() + + beforeEach(() => { + onEvent.mockClear() + setupApiTestMocks() + }) + + it('renders management copy and current home address from fixtures', async () => { + renderWithProviders() + + await waitFor( + () => { + expect(screen.getByText(/100 5th Ave/)).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + expect(screen.getByRole('heading', { name: 'Manage home address' })).toBeInTheDocument() + expect(screen.getByText('Current home address')).toBeInTheDocument() + expect(screen.getByText(/New York/)).toBeInTheDocument() + }) + + it('lists prior home addresses in history', async () => { + renderWithProviders() + + await waitFor( + () => { + expect(screen.getByText(/644 Fay Vista/)).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + expect(screen.getByRole('heading', { name: 'Home address history' })).toBeInTheDocument() + expect(screen.getByText(/Richmond/)).toBeInTheDocument() + }) + + it('opens the add-address modal when Change address is clicked', async () => { + const user = userEvent.setup() + + renderWithProviders() + + await waitFor( + () => { + expect(screen.getByRole('button', { name: 'Change address' })).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + await user.click(screen.getByRole('button', { name: 'Change address' })) + + expect(screen.getByRole('heading', { name: 'Add a new home address' })).toBeInTheDocument() + }) + + it('marks the Start date as required in the Add address modal', async () => { + const user = userEvent.setup() + renderWithProviders() + + await waitFor( + () => { + expect(screen.getByRole('button', { name: 'Change address' })).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + await user.click(screen.getByRole('button', { name: 'Change address' })) + + const dialog = await screen.findByRole('dialog') + const startDateLabel = within(dialog).getByText('Start date').closest('label') + expect(startDateLabel).not.toBeNull() + expect(startDateLabel?.textContent ?? '').not.toMatch(/\(optional\)/i) + }) + + it('keeps the Add address modal open and surfaces inline validation when Save is clicked with empty fields', async () => { + const user = userEvent.setup() + const createResolver = vi.fn(() => HttpResponse.json({}, { status: 201 })) + server.use( + http.post(`${API_BASE_URL}/v1/employees/:employee_id/home_addresses`, createResolver), + ) + + renderWithProviders() + + await waitFor( + () => { + expect(screen.getByRole('button', { name: 'Change address' })).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + await user.click(screen.getByRole('button', { name: 'Change address' })) + + const dialog = await screen.findByRole('dialog') + expect( + within(dialog).getByRole('heading', { name: 'Add a new home address' }), + ).toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: 'Save' })) + + expect(await within(dialog).findByText('Street address is required')).toBeInTheDocument() + expect( + within(dialog).getByRole('heading', { name: 'Add a new home address' }), + ).toBeInTheDocument() + expect(createResolver).not.toHaveBeenCalled() + }) + + it('clears entered values when the Add address modal is closed and reopened', async () => { + const user = userEvent.setup() + renderWithProviders() + + await waitFor( + () => { + expect(screen.getByRole('button', { name: 'Change address' })).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + await user.click(screen.getByRole('button', { name: 'Change address' })) + + const firstOpenDialog = await screen.findByRole('dialog') + const street1FirstOpen = within(firstOpenDialog).getByLabelText(/Street 1/i) + await user.type(street1FirstOpen, '999 Throwaway Lane') + expect(street1FirstOpen).toHaveValue('999 Throwaway Lane') + + await user.click(within(firstOpenDialog).getByRole('button', { name: 'Cancel' })) + + await waitFor(() => { + expect(screen.queryByRole('heading', { name: 'Add a new home address' })).toBeNull() + }) + + await user.click(screen.getByRole('button', { name: 'Change address' })) + + const reopenedDialog = await screen.findByRole('dialog') + expect(within(reopenedDialog).getByLabelText(/Street 1/i)).toHaveValue('') + }) + + it('opens the edit modal for a history row without re-fetching that row individually', async () => { + const user = userEvent.setup() + + // Block the single-address retrieve endpoint. With the cache-warming fix, + // editing a list-known row should not hit it; without the fix, the call + // would hang and the page would stay in a loading state. + const retrieveResolver = vi.fn(() => new Promise(() => {}) as never) + server.use(http.get(`${API_BASE_URL}/v1/home_addresses/:uuid`, retrieveResolver)) + + renderWithProviders() + + await waitFor( + () => { + expect(screen.getByText(/644 Fay Vista/)).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + const menuButtons = screen.getAllByRole('button', { + name: 'Open address row actions', + }) + await user.click(menuButtons[0] as HTMLElement) + await user.click(await screen.findByRole('menuitem', { name: 'Edit' })) + + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByRole('heading', { name: 'Edit home address' })).toBeInTheDocument() + expect(retrieveResolver).not.toHaveBeenCalled() + }) + + it('exposes the Start date field in the Edit modal so it can be corrected', async () => { + const user = userEvent.setup() + renderWithProviders() + + await waitFor( + () => { + expect(screen.getByText(/644 Fay Vista/)).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + const menuButtons = screen.getAllByRole('button', { + name: 'Open address row actions', + }) + await user.click(menuButtons[0] as HTMLElement) + await user.click(await screen.findByRole('menuitem', { name: 'Edit' })) + + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByRole('heading', { name: 'Edit home address' })).toBeInTheDocument() + // Start date field must be present so the effective date can be corrected + // without round-tripping through delete + create. + expect(within(dialog).getByText('Start date')).toBeInTheDocument() + }) +}) diff --git a/src/components/Employee/HomeAddress/management/HomeAddressEditForm.tsx b/src/components/Employee/HomeAddress/management/HomeAddressEditForm.tsx new file mode 100644 index 000000000..ea31fc8d5 --- /dev/null +++ b/src/components/Employee/HomeAddress/management/HomeAddressEditForm.tsx @@ -0,0 +1,76 @@ +import type { EmployeeAddress } from '@gusto/embedded-api-v-2025-11-15/models/components/employeeaddress' +import { HomeAddressView } from './HomeAddressView' +import { + isUseHomeAddressManagementSuccess, + useHomeAddressManagement, +} from './useHomeAddressManagement' +import { + BaseBoundaries, + BaseLayout, + type BaseComponentInterface, + type CommonComponentInterface, +} from '@/components/Base/Base' +import { useI18n, useComponentDictionary } from '@/i18n' +import type { HookSubmitResult } from '@/partner-hook-utils/types' +import { componentEvents } from '@/shared/constants' + +export interface HomeAddressEditFormProps extends CommonComponentInterface<'Employee.HomeAddress.Management'> { + employeeId: string + onEvent: BaseComponentInterface['onEvent'] +} + +function HomeAddressEditFormRoot({ employeeId, onEvent, dictionary }: HomeAddressEditFormProps) { + useI18n(['Employee.HomeAddress.Management', 'Employee.HomeAddress']) + useComponentDictionary('Employee.HomeAddress.Management', dictionary) + + const management = useHomeAddressManagement({ employeeId, onEvent }) + + if (management.isLoading) { + return + } + + if (!isUseHomeAddressManagementSuccess(management)) { + return + } + + const handleSaved = (result: HookSubmitResult) => { + if (result.mode === 'create') { + onEvent(componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_CREATED, result.data) + } else { + onEvent(componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_UPDATED, result.data) + } + } + + return ( + + { + onEvent(componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_CANCELLED) + }} + isDeletePending={management.status.isDeletePending} + /> + + ) +} + +export function HomeAddressEditForm({ + FallbackComponent, + ...props +}: HomeAddressEditFormProps & BaseComponentInterface) { + return ( + + + + ) +} From d9abb4ac0e87df7fb71473853ff86190e7597967 Mon Sep 17 00:00:00 2001 From: Steve Jensen Date: Tue, 2 Jun 2026 16:35:58 -0600 Subject: [PATCH 2/3] feat: extract HomeAddress card from DashboardFlow into standalone management block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the Home Address card out of the monolithic dashboard into four independently consumable pieces under `Employee/HomeAddress/`: - `useHomeAddressSummary` — read-only `BaseHookReady`-shaped data hook (`shared/useHomeAddressSummary/`) - `HomeAddressCard` — standalone, self-fetching card (`management/HomeAddressCard/`) - `HomeAddressEditForm` — the renamed existing edit screen (was `HomeAddress.tsx`) - `HomeAddress` — orchestrated block composing card + edit form via a local robot3 state machine Adds scoped `EMPLOYEE_HOME_ADDRESS_MANAGEMENT_*` events (`EDIT_REQUESTED`, `EDIT_CANCELLED`, `CREATED`, `UPDATED`, `DELETED`). The dashboard composes the new card + edit form pieces (not the block) and routes its existing card↔edit transitions through the scoped events. Legacy onboarding `EMPLOYEE_HOME_ADDRESS_CREATED`/`UPDATED` constants are preserved for non-management consumers. Splits the bundled `useEmployeeBasicDetails` so it no longer fetches home-address data — the card owns its own fetch via the new hook. The hook now only carries work-address data, queued for the WorkAddress migration to retire entirely. Decouples the management edit form's translations from `Employee.HomeAddress`: relocates the field labels, validation messages, courtesy-withholding copy, and no-current-address placeholder into `Employee.HomeAddress.Management.json` under `form.*`, and switches `HomeAddressView` to read everything from the management namespace. Onboarding consumers keep reading from `Employee.HomeAddress` unchanged. Other changes: - Registers `HomeAddress`, `HomeAddressCard`, `HomeAddressEditForm` in the SDK dev app registry - Drops `homeAddress.*` keys from `Employee.Dashboard.json` (moved into the block's namespace under `card.*`) - Updates `BasicDetailsView.stories.tsx` to drop the removed home-address props - Regenerates the endpoint inventory (the dashboard no longer hits `/v1/employees/:employeeId/home_addresses`; the card does) Co-authored-by: Cursor --- docs/reference/endpoint-inventory.json | 8 - docs/reference/endpoint-reference.md | 2 - sdk-app/src/generated-registry-data.ts | 2 + .../Dashboard/BasicDetailsView.stories.tsx | 15 -- .../Employee/Dashboard/BasicDetailsView.tsx | 58 +----- .../Employee/Dashboard/Dashboard.test.tsx | 9 +- .../Employee/Dashboard/Dashboard.tsx | 5 - .../Dashboard/DashboardComponents.tsx | 4 +- .../Dashboard/dashboardStateMachine.ts | 10 +- .../hooks/useEmployeeBasicDetails.test.tsx | 99 +--------- .../hooks/useEmployeeBasicDetails.tsx | 44 +---- .../management/HomeAddress.test.tsx | 180 +++--------------- .../HomeAddress/management/HomeAddress.tsx | 81 +++----- .../HomeAddressCard/HomeAddressCard.test.tsx | 63 ++++++ .../HomeAddressCard/HomeAddressCard.tsx | 71 +++++++ .../management/HomeAddressCard/index.ts | 2 + .../management/HomeAddressComponents.tsx | 18 ++ .../management/HomeAddressEditForm.tsx | 2 +- .../management/HomeAddressView.tsx | 63 +++--- .../management/homeAddressStateMachine.ts | 35 ++++ .../Employee/HomeAddress/management/index.ts | 6 + .../management/useHomeAddressManagement.tsx | 2 +- .../Employee/HomeAddress/shared/index.ts | 6 + .../shared/useHomeAddressSummary/index.ts | 6 + .../useHomeAddressSummary.test.tsx | 120 ++++++++++++ .../useHomeAddressSummary.tsx | 50 +++++ .../Employee/exports/employeeManagement.ts | 8 +- src/components/Employee/index.ts | 4 +- src/components/Flow/Flow.test.tsx | 2 +- src/i18n/en/Employee.Dashboard.json | 6 - .../en/Employee.HomeAddress.Management.json | 24 ++- src/shared/constants.ts | 8 +- src/types/i18next.d.ts | 28 ++- 33 files changed, 564 insertions(+), 477 deletions(-) create mode 100644 src/components/Employee/HomeAddress/management/HomeAddressCard/HomeAddressCard.test.tsx create mode 100644 src/components/Employee/HomeAddress/management/HomeAddressCard/HomeAddressCard.tsx create mode 100644 src/components/Employee/HomeAddress/management/HomeAddressCard/index.ts create mode 100644 src/components/Employee/HomeAddress/management/HomeAddressComponents.tsx create mode 100644 src/components/Employee/HomeAddress/management/homeAddressStateMachine.ts create mode 100644 src/components/Employee/HomeAddress/management/index.ts create mode 100644 src/components/Employee/HomeAddress/shared/index.ts create mode 100644 src/components/Employee/HomeAddress/shared/useHomeAddressSummary/index.ts create mode 100644 src/components/Employee/HomeAddress/shared/useHomeAddressSummary/useHomeAddressSummary.test.tsx create mode 100644 src/components/Employee/HomeAddress/shared/useHomeAddressSummary/useHomeAddressSummary.tsx diff --git a/docs/reference/endpoint-inventory.json b/docs/reference/endpoint-inventory.json index e46c5d267..15af4ac52 100644 --- a/docs/reference/endpoint-inventory.json +++ b/docs/reference/endpoint-inventory.json @@ -833,10 +833,6 @@ "method": "GET", "path": "/v1/employees/:employeeId/forms" }, - { - "method": "GET", - "path": "/v1/employees/:employeeId/home_addresses" - }, { "method": "GET", "path": "/v1/employees/:employeeId/jobs" @@ -1982,10 +1978,6 @@ "method": "GET", "path": "/v1/employees/:employeeId/forms" }, - { - "method": "GET", - "path": "/v1/employees/:employeeId/home_addresses" - }, { "method": "GET", "path": "/v1/employees/:employeeId/jobs" diff --git a/docs/reference/endpoint-reference.md b/docs/reference/endpoint-reference.md index 39aaed4eb..82162480d 100644 --- a/docs/reference/endpoint-reference.md +++ b/docs/reference/endpoint-reference.md @@ -162,7 +162,6 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json' | **Employee.DashboardFlow** | DELETE | `/v1/compensations/:compensationId` | | | GET | `/v1/employees/:employeeId` | | | GET | `/v1/employees/:employeeId/forms` | -| | GET | `/v1/employees/:employeeId/home_addresses` | | | GET | `/v1/employees/:employeeId/jobs` | | | GET | `/v1/employees/:employeeId/pay_stubs` | | | GET | `/v1/employees/:employeeId/work_addresses` | @@ -374,7 +373,6 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json' | **EmployeeManagement.DashboardFlow** | DELETE | `/v1/compensations/:compensationId` | | | GET | `/v1/employees/:employeeId` | | | GET | `/v1/employees/:employeeId/forms` | -| | GET | `/v1/employees/:employeeId/home_addresses` | | | GET | `/v1/employees/:employeeId/jobs` | | | GET | `/v1/employees/:employeeId/pay_stubs` | | | GET | `/v1/employees/:employeeId/work_addresses` | diff --git a/sdk-app/src/generated-registry-data.ts b/sdk-app/src/generated-registry-data.ts index 2c251b0a7..57b7a0e9d 100644 --- a/sdk-app/src/generated-registry-data.ts +++ b/sdk-app/src/generated-registry-data.ts @@ -39,6 +39,8 @@ export const ENTITY_REQUIREMENTS: Record = { 'EmployeeManagement.EmployeeListFlow': ['companyId'], 'EmployeeManagement.FederalTaxes': ['employeeId'], 'EmployeeManagement.HomeAddress': ['employeeId'], + 'EmployeeManagement.HomeAddressCard': ['employeeId'], + 'EmployeeManagement.HomeAddressEditForm': ['employeeId'], 'EmployeeManagement.PaymentMethod': ['employeeId'], 'EmployeeManagement.Profile': ['employeeId'], 'EmployeeManagement.ProfileCard': ['employeeId'], diff --git a/src/components/Employee/Dashboard/BasicDetailsView.stories.tsx b/src/components/Employee/Dashboard/BasicDetailsView.stories.tsx index 82f09137a..3e48be02f 100644 --- a/src/components/Employee/Dashboard/BasicDetailsView.stories.tsx +++ b/src/components/Employee/Dashboard/BasicDetailsView.stories.tsx @@ -1,5 +1,4 @@ import { fn } from 'storybook/test' -import type { EmployeeAddress } from '@gusto/embedded-api-v-2025-11-15/models/components/employeeaddress' import type { EmployeeWorkAddress } from '@gusto/embedded-api-v-2025-11-15/models/components/employeeworkaddress' import { BasicDetailsView } from './BasicDetailsView' import { BaseComponent } from '@/components/Base' @@ -16,21 +15,10 @@ export default { } const onEvent = fn().mockName('onEvent') -const onManageHomeAddress = fn().mockName('onManageHomeAddress') const onManageWorkAddress = fn().mockName('onManageWorkAddress') const EMPLOYEE_ID = 'employee-123' -const homeAddress: EmployeeAddress = { - uuid: 'home-address-1', - version: '1', - country: 'USA', - street1: '100 5th Ave', - city: 'New York', - state: 'NY', - zip: '10001', -} - const workAddress: EmployeeWorkAddress = { uuid: 'work-address-1', version: '1', @@ -49,9 +37,7 @@ export const WithAllDetails = () => ( ) @@ -60,7 +46,6 @@ export const WithoutAddresses = () => ( ) diff --git a/src/components/Employee/Dashboard/BasicDetailsView.tsx b/src/components/Employee/Dashboard/BasicDetailsView.tsx index 27fe01715..9a0fa5c27 100644 --- a/src/components/Employee/Dashboard/BasicDetailsView.tsx +++ b/src/components/Employee/Dashboard/BasicDetailsView.tsx @@ -1,11 +1,10 @@ import { useTranslation } from 'react-i18next' -import type { EmployeeAddress } from '@gusto/embedded-api-v-2025-11-15/models/components/employeeaddress' import type { EmployeeWorkAddress } from '@gusto/embedded-api-v-2025-11-15/models/components/employeeworkaddress' import { useEmployeeBasicDetails } from './hooks' import { ProfileCard } from '@/components/Employee/Profile/management/ProfileCard' +import { HomeAddressCard } from '@/components/Employee/HomeAddress/management/HomeAddressCard' import { Flex } from '@/components/Common/Flex/Flex' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' -import { getStreet, getCityStateZip } from '@/helpers/formattedStrings' import { Loading } from '@/components/Common' import { BaseLayout } from '@/components/Base/Base' import type { OnEventType } from '@/components/Base/useBase' @@ -14,34 +13,29 @@ import type { EventType } from '@/shared/constants' export interface BasicDetailsViewProps { employeeId: string onEvent: OnEventType - currentHomeAddress?: EmployeeAddress currentWorkAddress?: EmployeeWorkAddress - /** Loads the address cards. Per-section flags below take precedence - * when each query resolves independently. */ + /** Loads the work address card. */ isLoading?: boolean - isHomeAddressLoading?: boolean isWorkAddressLoading?: boolean - onManageHomeAddress?: () => void onManageWorkAddress?: () => void } export interface BasicDetailsViewWithDataProps { employeeId: string onEvent: OnEventType - onManageHomeAddress?: () => void onManageWorkAddress?: () => void } /** * Tab-mounted container for the Basic details tab. Owns the - * `useEmployeeBasicDetails` fetch for the home + work address cards. - * The basic-details card is now self-fetching via ``, - * so this container no longer threads employee data through. + * `useEmployeeBasicDetails` fetch for the work address card. The + * basic-details card is self-fetching via ``, and the + * home address card is self-fetching via ``, so + * this container only threads work-address data through. */ export function BasicDetailsViewWithData({ employeeId, onEvent, - onManageHomeAddress, onManageWorkAddress, }: BasicDetailsViewWithDataProps) { const basicDetails = useEmployeeBasicDetails({ employeeId }) @@ -51,11 +45,8 @@ export function BasicDetailsViewWithData({ @@ -65,12 +56,9 @@ export function BasicDetailsViewWithData({ export function BasicDetailsView({ employeeId, onEvent, - currentHomeAddress, currentWorkAddress, isLoading = false, - isHomeAddressLoading = isLoading, isWorkAddressLoading = isLoading, - onManageHomeAddress, onManageWorkAddress, }: BasicDetailsViewProps) { const { t } = useTranslation('Employee.Dashboard') @@ -80,39 +68,7 @@ export function BasicDetailsView({ - - {t('homeAddress.manageCta')} - - } - /> - } - > - - {isHomeAddressLoading ? ( - - ) : currentHomeAddress ? ( - - - {getStreet(currentHomeAddress).replace(',', '')} - - - {getCityStateZip(currentHomeAddress)} - - - ) : ( - {t('homeAddress.noAddress')} - )} - - + { ) }) - it('emits EMPLOYEE_HOME_ADDRESS event when clicking manage home address', async () => { + it('emits the scoped EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED event when clicking manage home address', async () => { const user = userEvent.setup() renderWithProviders() @@ -292,9 +292,10 @@ describe('Dashboard', () => { assertDefined(homeAddressBox) await user.click(within(homeAddressBox).getByRole('button', { name: 'Manage' })) - expect(onEvent).toHaveBeenCalledWith(componentEvents.EMPLOYEE_HOME_ADDRESS, { - employeeId: 'employee-123', - }) + expect(onEvent).toHaveBeenCalledWith( + componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED, + { employeeId: 'employee-123' }, + ) }) it('emits EMPLOYEE_WORK_ADDRESS event when clicking manage work address', async () => { diff --git a/src/components/Employee/Dashboard/Dashboard.tsx b/src/components/Employee/Dashboard/Dashboard.tsx index 512d8e129..e619ed7f6 100644 --- a/src/components/Employee/Dashboard/Dashboard.tsx +++ b/src/components/Employee/Dashboard/Dashboard.tsx @@ -39,10 +39,6 @@ function DashboardRoot({ const [internalTab, setInternalTab] = useState('basicDetails') const selectedTab = controlledTab ?? internalTab - const handleManageHomeAddress = useCallback(() => { - onEvent(componentEvents.EMPLOYEE_HOME_ADDRESS, { employeeId }) - }, [onEvent, employeeId]) - const handleManageWorkAddress = useCallback(() => { onEvent(componentEvents.EMPLOYEE_WORK_ADDRESS, { employeeId }) }, [onEvent, employeeId]) @@ -138,7 +134,6 @@ function DashboardRoot({ diff --git a/src/components/Employee/Dashboard/DashboardComponents.tsx b/src/components/Employee/Dashboard/DashboardComponents.tsx index ea93aa63c..7b8eba5c5 100644 --- a/src/components/Employee/Dashboard/DashboardComponents.tsx +++ b/src/components/Employee/Dashboard/DashboardComponents.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' import type { Job } from '@gusto/embedded-api-v-2025-11-15/models/components/job' import { Dashboard, type DashboardTab } from './Dashboard' import { getPendingCompensationChanges } from './getPendingCompensationChanges' -import { HomeAddress } from '@/components/Employee/HomeAddress/management/HomeAddress' +import { HomeAddressEditForm } from '@/components/Employee/HomeAddress/management/HomeAddressEditForm' import { WorkAddress } from '@/components/Employee/WorkAddress/management/WorkAddress' import { FederalTaxes } from '@/components/Employee/FederalTaxes/management/FederalTaxes' import { StateTaxes } from '@/components/Employee/StateTaxes/management/StateTaxes' @@ -88,7 +88,7 @@ export function DashboardViewContextual() { export function HomeAddressContextual() { const { employeeId, onEvent } = useFlow() - return + return } export function WorkAddressContextual() { diff --git a/src/components/Employee/Dashboard/dashboardStateMachine.ts b/src/components/Employee/Dashboard/dashboardStateMachine.ts index 2b654332b..95f05ae10 100644 --- a/src/components/Employee/Dashboard/dashboardStateMachine.ts +++ b/src/components/Employee/Dashboard/dashboardStateMachine.ts @@ -63,7 +63,7 @@ export const dashboardStateMachine = { ), ), transition( - componentEvents.EMPLOYEE_HOME_ADDRESS, + componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED, 'homeAddress', reduce( (ctx: DashboardContextInterface): DashboardContextInterface => ({ @@ -253,7 +253,13 @@ export const dashboardStateMachine = { ), ), ), - homeAddress: state(transition(componentEvents.CANCEL, 'index', returnToIndex)), + homeAddress: state( + transition( + componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_CANCELLED, + 'index', + returnToIndex, + ), + ), workAddress: state(transition(componentEvents.CANCEL, 'index', returnToIndex)), federalTaxes: state( transition(componentEvents.CANCEL, 'index', returnToIndex), diff --git a/src/components/Employee/Dashboard/hooks/useEmployeeBasicDetails.test.tsx b/src/components/Employee/Dashboard/hooks/useEmployeeBasicDetails.test.tsx index 35c4e5d75..127b94ca6 100644 --- a/src/components/Employee/Dashboard/hooks/useEmployeeBasicDetails.test.tsx +++ b/src/components/Employee/Dashboard/hooks/useEmployeeBasicDetails.test.tsx @@ -4,7 +4,6 @@ import { http, HttpResponse } from 'msw' import { useEmployeeBasicDetails } from './useEmployeeBasicDetails' import { GustoTestProvider } from '@/test/GustoTestApiProvider' import { server } from '@/test/mocks/server' -import { handleGetEmployee } from '@/test/mocks/apis/employees' import { setupApiTestMocks } from '@/test/mocks/apiServer' import { API_BASE_URL } from '@/test/constants' @@ -13,74 +12,21 @@ describe('useEmployeeBasicDetails', () => { setupApiTestMocks() }) - it('starts loading with all three per-query flags true and resolves to populated data', async () => { + it('starts loading with the work-address flag true and resolves to populated data', async () => { const { result } = renderHook(() => useEmployeeBasicDetails({ employeeId: 'employee-123' }), { wrapper: GustoTestProvider, }) - expect(result.current.status.isEmployeeLoading).toBe(true) - expect(result.current.status.isHomeAddressLoading).toBe(true) expect(result.current.status.isWorkAddressLoading).toBe(true) - expect(result.current.data.employee).toBeUndefined() - expect(result.current.data.currentHomeAddress).toBeUndefined() expect(result.current.data.currentWorkAddress).toBeUndefined() await waitFor(() => { - expect(result.current.status.isEmployeeLoading).toBe(false) - expect(result.current.status.isHomeAddressLoading).toBe(false) expect(result.current.status.isWorkAddressLoading).toBe(false) }) - expect(result.current.data.employee).toMatchObject({ - firstName: 'Isom', - lastName: 'Jaskolski', - }) - expect(result.current.data.currentHomeAddress).toBeDefined() expect(result.current.data.currentWorkAddress).toBeDefined() }) - it('picks the active home address out of the returned list', async () => { - server.use( - http.get(`${API_BASE_URL}/v1/employees/:employee_id/home_addresses`, () => - HttpResponse.json([ - { - uuid: 'home-inactive', - version: '1', - street_1: '1 Old St', - city: 'Oldtown', - state: 'CA', - zip: '90001', - country: 'USA', - active: false, - }, - { - uuid: 'home-active', - version: '1', - street_1: '2 Current Ave', - city: 'Newtown', - state: 'CA', - zip: '90002', - country: 'USA', - active: true, - }, - ]), - ), - ) - - const { result } = renderHook(() => useEmployeeBasicDetails({ employeeId: 'employee-123' }), { - wrapper: GustoTestProvider, - }) - - await waitFor(() => { - expect(result.current.status.isHomeAddressLoading).toBe(false) - }) - - expect(result.current.data.currentHomeAddress).toMatchObject({ - uuid: 'home-active', - active: true, - }) - }) - it('picks the active work address out of the returned list', async () => { server.use( http.get(`${API_BASE_URL}/v1/employees/:employee_id/work_addresses`, () => @@ -121,11 +67,8 @@ describe('useEmployeeBasicDetails', () => { }) }) - it('returns undefined active addresses when none in the list are active', async () => { + it('returns undefined currentWorkAddress when none in the list are active', async () => { server.use( - http.get(`${API_BASE_URL}/v1/employees/:employee_id/home_addresses`, () => - HttpResponse.json([]), - ), http.get(`${API_BASE_URL}/v1/employees/:employee_id/work_addresses`, () => HttpResponse.json([]), ), @@ -136,17 +79,15 @@ describe('useEmployeeBasicDetails', () => { }) await waitFor(() => { - expect(result.current.status.isHomeAddressLoading).toBe(false) expect(result.current.status.isWorkAddressLoading).toBe(false) }) - expect(result.current.data.currentHomeAddress).toBeUndefined() expect(result.current.data.currentWorkAddress).toBeUndefined() }) - it('surfaces a query failure through errorHandling.errors', async () => { + it('surfaces a work-address query failure through errorHandling.errors', async () => { server.use( - handleGetEmployee(() => + http.get(`${API_BASE_URL}/v1/employees/:employee_id/work_addresses`, () => HttpResponse.json( { errors: [{ category: 'server_error', message: 'Boom' }] }, { status: 500 }, @@ -161,37 +102,5 @@ describe('useEmployeeBasicDetails', () => { await waitFor(() => { expect(result.current.errorHandling.errors.length).toBeGreaterThan(0) }) - - // Employee query has settled with an error; the per-section flag - // is no longer "loading", so the consuming view will fall through - // its skeleton branch instead of showing one forever. - expect(result.current.status.isEmployeeLoading).toBe(false) - expect(result.current.data.employee).toBeUndefined() - }) - - it('does not include `?include=all_compensations` in the employee fetch', async () => { - let employeeRequestUrl: string | null = null - server.use( - handleGetEmployee(({ request }) => { - employeeRequestUrl = request.url - return HttpResponse.json({ - uuid: 'employee-123', - first_name: 'Isom', - last_name: 'Jaskolski', - jobs: [], - }) - }), - ) - - const { result } = renderHook(() => useEmployeeBasicDetails({ employeeId: 'employee-123' }), { - wrapper: GustoTestProvider, - }) - - await waitFor(() => { - expect(result.current.status.isEmployeeLoading).toBe(false) - }) - - expect(employeeRequestUrl).not.toBeNull() - expect(employeeRequestUrl).not.toContain('all_compensations') }) }) diff --git a/src/components/Employee/Dashboard/hooks/useEmployeeBasicDetails.tsx b/src/components/Employee/Dashboard/hooks/useEmployeeBasicDetails.tsx index e4259d365..cd97ac61f 100644 --- a/src/components/Employee/Dashboard/hooks/useEmployeeBasicDetails.tsx +++ b/src/components/Employee/Dashboard/hooks/useEmployeeBasicDetails.tsx @@ -1,9 +1,5 @@ import { useMemo } from 'react' -import { useEmployeesGet } from '@gusto/embedded-api-v-2025-11-15/react-query/employeesGet' -import { useEmployeeAddressesGet } from '@gusto/embedded-api-v-2025-11-15/react-query/employeeAddressesGet' import { useEmployeeAddressesGetWorkAddresses } from '@gusto/embedded-api-v-2025-11-15/react-query/employeeAddressesGetWorkAddresses' -import type { Employee } from '@gusto/embedded-api-v-2025-11-15/models/components/employee' -import type { EmployeeAddress } from '@gusto/embedded-api-v-2025-11-15/models/components/employeeaddress' import type { EmployeeWorkAddress } from '@gusto/embedded-api-v-2025-11-15/models/components/employeeworkaddress' import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler' import type { BaseHookReady } from '@/partner-hook-utils/types' @@ -14,51 +10,32 @@ export interface UseEmployeeBasicDetailsProps { export type UseEmployeeBasicDetailsResult = BaseHookReady< { - employee?: Employee - currentHomeAddress?: EmployeeAddress currentWorkAddress?: EmployeeWorkAddress }, { isPending: boolean - isEmployeeLoading: boolean - isHomeAddressLoading: boolean isWorkAddressLoading: boolean } > /** - * Phase B: each query runs non-Suspense so the three cards (employee - * info, home address, work address) can paint independently. The - * consuming view branches on the per-query loading flags to render - * skeletons inside the box bodies. + * Tab-mounted hook for the Basic details tab. After the Profile and + * Home address cards moved to standalone self-fetching surfaces, this + * hook only feeds the inline Work address card. It will be deleted + * once the Work address card migrates to its own self-fetching block. */ export function useEmployeeBasicDetails({ employeeId, }: UseEmployeeBasicDetailsProps): UseEmployeeBasicDetailsResult { - // staleTime: Infinity — see useEmployeeCompensation for rationale (SDK - // QueryClient invalidates on any mutation success). - // - // No `include: ['all_compensations']` here: BasicDetails only reads - // `firstName/lastName/dateOfBirth/email/hasSsn` and the first job's - // `hireDate` — the historical compensations are dead weight in this - // payload. `useEmployeeCompensation` keeps the include since the - // JobAndPay tab actually consumes it. The two hooks intentionally use - // different query keys; mount-time payload shrinks on the active tab. - const employeeQuery = useEmployeesGet({ employeeId }, { staleTime: Infinity }) - const addressesQuery = useEmployeeAddressesGet({ employeeId }, { staleTime: Infinity }) + // staleTime: Infinity — the SDK QueryClient invalidates on any mutation + // success, so individual hooks don't need their own refetch policy. const workAddressesQuery = useEmployeeAddressesGetWorkAddresses( { employeeId }, { staleTime: Infinity }, ) - const employee = employeeQuery.data?.employee - const employeeAddressList = addressesQuery.data?.employeeAddressList const employeeWorkAddressesList = workAddressesQuery.data?.employeeWorkAddressesList - const currentHomeAddress = useMemo(() => { - return employeeAddressList?.find(address => address.active) - }, [employeeAddressList]) - const currentWorkAddress = useMemo(() => { return employeeWorkAddressesList?.find(address => address.active) }, [employeeWorkAddressesList]) @@ -66,17 +43,12 @@ export function useEmployeeBasicDetails({ return { isLoading: false, data: { - employee, - currentHomeAddress, currentWorkAddress, }, status: { - isPending: - employeeQuery.isFetching || addressesQuery.isFetching || workAddressesQuery.isFetching, - isEmployeeLoading: employeeQuery.isLoading, - isHomeAddressLoading: addressesQuery.isLoading, + isPending: workAddressesQuery.isFetching, isWorkAddressLoading: workAddressesQuery.isLoading, }, - errorHandling: composeErrorHandler([employeeQuery, addressesQuery, workAddressesQuery]), + errorHandling: composeErrorHandler([workAddressesQuery]), } } diff --git a/src/components/Employee/HomeAddress/management/HomeAddress.test.tsx b/src/components/Employee/HomeAddress/management/HomeAddress.test.tsx index 79cc007d1..d8b45bdbf 100644 --- a/src/components/Employee/HomeAddress/management/HomeAddress.test.tsx +++ b/src/components/Employee/HomeAddress/management/HomeAddress.test.tsx @@ -1,198 +1,80 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { screen, waitFor, within } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { HttpResponse, http, type HttpResponseResolver } from 'msw' import { HomeAddress } from './HomeAddress' import { renderWithProviders } from '@/test-utils/renderWithProviders' import { setupApiTestMocks } from '@/test/mocks/apiServer' -import { server } from '@/test/mocks/server' -import { API_BASE_URL } from '@/test/constants' +import { componentEvents } from '@/shared/constants' -describe('HomeAddress', () => { +describe('HomeAddress (management block)', () => { const onEvent = vi.fn() beforeEach(() => { - onEvent.mockClear() setupApiTestMocks() + onEvent.mockClear() }) - it('renders management copy and current home address from fixtures', async () => { - renderWithProviders() - - await waitFor( - () => { - expect(screen.getByText(/100 5th Ave/)).toBeInTheDocument() - }, - { timeout: 5000 }, - ) - - expect(screen.getByRole('heading', { name: 'Manage home address' })).toBeInTheDocument() - expect(screen.getByText('Current home address')).toBeInTheDocument() - expect(screen.getByText(/New York/)).toBeInTheDocument() - }) - - it('lists prior home addresses in history', async () => { + it('renders the card initially with the title and Manage CTA', async () => { renderWithProviders() - await waitFor( - () => { - expect(screen.getByText(/644 Fay Vista/)).toBeInTheDocument() - }, - { timeout: 5000 }, - ) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Manage' })).toBeEnabled() + }) - expect(screen.getByRole('heading', { name: 'Home address history' })).toBeInTheDocument() - expect(screen.getByText(/Richmond/)).toBeInTheDocument() + expect(screen.getByText('Home address')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Manage home address' })).toBeNull() }) - it('opens the add-address modal when Change address is clicked', async () => { + it('transitions card → editHomeAddress when Manage is clicked', async () => { const user = userEvent.setup() - renderWithProviders() - await waitFor( - () => { - expect(screen.getByRole('button', { name: 'Change address' })).toBeInTheDocument() - }, - { timeout: 5000 }, - ) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Manage' })).toBeEnabled() + }) - await user.click(screen.getByRole('button', { name: 'Change address' })) - - expect(screen.getByRole('heading', { name: 'Add a new home address' })).toBeInTheDocument() - }) - - it('marks the Start date as required in the Add address modal', async () => { - const user = userEvent.setup() - renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Manage' })) await waitFor( () => { - expect(screen.getByRole('button', { name: 'Change address' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Manage home address' })).toBeInTheDocument() }, { timeout: 5000 }, ) - await user.click(screen.getByRole('button', { name: 'Change address' })) - - const dialog = await screen.findByRole('dialog') - const startDateLabel = within(dialog).getByText('Start date').closest('label') - expect(startDateLabel).not.toBeNull() - expect(startDateLabel?.textContent ?? '').not.toMatch(/\(optional\)/i) - }) - - it('keeps the Add address modal open and surfaces inline validation when Save is clicked with empty fields', async () => { - const user = userEvent.setup() - const createResolver = vi.fn(() => HttpResponse.json({}, { status: 201 })) - server.use( - http.post(`${API_BASE_URL}/v1/employees/:employee_id/home_addresses`, createResolver), + expect(onEvent).toHaveBeenCalledWith( + componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED, + { employeeId: 'employee-123' }, ) - - renderWithProviders() - - await waitFor( - () => { - expect(screen.getByRole('button', { name: 'Change address' })).toBeInTheDocument() - }, - { timeout: 5000 }, - ) - - await user.click(screen.getByRole('button', { name: 'Change address' })) - - const dialog = await screen.findByRole('dialog') - expect( - within(dialog).getByRole('heading', { name: 'Add a new home address' }), - ).toBeInTheDocument() - - await user.click(within(dialog).getByRole('button', { name: 'Save' })) - - expect(await within(dialog).findByText('Street address is required')).toBeInTheDocument() - expect( - within(dialog).getByRole('heading', { name: 'Add a new home address' }), - ).toBeInTheDocument() - expect(createResolver).not.toHaveBeenCalled() }) - it('clears entered values when the Add address modal is closed and reopened', async () => { + it('returns to the card when Back is clicked in the edit form', async () => { const user = userEvent.setup() renderWithProviders() - await waitFor( - () => { - expect(screen.getByRole('button', { name: 'Change address' })).toBeInTheDocument() - }, - { timeout: 5000 }, - ) - - await user.click(screen.getByRole('button', { name: 'Change address' })) - - const firstOpenDialog = await screen.findByRole('dialog') - const street1FirstOpen = within(firstOpenDialog).getByLabelText(/Street 1/i) - await user.type(street1FirstOpen, '999 Throwaway Lane') - expect(street1FirstOpen).toHaveValue('999 Throwaway Lane') - - await user.click(within(firstOpenDialog).getByRole('button', { name: 'Cancel' })) - await waitFor(() => { - expect(screen.queryByRole('heading', { name: 'Add a new home address' })).toBeNull() + expect(screen.getByRole('button', { name: 'Manage' })).toBeEnabled() }) - await user.click(screen.getByRole('button', { name: 'Change address' })) - - const reopenedDialog = await screen.findByRole('dialog') - expect(within(reopenedDialog).getByLabelText(/Street 1/i)).toHaveValue('') - }) - - it('opens the edit modal for a history row without re-fetching that row individually', async () => { - const user = userEvent.setup() - - // Block the single-address retrieve endpoint. With the cache-warming fix, - // editing a list-known row should not hit it; without the fix, the call - // would hang and the page would stay in a loading state. - const retrieveResolver = vi.fn(() => new Promise(() => {}) as never) - server.use(http.get(`${API_BASE_URL}/v1/home_addresses/:uuid`, retrieveResolver)) - - renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Manage' })) await waitFor( () => { - expect(screen.getByText(/644 Fay Vista/)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Manage home address' })).toBeInTheDocument() }, { timeout: 5000 }, ) - const menuButtons = screen.getAllByRole('button', { - name: 'Open address row actions', - }) - await user.click(menuButtons[0] as HTMLElement) - await user.click(await screen.findByRole('menuitem', { name: 'Edit' })) + await user.click(screen.getByRole('button', { name: 'Back' })) - const dialog = await screen.findByRole('dialog') - expect(within(dialog).getByRole('heading', { name: 'Edit home address' })).toBeInTheDocument() - expect(retrieveResolver).not.toHaveBeenCalled() - }) - - it('exposes the Start date field in the Edit modal so it can be corrected', async () => { - const user = userEvent.setup() - renderWithProviders() + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Manage' })).toBeInTheDocument() + }) - await waitFor( - () => { - expect(screen.getByText(/644 Fay Vista/)).toBeInTheDocument() - }, - { timeout: 5000 }, + expect(screen.queryByRole('heading', { name: 'Manage home address' })).toBeNull() + expect(onEvent).toHaveBeenCalledWith( + componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_CANCELLED, + undefined, ) - - const menuButtons = screen.getAllByRole('button', { - name: 'Open address row actions', - }) - await user.click(menuButtons[0] as HTMLElement) - await user.click(await screen.findByRole('menuitem', { name: 'Edit' })) - - const dialog = await screen.findByRole('dialog') - expect(within(dialog).getByRole('heading', { name: 'Edit home address' })).toBeInTheDocument() - // Start date field must be present so the effective date can be corrected - // without round-tripping through delete + create. - expect(within(dialog).getByText('Start date')).toBeInTheDocument() }) }) diff --git a/src/components/Employee/HomeAddress/management/HomeAddress.tsx b/src/components/Employee/HomeAddress/management/HomeAddress.tsx index 8c22f1bda..19f674b4b 100644 --- a/src/components/Employee/HomeAddress/management/HomeAddress.tsx +++ b/src/components/Employee/HomeAddress/management/HomeAddress.tsx @@ -1,76 +1,51 @@ -import type { EmployeeAddress } from '@gusto/embedded-api-v-2025-11-15/models/components/employeeaddress' -import { HomeAddressView } from './HomeAddressView' -import { - isUseHomeAddressManagementSuccess, - useHomeAddressManagement, -} from './useHomeAddressManagement' +import { createMachine } from 'robot3' +import { useMemo } from 'react' +import { CardContextual, type HomeAddressContextInterface } from './HomeAddressComponents' +import { homeAddressStateMachine } from './homeAddressStateMachine' +import { Flow } from '@/components/Flow/Flow' import { BaseBoundaries, - BaseLayout, type BaseComponentInterface, type CommonComponentInterface, -} from '@/components/Base/Base' -import { useI18n, useComponentDictionary } from '@/i18n' -import type { HookSubmitResult } from '@/partner-hook-utils/types' -import { componentEvents } from '@/shared/constants' +} from '@/components/Base' +import { type EventType } from '@/shared/constants' +import { useComponentDictionary } from '@/i18n/I18n' +import { useI18n } from '@/i18n' +import type { OnEventType } from '@/components/Base/useBase' export interface HomeAddressProps extends CommonComponentInterface<'Employee.HomeAddress.Management'> { employeeId: string - onEvent: BaseComponentInterface['onEvent'] + onEvent: OnEventType } -function HomeAddressRoot({ employeeId, onEvent, dictionary }: HomeAddressProps) { - useI18n(['Employee.HomeAddress.Management', 'Employee.HomeAddress']) - useComponentDictionary('Employee.HomeAddress.Management', dictionary) - - const management = useHomeAddressManagement({ employeeId, onEvent }) - - if (management.isLoading) { - return - } - - if (!isUseHomeAddressManagementSuccess(management)) { - return - } - - const handleSaved = (result: HookSubmitResult) => { - if (result.mode === 'create') { - onEvent(componentEvents.EMPLOYEE_HOME_ADDRESS_CREATED, result.data) - } else { - onEvent(componentEvents.EMPLOYEE_HOME_ADDRESS_UPDATED, result.data) - } - } - - return ( - - { - onEvent(componentEvents.CANCEL) - }} - isDeletePending={management.status.isDeletePending} - /> - +function HomeAddressFlow({ employeeId, onEvent }: HomeAddressProps) { + useI18n('Employee.HomeAddress.Management') + + const machine = useMemo( + () => + createMachine('card', homeAddressStateMachine, (ctx: HomeAddressContextInterface) => ({ + ...ctx, + component: CardContextual, + employeeId, + })), + [employeeId], ) + + return } export function HomeAddress({ + dictionary, FallbackComponent, ...props -}: HomeAddressProps & BaseComponentInterface) { +}: HomeAddressProps & BaseComponentInterface<'Employee.HomeAddress.Management'>) { + useComponentDictionary('Employee.HomeAddress.Management', dictionary) return ( - + ) } diff --git a/src/components/Employee/HomeAddress/management/HomeAddressCard/HomeAddressCard.test.tsx b/src/components/Employee/HomeAddress/management/HomeAddressCard/HomeAddressCard.test.tsx new file mode 100644 index 000000000..2a2d72d05 --- /dev/null +++ b/src/components/Employee/HomeAddress/management/HomeAddressCard/HomeAddressCard.test.tsx @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { HomeAddressCard } from './HomeAddressCard' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { server } from '@/test/mocks/server' +import { API_BASE_URL } from '@/test/constants' +import { componentEvents } from '@/shared/constants' + +describe('HomeAddressCard', () => { + const onEvent = vi.fn() + + beforeEach(() => { + setupApiTestMocks() + onEvent.mockClear() + }) + + it('renders the card title and active home address once loaded', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Manage' })).toBeEnabled() + }) + + expect(screen.getByText('Home address')).toBeInTheDocument() + expect(screen.getByText(/100 5th Ave/)).toBeInTheDocument() + expect(screen.getByText(/New York/)).toBeInTheDocument() + }) + + it('renders the empty-state copy when the employee has no addresses on file', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/home_addresses`, () => + HttpResponse.json([]), + ), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Manage' })).toBeEnabled() + }) + + expect(screen.getByText('No home address on file')).toBeInTheDocument() + }) + + it('fires EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED with { employeeId } when Manage is clicked', async () => { + const user = userEvent.setup() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Manage' })).toBeEnabled() + }) + + await user.click(screen.getByRole('button', { name: 'Manage' })) + + expect(onEvent).toHaveBeenCalledWith( + componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED, + { employeeId: 'employee-123' }, + ) + }) +}) diff --git a/src/components/Employee/HomeAddress/management/HomeAddressCard/HomeAddressCard.tsx b/src/components/Employee/HomeAddress/management/HomeAddressCard/HomeAddressCard.tsx new file mode 100644 index 000000000..bd8137169 --- /dev/null +++ b/src/components/Employee/HomeAddress/management/HomeAddressCard/HomeAddressCard.tsx @@ -0,0 +1,71 @@ +import { useTranslation } from 'react-i18next' +import { useHomeAddressSummary } from '../../shared/useHomeAddressSummary' +import { Loading } from '@/components/Common' +import { Flex } from '@/components/Common/Flex/Flex' +import { BaseLayout } from '@/components/Base/Base' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { getStreet, getCityStateZip } from '@/helpers/formattedStrings' +import { useI18n } from '@/i18n' +import { componentEvents, type EventType } from '@/shared/constants' +import type { OnEventType } from '@/components/Base/useBase' + +export interface HomeAddressCardProps { + employeeId: string + onEvent: OnEventType +} + +/** + * Standalone "Home address" card. Owns its own data fetch via + * `useHomeAddressSummary` and emits + * `EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED` when the Manage + * button is clicked. The card has no alert API — alert rendering + * (when introduced) is the orchestrator's responsibility. + */ +export function HomeAddressCard({ employeeId, onEvent }: HomeAddressCardProps) { + useI18n('Employee.HomeAddress.Management') + const { t } = useTranslation('Employee.HomeAddress.Management') + const Components = useComponentContext() + + const summary = useHomeAddressSummary({ employeeId }) + + const isLoading = summary.isLoading + const currentHomeAddress = summary.isLoading ? undefined : summary.data.currentHomeAddress + + const handleManage = () => { + onEvent(componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED, { employeeId }) + } + + return ( + + + {t('card.manageCta')} + + } + /> + } + > + + {isLoading ? ( + + ) : currentHomeAddress ? ( + + + {getStreet(currentHomeAddress).replace(',', '')} + + + {getCityStateZip(currentHomeAddress)} + + + ) : ( + {t('card.noAddress')} + )} + + + + ) +} diff --git a/src/components/Employee/HomeAddress/management/HomeAddressCard/index.ts b/src/components/Employee/HomeAddress/management/HomeAddressCard/index.ts new file mode 100644 index 000000000..494aa7d72 --- /dev/null +++ b/src/components/Employee/HomeAddress/management/HomeAddressCard/index.ts @@ -0,0 +1,2 @@ +export { HomeAddressCard } from './HomeAddressCard' +export type { HomeAddressCardProps } from './HomeAddressCard' diff --git a/src/components/Employee/HomeAddress/management/HomeAddressComponents.tsx b/src/components/Employee/HomeAddress/management/HomeAddressComponents.tsx new file mode 100644 index 000000000..f525c41b1 --- /dev/null +++ b/src/components/Employee/HomeAddress/management/HomeAddressComponents.tsx @@ -0,0 +1,18 @@ +import { HomeAddressCard } from './HomeAddressCard' +import { HomeAddressEditForm } from './HomeAddressEditForm' +import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' +import { ensureRequired } from '@/helpers/ensureRequired' + +export interface HomeAddressContextInterface extends FlowContextInterface { + employeeId?: string +} + +export function CardContextual() { + const { employeeId, onEvent } = useFlow() + return +} + +export function HomeAddressEditFormContextual() { + const { employeeId, onEvent } = useFlow() + return +} diff --git a/src/components/Employee/HomeAddress/management/HomeAddressEditForm.tsx b/src/components/Employee/HomeAddress/management/HomeAddressEditForm.tsx index ea31fc8d5..ac047f979 100644 --- a/src/components/Employee/HomeAddress/management/HomeAddressEditForm.tsx +++ b/src/components/Employee/HomeAddress/management/HomeAddressEditForm.tsx @@ -20,7 +20,7 @@ export interface HomeAddressEditFormProps extends CommonComponentInterface<'Empl } function HomeAddressEditFormRoot({ employeeId, onEvent, dictionary }: HomeAddressEditFormProps) { - useI18n(['Employee.HomeAddress.Management', 'Employee.HomeAddress']) + useI18n('Employee.HomeAddress.Management') useComponentDictionary('Employee.HomeAddress.Management', dictionary) const management = useHomeAddressManagement({ employeeId, onEvent }) diff --git a/src/components/Employee/HomeAddress/management/HomeAddressView.tsx b/src/components/Employee/HomeAddress/management/HomeAddressView.tsx index 30c6d7410..ac27cd635 100644 --- a/src/components/Employee/HomeAddress/management/HomeAddressView.tsx +++ b/src/components/Employee/HomeAddress/management/HomeAddressView.tsx @@ -24,11 +24,11 @@ import { formatStreetForDisplay, getCityStateZip } from '@/helpers/formattedStri function HomeAddressCourtesyWithholdingBlock({ CourtesyWithholding, formHook, - tHa, + t, }: { CourtesyWithholding: UseHomeAddressFormReady['form']['Fields']['CourtesyWithholding'] formHook: UseHomeAddressFormReady - tHa: TFunction<'Employee.HomeAddress'> + t: TFunction<'Employee.HomeAddress.Management'> }) { const Components = useComponentContext() const { control } = formHook.form.hookFormInternals.formMethods @@ -37,13 +37,13 @@ function HomeAddressCourtesyWithholdingBlock({ return ( <> - {tHa('courtesyWithholdingDescription')} + {t('form.courtesyWithholdingDescription')} , }} @@ -52,8 +52,8 @@ function HomeAddressCourtesyWithholdingBlock({ } /> {courtesyWithholdingEnabled ? ( - - + + ) : null} @@ -89,7 +89,6 @@ export function HomeAddressView({ isDeletePending = false, }: HomeAddressViewProps) { const { t } = useTranslation('Employee.HomeAddress.Management') - const { t: tHa } = useTranslation('Employee.HomeAddress') const Components = useComponentContext() const [addressModal, setAddressModal] = useState<'edit' | 'create' | null>(null) const [deleteConfirmUuid, setDeleteConfirmUuid] = useState(null) @@ -153,8 +152,8 @@ export function HomeAddressView({ } = createForm const zipValidation = { - [HomeAddressErrorCodes.REQUIRED]: tHa('validations.zip'), - [HomeAddressErrorCodes.INVALID_ZIP]: tHa('validations.zip'), + [HomeAddressErrorCodes.REQUIRED]: t('form.validations.zip'), + [HomeAddressErrorCodes.INVALID_ZIP]: t('form.validations.zip'), } const startDateValidation = { @@ -373,7 +372,7 @@ export function HomeAddressView({ ) : null} ) : ( - {tHa('formTitle')} + {t('form.noCurrentAddress')} )} {pendingFutureAddress ? ( @@ -459,35 +458,35 @@ export function HomeAddressView({ /> ) : null} - + @@ -510,35 +509,35 @@ export function HomeAddressView({ /> ) : null} - + diff --git a/src/components/Employee/HomeAddress/management/homeAddressStateMachine.ts b/src/components/Employee/HomeAddress/management/homeAddressStateMachine.ts new file mode 100644 index 000000000..925040764 --- /dev/null +++ b/src/components/Employee/HomeAddress/management/homeAddressStateMachine.ts @@ -0,0 +1,35 @@ +import { reduce, state, transition } from 'robot3' +import type { ComponentType } from 'react' +import type { HomeAddressContextInterface } from './HomeAddressComponents' +import { CardContextual, HomeAddressEditFormContextual } from './HomeAddressComponents' +import { componentEvents } from '@/shared/constants' +import type { MachineTransition } from '@/types/Helpers' + +const returnToCard = reduce( + (ctx: HomeAddressContextInterface): HomeAddressContextInterface => ({ + ...ctx, + component: CardContextual as ComponentType, + }), +) + +export const homeAddressStateMachine = { + card: state( + transition( + componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED, + 'editHomeAddress', + reduce( + (ctx: HomeAddressContextInterface): HomeAddressContextInterface => ({ + ...ctx, + component: HomeAddressEditFormContextual as ComponentType, + }), + ), + ), + ), + editHomeAddress: state( + transition( + componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_CANCELLED, + 'card', + returnToCard, + ), + ), +} diff --git a/src/components/Employee/HomeAddress/management/index.ts b/src/components/Employee/HomeAddress/management/index.ts new file mode 100644 index 000000000..d6ea937a0 --- /dev/null +++ b/src/components/Employee/HomeAddress/management/index.ts @@ -0,0 +1,6 @@ +export { HomeAddress } from './HomeAddress' +export type { HomeAddressProps } from './HomeAddress' +export { HomeAddressCard } from './HomeAddressCard' +export type { HomeAddressCardProps } from './HomeAddressCard' +export { HomeAddressEditForm } from './HomeAddressEditForm' +export type { HomeAddressEditFormProps } from './HomeAddressEditForm' diff --git a/src/components/Employee/HomeAddress/management/useHomeAddressManagement.tsx b/src/components/Employee/HomeAddress/management/useHomeAddressManagement.tsx index b9253d6dd..1de5cabcf 100644 --- a/src/components/Employee/HomeAddress/management/useHomeAddressManagement.tsx +++ b/src/components/Employee/HomeAddress/management/useHomeAddressManagement.tsx @@ -175,7 +175,7 @@ export function useHomeAddressManagement({ }) succeeded = true if (snap) { - onEvent(componentEvents.EMPLOYEE_HOME_ADDRESS_DELETED, snap) + onEvent(componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_DELETED, snap) } }, ) diff --git a/src/components/Employee/HomeAddress/shared/index.ts b/src/components/Employee/HomeAddress/shared/index.ts new file mode 100644 index 000000000..7135afee9 --- /dev/null +++ b/src/components/Employee/HomeAddress/shared/index.ts @@ -0,0 +1,6 @@ +export { useHomeAddressSummary } from './useHomeAddressSummary' +export type { + UseHomeAddressSummaryParams, + UseHomeAddressSummaryReady, + UseHomeAddressSummaryResult, +} from './useHomeAddressSummary' diff --git a/src/components/Employee/HomeAddress/shared/useHomeAddressSummary/index.ts b/src/components/Employee/HomeAddress/shared/useHomeAddressSummary/index.ts new file mode 100644 index 000000000..7135afee9 --- /dev/null +++ b/src/components/Employee/HomeAddress/shared/useHomeAddressSummary/index.ts @@ -0,0 +1,6 @@ +export { useHomeAddressSummary } from './useHomeAddressSummary' +export type { + UseHomeAddressSummaryParams, + UseHomeAddressSummaryReady, + UseHomeAddressSummaryResult, +} from './useHomeAddressSummary' diff --git a/src/components/Employee/HomeAddress/shared/useHomeAddressSummary/useHomeAddressSummary.test.tsx b/src/components/Employee/HomeAddress/shared/useHomeAddressSummary/useHomeAddressSummary.test.tsx new file mode 100644 index 000000000..4f89ebd60 --- /dev/null +++ b/src/components/Employee/HomeAddress/shared/useHomeAddressSummary/useHomeAddressSummary.test.tsx @@ -0,0 +1,120 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, beforeEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { useHomeAddressSummary } from './useHomeAddressSummary' +import { GustoTestProvider } from '@/test/GustoTestApiProvider' +import { server } from '@/test/mocks/server' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { API_BASE_URL } from '@/test/constants' + +describe('useHomeAddressSummary', () => { + beforeEach(() => { + setupApiTestMocks() + }) + + it('starts in the loading branch and resolves into the ready branch with the active home address', async () => { + const { result } = renderHook(() => useHomeAddressSummary({ employeeId: 'employee-123' }), { + wrapper: GustoTestProvider, + }) + + expect(result.current.isLoading).toBe(true) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + if (result.current.isLoading) return + + expect(result.current.data.currentHomeAddress).toMatchObject({ active: true }) + expect(result.current.data.employeeAddressList.length).toBeGreaterThan(0) + expect(result.current.status).toMatchObject({ + isFetching: false, + isPending: false, + }) + }) + + it('picks the active home address out of a list with inactive rows', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/home_addresses`, () => + HttpResponse.json([ + { + uuid: 'home-inactive', + version: '1', + street_1: '1 Old St', + city: 'Oldtown', + state: 'CA', + zip: '90001', + country: 'USA', + active: false, + }, + { + uuid: 'home-active', + version: '1', + street_1: '2 Current Ave', + city: 'Newtown', + state: 'CA', + zip: '90002', + country: 'USA', + active: true, + }, + ]), + ), + ) + + const { result } = renderHook(() => useHomeAddressSummary({ employeeId: 'employee-123' }), { + wrapper: GustoTestProvider, + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + if (result.current.isLoading) return + + expect(result.current.data.currentHomeAddress).toMatchObject({ + uuid: 'home-active', + active: true, + }) + expect(result.current.data.employeeAddressList).toHaveLength(2) + }) + + it('returns undefined currentHomeAddress when no address is active', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/home_addresses`, () => + HttpResponse.json([]), + ), + ) + + const { result } = renderHook(() => useHomeAddressSummary({ employeeId: 'employee-123' }), { + wrapper: GustoTestProvider, + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + if (result.current.isLoading) return + + expect(result.current.data.currentHomeAddress).toBeUndefined() + expect(result.current.data.employeeAddressList).toEqual([]) + }) + + it('surfaces a query failure through errorHandling.errors', async () => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_id/home_addresses`, () => + HttpResponse.json( + { errors: [{ category: 'server_error', message: 'Boom' }] }, + { status: 500 }, + ), + ), + ) + + const { result } = renderHook(() => useHomeAddressSummary({ employeeId: 'employee-123' }), { + wrapper: GustoTestProvider, + }) + + await waitFor(() => { + expect(result.current.errorHandling.errors.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/src/components/Employee/HomeAddress/shared/useHomeAddressSummary/useHomeAddressSummary.tsx b/src/components/Employee/HomeAddress/shared/useHomeAddressSummary/useHomeAddressSummary.tsx new file mode 100644 index 000000000..5921db50f --- /dev/null +++ b/src/components/Employee/HomeAddress/shared/useHomeAddressSummary/useHomeAddressSummary.tsx @@ -0,0 +1,50 @@ +import { useEmployeeAddressesGet } from '@gusto/embedded-api-v-2025-11-15/react-query/employeeAddressesGet' +import type { EmployeeAddress } from '@gusto/embedded-api-v-2025-11-15/models/components/employeeaddress' +import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler' +import type { BaseHookReady, HookLoadingResult } from '@/partner-hook-utils/types' + +export interface UseHomeAddressSummaryParams { + employeeId: string +} + +export type UseHomeAddressSummaryReady = BaseHookReady< + { + currentHomeAddress: EmployeeAddress | undefined + employeeAddressList: EmployeeAddress[] + }, + { isFetching: boolean; isPending: boolean } +> + +export type UseHomeAddressSummaryResult = HookLoadingResult | UseHomeAddressSummaryReady + +/** + * Read-only data hook for the Home address management card. Wraps + * `useEmployeeAddressesGet` and surfaces the active row plus the full + * list (the latter lets partners build their own history/pending UI). + * Mutations live in `useHomeAddressManagement` (the form-driven hook + * the edit form consumes). + */ +export function useHomeAddressSummary({ + employeeId, +}: UseHomeAddressSummaryParams): UseHomeAddressSummaryResult { + const addressesQuery = useEmployeeAddressesGet({ employeeId }, { staleTime: Infinity }) + + const errorHandling = composeErrorHandler([addressesQuery]) + + if (addressesQuery.isLoading) { + return { isLoading: true, errorHandling } + } + + const employeeAddressList = addressesQuery.data?.employeeAddressList ?? [] + const currentHomeAddress = employeeAddressList.find(address => address.active) + + return { + isLoading: false, + data: { currentHomeAddress, employeeAddressList }, + status: { + isFetching: addressesQuery.isFetching, + isPending: false, + }, + errorHandling, + } +} diff --git a/src/components/Employee/exports/employeeManagement.ts b/src/components/Employee/exports/employeeManagement.ts index 78a827967..29c975036 100644 --- a/src/components/Employee/exports/employeeManagement.ts +++ b/src/components/Employee/exports/employeeManagement.ts @@ -5,8 +5,12 @@ export type { EmployeeListFlowProps } from '../EmployeeListFlow' export { EmployeeDocuments } from '../Documents/onboarding/EmployeeDocuments' export { DocumentManager } from '../Documents/management/DocumentManager' export { DashboardFlow } from '../Dashboard' -export { HomeAddress } from '../HomeAddress/management/HomeAddress' -export type { HomeAddressProps } from '../HomeAddress/management/HomeAddress' +export { HomeAddress, HomeAddressCard, HomeAddressEditForm } from '../HomeAddress/management' +export type { + HomeAddressProps, + HomeAddressCardProps, + HomeAddressEditFormProps, +} from '../HomeAddress/management' export { WorkAddress } from '../WorkAddress/management/WorkAddress' export type { WorkAddressProps } from '../WorkAddress/management/WorkAddress' export { FederalTaxes, type FederalTaxesProps } from '../FederalTaxes/management/FederalTaxes' diff --git a/src/components/Employee/index.ts b/src/components/Employee/index.ts index 3e9a1da58..70e077dfa 100644 --- a/src/components/Employee/index.ts +++ b/src/components/Employee/index.ts @@ -16,8 +16,8 @@ export { DashboardFlow } from './Dashboard' export type { DashboardFlowProps } from './Dashboard' export { EmployeeListFlow } from './EmployeeListFlow' export type { EmployeeListFlowProps } from './EmployeeListFlow' -export { HomeAddress } from './HomeAddress/management/HomeAddress' -export type { HomeAddressProps } from './HomeAddress/management/HomeAddress' +export { HomeAddress } from './HomeAddress/management' +export type { HomeAddressProps } from './HomeAddress/management' export { EmploymentEligibility } from './Documents/onboarding/DocumentSigner/EmploymentEligibility' export type { EmploymentEligibilityProps } from './Documents/onboarding/DocumentSigner/EmploymentEligibility' diff --git a/src/components/Flow/Flow.test.tsx b/src/components/Flow/Flow.test.tsx index 68ee049aa..2b3d21bb0 100644 --- a/src/components/Flow/Flow.test.tsx +++ b/src/components/Flow/Flow.test.tsx @@ -13,7 +13,7 @@ import type { MachineTransition } from '@/types/Helpers' const FirstScreen: ComponentType = () =>
First
const SecondScreen: ComponentType = () =>
Second
-const NEXT_EVENT = componentEvents.EMPLOYEE_HOME_ADDRESS +const NEXT_EVENT = componentEvents.EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED const BACK_EVENT = componentEvents.CANCEL const buildMachine = (overrides: Partial = {}) => diff --git a/src/i18n/en/Employee.Dashboard.json b/src/i18n/en/Employee.Dashboard.json index 4e0e7738f..93821d98a 100644 --- a/src/i18n/en/Employee.Dashboard.json +++ b/src/i18n/en/Employee.Dashboard.json @@ -9,12 +9,6 @@ "taxes": "Taxes", "documents": "Documents" }, - "homeAddress": { - "title": "Home address", - "manageCta": "Manage", - "currentAddress": "Current address", - "noAddress": "No home address on file" - }, "workAddress": { "title": "Work address", "manageCta": "Manage", diff --git a/src/i18n/en/Employee.HomeAddress.Management.json b/src/i18n/en/Employee.HomeAddress.Management.json index 5bace18a8..4f28fb33f 100644 --- a/src/i18n/en/Employee.HomeAddress.Management.json +++ b/src/i18n/en/Employee.HomeAddress.Management.json @@ -1,4 +1,9 @@ { + "card": { + "title": "Home address", + "manageCta": "Manage", + "noAddress": "No home address on file" + }, "title": "Manage home address", "description": "An employee's home address is used to calculate their taxes and determine eligibility for certain benefits. Make sure to keep it up-to-date.", "rowMenuAriaLabel": "Open address row actions", @@ -31,6 +36,23 @@ "deleteModalDescription": "Deleting an address can't be undone. {{address}} will be deleted. This can have implications on your tax calculations & withholdings.", "deleteModalConfirmCta": "Delete address", "form": { - "startDateRequired": "Start date is required" + "street1": "Street 1", + "street2": "Street 2", + "city": "City", + "state": "State", + "zip": "Zip", + "noCurrentAddress": "Home address", + "courtesyWithholdingLabel": "Include courtesy withholding", + "courtesyWithholdingDescription": "Withhold and pay local income taxes for employees who live and work in different states. ", + "learnMoreCta": "Learn more about courtesy withholdings.", + "withholdingTitle": "Courtesy withholding", + "withholdingNote": "

Withholding on an employee's behalf will require your company to register with any corresponding agencies.

Also, if this employee's home address will change your company's state tax requirements, you may need to complete your company's state tax setup again.

", + "startDateRequired": "Start date is required", + "validations": { + "street1": "Street address is required", + "city": "Please provide valid city name", + "state": "Please select a state", + "zip": "Please provide valid zip code" + } } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index b9d64ab1d..d8ad850ab 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -10,11 +10,13 @@ export const employeeEvents = { EMPLOYEE_DISMISS: 'employee/dismiss', EMPLOYEE_ONBOARDING_DONE: 'employee/onboarding/done', EMPLOYEE_PROFILE_DONE: 'employee/profile/done', - EMPLOYEE_HOME_ADDRESS: 'employee/addresses/home', - EMPLOYEE_HOME_ADDRESS_UPDATE: 'employee/addresses/home/update', EMPLOYEE_HOME_ADDRESS_CREATED: 'employee/addresses/home/created', EMPLOYEE_HOME_ADDRESS_UPDATED: 'employee/addresses/home/updated', - EMPLOYEE_HOME_ADDRESS_DELETED: 'employee/addresses/home/deleted', + EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_REQUESTED: 'employee/homeAddress/management/editRequested', + EMPLOYEE_HOME_ADDRESS_MANAGEMENT_EDIT_CANCELLED: 'employee/homeAddress/management/editCancelled', + EMPLOYEE_HOME_ADDRESS_MANAGEMENT_CREATED: 'employee/homeAddress/management/created', + EMPLOYEE_HOME_ADDRESS_MANAGEMENT_UPDATED: 'employee/homeAddress/management/updated', + EMPLOYEE_HOME_ADDRESS_MANAGEMENT_DELETED: 'employee/homeAddress/management/deleted', EMPLOYEE_WORK_ADDRESS: 'employee/addresses/work', EMPLOYEE_WORK_ADDRESS_UPDATE: 'employee/addresses/work/update', EMPLOYEE_WORK_ADDRESS_CREATED: 'employee/addresses/work/created', diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index 7b7474d2c..139659c12 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -1437,12 +1437,6 @@ export interface EmployeeDashboard{ "taxes":string; "documents":string; }; -"homeAddress":{ -"title":string; -"manageCta":string; -"currentAddress":string; -"noAddress":string; -}; "workAddress":{ "title":string; "manageCta":string; @@ -1902,6 +1896,11 @@ export interface EmployeeFederalTaxes{ }; }; export interface EmployeeHomeAddressManagement{ +"card":{ +"title":string; +"manageCta":string; +"noAddress":string; +}; "title":string; "description":string; "rowMenuAriaLabel":string; @@ -1934,7 +1933,24 @@ export interface EmployeeHomeAddressManagement{ "deleteModalDescription":string; "deleteModalConfirmCta":string; "form":{ +"street1":string; +"street2":string; +"city":string; +"state":string; +"zip":string; +"noCurrentAddress":string; +"courtesyWithholdingLabel":string; +"courtesyWithholdingDescription":string; +"learnMoreCta":string; +"withholdingTitle":string; +"withholdingNote":string; "startDateRequired":string; +"validations":{ +"street1":string; +"city":string; +"state":string; +"zip":string; +}; }; }; export interface EmployeeHomeAddress{ From 981f94fa14b1fbc2d2493f323352093d6cb1b934 Mon Sep 17 00:00:00 2001 From: Steve Jensen Date: Tue, 2 Jun 2026 16:37:31 -0600 Subject: [PATCH 3/3] chore: tighten dashboard-card-to-block skill and lock docs subagents out of git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clarifies the "multiple namespaces" guidance in migrate-dashboard-card-to-block: the default is to load only the block's own namespace. Dual-loading the feature's base namespace is only justified when a runtime-shared piece of UI inside the management path itself calls `useTranslation` on the base namespace — sharing a form hook that emits field components is not enough, because callers always pass `label`/`validationMessages` props from whatever namespace they like. Cites HomeAddress as the worked example. - Adds an explicit "Workspace ownership" section to the dashboard-block-documenter and sdk-hook-documenter agents forbidding `git` operations (stage/commit/push/amend). The parent agent owns the workspace; documenters write files and return. Co-authored-by: Cursor --- .claude/agents/dashboard-block-documenter.md | 15 ++++++++++++++- .claude/agents/sdk-hook-documenter.md | 14 +++++++++++++- .../migrate-dashboard-card-to-block/SKILL.md | 12 +++++++++--- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/.claude/agents/dashboard-block-documenter.md b/.claude/agents/dashboard-block-documenter.md index f3cecb373..2afc211b5 100644 --- a/.claude/agents/dashboard-block-documenter.md +++ b/.claude/agents/dashboard-block-documenter.md @@ -14,6 +14,18 @@ You write the partner-facing documentation entry for a newly migrated Employee Dashboard block in the embedded-react-sdk. The feature name and the paths to the new block, card, and edit-form files are provided in the user's message. +## Workspace ownership + +You write documentation files. You do not control the git workspace. **Do not +run `git` commands. Do not stage. Do not commit. Do not push. Do not amend.** +The parent agent owns staging and commits; it will pick up your edits as part +of whatever commit grouping it chooses. + +You may run formatters (`prettier`, etc.) against the files you edited if it +keeps your output consistent with the rest of the repo. You may not run lint, +tests, or any other workspace-mutating command beyond your edits and +optional formatting. + ## Step 1 — Read the reference docs before writing anything Read these files in full. Your output must match their structure, section order, @@ -107,6 +119,7 @@ surfaces. Return: -- Confirmation that `employee-management.md` was updated +- Confirmation that `employee-management.md` was updated (and unstaged — see + "Workspace ownership" above) - The heading path of the new section (e.g. `### EmployeeManagement.Compensation`) - Any events or props you could not document with reasons diff --git a/.claude/agents/sdk-hook-documenter.md b/.claude/agents/sdk-hook-documenter.md index 192de3442..595778d72 100644 --- a/.claude/agents/sdk-hook-documenter.md +++ b/.claude/agents/sdk-hook-documenter.md @@ -15,6 +15,18 @@ You write partner-facing documentation for a new SDK form hook in the embedded-react-sdk. The hook name and file path are provided in the user's message. +## Workspace ownership + +You write documentation files. You do not control the git workspace. **Do not +run `git` commands. Do not stage. Do not commit. Do not push. Do not amend.** +The parent agent owns staging and commits; it will pick up your edits as part +of whatever commit grouping it chooses. + +You may run formatters (`prettier`, etc.) against the files you edited if it +keeps your output consistent with the rest of the repo. You may not run lint, +tests, or any other workspace-mutating command beyond your edits and +optional formatting. + ## Step 1 — Read the reference docs before writing anything Read these files in full. Your output must match their structure, section order, @@ -73,6 +85,6 @@ Voice and style rules (from CLAUDE.md `docs/` section): Return: -- The path to the created doc file +- The path to the created doc file (unstaged — see "Workspace ownership" above) - Confirmation that `docs/hooks/hooks.md` was updated - Any fields or behaviors you skipped with reasons diff --git a/.claude/skills/migrate-dashboard-card-to-block/SKILL.md b/.claude/skills/migrate-dashboard-card-to-block/SKILL.md index 6f7f3aa31..522a1ef3d 100644 --- a/.claude/skills/migrate-dashboard-card-to-block/SKILL.md +++ b/.claude/skills/migrate-dashboard-card-to-block/SKILL.md @@ -572,13 +572,19 @@ The block does **not** read from `Employee.Dashboard`. Whatever the card surface ### When the feature already has multiple namespaces -Some features have `Employee..json` (the onboarding/form namespace) plus `Employee..Management.json` (the management namespace). Pattern from [`HomeAddress.tsx`](../../../src/components/Employee/HomeAddress/management/HomeAddress.tsx): +Some features have `Employee..json` (the onboarding/form namespace) plus `Employee..Management.json` (the management namespace). The default is to load **only** the management namespace from the block, the card, and the edit screen: ```tsx -useI18n(['Employee.HomeAddress.Management', 'Employee.HomeAddress']) +useI18n('Employee.HomeAddress.Management') ``` -The management block can load both: its own `Management` namespace for management-specific strings, plus the feature's base namespace for strings shared with the edit/form path. The block's _new_ strings always go into `.Management.json`; reuse from the base namespace only for strings that genuinely live in both contexts (e.g. validation messages on a form field shared with onboarding). When in doubt, copy the string into `.Management.json` — duplication is cheaper than coupling the block to onboarding's namespace. +Don't reach for the base namespace just because a shared form **hook** is rendered in both places. Form hooks like `useHomeAddressForm` emit field _components_ (e.g. `Street1`, `City`, `Zip`); they don't render any text themselves. Each call site passes its own `label` and `validationMessages` props from whatever namespace is convenient. The onboarding consumer reads from `Employee.HomeAddress`, the management consumer reads from `Employee.HomeAddress.Management`, and the two never need to share a translation namespace just because they share field components. + +Concrete heuristic — only dual-load the base namespace if **a runtime-shared piece of UI** (a shared presentational component that itself calls `useTranslation('Employee.')`) is rendered inside the management path. If the management-side consumer of those strings is only ever rendered in the management path (e.g. a `View` that exists solely under `management/`), copy the strings it needs into `Employee..Management.json` and read everything from one namespace. Don't reach across. + +Concretely: the field labels, validation messages, and courtesy-withholding copy that `HomeAddress/management/HomeAddressView.tsx` renders are duplicated into `Employee.HomeAddress.Management.json` rather than dual-loading `Employee.HomeAddress`, because `HomeAddressView` is exclusive to the management path. Onboarding's `EmployeeProfile`/`AdminProfile` keep reading the same keys from `Employee.HomeAddress`. + +Duplication is the cost; it buys a fully self-contained block. A partner who only overrides `Employee.HomeAddress.Management` via `useComponentDictionary` gets a coherent result without having to discover that they also need to override `Employee.HomeAddress`. When the same copy needs to change in both contexts (rare for field labels; almost never for validation messages), the change is two edits instead of one — that's a worthwhile trade. ### Strings to move out of `Employee.Dashboard` during migration