Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/reference/endpoint-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,30 @@
"jobId",
"locationUuid"
]
},
"UNSTABLE_Hooks.hooks.useEmployeeDetailsForm": {
"endpoints": [
{
"method": "GET",
"path": "/v1/employees/:employeeId"
},
{
"method": "POST",
"path": "/v1/companies/:companyId/employees"
},
{
"method": "PUT",
"path": "/v1/employees/:employeeId"
},
{
"method": "PUT",
"path": "/v1/employees/:employeeId/onboarding_status"
}
],
"variables": [
"companyId",
"employeeId"
]
}
},
"flows": {
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/endpoint-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json'
| | GET | `/v1/employees/:employeeId/work_addresses` |
| | GET | `/v1/employees/:employeeId` |
| | GET | `/v1/companies/:companyId/federal_tax_details` |
| **UNSTABLE_Hooks.hooks.useEmployeeDetailsForm** | GET | `/v1/employees/:employeeId` |
| | POST | `/v1/companies/:companyId/employees` |
| | PUT | `/v1/employees/:employeeId` |
| | PUT | `/v1/employees/:employeeId/onboarding_status` |

## Flows

Expand Down
33 changes: 33 additions & 0 deletions src/UNSTABLE_Hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,37 @@ export {
type DatePickerProps,
type RadioGroupProps,
type RadioGroupOption,

// Employee details hook + component
useEmployeeDetailsForm,
EmployeeDetailsForm,
EmployeeDetailsErrorCodes,

// Employee details types
type EmployeeDetailsFormProps,
type EmployeeDetailsSubmitCallbacks,
type EmployeeDetailsRequiredFields,
type UseEmployeeDetailsFormProps,
type UseEmployeeDetailsFormResult,
type UseEmployeeDetailsFormReady,
type EmployeeDetailsFieldsMetadata,
type EmployeeDetailsFormFields,
type EmployeeDetailsErrorCode,
type EmployeeDetailsFormData,
type EmployeeDetailsFormOutputs,
type EmployeeDetailsField,

// Employee details validation code types
type EmployeeDetailsRequiredValidation,
type EmailValidation,
type SsnValidation,

// Employee details field prop types
type FirstNameFieldProps,
type MiddleInitialFieldProps,
type LastNameFieldProps,
type EmailFieldProps,
type DateOfBirthFieldProps,
type SsnFieldProps,
type SelfOnboardingFieldProps,
} from '@/components/UNSTABLE_Hooks'
50 changes: 40 additions & 10 deletions src/components/Common/Fields/DatePickerField/DatePickerField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import type { ComponentType } from 'react'
import { useField, type UseFieldProps } from '@/components/Common/Fields/hooks/useField'
import type { DatePickerProps } from '@/components/Common/UI/DatePicker/DatePickerTypes'
import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
import { normalizeDateToLocal } from '@/helpers/dateFormatting'
import {
normalizeDateToLocal,
normalizeToDate,
formatDateToStringDate,
} from '@/helpers/dateFormatting'

export interface DatePickerFieldProps
extends Omit<DatePickerProps, 'name' | 'onChange' | 'isInvalid'>, UseFieldProps<Date | null> {
type DateFieldValue = string | Date | null

export interface DatePickerFieldProps<TValue extends DateFieldValue = Date | null>
extends Omit<DatePickerProps, 'name' | 'onChange' | 'isInvalid'>, UseFieldProps<TValue> {
FieldComponent?: ComponentType<DatePickerProps>
}

export const DatePickerField: React.FC<DatePickerFieldProps> = ({
export const DatePickerField = <TValue extends DateFieldValue = Date | null>({
rules,
defaultValue,
name,
Expand All @@ -23,9 +29,13 @@ export const DatePickerField: React.FC<DatePickerFieldProps> = ({
inputRef,
FieldComponent,
...datePickerProps
}: DatePickerFieldProps) => {
}: DatePickerFieldProps<TValue>) => {
const Components = useComponentContext()
const fieldProps = useField({
const {
value: fieldValue,
onChange: fieldOnChange,
...restFieldProps
} = useField<TValue>({
name,
rules,
defaultValue,
Expand All @@ -38,16 +48,36 @@ export const DatePickerField: React.FC<DatePickerFieldProps> = ({
inputRef,
})

const handleTimezoneSafeChange = React.useCallback(
const isStringMode = typeof fieldValue === 'string'

const toDateValue = (): Date | null => {
if (fieldValue == null) return null
if (fieldValue instanceof Date) return fieldValue
if (typeof fieldValue === 'string' && fieldValue) return normalizeToDate(fieldValue)
return null
}

const handleChange = React.useCallback(
(value: Date | null) => {
const normalizedDate = normalizeDateToLocal(value)
fieldProps.onChange(normalizedDate)

if (isStringMode) {
const dateString = normalizedDate ? (formatDateToStringDate(normalizedDate) ?? '') : ''
fieldOnChange(dateString as TValue)
} else {
fieldOnChange(normalizedDate as TValue)
}
},
[fieldProps],
[isStringMode, fieldOnChange],
)

const RenderComponent = FieldComponent ?? Components.DatePicker
return (
<RenderComponent {...datePickerProps} {...fieldProps} onChange={handleTimezoneSafeChange} />
<RenderComponent
{...datePickerProps}
{...restFieldProps}
value={toDateValue()}
onChange={handleChange}
/>
)
}
52 changes: 52 additions & 0 deletions src/components/UNSTABLE_Hooks/form/composeFormSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { z } from 'zod'
import { requiredIf } from '@/helpers/requiredIf'

interface ComposeFormSchemaOptions<T extends Record<string, z.ZodType>> {
fieldValidators: T
fixedFields?: Set<string>
requiredOnCreate?: Set<string>
requiredOnUpdate?: Set<string>
mode: 'create' | 'update'
requiredFields?: string[]
}

/**
* Composes a Zod form schema by applying `requiredIf` to configurable fields
* based on mode and partner-specified required fields.
*
* Pattern: define all field validators as if required, then pass them here.
* Configurable fields get wrapped with `requiredIf` to conditionally accept
* empty input. Fixed fields (e.g. booleans, always-present controls) pass through as-is.
*
* `requiredOnCreate` / `requiredOnUpdate` define mode-specific defaults.
* `requiredFields` (partner overrides) are additive on top of those defaults.
*
* Generic `T` preserves the fieldValidators type so zodResolver can infer
* the correct form data type without manual casts at the call site.
*/
export function composeFormSchema<T extends Record<string, z.ZodType>>({
fieldValidators,
fixedFields = new Set(),
requiredOnCreate = new Set(),
requiredOnUpdate = new Set(),
mode,
requiredFields = [],
}: ComposeFormSchemaOptions<T>) {
const required = new Set(requiredFields)
const modeDefaults = mode === 'create' ? requiredOnCreate : requiredOnUpdate

const shape = Object.fromEntries(
Object.entries(fieldValidators).map(([key, validator]) => {
if (fixedFields.has(key)) return [key, validator]

const isRequired = modeDefaults.has(key) || required.has(key)
return [key, requiredIf(validator, isRequired)]
}),
)

// Object.fromEntries erases specific field types to Record<string, ZodType>,
// and requiredIf wraps fields with z.preprocess which makes z.input infer unknown.
// Cast back to ZodObject<T> so zodResolver infers the correct form data type
// from the original fieldValidators — consumers don't need per-hook casts.
return z.object(shape) as unknown as z.ZodObject<T>
}
57 changes: 57 additions & 0 deletions src/components/UNSTABLE_Hooks/form/deriveFieldsMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { deriveFieldsMetadata } from './deriveFieldsMetadata'
import { requiredIf } from '@/helpers/requiredIf'

describe('deriveFieldsMetadata', () => {
it('derives isRequired: true for required string fields', () => {
Expand Down Expand Up @@ -83,6 +84,62 @@ describe('deriveFieldsMetadata', () => {
expect(metadata.state.isRequired).toBe(false)
})

it('derives isRequired: true for requiredIf(validator, true)', () => {
const schema = z.object({
email: requiredIf(z.email(), true),
})

const metadata = deriveFieldsMetadata(schema)

expect(metadata.email.isRequired).toBe(true)
})

it('derives isRequired: false for requiredIf(validator, false)', () => {
const schema = z.object({
email: requiredIf(z.email(), false),
})

const metadata = deriveFieldsMetadata(schema)

expect(metadata.email.isRequired).toBe(false)
})

it('handles mixed requiredIf and standard fields', () => {
const schema = z.object({
firstName: requiredIf(z.string().min(1), true),
middleName: requiredIf(z.string(), false),
lastName: z.string().min(1),
email: requiredIf(z.email(), false),
active: z.boolean(),
})

const metadata = deriveFieldsMetadata(schema)

expect(metadata.firstName.isRequired).toBe(true)
expect(metadata.middleName.isRequired).toBe(false)
expect(metadata.lastName.isRequired).toBe(true)
expect(metadata.email.isRequired).toBe(false)
expect(metadata.active.isRequired).toBe(true)
})

it('works on schemas with superRefine applied', () => {
const schema = z
.object({
email: requiredIf(z.email(), false),
notify: z.boolean(),
})
.superRefine((data, ctx) => {
if (data.notify && !data.email) {
ctx.addIssue({ code: 'custom', path: ['email'], message: 'required' })
}
})

const metadata = deriveFieldsMetadata(schema)

expect(metadata.email.isRequired).toBe(false)
expect(metadata.notify.isRequired).toBe(true)
})

it('returns metadata for all fields in the schema', () => {
const schema = z.object({
street1: z.string().min(1),
Expand Down
8 changes: 5 additions & 3 deletions src/components/UNSTABLE_Hooks/form/deriveFieldsMetadata.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import type { z } from 'zod'
import { z } from 'zod'
import type { FieldMetadata } from './types'

export function deriveFieldsMetadata<T extends z.ZodObject<z.ZodRawShape>>(
schema: T,
): Record<keyof z.infer<T>, FieldMetadata> {
const jsonSchema = z.toJSONSchema(schema)
const requiredFields = new Set((jsonSchema as { required?: string[] }).required ?? [])
const shape = schema.shape as Record<string, z.ZodType>
const result: Record<string, FieldMetadata> = {}

for (const [key, fieldSchema] of Object.entries(shape)) {
for (const key of Object.keys(shape)) {
result[key] = {
name: key,
isRequired: !fieldSchema.isOptional(),
isRequired: requiredFields.has(key),
}
}

Expand Down
31 changes: 31 additions & 0 deletions src/components/UNSTABLE_Hooks/form/fields/SwitchHookField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { BaseFieldProps, ValidationMessages } from '../types'
import { useFieldsMetadata } from '../useFieldsMetadata'
import { useFieldErrorMessage } from '../useFieldErrorMessage'
import { SwitchField } from '@/components/Common'

export interface SwitchHookFieldProps<TErrorCode extends string = never> extends BaseFieldProps {
name: string
validationMessages?: ValidationMessages<TErrorCode>
}

export function SwitchHookField<TErrorCode extends string>({
name,
label,
description,
validationMessages,
}: SwitchHookFieldProps<TErrorCode>) {
const metadata = useFieldsMetadata()
const fieldMetadata = metadata[name]
const errorMessage = useFieldErrorMessage(name, validationMessages)

return (
<SwitchField
name={name}
label={label}
description={description}
errorMessage={errorMessage}
isRequired={fieldMetadata?.isRequired}
isDisabled={fieldMetadata?.isDisabled}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { TextInputProps } from '@/components/Common/UI/TextInput/TextInputT
export interface TextInputHookFieldProps<TErrorCode extends string = never> extends BaseFieldProps {
name: string
validationMessages?: ValidationMessages<TErrorCode>
transform?: (value: string) => string
placeholder?: string
FieldComponent?: ComponentType<TextInputProps>
}

Expand All @@ -16,6 +18,8 @@ export function TextInputHookField<TErrorCode extends string>({
label,
description,
validationMessages,
transform,
placeholder,
FieldComponent,
}: TextInputHookFieldProps<TErrorCode>) {
const metadata = useFieldsMetadata()
Expand All @@ -30,6 +34,8 @@ export function TextInputHookField<TErrorCode extends string>({
errorMessage={errorMessage}
isRequired={fieldMetadata?.isRequired}
isDisabled={fieldMetadata?.isDisabled}
transform={transform}
placeholder={placeholder}
FieldComponent={FieldComponent}
/>
)
Expand Down
2 changes: 2 additions & 0 deletions src/components/UNSTABLE_Hooks/form/fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { CheckboxHookField, type CheckboxHookFieldProps } from './CheckboxHookFi
export { NumberInputHookField, type NumberInputHookFieldProps } from './NumberInputHookField'
export { DatePickerHookField, type DatePickerHookFieldProps } from './DatePickerHookField'
export { RadioGroupHookField, type RadioGroupHookFieldProps } from './RadioGroupHookField'
export { SwitchHookField, type SwitchHookFieldProps } from './SwitchHookField'

export type { TextInputProps } from '@/components/Common/UI/TextInput/TextInputTypes'
export type { SelectProps, SelectOption } from '@/components/Common/UI/Select/SelectTypes'
Expand All @@ -14,3 +15,4 @@ export type {
RadioGroupProps,
RadioGroupOption,
} from '@/components/Common/UI/RadioGroup/RadioGroupTypes'
export type { SwitchProps } from '@/components/Common/UI/Switch/SwitchTypes'
1 change: 1 addition & 0 deletions src/components/UNSTABLE_Hooks/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { SDKFormProvider } from './SDKFormProvider'
export { useFieldsMetadata } from './useFieldsMetadata'
export { useFieldErrorMessage } from './useFieldErrorMessage'
export { deriveFieldsMetadata } from './deriveFieldsMetadata'
export { composeFormSchema } from './composeFormSchema'
export { composeSubmitHandler, type ComposableFormHookResult } from './composeSubmitHandler'
export {
TextInputHookField,
Expand Down
1 change: 1 addition & 0 deletions src/components/UNSTABLE_Hooks/form/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface FieldMetadata {
name: string
isRequired?: boolean
isDisabled?: boolean
hasRedactedValue?: boolean
}

export interface FieldMetadataWithOptions<TEntry = unknown> extends FieldMetadata {
Expand Down
Loading
Loading