diff --git a/.reports/embedded-react-sdk.api.md b/.reports/embedded-react-sdk.api.md index 34c908ca0..fc6fb4a8d 100644 --- a/.reports/embedded-react-sdk.api.md +++ b/.reports/embedded-react-sdk.api.md @@ -1305,6 +1305,10 @@ export const componentEvents: { readonly CONTRACTOR_REHIRE: "contractor/rehire"; readonly CONTRACTOR_DISMISSAL_CANCELLED: "contractor/dismissal/cancelled"; readonly CONTRACTOR_REHIRE_CANCELLED: "contractor/rehire/cancelled"; + readonly CONTRACTOR_MANAGEMENT_PROFILE_EDIT_REQUESTED: "contractor/management/profile/editRequested"; + readonly CONTRACTOR_MANAGEMENT_PROFILE_UPDATED: "contractor/management/profile/updated"; + readonly CONTRACTOR_MANAGEMENT_PROFILE_EDIT_CANCELLED: "contractor/management/profile/editCancelled"; + readonly CONTRACTOR_MANAGEMENT_PROFILE_ALERT_DISMISSED: "contractor/management/profile/alertDismissed"; readonly PAY_SCHEDULE_CREATE: "paySchedule/create"; readonly PAY_SCHEDULE_CREATED: "paySchedule/created"; readonly PAY_SCHEDULE_UPDATE: "paySchedule/update"; @@ -1774,6 +1778,12 @@ declare namespace ContractorManagement { ManagementContractorList as ContractorList, ManagementContractorListProps, ContractorTab, + Profile_3 as Profile, + ProfileCard_2 as ProfileCard, + ProfileEditForm_2 as ProfileEditForm, + ProfileProps_3 as ProfileProps, + ProfileCardProps_2 as ProfileCardProps, + ProfileEditFormProps_2 as ProfileEditFormProps, PaymentFlow, PaymentFlowProps, PaymentsList, @@ -4315,15 +4325,27 @@ function Profile(input: ProfileProps): JSX; // @public function Profile_2(input: ProfileProps_2): JSX; +// @public +function Profile_3(input: ProfileProps_3): JSX; + // @public function ProfileCard(props: ProfileCardProps): JSX; +// @public +function ProfileCard_2(props: ProfileCardProps_2): JSX; + // @public interface ProfileCardProps { employeeId: string; onEvent: OnEventType; } +// @public +interface ProfileCardProps_2 { + contractorId: string; + onEvent: OnEventType; +} + // @public type ProfileDefaultValues = RequireAtLeastOne<{ employee?: RequireAtLeastOne<{ @@ -4346,11 +4368,19 @@ type ProfileDefaultValues = RequireAtLeastOne<{ // @public function ProfileEditForm(input: ProfileEditFormProps): JSX; +// @public +function ProfileEditForm_2(input: ProfileEditFormProps_2): JSX; + // @public interface ProfileEditFormProps extends BaseComponentInterface<'Employee.Management.Profile'> { employeeId: string; } +// @public +interface ProfileEditFormProps_2 extends BaseComponentInterface<'Contractor.Management.Profile'> { + contractorId: string; +} + // @public interface ProfileProps extends BaseComponentInterface<'Employee.Profile'> { companyId: string; @@ -4365,6 +4395,11 @@ interface ProfileProps_2 extends BaseComponentInterface<'Employee.Management.Pro employeeId: string; } +// @public +interface ProfileProps_3 extends BaseComponentInterface<'Contractor.Management.Profile'> { + contractorId: string; +} + // @public export interface ProgressBarProps { className?: string; @@ -4532,6 +4567,8 @@ export interface Resources { // (undocumented) 'Contractor.Landing': Translations.ContractorLanding // (undocumented) + 'Contractor.Management.Profile': Translations.ContractorManagementProfile + // (undocumented) 'Contractor.ManagementContractorList': Translations.ContractorManagementContractorList // (undocumented) 'Contractor.NewHireReport': Translations.ContractorNewHireReport @@ -5690,6 +5727,7 @@ export type UseContractorDetailsFormResult = HookLoadingResult | UseContractorDe // @public export type UseContractorDetailsFormSharedProps = { withSelfOnboardingField?: boolean; + showEmailField?: boolean; optionalFieldsToRequire?: ContractorDetailsOptionalFieldsToRequire; defaultValues?: Partial; validationMode?: UseFormProps['mode']; diff --git a/docs/reference/Translations/index.md b/docs/reference/Translations/index.md index cb01b512e..19f04bea2 100644 --- a/docs/reference/Translations/index.md +++ b/docs/reference/Translations/index.md @@ -1424,6 +1424,55 @@ Translation keys for the `Contractor.ManagementContractorList` i18n namespace. *** + + +### ContractorManagementProfile + +Translation keys for the `Contractor.Management.Profile` i18n namespace. + +#### Properties + +| Property | Default value | +| ------ | ------ | +| `alerts` | | +| `alerts.profileUpdated` | `"Profile updated"` | +| `editCta` | `"Edit"` | +| `email` | `"Email"` | +| `employerIdentificationNumber` | `"Employer Identification Number (EIN)"` | +| `form` | | +| `form.businessName` | `"Business name"` | +| `form.cancelCta` | `"Cancel"` | +| `form.changeCta` | `"Change"` | +| `form.einLabel` | `"Employer Identification Number (EIN)"` | +| `form.einMask` | `"••-•••••••"` | +| `form.email` | `"Email address"` | +| `form.emailDescription` | `"Used to send onboarding and payment notifications."` | +| `form.firstName` | `"First name"` | +| `form.lastName` | `"Last name"` | +| `form.middleInitial` | `"Middle initial"` | +| `form.onFileHint` | `"Already on file."` | +| `form.saveCta` | `"Save"` | +| `form.ssnLabel` | `"Social security number"` | +| `form.ssnMask` | `"•••-••-••••"` | +| `form.startDate` | `"Start date"` | +| `form.successAlert` | `"Profile updated"` | +| `form.title` | `"Basic details"` | +| `form.validations` | | +| `form.validations.businessName` | `"Business name is required"` | +| `form.validations.ein` | `"The EIN must be exactly 9 digits long."` | +| `form.validations.email` | `"Enter a valid email address"` | +| `form.validations.firstName` | `"First name is required"` | +| `form.validations.lastName` | `"Last name is required"` | +| `form.validations.ssn` | `"The SSN must be exactly 9 digits long, cannot contain all zeros in any group, and the first three digits cannot be '666' or in the range 900–999."` | +| `form.validations.startDate` | `"Start date is required"` | +| `legalName` | `"Legal name"` | +| `listEmptyPlaceholder` | `"No value on file"` | +| `socialSecurityNumber` | `"Social security number"` | +| `startDate` | `"Start date"` | +| `title` | `"Basic details"` | + +*** + ### ContractorNewHireReport @@ -5120,6 +5169,7 @@ yields that namespace's keys. Backs i18next `t()` typing and `ResourceDictionary | `Contractor.ContractorList` | [`ContractorContractorList`](#contractorcontractorlist) | | `Contractor.DocumentsList` | [`ContractorDocumentsList`](#contractordocumentslist) | | `Contractor.Landing` | [`ContractorLanding`](#contractorlanding) | +| `Contractor.Management.Profile` | [`ContractorManagementProfile`](#contractormanagementprofile) | | `Contractor.ManagementContractorList` | [`ContractorManagementContractorList`](#contractormanagementcontractorlist) | | `Contractor.NewHireReport` | [`ContractorNewHireReport`](#contractornewhirereport) | | `Contractor.OnboardingSummary` | [`ContractorOnboardingSummary`](#contractoronboardingsummary) | diff --git a/docs/reference/blocks.md b/docs/reference/blocks.md index 36a68f20b..559b2b8e2 100644 --- a/docs/reference/blocks.md +++ b/docs/reference/blocks.md @@ -40,6 +40,9 @@ Individual form and UI components with SDK logic built in — use these for cust | [ContractorManagement.PaymentsList](contractor/management/blocks#paymentslist) | Displays a list of contractor payment groups for a company. | | [ContractorManagement.PaymentStatement](contractor/management/blocks#paymentstatement) | Displays a single contractor's payment statement within a payment group, including wage breakdown, bonuses, reimbursements, and a receipt card for funded direct-deposit payments. | | [ContractorManagement.PaymentSummary](contractor/management/blocks#paymentsummary) | Displays a summary of a created contractor payment group, including payment totals, debit information, contractor details, and wire transfer instructions when required. | +| [ContractorManagement.Profile](contractor/management/blocks#profile) | Management surface for viewing and editing a contractor's basic profile details after onboarding. | +| [ContractorManagement.ProfileCard](contractor/management/blocks#profilecard) | Read-only card showing a contractor's basic profile details with an Edit action. | +| [ContractorManagement.ProfileEditForm](contractor/management/blocks#profileeditform) | Standalone edit form for a contractor's basic profile details. | | [ContractorOnboarding.Address](contractor/onboarding/blocks#address) | Form for collecting and updating a contractor's mailing address. Renders a business or home address title based on the contractor type. | | [ContractorOnboarding.ContractorList](contractor/onboarding/blocks#contractorlist) | Lists a company's contractors with controls to add, edit, delete, cancel self-onboarding, and continue onboarding. | | [ContractorOnboarding.ContractorProfile](contractor/onboarding/blocks#contractorprofile) | Form for creating or editing a contractor profile, supporting both individual and business contractor types. | @@ -253,6 +256,8 @@ shape mixed into every public SDK feature component. - [`DocumentsListProps`](contractor/onboarding/blocks.md#documentslistprops) - [`SignatureFormProps`](contractor/onboarding/blocks.md#signatureformprops) - [`ManagementContractorListProps`](contractor/management/blocks.md#managementcontractorlistprops) +- [`ProfileProps`](contractor/management/blocks.md#profileprops) +- [`ProfileEditFormProps`](contractor/management/blocks.md#profileeditformprops) - [`PaymentFlowProps`](contractor/management/payment-flow.md#paymentflowprops) - [`PaymentsListProps`](contractor/management/blocks.md#paymentslistprops) - [`CreatePaymentProps`](contractor/management/blocks.md#createpaymentprops) diff --git a/docs/reference/contractor/hooks/use-contractor-details-form.md b/docs/reference/contractor/hooks/use-contractor-details-form.md index 6d4325e45..c8e6af36f 100644 --- a/docs/reference/contractor/hooks/use-contractor-details-form.md +++ b/docs/reference/contractor/hooks/use-contractor-details-form.md @@ -24,8 +24,8 @@ self-onboarding preference. Returns a discriminated union: a loading variant while the contractor fetch resolves, and a ready variant exposing the form's data, pending status, submit action, error handling, and bound `Fields`. Field visibility is -driven by the current `type` and `wageType` (self-onboarding only toggles the -`Email` field); fields that do not apply are `undefined` on `form.Fields`. +driven by the current `type` and `wageType` (self-onboarding toggles the +`Email` field unless `showEmailField` is set); fields that do not apply are `undefined` on `form.Fields`. SSN/EIN are exposed by contractor type regardless of self-onboarding — each consumer decides whether to render them. Self-onboarding is only toggleable when the contractor's onboarding status allows it. @@ -97,6 +97,7 @@ Discriminated by mode: in create mode supply `companyId` and omit | `defaultValues?` | `Partial`\<[`ContractorDetailsFormData`](#contractordetailsformdata)\> | Initial values applied before any contractor data loads. | | `optionalFieldsToRequire?` | [`ContractorDetailsOptionalFieldsToRequire`](#contractordetailsoptionalfieldstorequire) | Fields that are optional by default but should be promoted to required for this form instance. | | `shouldFocusError?` | `boolean` | Whether react-hook-form should focus the first error on validation failure. Defaults to `true`. | +| `showEmailField?` | `boolean` | Whether `form.Fields.Email` is available regardless of the `selfOnboarding` value. **Remarks** By default `Email` is only exposed while `selfOnboarding` is `true`, since email is otherwise only collected to send a self-onboarding invite. Set this to `true` for surfaces that let an admin edit an already-onboarded contractor's email directly. Defaults to `false`. | | `validationMode?` | `UseFormProps`\[`"mode"`\] | When validation runs. Forwarded to react-hook-form's `mode`. Defaults to `'onSubmit'`. | | `withSelfOnboardingField?` | `boolean` | Whether to expose the self-onboarding toggle as `form.Fields.SelfOnboarding`. Defaults to `true`. | diff --git a/docs/reference/contractor/index.mdx b/docs/reference/contractor/index.mdx index a6ed387ae..ec9d6a1d7 100644 --- a/docs/reference/contractor/index.mdx +++ b/docs/reference/contractor/index.mdx @@ -34,7 +34,7 @@ Flows and blocks for managing contractors after onboarding — payments, payment import { ContractorManagement } from '@gusto/embedded-react-sdk' ``` - + --- diff --git a/docs/reference/contractor/management/blocks.md b/docs/reference/contractor/management/blocks.md index baf58e368..08a43da68 100644 --- a/docs/reference/contractor/management/blocks.md +++ b/docs/reference/contractor/management/blocks.md @@ -338,6 +338,124 @@ Props for [PaymentSummary](#paymentsummary). *** + + +## Profile + +Management surface for viewing and editing a contractor's basic profile details after onboarding. + +### Remarks + +Drives the read-view card and edit form via an internal state machine. +Emits events on the supplied `onEvent` handler when the user requests an +edit, saves changes, or cancels. + +
+ +### ProfileProps + + + +Props for [Profile](#profile). + +| Property | Type | Description | +| ------ | ------ | ------ | +| `contractorId` | `string` | The associated contractor identifier. | +| `onEvent` | [`OnEventType`](../../events.md#oneventtype)\<[`EventType`](../../events.md#eventtype), `unknown`\> | Callback invoked each time the component emits an event — user interactions, successful API responses, step transitions, or errors. Receives the event type constant and an optional payload whose shape varies by event. See the [Event Handling guide](https://docs.gusto.com/embedded-payroll/docs/event-handling) and each component's event table for the full list of emitted events. | +| `dictionary?` | `Record`\<`"en"`, [`DeepPartial`](../../Translations/index.md#deeppartial)\<[`ContractorManagementProfile`](../../Translations/index.md#contractormanagementprofile)\>\> | Overrides for the component's i18n strings. Supply a partial object whose keys match the component's resource namespace — any omitted keys fall back to SDK defaults. See the [Translation guide](https://docs.gusto.com/embedded-payroll/docs/translation) for details. | + +_Inherits `children`, `className`, `defaultValues`, `FallbackComponent`, `LoaderComponent` from [BaseComponentInterface](../../blocks.md#basecomponentinterface)._ + +
+ +### Events + +| Event | Description | Data | +| ----- | ----------- | ---- | +| `contractor/management/profile/editRequested` | Fired when the user clicks Edit on the read-view card | `{ contractorId: string }` | +| `contractor/management/profile/updated` | Fired after the profile is successfully saved | [APIModels.Contractor](../../APIModels/index.md#contractor) | +| `contractor/management/profile/editCancelled` | Fired when the user cancels editing | — | + +*** + + + +## ProfileCard + +Read-only card showing a contractor's basic profile details with an Edit action. + +### Remarks + +Standalone card that fetches its own data. Emits an event when the user +clicks Edit so the parent can switch to the edit form. The card does not +render success or error alerts itself — alert presentation is the +surrounding surface's responsibility. + +
+ +### ProfileCardProps + + + +Props for [ProfileCard](#profilecard). + +| Property | Type | Description | +| ------ | ------ | ------ | +| `contractorId` | `string` | The associated contractor identifier. | +| `onEvent` | [`OnEventType`](../../events.md#oneventtype)\<[`EventType`](../../events.md#eventtype), `unknown`\> | Event handler fired when the user requests to edit the profile. | + +
+ +### Events + +| Event | Description | Data | +| ----- | ----------- | ---- | +| `contractor/management/profile/editRequested` | Fired when the user clicks the Edit button | `{ contractorId: string }` | + +*** + + + +## ProfileEditForm + +Standalone edit form for a contractor's basic profile details. + +### Remarks + +Renders fields for the contractor's name (or business name), start date, +tax ID (SSN or EIN, depending on the contractor's type), and email — all +required on update except tax ID and email — and shows a success alert +when the save completes. An SSN/EIN already on file renders as a locked, +masked value with a "Change" action rather than a blank editable input. +Save and Cancel both emit events so the parent can return to the read view. + +
+ +### ProfileEditFormProps + + + +Props for [ProfileEditForm](#profileeditform). + +| Property | Type | Description | +| ------ | ------ | ------ | +| `contractorId` | `string` | The associated contractor identifier. | +| `onEvent` | [`OnEventType`](../../events.md#oneventtype)\<[`EventType`](../../events.md#eventtype), `unknown`\> | Callback invoked each time the component emits an event — user interactions, successful API responses, step transitions, or errors. Receives the event type constant and an optional payload whose shape varies by event. See the [Event Handling guide](https://docs.gusto.com/embedded-payroll/docs/event-handling) and each component's event table for the full list of emitted events. | +| `dictionary?` | `Record`\<`"en"`, [`DeepPartial`](../../Translations/index.md#deeppartial)\<[`ContractorManagementProfile`](../../Translations/index.md#contractormanagementprofile)\>\> | Overrides for the component's i18n strings. Supply a partial object whose keys match the component's resource namespace — any omitted keys fall back to SDK defaults. See the [Translation guide](https://docs.gusto.com/embedded-payroll/docs/translation) for details. | + +_Inherits `children`, `className`, `defaultValues`, `FallbackComponent`, `LoaderComponent` from [BaseComponentInterface](../../blocks.md#basecomponentinterface)._ + +
+ +### Events + +| Event | Description | Data | +| ----- | ----------- | ---- | +| `contractor/management/profile/updated` | Fired after the contractor profile is successfully saved | [APIModels.Contractor](../../APIModels/index.md#contractor) | +| `contractor/management/profile/editCancelled` | Fired when the user clicks Cancel | — | + +*** + ## Utility types diff --git a/docs/reference/contractor/management/index.md b/docs/reference/contractor/management/index.md index 3edad495a..628be3447 100644 --- a/docs/reference/contractor/management/index.md +++ b/docs/reference/contractor/management/index.md @@ -34,3 +34,6 @@ import { ContractorManagement } from '@gusto/embedded-react-sdk' | [PaymentsList](blocks.md#paymentslist) | Displays a list of contractor payment groups for a company. | | [PaymentStatement](blocks.md#paymentstatement) | Displays a single contractor's payment statement within a payment group, including wage breakdown, bonuses, reimbursements, and a receipt card for funded direct-deposit payments. | | [PaymentSummary](blocks.md#paymentsummary) | Displays a summary of a created contractor payment group, including payment totals, debit information, contractor details, and wire transfer instructions when required. | +| [Profile](blocks.md#profile) | Management surface for viewing and editing a contractor's basic profile details after onboarding. | +| [ProfileCard](blocks.md#profilecard) | Read-only card showing a contractor's basic profile details with an Edit action. | +| [ProfileEditForm](blocks.md#profileeditform) | Standalone edit form for a contractor's basic profile details. | diff --git a/docs/reference/events.md b/docs/reference/events.md index 4907ab7b4..77c903e0b 100644 --- a/docs/reference/events.md +++ b/docs/reference/events.md @@ -95,6 +95,10 @@ import { componentEvents, EmployeeOnboarding } from '@gusto/embedded-react-sdk' | `CONTRACTOR_DISMISSAL_CANCELLED` | `"contractor/dismissal/cancelled"` | | `CONTRACTOR_DOCUMENTS_DONE` | `"contractor/documents/done"` | | `CONTRACTOR_INVITE_CONTRACTOR` | `"contractor/invite/selfOnboarding"` | +| `CONTRACTOR_MANAGEMENT_PROFILE_ALERT_DISMISSED` | `"contractor/management/profile/alertDismissed"` | +| `CONTRACTOR_MANAGEMENT_PROFILE_EDIT_CANCELLED` | `"contractor/management/profile/editCancelled"` | +| `CONTRACTOR_MANAGEMENT_PROFILE_EDIT_REQUESTED` | `"contractor/management/profile/editRequested"` | +| `CONTRACTOR_MANAGEMENT_PROFILE_UPDATED` | `"contractor/management/profile/updated"` | | `CONTRACTOR_NEW_HIRE_REPORT_DONE` | `"contractor/newHireReport/done"` | | `CONTRACTOR_NEW_HIRE_REPORT_UPDATED` | `"contractor/newHireReport/updated"` | | `CONTRACTOR_ONBOARDING_CONTINUE` | `"contractor/onboarding/continue"` | diff --git a/docs/reference/index.mdx b/docs/reference/index.mdx index bc14cda6c..9f87a9f18 100644 --- a/docs/reference/index.mdx +++ b/docs/reference/index.mdx @@ -12,7 +12,7 @@ custom_edit_url: null ## Browse by domain - + --- diff --git a/sdk-app/src/generated-registry-data.ts b/sdk-app/src/generated-registry-data.ts index 9f66eaf60..8d8989164 100644 --- a/sdk-app/src/generated-registry-data.ts +++ b/sdk-app/src/generated-registry-data.ts @@ -20,12 +20,16 @@ export const ENTITY_REQUIREMENTS: Record = { 'CompanyOnboarding.StateTaxes': ['companyId'], 'CompanyOnboarding.StateTaxesForm': ['companyId'], 'CompanyOnboarding.StateTaxesList': ['companyId'], + 'ContractorManagement.ContractorList': ['companyId'], 'ContractorManagement.CreatePayment': ['companyId'], 'ContractorManagement.PaymentFlow': ['companyId'], 'ContractorManagement.PaymentHistory': ['companyId'], 'ContractorManagement.PaymentStatement': ['companyId'], 'ContractorManagement.PaymentSummary': ['companyId'], 'ContractorManagement.PaymentsList': ['companyId'], + 'ContractorManagement.Profile': ['contractorId'], + 'ContractorManagement.ProfileCard': ['contractorId'], + 'ContractorManagement.ProfileEditForm': ['contractorId'], 'ContractorOnboarding.Address': ['contractorId'], 'ContractorOnboarding.ContractorList': ['companyId'], 'ContractorOnboarding.ContractorProfile': ['companyId'], diff --git a/src/components/Contractor/Profile/management/Profile.test.tsx b/src/components/Contractor/Profile/management/Profile.test.tsx new file mode 100644 index 000000000..7fcb77c18 --- /dev/null +++ b/src/components/Contractor/Profile/management/Profile.test.tsx @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse, type HttpResponseResolver } from 'msw' +import { Profile } from './Profile' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { server } from '@/test/mocks/server' +import { handleGetContractor, handleUpdateContractor } from '@/test/mocks/apis/contractors' +import { componentEvents } from '@/shared/constants' + +const contractorWithRequiredFields = { + uuid: 'contractor-123', + company_uuid: 'company-123', + type: 'Individual', + first_name: 'Ada', + middle_initial: 'J', + last_name: 'Lovelace', + email: 'ada.lovelace@example.com', + start_date: '2024-03-15', + version: '1', + has_ssn: true, + has_ein: false, + wage_type: 'Hourly', + hourly_rate: '65.00', + is_active: true, + onboarded: true, + onboarding_status: 'onboarding_completed', +} + +describe('Profile (management block)', () => { + const onEvent = vi.fn() + + beforeEach(() => { + setupApiTestMocks() + onEvent.mockClear() + server.use(handleGetContractor(() => HttpResponse.json(contractorWithRequiredFields))) + }) + + it('renders the card initially with the basic-details title and Edit button', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit' })).toBeEnabled() + }) + + expect(screen.getByText('Basic details')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save' })).toBeNull() + }) + + it('transitions card → editProfile when the Edit button is clicked', async () => { + const user = userEvent.setup() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit' })).toBeEnabled() + }) + + await user.click(screen.getByRole('button', { name: 'Edit' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() + }) + + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + expect(onEvent).toHaveBeenCalledWith( + componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_EDIT_REQUESTED, + { contractorId: 'contractor-123' }, + ) + }) + + it('returns to the card without an alert when Cancel is clicked from edit', async () => { + const user = userEvent.setup() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit' })).toBeEnabled() + }) + await user.click(screen.getByRole('button', { name: 'Edit' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + }) + + expect(screen.queryByText('Profile updated')).toBeNull() + expect(onEvent).toHaveBeenCalledWith( + componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_EDIT_CANCELLED, + undefined, + ) + }) + + it('returns to the card with the profileUpdated alert after a successful save', async () => { + const user = userEvent.setup() + server.use( + handleGetContractor(() => HttpResponse.json(contractorWithRequiredFields)), + handleUpdateContractor( + vi.fn(async ({ request }) => { + const body = (await request.json()) as Record + return HttpResponse.json({ + ...contractorWithRequiredFields, + ...body, + version: '2', + }) + }), + ), + ) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit' })).toBeEnabled() + }) + await user.click(screen.getByRole('button', { name: 'Edit' })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor( + () => { + expect(screen.getByText('Profile updated')).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + expect(onEvent).toHaveBeenCalledWith( + componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_UPDATED, + expect.any(Object), + ) + }) +}) diff --git a/src/components/Contractor/Profile/management/Profile.tsx b/src/components/Contractor/Profile/management/Profile.tsx new file mode 100644 index 000000000..9672f56bc --- /dev/null +++ b/src/components/Contractor/Profile/management/Profile.tsx @@ -0,0 +1,66 @@ +import { createMachine } from 'robot3' +import { useMemo } from 'react' +import { CardContextual, type ProfileContextInterface } from './ProfileComponents' +import { profileStateMachine } from './profileStateMachine' +import { Flow } from '@/components/Flow/Flow' +import { BaseBoundaries, type BaseComponentInterface } from '@/components/Base' +import { useComponentDictionary } from '@/i18n/I18n' +import { useI18n } from '@/i18n' + +/** + * Props for {@link Profile}. + * + * @public + */ +export interface ProfileProps extends BaseComponentInterface<'Contractor.Management.Profile'> { + /** The associated contractor identifier. */ + contractorId: string +} + +function ProfileFlow({ contractorId, onEvent }: ProfileProps) { + useI18n('Contractor.Management.Profile') + + const machine = useMemo( + () => + createMachine('card', profileStateMachine, (ctx: ProfileContextInterface) => ({ + ...ctx, + component: CardContextual, + contractorId, + successAlert: null, + })), + [contractorId], + ) + + return +} + +/** + * Management surface for viewing and editing a contractor's basic profile details after onboarding. + * + * @remarks + * Drives the read-view card and edit form via an internal state machine. + * Emits events on the supplied `onEvent` handler when the user requests an + * edit, saves changes, or cancels. + * + * @events + * | Event | Description | Data | + * | ----- | ----------- | ---- | + * | `contractor/management/profile/editRequested` | Fired when the user clicks Edit on the read-view card | `{ contractorId: string }` | + * | `contractor/management/profile/updated` | Fired after the profile is successfully saved | {@link APIModels.Contractor} | + * | `contractor/management/profile/editCancelled` | Fired when the user cancels editing | — | + * + * @param props - See {@link ProfileProps}. + * @returns The contractor profile management surface. + * @public + */ +export function Profile({ dictionary, FallbackComponent, ...props }: ProfileProps) { + useComponentDictionary('Contractor.Management.Profile', dictionary) + return ( + + + + ) +} diff --git a/src/components/Contractor/Profile/management/ProfileCard/ProfileCard.test.tsx b/src/components/Contractor/Profile/management/ProfileCard/ProfileCard.test.tsx new file mode 100644 index 000000000..aa37b9f7d --- /dev/null +++ b/src/components/Contractor/Profile/management/ProfileCard/ProfileCard.test.tsx @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import { ProfileCard } from './ProfileCard' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { server } from '@/test/mocks/server' +import { handleGetContractor } from '@/test/mocks/apis/contractors' +import { componentEvents } from '@/shared/constants' + +function mockContractor(overrides: Record = {}) { + server.use( + handleGetContractor(() => + HttpResponse.json({ + uuid: 'contractor-123', + company_uuid: 'company-123', + type: 'Individual', + first_name: 'Ada', + last_name: 'Lovelace', + start_date: '2024-03-15', + has_ssn: true, + has_ein: false, + email: 'ada.lovelace@example.com', + wage_type: 'Hourly', + is_active: true, + version: 'version-123', + onboarded: true, + onboarding_status: 'onboarding_completed', + ...overrides, + }), + ), + ) +} + +describe('ProfileCard', () => { + const onEvent = vi.fn() + + beforeEach(() => { + setupApiTestMocks() + onEvent.mockClear() + mockContractor() + }) + + it('renders the basic-details title and Edit button once the contractor loads', async () => { + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit' })).toBeEnabled() + }) + + expect(screen.getByText('Basic details')).toBeInTheDocument() + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + expect(screen.getByText('XXX-XX-XXXX')).toBeInTheDocument() + }) + + it('shows the EIN label and masked value for a business contractor', async () => { + mockContractor({ + type: 'Business', + business_name: 'Pacific Design Co.', + first_name: null, + last_name: null, + has_ssn: false, + has_ein: true, + ein: 'XX-XXX4879', + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Pacific Design Co.')).toBeInTheDocument() + }) + + expect(screen.getByText('Employer Identification Number (EIN)')).toBeInTheDocument() + expect(screen.getByText('XX-XXX4879')).toBeInTheDocument() + }) + + it('fires CONTRACTOR_MANAGEMENT_PROFILE_EDIT_REQUESTED with { contractorId } when Edit is clicked', async () => { + const user = userEvent.setup() + renderWithProviders() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Edit' })).toBeEnabled() + }) + + await user.click(screen.getByRole('button', { name: 'Edit' })) + + expect(onEvent).toHaveBeenCalledWith( + componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_EDIT_REQUESTED, + { contractorId: 'contractor-123' }, + ) + }) +}) diff --git a/src/components/Contractor/Profile/management/ProfileCard/ProfileCard.tsx b/src/components/Contractor/Profile/management/ProfileCard/ProfileCard.tsx new file mode 100644 index 000000000..fb15b7146 --- /dev/null +++ b/src/components/Contractor/Profile/management/ProfileCard/ProfileCard.tsx @@ -0,0 +1,120 @@ +import { useTranslation } from 'react-i18next' +import { useContractorProfileSummary } from '../../shared/useContractorProfileSummary' +import { Loading } from '@/components/Common' +import { BaseBoundaries, BaseLayout } from '@/components/Base/Base' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { formatDateLongWithYear } from '@/helpers/dateFormatting' +import { firstLastName } from '@/helpers/formattedStrings' +import { useI18n } from '@/i18n' +import { componentEvents, CONTRACTOR_TYPE, type EventType } from '@/shared/constants' +import type { OnEventType } from '@/components/Base/useBase' + +/** + * Props for {@link ProfileCard}. + * + * @public + */ +export interface ProfileCardProps { + /** The associated contractor identifier. */ + contractorId: string + /** Event handler fired when the user requests to edit the profile. */ + onEvent: OnEventType +} + +/** + * Read-only card showing a contractor's basic profile details with an Edit action. + * + * @remarks + * Standalone card that fetches its own data. Emits an event when the user + * clicks Edit so the parent can switch to the edit form. The card does not + * render success or error alerts itself — alert presentation is the + * surrounding surface's responsibility. + * + * @events + * | Event | Description | Data | + * | ----- | ----------- | ---- | + * | `contractor/management/profile/editRequested` | Fired when the user clicks the Edit button | `{ contractorId: string }` | + * + * @param props - See {@link ProfileCardProps}. + * @returns The basic-details profile card. + * @public + */ +export function ProfileCard(props: ProfileCardProps) { + return ( + + + + ) +} + +function ProfileCardContent({ contractorId, onEvent }: ProfileCardProps) { + useI18n('Contractor.Management.Profile') + const { t } = useTranslation('Contractor.Management.Profile') + const Components = useComponentContext() + + const profile = useContractorProfileSummary({ contractorId }) + + const isContractorLoading = profile.isLoading + + const contractor = profile.isLoading ? undefined : profile.data.contractor + + const isBusiness = contractor?.type === CONTRACTOR_TYPE.BUSINESS + + const legalName = contractor + ? isBusiness + ? contractor.businessName + : firstLastName({ first_name: contractor.firstName, last_name: contractor.lastName }) + : undefined + const startDate = contractor ? formatDateLongWithYear(contractor.startDate) : undefined + const maskedSsn = contractor?.hasSsn ? 'XXX-XX-XXXX' : undefined + const maskedEin = contractor?.hasEin ? (contractor.ein ?? undefined) : undefined + + const emptyPlaceholder = + const profileItems = contractor + ? [ + { term: t('legalName'), description: legalName || emptyPlaceholder }, + { term: t('startDate'), description: startDate || emptyPlaceholder }, + isBusiness + ? { + term: t('employerIdentificationNumber'), + description: maskedEin || emptyPlaceholder, + } + : { + term: t('socialSecurityNumber'), + description: maskedSsn || emptyPlaceholder, + }, + { term: t('email'), description: contractor.email || emptyPlaceholder }, + ] + : [] + + const handleEdit = () => { + onEvent(componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_EDIT_REQUESTED, { contractorId }) + } + + return ( + + + {t('editCta')} + + } + /> + } + > + {isContractorLoading ? ( + + ) : contractor ? ( + + ) : null} + + + ) +} diff --git a/src/components/Contractor/Profile/management/ProfileCard/index.ts b/src/components/Contractor/Profile/management/ProfileCard/index.ts new file mode 100644 index 000000000..fc654a197 --- /dev/null +++ b/src/components/Contractor/Profile/management/ProfileCard/index.ts @@ -0,0 +1 @@ +export { ProfileCard, type ProfileCardProps } from './ProfileCard' diff --git a/src/components/Contractor/Profile/management/ProfileComponents.tsx b/src/components/Contractor/Profile/management/ProfileComponents.tsx new file mode 100644 index 000000000..296ee95cd --- /dev/null +++ b/src/components/Contractor/Profile/management/ProfileComponents.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' +import { ProfileEditForm } from './ProfileEditForm' +import { ProfileCard } from './ProfileCard' +import { Flex } from '@/components/Common/Flex/Flex' +import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { ensureRequired } from '@/helpers/ensureRequired' +import { componentEvents } from '@/shared/constants' + +type ProfileSuccessAlertCode = 'profileUpdated' + +/** @internal */ +export interface ProfileContextInterface extends FlowContextInterface { + contractorId?: string + successAlert?: ProfileSuccessAlertCode | null +} + +/** @internal */ +export function CardContextual() { + const { contractorId, onEvent, successAlert } = useFlow() + const { t } = useTranslation('Contractor.Management.Profile') + const Components = useComponentContext() + return ( + + {successAlert ? ( + { + onEvent(componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_ALERT_DISMISSED, null) + }} + /> + ) : null} + + + ) +} + +/** @internal */ +export function ProfileEditFormContextual() { + const { contractorId, onEvent } = useFlow() + return +} diff --git a/src/components/Contractor/Profile/management/ProfileEditForm.module.scss b/src/components/Contractor/Profile/management/ProfileEditForm.module.scss new file mode 100644 index 000000000..c0923324b --- /dev/null +++ b/src/components/Contractor/Profile/management/ProfileEditForm.module.scss @@ -0,0 +1,24 @@ +.container { + width: 100%; +} + +.lockedField { + width: 100%; +} + +.lockedFieldRow { + display: flex; + align-items: center; + gap: toRem(8); +} + +.lockedFieldInput { + flex: 1; + min-width: 0; +} + +.lockedFieldButton:global(.react-aria-Button) { + min-height: toRem(40); + height: toRem(40); + padding-block: toRem(7); +} diff --git a/src/components/Contractor/Profile/management/ProfileEditForm.test.tsx b/src/components/Contractor/Profile/management/ProfileEditForm.test.tsx new file mode 100644 index 000000000..75bc14514 --- /dev/null +++ b/src/components/Contractor/Profile/management/ProfileEditForm.test.tsx @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse, type HttpResponseResolver } from 'msw' +import { ProfileEditForm } from './ProfileEditForm' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { server } from '@/test/mocks/server' +import { handleGetContractor, handleUpdateContractor } from '@/test/mocks/apis/contractors' +import { componentEvents } from '@/shared/constants' + +const baseIndividual = { + uuid: 'contractor-123', + company_uuid: 'company-123', + type: 'Individual', + first_name: 'Ada', + last_name: 'Lovelace', + middle_initial: null, + start_date: '2024-03-15', + has_ssn: true, + has_ein: false, + email: 'ada.lovelace@example.com', + wage_type: 'Hourly', + hourly_rate: '65.00', + is_active: true, + version: 'version-123', + onboarded: true, + onboarding_status: 'onboarding_completed', + self_onboarding: false, + file_new_hire_report: false, +} + +const baseBusiness = { + ...baseIndividual, + type: 'Business', + business_name: 'Pacific Design Co.', + first_name: null, + last_name: null, + has_ssn: false, + has_ein: true, + ein: 'XX-XXX4879', +} + +function mockContractor(overrides: Record = {}) { + server.use(handleGetContractor(() => HttpResponse.json({ ...baseIndividual, ...overrides }))) +} + +describe('ProfileEditForm — individual contractor', () => { + const onEvent = vi.fn() + + beforeEach(() => { + setupApiTestMocks() + onEvent.mockClear() + mockContractor() + }) + + it('renders a locked SSN field with a Change button when an SSN is already on file', async () => { + renderWithProviders() + + await screen.findByDisplayValue('Ada') + + expect(screen.getByDisplayValue('•••-••-••••')).toBeDisabled() + expect(screen.getByText('Already on file.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Change' })).toBeInTheDocument() + }) + + it('reveals an editable SSN input after clicking Change, without submitting the form', async () => { + const updateResolver = vi.fn(() => HttpResponse.json(baseIndividual)) + server.use(handleUpdateContractor(updateResolver)) + + const user = userEvent.setup() + renderWithProviders() + + await screen.findByDisplayValue('Ada') + await user.click(screen.getByRole('button', { name: 'Change' })) + + expect(screen.queryByRole('button', { name: 'Change' })).not.toBeInTheDocument() + expect(screen.getByLabelText(/Social security number/)).toBeEnabled() + expect(updateResolver).not.toHaveBeenCalled() + }) + + it('always shows the email field, independent of self-onboarding', async () => { + renderWithProviders() + + await screen.findByDisplayValue('Ada') + expect(screen.getByDisplayValue('ada.lovelace@example.com')).toBeInTheDocument() + }) + + it('blocks submission when a required field is cleared', async () => { + const updateResolver = vi.fn(() => HttpResponse.json(baseIndividual)) + server.use(handleUpdateContractor(updateResolver)) + + const user = userEvent.setup() + renderWithProviders() + + await screen.findByDisplayValue('Ada') + await user.clear(screen.getByLabelText('First name')) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByText('First name is required')).toBeInTheDocument() + }) + expect(updateResolver).not.toHaveBeenCalled() + expect(onEvent).not.toHaveBeenCalledWith( + componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_UPDATED, + expect.anything(), + ) + }) + + it('saves successfully, firing the updated event and showing a success alert', async () => { + let requestBody: Record | null = null + const updateResolver = vi.fn(async ({ request }) => { + requestBody = (await request.json()) as Record + return HttpResponse.json({ ...baseIndividual, last_name: 'Byron' }) + }) + server.use(handleUpdateContractor(updateResolver)) + + const user = userEvent.setup() + renderWithProviders() + + await screen.findByDisplayValue('Ada') + await user.clear(screen.getByLabelText('Last name')) + await user.type(screen.getByLabelText('Last name'), 'Byron') + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(updateResolver).toHaveBeenCalledTimes(1) + }) + expect(requestBody).toMatchObject({ last_name: 'Byron' }) + expect(onEvent).toHaveBeenCalledWith( + componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_UPDATED, + expect.objectContaining({ lastName: 'Byron' }), + ) + expect(await screen.findByText('Profile updated')).toBeInTheDocument() + }) + + it('fires CONTRACTOR_MANAGEMENT_PROFILE_EDIT_CANCELLED when Cancel is clicked', async () => { + const user = userEvent.setup() + renderWithProviders() + + await screen.findByDisplayValue('Ada') + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(onEvent).toHaveBeenCalledWith( + componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_EDIT_CANCELLED, + ) + }) +}) + +describe('ProfileEditForm — business contractor', () => { + const onEvent = vi.fn() + + beforeEach(() => { + setupApiTestMocks() + onEvent.mockClear() + mockContractor(baseBusiness) + }) + + it('renders business name and a locked EIN field, hiding individual fields', async () => { + renderWithProviders() + + await screen.findByDisplayValue('Pacific Design Co.') + + expect(screen.getByDisplayValue('XX-XXX4879')).toBeDisabled() + expect(screen.getAllByText('Employer Identification Number (EIN)').length).toBeGreaterThan(0) + expect(screen.queryByLabelText('First name')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/Contractor/Profile/management/ProfileEditForm.tsx b/src/components/Contractor/Profile/management/ProfileEditForm.tsx new file mode 100644 index 000000000..58f3ea502 --- /dev/null +++ b/src/components/Contractor/Profile/management/ProfileEditForm.tsx @@ -0,0 +1,281 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import classNames from 'classnames' +import { useContractorDetailsForm } from '../shared/useContractorDetailsForm' +import styles from './ProfileEditForm.module.scss' +import { BaseBoundaries, BaseLayout, type BaseComponentInterface } from '@/components/Base' +import { ActionsLayout } from '@/components/Common' +import { Form } from '@/components/Common/Form' +import { Grid } from '@/components/Common/Grid/Grid' +import { SDKFormProvider } from '@/partner-hook-utils/form/SDKFormProvider' +import { useI18n, useComponentDictionary } from '@/i18n' +import { componentEvents, CONTRACTOR_TYPE } from '@/shared/constants' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' + +/** + * Props for {@link ProfileEditForm}. + * + * @public + */ +export interface ProfileEditFormProps extends BaseComponentInterface<'Contractor.Management.Profile'> { + /** The associated contractor identifier. */ + contractorId: string +} + +/** + * Standalone edit form for a contractor's basic profile details. + * + * @remarks + * Renders fields for the contractor's name (or business name), start date, + * tax ID (SSN or EIN, depending on the contractor's type), and email — all + * required on update except tax ID and email — and shows a success alert + * when the save completes. An SSN/EIN already on file renders as a locked, + * masked value with a "Change" action rather than a blank editable input. + * Save and Cancel both emit events so the parent can return to the read view. + * + * @events + * | Event | Description | Data | + * | ----- | ----------- | ---- | + * | `contractor/management/profile/updated` | Fired after the contractor profile is successfully saved | {@link APIModels.Contractor} | + * | `contractor/management/profile/editCancelled` | Fired when the user clicks Cancel | — | + * + * @param input - See {@link ProfileEditFormProps}. + * @returns The contractor profile edit form. + * @public + */ +export function ProfileEditForm({ FallbackComponent, ...props }: ProfileEditFormProps) { + return ( + + + + ) +} + +function ProfileEditFormRoot({ + contractorId, + className, + dictionary, + onEvent, +}: ProfileEditFormProps) { + useI18n('Contractor.Management.Profile') + useComponentDictionary('Contractor.Management.Profile', dictionary) + const { t } = useTranslation('Contractor.Management.Profile') + const Components = useComponentContext() + + const contractorDetails = useContractorDetailsForm({ + contractorId, + withSelfOnboardingField: false, + showEmailField: true, + optionalFieldsToRequire: { + update: ['firstName', 'lastName', 'businessName', 'startDate'], + }, + }) + + const [showSuccess, setShowSuccess] = useState(false) + const [isEditingSsn, setIsEditingSsn] = useState(false) + const [isEditingEin, setIsEditingEin] = useState(false) + + if (contractorDetails.isLoading) { + return + } + + const contractor = contractorDetails.data.contractor + if (!contractor) { + return + } + + const isBusiness = contractor.type === CONTRACTOR_TYPE.BUSINESS + const Fields = contractorDetails.form.Fields + const { setValue } = contractorDetails.form.hookFormInternals.formMethods + + const handleSubmit = async () => { + setShowSuccess(false) + const result = await contractorDetails.actions.onSubmit() + if (!result) return + onEvent(componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_UPDATED, result.data) + setShowSuccess(true) + } + + const handleCancel = () => { + onEvent(componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_EDIT_CANCELLED) + } + + const alert = showSuccess ? ( + { + setShowSuccess(false) + }} + /> + ) : undefined + + return ( +
+ + +
+ {alert} + {t('form.title')} + {isBusiness ? ( + <> + {Fields.BusinessName && ( + + )} + + {contractor.hasEin && !isEditingEin ? ( + { + setIsEditingEin(true) + setValue('ein', '') + }} + /> + ) : ( + Fields.Ein && ( + + ) + )} + + ) : ( + <> + + {Fields.FirstName && ( + + )} + {Fields.MiddleInitial && } + + {Fields.LastName && ( + + )} + + {contractor.hasSsn && !isEditingSsn ? ( + { + setIsEditingSsn(true) + setValue('ssn', '') + }} + /> + ) : ( + Fields.Ssn && ( + + ) + )} + + )} + {Fields.Email && ( + + )} + + + {t('form.cancelCta')} + + + {t('form.saveCta')} + + + +
+
+
+ ) +} + +function LockedField({ + label, + maskedValue, + hint, + changeLabel, + onEdit, +}: { + label: string + maskedValue: string + hint: string + changeLabel: string + onEdit: () => void +}) { + const Components = useComponentContext() + + return ( +
+ + {label} + +
+
+ +
+ + {changeLabel} + +
+ + {hint} + +
+ ) +} diff --git a/src/components/Contractor/Profile/management/index.ts b/src/components/Contractor/Profile/management/index.ts new file mode 100644 index 000000000..4a8c17899 --- /dev/null +++ b/src/components/Contractor/Profile/management/index.ts @@ -0,0 +1,3 @@ +export { Profile, type ProfileProps } from './Profile' +export { ProfileCard, type ProfileCardProps } from './ProfileCard' +export { ProfileEditForm, type ProfileEditFormProps } from './ProfileEditForm' diff --git a/src/components/Contractor/Profile/management/profileStateMachine.ts b/src/components/Contractor/Profile/management/profileStateMachine.ts new file mode 100644 index 000000000..21aadcc5c --- /dev/null +++ b/src/components/Contractor/Profile/management/profileStateMachine.ts @@ -0,0 +1,43 @@ +import { reduce, state, transition } from 'robot3' +import type { ComponentType } from 'react' +import type { ProfileContextInterface } from './ProfileComponents' +import { CardContextual, ProfileEditFormContextual } from './ProfileComponents' +import { componentEvents } from '@/shared/constants' +import type { MachineTransition } from '@/types/Helpers' + +const returnToCard = reduce((ctx: ProfileContextInterface): ProfileContextInterface => ({ + ...ctx, + component: CardContextual as ComponentType, + successAlert: null, +})) + +const returnToCardWithAlert = (alert: ProfileContextInterface['successAlert']) => + reduce((ctx: ProfileContextInterface): ProfileContextInterface => ({ + ...ctx, + component: CardContextual as ComponentType, + successAlert: alert, + })) + +/** @internal */ +export const profileStateMachine = { + card: state( + transition( + componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_EDIT_REQUESTED, + 'editProfile', + reduce((ctx: ProfileContextInterface): ProfileContextInterface => ({ + ...ctx, + component: ProfileEditFormContextual as ComponentType, + successAlert: null, + })), + ), + transition(componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_ALERT_DISMISSED, 'card', returnToCard), + ), + editProfile: state( + transition( + componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_UPDATED, + 'card', + returnToCardWithAlert('profileUpdated'), + ), + transition(componentEvents.CONTRACTOR_MANAGEMENT_PROFILE_EDIT_CANCELLED, 'card', returnToCard), + ), +} diff --git a/src/components/Contractor/Profile/shared/useContractorDetailsForm/useContractorDetailsForm.test.tsx b/src/components/Contractor/Profile/shared/useContractorDetailsForm/useContractorDetailsForm.test.tsx index 0112970fe..745faa35d 100644 --- a/src/components/Contractor/Profile/shared/useContractorDetailsForm/useContractorDetailsForm.test.tsx +++ b/src/components/Contractor/Profile/shared/useContractorDetailsForm/useContractorDetailsForm.test.tsx @@ -59,6 +59,18 @@ describe('useContractorDetailsForm', () => { expect(ready.form.Fields.Ein).toBeUndefined() }) + it('exposes email regardless of selfOnboarding when showEmailField is set', async () => { + const { result } = renderForm({ companyId: 'company-1', showEmailField: true }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + const ready = result.current + assertReady(ready) + + expect(ready.form.Fields.Email).toBeDefined() + }) + it('shows business fields and hides individual fields for a business contractor', async () => { const { result } = renderForm({ companyId: 'company-1', diff --git a/src/components/Contractor/Profile/shared/useContractorDetailsForm/useContractorDetailsForm.tsx b/src/components/Contractor/Profile/shared/useContractorDetailsForm/useContractorDetailsForm.tsx index 273a874e4..83b78fe6a 100644 --- a/src/components/Contractor/Profile/shared/useContractorDetailsForm/useContractorDetailsForm.tsx +++ b/src/components/Contractor/Profile/shared/useContractorDetailsForm/useContractorDetailsForm.tsx @@ -90,6 +90,16 @@ export interface ContractorDetailsSubmitOptions { export type UseContractorDetailsFormSharedProps = { /** Whether to expose the self-onboarding toggle as `form.Fields.SelfOnboarding`. Defaults to `true`. */ withSelfOnboardingField?: boolean + /** + * Whether `form.Fields.Email` is available regardless of the `selfOnboarding` value. + * + * @remarks + * By default `Email` is only exposed while `selfOnboarding` is `true`, since email is + * otherwise only collected to send a self-onboarding invite. Set this to `true` for + * surfaces that let an admin edit an already-onboarded contractor's email directly. + * Defaults to `false`. + */ + showEmailField?: boolean /** Fields that are optional by default but should be promoted to required for this form instance. */ optionalFieldsToRequire?: ContractorDetailsOptionalFieldsToRequire /** Initial values applied before any contractor data loads. */ @@ -251,8 +261,8 @@ function buildContractorDetailsFieldsMetadata( * Returns a discriminated union: a loading variant while the contractor fetch * resolves, and a ready variant exposing the form's data, pending status, * submit action, error handling, and bound `Fields`. Field visibility is - * driven by the current `type` and `wageType` (self-onboarding only toggles the - * `Email` field); fields that do not apply are `undefined` on `form.Fields`. + * driven by the current `type` and `wageType` (self-onboarding toggles the + * `Email` field unless `showEmailField` is set); fields that do not apply are `undefined` on `form.Fields`. * SSN/EIN are exposed by contractor type regardless of self-onboarding — each * consumer decides whether to render them. Self-onboarding is only toggleable * when the contractor's onboarding status allows it. @@ -310,6 +320,7 @@ export function useContractorDetailsForm({ companyId, contractorId, withSelfOnboardingField = true, + showEmailField = false, optionalFieldsToRequire, defaultValues: partnerDefaults, validationMode = 'onSubmit', @@ -526,7 +537,7 @@ export function useContractorDetailsForm({ SelfOnboarding: withSelfOnboardingField && isSelfOnboardingToggleable ? SelfOnboardingField : undefined, FileNewHireReport: isIndividual ? FileNewHireReportField : undefined, - Email: watchedSelfOnboarding ? EmailField : undefined, + Email: showEmailField || watchedSelfOnboarding ? EmailField : undefined, FirstName: isIndividual ? FirstNameField : undefined, LastName: isIndividual ? LastNameField : undefined, MiddleInitial: isIndividual ? MiddleInitialField : undefined, diff --git a/src/components/Contractor/Profile/shared/useContractorProfileSummary/index.ts b/src/components/Contractor/Profile/shared/useContractorProfileSummary/index.ts new file mode 100644 index 000000000..24f795b48 --- /dev/null +++ b/src/components/Contractor/Profile/shared/useContractorProfileSummary/index.ts @@ -0,0 +1,5 @@ +export { + useContractorProfileSummary, + type UseContractorProfileSummaryParams, + type UseContractorProfileSummaryResult, +} from './useContractorProfileSummary' diff --git a/src/components/Contractor/Profile/shared/useContractorProfileSummary/useContractorProfileSummary.test.tsx b/src/components/Contractor/Profile/shared/useContractorProfileSummary/useContractorProfileSummary.test.tsx new file mode 100644 index 000000000..decfebf9f --- /dev/null +++ b/src/components/Contractor/Profile/shared/useContractorProfileSummary/useContractorProfileSummary.test.tsx @@ -0,0 +1,60 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, beforeEach } from 'vitest' +import { HttpResponse } from 'msw' +import { useContractorProfileSummary } from './useContractorProfileSummary' +import { GustoTestProvider } from '@/test/GustoTestApiProvider' +import { server } from '@/test/mocks/server' +import { handleGetContractor } from '@/test/mocks/apis/contractors' +import { setupApiTestMocks } from '@/test/mocks/apiServer' + +describe('useContractorProfileSummary', () => { + beforeEach(() => { + setupApiTestMocks() + }) + + it('starts in the loading branch and resolves into the ready branch with the contractor', async () => { + const { result } = renderHook( + () => useContractorProfileSummary({ contractorId: 'contractor-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.contractor).toMatchObject({ + firstName: 'Kory', + lastName: 'Gottlieb', + }) + expect(result.current.status).toMatchObject({ + isFetching: false, + isPending: false, + }) + }) + + it('surfaces a query failure through errorHandling.errors', async () => { + server.use( + handleGetContractor(() => + HttpResponse.json( + { errors: [{ category: 'server_error', message: 'Boom' }] }, + { status: 500 }, + ), + ), + ) + + const { result } = renderHook( + () => useContractorProfileSummary({ contractorId: 'contractor-123' }), + { wrapper: GustoTestProvider }, + ) + + await waitFor(() => { + expect(result.current.errorHandling.errors.length).toBeGreaterThan(0) + }) + + expect(result.current.isLoading).toBe(true) + }) +}) diff --git a/src/components/Contractor/Profile/shared/useContractorProfileSummary/useContractorProfileSummary.tsx b/src/components/Contractor/Profile/shared/useContractorProfileSummary/useContractorProfileSummary.tsx new file mode 100644 index 000000000..bfe7d6e35 --- /dev/null +++ b/src/components/Contractor/Profile/shared/useContractorProfileSummary/useContractorProfileSummary.tsx @@ -0,0 +1,45 @@ +import { useContractorsGet } from '@gusto/embedded-api/react-query/contractorsGet' +import type { Contractor } from '@gusto/embedded-api/models/components/contractor' +import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler' +import type { BaseHookReady, HookLoadingResult } from '@/partner-hook-utils/types' + +/** @internal */ +export interface UseContractorProfileSummaryParams { + contractorId: string +} + +type UseContractorProfileSummaryReady = BaseHookReady< + { contractor: Contractor }, + { isFetching: boolean; isPending: boolean } +> + +/** @internal */ +export type UseContractorProfileSummaryResult = HookLoadingResult | UseContractorProfileSummaryReady + +/** @internal */ +export function useContractorProfileSummary({ + contractorId, +}: UseContractorProfileSummaryParams): UseContractorProfileSummaryResult { + const contractorQuery = useContractorsGet( + { contractorUuid: contractorId }, + { staleTime: Infinity }, + ) + + const errorHandling = composeErrorHandler([contractorQuery]) + + const contractor = contractorQuery.data?.contractor + + if (contractorQuery.isLoading || !contractor) { + return { isLoading: true, errorHandling } + } + + return { + isLoading: false, + data: { contractor }, + status: { + isFetching: contractorQuery.isFetching, + isPending: false, + }, + errorHandling, + } +} diff --git a/src/components/Contractor/exports/contractorManagement.ts b/src/components/Contractor/exports/contractorManagement.ts index 06611a9ab..33a853769 100644 --- a/src/components/Contractor/exports/contractorManagement.ts +++ b/src/components/Contractor/exports/contractorManagement.ts @@ -3,6 +3,8 @@ export { type ManagementContractorListProps, type ContractorTab, } from '../ContractorList/management/ManagementContractorList' +export { Profile, ProfileCard, ProfileEditForm } from '../Profile/management' +export type { ProfileProps, ProfileCardProps, ProfileEditFormProps } from '../Profile/management' export { PaymentFlow, type PaymentFlowProps } from '../Payments/PaymentFlow' export { PaymentsList, type PaymentsListProps } from '../Payments/PaymentsList/PaymentsList' export { CreatePayment, type CreatePaymentProps } from '../Payments/CreatePayment/CreatePayment' diff --git a/src/i18n/en/Contractor.Management.Profile.json b/src/i18n/en/Contractor.Management.Profile.json new file mode 100644 index 000000000..f3adbf287 --- /dev/null +++ b/src/i18n/en/Contractor.Management.Profile.json @@ -0,0 +1,41 @@ +{ + "title": "Basic details", + "editCta": "Edit", + "legalName": "Legal name", + "startDate": "Start date", + "socialSecurityNumber": "Social security number", + "employerIdentificationNumber": "Employer Identification Number (EIN)", + "email": "Email", + "listEmptyPlaceholder": "No value on file", + "alerts": { + "profileUpdated": "Profile updated" + }, + "form": { + "title": "Basic details", + "firstName": "First name", + "middleInitial": "Middle initial", + "lastName": "Last name", + "businessName": "Business name", + "startDate": "Start date", + "ssnLabel": "Social security number", + "ssnMask": "•••-••-••••", + "einLabel": "Employer Identification Number (EIN)", + "einMask": "••-•••••••", + "onFileHint": "Already on file.", + "changeCta": "Change", + "email": "Email address", + "emailDescription": "Used to send onboarding and payment notifications.", + "cancelCta": "Cancel", + "saveCta": "Save", + "successAlert": "Profile updated", + "validations": { + "firstName": "First name is required", + "lastName": "Last name is required", + "businessName": "Business name is required", + "startDate": "Start date is required", + "email": "Enter a valid email address", + "ssn": "The SSN must be exactly 9 digits long, cannot contain all zeros in any group, and the first three digits cannot be '666' or in the range 900–999.", + "ein": "The EIN must be exactly 9 digits long." + } + } +} diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts index c98ed7308..79e1d6b80 100644 --- a/src/i18n/types.d.ts +++ b/src/i18n/types.d.ts @@ -41,6 +41,7 @@ export interface Resources { 'Contractor.ContractorList': Translations.ContractorContractorList 'Contractor.DocumentsList': Translations.ContractorDocumentsList 'Contractor.Landing': Translations.ContractorLanding + 'Contractor.Management.Profile': Translations.ContractorManagementProfile 'Contractor.ManagementContractorList': Translations.ContractorManagementContractorList 'Contractor.NewHireReport': Translations.ContractorNewHireReport 'Contractor.OnboardingSummary': Translations.ContractorOnboardingSummary @@ -1885,6 +1886,81 @@ export namespace Translations { /** @defaultValue `"there"` */ fallbackName: string } + /** Translation keys for the `Contractor.Management.Profile` i18n namespace. */ + export interface ContractorManagementProfile { + /** @defaultValue `"Basic details"` */ + title: string + /** @defaultValue `"Edit"` */ + editCta: string + /** @defaultValue `"Legal name"` */ + legalName: string + /** @defaultValue `"Start date"` */ + startDate: string + /** @defaultValue `"Social security number"` */ + socialSecurityNumber: string + /** @defaultValue `"Employer Identification Number (EIN)"` */ + employerIdentificationNumber: string + /** @defaultValue `"Email"` */ + email: string + /** @defaultValue `"No value on file"` */ + listEmptyPlaceholder: string + alerts: { + /** @defaultValue `"Profile updated"` */ + profileUpdated: string + } + form: { + /** @defaultValue `"Basic details"` */ + title: string + /** @defaultValue `"First name"` */ + firstName: string + /** @defaultValue `"Middle initial"` */ + middleInitial: string + /** @defaultValue `"Last name"` */ + lastName: string + /** @defaultValue `"Business name"` */ + businessName: string + /** @defaultValue `"Start date"` */ + startDate: string + /** @defaultValue `"Social security number"` */ + ssnLabel: string + /** @defaultValue `"•••-••-••••"` */ + ssnMask: string + /** @defaultValue `"Employer Identification Number (EIN)"` */ + einLabel: string + /** @defaultValue `"••-•••••••"` */ + einMask: string + /** @defaultValue `"Already on file."` */ + onFileHint: string + /** @defaultValue `"Change"` */ + changeCta: string + /** @defaultValue `"Email address"` */ + email: string + /** @defaultValue `"Used to send onboarding and payment notifications."` */ + emailDescription: string + /** @defaultValue `"Cancel"` */ + cancelCta: string + /** @defaultValue `"Save"` */ + saveCta: string + /** @defaultValue `"Profile updated"` */ + successAlert: string + validations: { + /** @defaultValue `"First name is required"` */ + firstName: string + /** @defaultValue `"Last name is required"` */ + lastName: string + /** @defaultValue `"Business name is required"` */ + businessName: string + /** @defaultValue `"Start date is required"` */ + startDate: string + /** @defaultValue `"Enter a valid email address"` */ + email: string + /** @defaultValue `"The SSN must be exactly 9 digits long, cannot contain all zeros in any group, and the first three digits cannot be '666' or in the range 900–999."` */ + ssn: string + /** @defaultValue `"The EIN must be exactly 9 digits long."` */ + ein: string + } + } + } /** Translation keys for the `Contractor.ManagementContractorList` i18n namespace. */ export interface ContractorManagementContractorList { /** @defaultValue `"Contractors"` */ diff --git a/src/shared/constants.ts b/src/shared/constants.ts index e5496eab8..e8fc31a2f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -247,6 +247,10 @@ export const contractorEvents = { CONTRACTOR_REHIRE: 'contractor/rehire', CONTRACTOR_DISMISSAL_CANCELLED: 'contractor/dismissal/cancelled', CONTRACTOR_REHIRE_CANCELLED: 'contractor/rehire/cancelled', + CONTRACTOR_MANAGEMENT_PROFILE_EDIT_REQUESTED: 'contractor/management/profile/editRequested', + CONTRACTOR_MANAGEMENT_PROFILE_UPDATED: 'contractor/management/profile/updated', + CONTRACTOR_MANAGEMENT_PROFILE_EDIT_CANCELLED: 'contractor/management/profile/editCancelled', + CONTRACTOR_MANAGEMENT_PROFILE_ALERT_DISMISSED: 'contractor/management/profile/alertDismissed', } as const /**