manual improvement 2 & add telegram bot
This commit is contained in:
@@ -3,37 +3,30 @@ import SurfaceCard, { SurfaceCardProps } from '../SurfaceCard';
|
||||
|
||||
export type AccentTone = 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info' | 'trust' | 'neutral';
|
||||
|
||||
const ACCENT_WIDTH = '4px';
|
||||
|
||||
const TONE_VAR: Record<AccentTone, string> = {
|
||||
primary: 'var(--bal-primary)',
|
||||
secondary: 'var(--bal-secondary)',
|
||||
success: 'var(--bal-success)',
|
||||
error: 'var(--bal-error)',
|
||||
warning: 'var(--bal-warning)',
|
||||
info: 'var(--bal-info)',
|
||||
trust: 'var(--bal-trust)',
|
||||
neutral: 'var(--bal-text-secondary)',
|
||||
};
|
||||
|
||||
export interface AccentCardProps extends SurfaceCardProps {
|
||||
/** Semantic tone driving the `borderInlineStart` accent stripe — never a raw color. */
|
||||
/** Semantic tone of the panel — a state label, not a color instruction. */
|
||||
tone: AccentTone;
|
||||
}
|
||||
|
||||
/**
|
||||
* `SurfaceCard` plus a `borderInlineStart` accent stripe at one standardized width (4px — this app
|
||||
* previously drifted between 3px and 4px across `EarningsBalanceHeader`/`PayoutHistoryRow` vs
|
||||
* `BankStatusPanel`/`DocumentUpload`). Logical property, so the stripe sits on the correct edge under
|
||||
* RTL automatically. Used for stateful panels (bank ownership, upload state, a nurse's earnings header).
|
||||
* A `SurfaceCard` for a **stateful** panel (bank ownership, upload state, a nurse's earnings header,
|
||||
* the next-visit card, the activation tracker).
|
||||
*
|
||||
* It no longer paints a colored edge stripe. The stripe was a hard vertical rule on the card's
|
||||
* inline-start edge, and under RTL a column of these read as a row of loose colored lines down the
|
||||
* right-hand side of the screen rather than a stack of cards — insetting and rounding it softened
|
||||
* the artefact without fixing the underlying problem, which was that the device drew the eye to a
|
||||
* decoration instead of to the card's own content. State is carried by the things inside the card
|
||||
* that actually say it: a `StatusChip`, a tinted icon, the copy.
|
||||
*
|
||||
* `tone` is kept because it is the semantic label callers already pass and it still reaches the DOM
|
||||
* as `data-accent-tone` (used by tests and available to any future treatment). Keeping the component
|
||||
* rather than collapsing every call site into `SurfaceCard` also keeps "this panel represents a
|
||||
* state" stated in the code.
|
||||
* @component AccentCard
|
||||
*/
|
||||
const AccentCard: FunctionComponent<AccentCardProps> = ({ tone, sx, children, ...rest }) => (
|
||||
<SurfaceCard
|
||||
sx={{ borderInlineStart: `${ACCENT_WIDTH} solid ${TONE_VAR[tone]}`, ...sx }}
|
||||
data-accent-tone={tone}
|
||||
{...rest}
|
||||
>
|
||||
const AccentCard: FunctionComponent<AccentCardProps> = ({ tone, children, ...rest }) => (
|
||||
<SurfaceCard data-accent-tone={tone} {...rest}>
|
||||
{children}
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,8 @@ describe('<StickyActionBar/> component', () => {
|
||||
expect(screen.getByText('مشاهده ۱۲ پرستار')).toBeInTheDocument();
|
||||
const bar = container.querySelector('[data-sticky-action-bar]');
|
||||
expect(bar).toBeInTheDocument();
|
||||
expect(bar).toHaveStyle({ position: 'sticky', bottom: '0px' });
|
||||
// The offset is the frame's published chrome height, so the bar clears a pinned bottom nav; the
|
||||
// `0px` fallback is what a chrome-free shell (and this bare render) resolves to.
|
||||
expect(bar).toHaveStyle({ position: 'sticky', bottom: 'var(--bal-chrome-bottom, 0px)' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,12 @@ export interface StickyActionBarProps {
|
||||
/**
|
||||
* A bottom-pinned action bar for a scrolling screen — the C1 live-count CTA and the C3 booking CTA.
|
||||
* Rendered as the last child of a page's scrolling content, `position: sticky` pins it to the bottom
|
||||
* of the nearest scrolling ancestor (the shell's `main`), so on mobile it naturally sits directly above
|
||||
* `BottomBar` (a separate flex sibling below `main`, which already owns the `env(safe-area-inset-bottom)`
|
||||
* padding — this component does not re-implement it) and on desktop it sits at the viewport bottom.
|
||||
* of the nearest scrolling ancestor (the shell's `main`).
|
||||
*
|
||||
* The offset comes from `--bal-chrome-bottom`, which `AppFrame` publishes on that scroll container:
|
||||
* the bottom nav is pinned *over* the scrollport, so a bar at `bottom: 0` would sit behind it. The
|
||||
* property already includes `env(safe-area-inset-bottom)` and resolves to `0px` in a chrome-free
|
||||
* shell, so this component neither re-implements the safe area nor needs to know which shell it is in.
|
||||
* @component StickyActionBar
|
||||
*/
|
||||
const StickyActionBar: FunctionComponent<StickyActionBarProps> = ({ children }) => (
|
||||
@@ -18,7 +21,7 @@ const StickyActionBar: FunctionComponent<StickyActionBarProps> = ({ children })
|
||||
data-sticky-action-bar
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
bottom: 'var(--bal-chrome-bottom, 0px)',
|
||||
zIndex: 1,
|
||||
mt: 2,
|
||||
pt: 1.5,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import FormSection from './FormSection';
|
||||
|
||||
function renderSection(props: Partial<React.ComponentProps<typeof FormSection>> = {}) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<FormSection title="Identity" {...props}>
|
||||
<input aria-label="national id" />
|
||||
</FormSection>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<FormSection/> component', () => {
|
||||
it('names the group with a heading and renders its fields', () => {
|
||||
renderSection({ description: 'Who you are' });
|
||||
expect(screen.getByRole('heading', { name: 'Identity' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Who you are')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('national id')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks a skippable group as optional instead of showing its status', () => {
|
||||
// The whole point of the optional marker is that it wins the slot — a completion count on a
|
||||
// group nobody has to fill in is noise.
|
||||
renderSection({ optional: true, optionalLabel: 'Optional', status: <span>0 of 3</span> });
|
||||
expect(screen.getByText('Optional')).toBeInTheDocument();
|
||||
expect(screen.queryByText('0 of 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the status line on a required group', () => {
|
||||
renderSection({ status: <span>2 of 3</span> });
|
||||
expect(screen.getByText('2 of 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '../AppIcon';
|
||||
import SurfaceCard from '../SurfaceCard';
|
||||
|
||||
export interface FormSectionProps {
|
||||
/** Already-translated section heading — names one coherent group of fields. */
|
||||
title: string;
|
||||
/** Already-translated one-line explanation of *why* this group is being asked for. */
|
||||
description?: string;
|
||||
/** Registered `AppIcon` name for the leading glyph. */
|
||||
icon?: string;
|
||||
/**
|
||||
* Short status line rendered end-aligned in the header — a completion count, an "optional" marker,
|
||||
* whatever tells the reader where they stand without opening the group.
|
||||
*/
|
||||
status?: ReactNode;
|
||||
/** Renders the group as explicitly skippable. */
|
||||
optional?: boolean;
|
||||
/** Already-translated «اختیاری» label; required when `optional` is set. */
|
||||
optionalLabel?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* One labelled group of fields inside a form.
|
||||
*
|
||||
* The problem it solves: this app's longer forms (nurse profile, credentials, the variant builder)
|
||||
* were flat runs of ten-plus `TextField`s with nothing between them — no grouping, no explanation of
|
||||
* what any stretch of fields was *for*, and no sense of how much was left. Everything looked equally
|
||||
* important and equally mandatory, which is the worst possible shape for a form a nurse fills in once
|
||||
* under pressure to get listed.
|
||||
*
|
||||
* A section is a `SurfaceCard` with a heading, a one-line rationale, and an optional status/optional
|
||||
* marker. Grouping is the cheapest legibility win available: it turns "a wall of inputs" into "four
|
||||
* questions", makes optional groups visibly skippable, and gives errors somewhere to be attributed to.
|
||||
* Presentational — no state, caller-owned i18n.
|
||||
* @component FormSection
|
||||
*/
|
||||
const FormSection: FunctionComponent<FormSectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
status,
|
||||
optional = false,
|
||||
optionalLabel,
|
||||
children,
|
||||
}) => (
|
||||
<SurfaceCard padding="md" data-form-section={title}>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
{icon ? (
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
width: 34,
|
||||
height: 34,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
borderRadius: 'var(--bal-radius-sm)',
|
||||
bgcolor: 'var(--bal-primary-soft)',
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={icon} size={18} color="var(--bal-primary)" aria-hidden="true" />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<Typography variant="subtitle1" component="h2" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{optional && optionalLabel ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', flexShrink: 0 }}>
|
||||
{optionalLabel}
|
||||
</Typography>
|
||||
) : (
|
||||
status
|
||||
)}
|
||||
</Stack>
|
||||
{description ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 2 }}>{children}</Stack>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
export default FormSection;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import RhfChipSelect from './RhfChipSelect';
|
||||
|
||||
const OPTIONS = [
|
||||
{ code: 'icu', label: 'ICU' },
|
||||
{ code: 'elderly', label: 'Elderly' },
|
||||
];
|
||||
|
||||
interface MultiValues {
|
||||
specialties: string[];
|
||||
}
|
||||
interface SingleValues {
|
||||
relation: string | null;
|
||||
}
|
||||
|
||||
function MultiHarness({ onSubmit, initial = [] }: { onSubmit: (values: MultiValues) => void; initial?: string[] }) {
|
||||
const form = useForm<MultiValues>({ defaultValues: { specialties: initial } });
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<RhfChipSelect<MultiValues> name="specialties" label="Specialties" options={OPTIONS} allowCustomValues />
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleHarness({ onSubmit }: { onSubmit: (values: SingleValues) => void }) {
|
||||
const form = useForm<SingleValues>({ defaultValues: { relation: null } });
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<RhfChipSelect<SingleValues> name="relation" options={OPTIONS} multiple={false} />
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('<RhfChipSelect/> component', () => {
|
||||
it('toggles codes in and out of a multi-select field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<MultiHarness onSubmit={onSubmit} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
await user.click(screen.getByText('ICU'));
|
||||
await user.click(screen.getByText('Elderly'));
|
||||
await user.click(screen.getByText('ICU'));
|
||||
await user.click(screen.getByText('Save'));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ specialties: ['elderly'] }, expect.anything()));
|
||||
});
|
||||
|
||||
it('behaves like a radio group when single-select, clearing on a re-tap', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<SingleHarness onSubmit={onSubmit} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
await user.click(screen.getByText('ICU'));
|
||||
await user.click(screen.getByText('Elderly'));
|
||||
await user.click(screen.getByText('Save'));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ relation: 'elderly' }, expect.anything()));
|
||||
|
||||
await user.click(screen.getByText('Elderly'));
|
||||
await user.click(screen.getByText('Save'));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenLastCalledWith({ relation: null }, expect.anything()));
|
||||
});
|
||||
|
||||
it('renders a stored value that is not in the option list, so it can never silently vanish', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<MultiHarness onSubmit={jest.fn()} initial={['wound care']} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('wound care')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
import { Box, Chip, FormHelperText, FormLabel, Stack, Typography } from '@mui/material';
|
||||
import { Controller, FieldValues, RegisterOptions, useFormContext } from 'react-hook-form';
|
||||
import type { RhfFieldProps } from './types';
|
||||
|
||||
export interface ChipOption {
|
||||
/** Stable code stored in form state — never the label. */
|
||||
code: string;
|
||||
/** Already-translated display label. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type RhfChipSelectProps<TFieldValues extends FieldValues> = RhfFieldProps<TFieldValues> & {
|
||||
/** Already-translated group label. */
|
||||
label?: string;
|
||||
/** Already-translated one-line hint under the label. */
|
||||
hint?: string;
|
||||
options: ReadonlyArray<ChipOption>;
|
||||
/** Single-select behaves like a radio group: re-tapping the selected chip clears it. */
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Renders codes that aren't in `options` as removable chips (a free-text specialty the nurse added).
|
||||
* Without it a stored custom value would silently vanish from the UI while staying in form state.
|
||||
*/
|
||||
allowCustomValues?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A chip group bound to a react-hook-form field — `string[]` when `multiple`, `string | null`
|
||||
* otherwise.
|
||||
*
|
||||
* The same "tap to toggle a code" chip row was hand-rolled with bespoke `sx` in the nurse profile,
|
||||
* the credentials form and the variant builder, each with its own selected-state colors and its own
|
||||
* `useState` array. One binding replaces all three, and selection participates in validation like any
|
||||
* other field (a required group can simply carry a `rules.validate`).
|
||||
* @component RhfChipSelect
|
||||
*/
|
||||
const RhfChipSelect = <TFieldValues extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
label,
|
||||
hint,
|
||||
options,
|
||||
multiple = true,
|
||||
disabled = false,
|
||||
allowCustomValues = false,
|
||||
}: RhfChipSelectProps<TFieldValues>) => {
|
||||
const context = useFormContext<TFieldValues>();
|
||||
const resolvedControl = control ?? context?.control;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={resolvedControl}
|
||||
rules={rules as RegisterOptions<TFieldValues>}
|
||||
render={({ field, fieldState }) => {
|
||||
const selected: string[] = multiple
|
||||
? ((field.value as string[] | undefined) ?? [])
|
||||
: field.value == null
|
||||
? []
|
||||
: [field.value as string];
|
||||
|
||||
const toggle = (code: string) => {
|
||||
if (!multiple) {
|
||||
field.onChange(selected.includes(code) ? null : code);
|
||||
return;
|
||||
}
|
||||
field.onChange(selected.includes(code) ? selected.filter((item) => item !== code) : [...selected, code]);
|
||||
};
|
||||
|
||||
const knownCodes = options.map((option) => option.code);
|
||||
const customCodes = allowCustomValues ? selected.filter((code) => !knownCodes.includes(code)) : [];
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{label ? <FormLabel error={Boolean(fieldState.error)}>{label}</FormLabel> : null}
|
||||
{hint ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{options.map((option) => {
|
||||
const isSelected = selected.includes(option.code);
|
||||
return (
|
||||
<Chip
|
||||
key={option.code}
|
||||
label={option.label}
|
||||
data-code={option.code}
|
||||
aria-pressed={isSelected}
|
||||
clickable
|
||||
disabled={disabled}
|
||||
color={isSelected ? 'primary' : 'default'}
|
||||
variant={isSelected ? 'filled' : 'outlined'}
|
||||
onClick={() => toggle(option.code)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{customCodes.map((code) => (
|
||||
<Chip
|
||||
key={code}
|
||||
label={code}
|
||||
data-code={code}
|
||||
disabled={disabled}
|
||||
color="primary"
|
||||
variant="filled"
|
||||
onDelete={() => toggle(code)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{fieldState.error?.message ? <FormHelperText error>{fieldState.error.message}</FormHelperText> : null}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RhfChipSelect;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import RhfControlGroup from './RhfControlGroup';
|
||||
|
||||
interface Values {
|
||||
gender: string | null;
|
||||
}
|
||||
|
||||
function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) {
|
||||
const form = useForm<Values>({ mode: 'onTouched', defaultValues: { gender: null } });
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<RhfControlGroup<Values>
|
||||
name="gender"
|
||||
label="Gender"
|
||||
hint="Required for same-gender matching"
|
||||
rules={{ validate: (value) => value != null || 'Pick one' }}
|
||||
>
|
||||
{({ field, hasError }) => (
|
||||
<button type="button" data-invalid={hasError} onClick={() => field.onChange('female')}>
|
||||
Female
|
||||
</button>
|
||||
)}
|
||||
</RhfControlGroup>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGroup() {
|
||||
const onSubmit = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Harness onSubmit={onSubmit} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { onSubmit };
|
||||
}
|
||||
|
||||
describe('<RhfControlGroup/> component', () => {
|
||||
it('gives a non-input control the same label + hint shell as a text field', () => {
|
||||
renderGroup();
|
||||
expect(screen.getByText('Gender')).toBeInTheDocument();
|
||||
expect(screen.getByText('Required for same-gender matching')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the rule message and flags the control when validation fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderGroup();
|
||||
await user.click(screen.getByText('Save'));
|
||||
expect(await screen.findByText('Pick one')).toBeInTheDocument();
|
||||
expect(screen.getByText('Female')).toHaveAttribute('data-invalid', 'true');
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes the control’s value into form state', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderGroup();
|
||||
await user.click(screen.getByText('Female'));
|
||||
await user.click(screen.getByText('Save'));
|
||||
expect(onSubmit).toHaveBeenCalledWith({ gender: 'female' }, expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
import { ReactNode } from 'react';
|
||||
import { FormHelperText, FormLabel, Stack, Typography } from '@mui/material';
|
||||
import { Controller, ControllerRenderProps, FieldValues, RegisterOptions, useFormContext } from 'react-hook-form';
|
||||
import type { RhfFieldProps } from './types';
|
||||
|
||||
export type RhfControlGroupProps<TFieldValues extends FieldValues> = RhfFieldProps<TFieldValues> & {
|
||||
/** Already-translated group label. */
|
||||
label?: string;
|
||||
/** Already-translated one-line hint under the label. */
|
||||
hint?: string;
|
||||
/**
|
||||
* Renders the actual control. `field` is react-hook-form's binding (`value`/`onChange`/`onBlur`);
|
||||
* `hasError` lets a control that carries its own error styling (`GenderToggle`) reflect it.
|
||||
*/
|
||||
children: (args: { field: ControllerRenderProps<TFieldValues>; hasError: boolean }) => ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Binds an arbitrary non-input control — `GenderToggle`, `RelationSelect`, `RatingInput`, a map-pin
|
||||
* picker — to a react-hook-form field, and gives it the same label/hint/error-message shell the
|
||||
* `TextField`-based wrappers get for free.
|
||||
*
|
||||
* Without it these controls kept their own `useState` plus a hand-rolled `xxxError` boolean and a
|
||||
* bespoke error `<Typography>` per screen, which is exactly the pattern that made "is this form
|
||||
* valid?" un-answerable from one place.
|
||||
* @component RhfControlGroup
|
||||
*/
|
||||
const RhfControlGroup = <TFieldValues extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: RhfControlGroupProps<TFieldValues>) => {
|
||||
const context = useFormContext<TFieldValues>();
|
||||
const resolvedControl = control ?? context?.control;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={resolvedControl}
|
||||
rules={rules as RegisterOptions<TFieldValues>}
|
||||
render={({ field, fieldState }) => (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{label ? <FormLabel error={Boolean(fieldState.error)}>{label}</FormLabel> : null}
|
||||
{hint ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
{children({ field: field as ControllerRenderProps<TFieldValues>, hasError: Boolean(fieldState.error) })}
|
||||
{fieldState.error?.message ? <FormHelperText error>{fieldState.error.message}</FormHelperText> : null}
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RhfControlGroup;
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
import { Controller, FieldValues, RegisterOptions, useFormContext } from 'react-hook-form';
|
||||
import JalaliDateField, { JalaliDateFieldProps } from '../JalaliDateField';
|
||||
import type { RhfFieldProps } from './types';
|
||||
|
||||
export type RhfJalaliDateFieldProps<TFieldValues extends FieldValues> = RhfFieldProps<TFieldValues> &
|
||||
Omit<JalaliDateFieldProps, 'name' | 'value' | 'onChange' | 'error' | 'helperText'> & {
|
||||
helperText?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* `JalaliDateField` bound to a react-hook-form field. Stores the wire ISO (Gregorian) `YYYY-MM-DD`
|
||||
* string the picker emits, or `null` — the same shape every date on the wire uses.
|
||||
* @component RhfJalaliDateField
|
||||
*/
|
||||
const RhfJalaliDateField = <TFieldValues extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
helperText,
|
||||
...fieldProps
|
||||
}: RhfJalaliDateFieldProps<TFieldValues>) => {
|
||||
const context = useFormContext<TFieldValues>();
|
||||
const resolvedControl = control ?? context?.control;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={resolvedControl}
|
||||
rules={rules as RegisterOptions<TFieldValues>}
|
||||
render={({ field, fieldState }) => (
|
||||
<JalaliDateField
|
||||
{...fieldProps}
|
||||
name={field.name}
|
||||
value={(field.value as string | null) ?? null}
|
||||
onChange={field.onChange}
|
||||
error={Boolean(fieldState.error)}
|
||||
helperText={fieldState.error?.message ?? helperText}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RhfJalaliDateField;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import RhfTextField from './RhfTextField';
|
||||
|
||||
interface Values {
|
||||
years: string;
|
||||
}
|
||||
|
||||
function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) {
|
||||
const form = useForm<Values>({ mode: 'onTouched', defaultValues: { years: '' } });
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<RhfTextField<Values>
|
||||
name="years"
|
||||
label="Years"
|
||||
helperText="How long you have practised"
|
||||
transform={(raw) => raw.replace(/\D/g, '').slice(0, 2)}
|
||||
rules={{ validate: (value) => String(value ?? '').length > 0 || 'Years is required' }}
|
||||
/>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderField() {
|
||||
const onSubmit = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<Harness onSubmit={onSubmit} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { onSubmit };
|
||||
}
|
||||
|
||||
describe('<RhfTextField/> component', () => {
|
||||
it('reads its control off the enclosing FormProvider and shows the helper text', () => {
|
||||
renderField();
|
||||
expect(screen.getByLabelText('Years')).toBeInTheDocument();
|
||||
expect(screen.getByText('How long you have practised')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('normalizes keystrokes through `transform` before they reach form state', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderField();
|
||||
await user.type(screen.getByLabelText('Years'), 'a1b2c3');
|
||||
await user.click(screen.getByText('Save'));
|
||||
// Non-digits stripped and capped at two — the stored value, not just the displayed one.
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ years: '12' }), expect.anything()));
|
||||
});
|
||||
|
||||
it('replaces the helper text with the field rule message on a failed submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderField();
|
||||
await user.click(screen.getByText('Save'));
|
||||
expect(await screen.findByText('Years is required')).toBeInTheDocument();
|
||||
expect(screen.queryByText('How long you have practised')).not.toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
import { ReactNode } from 'react';
|
||||
import TextField, { TextFieldProps } from '@mui/material/TextField';
|
||||
import { Controller, FieldValues, RegisterOptions, useFormContext } from 'react-hook-form';
|
||||
import type { RhfFieldProps } from './types';
|
||||
|
||||
export type RhfTextFieldProps<TFieldValues extends FieldValues> = RhfFieldProps<TFieldValues> &
|
||||
Omit<TextFieldProps, 'name' | 'value' | 'onChange' | 'onBlur' | 'error' | 'defaultValue' | 'inputRef'> & {
|
||||
/**
|
||||
* Normalizes raw keystrokes before they reach form state — digit stripping, max length, locale
|
||||
* digit folding. Applying it here (rather than in each screen's `onChange`) is what keeps the
|
||||
* *stored* value canonical instead of merely the *displayed* one.
|
||||
*/
|
||||
transform?: (raw: string) => string;
|
||||
/** Shown below the field whenever there is no validation error to show instead. */
|
||||
helperText?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* `TextField` bound to a react-hook-form field.
|
||||
*
|
||||
* Every form in this app used to hold one `useState` per input plus a parallel `useState` per error
|
||||
* flag, which re-rendered the entire screen — including price previews, query-backed cards and
|
||||
* uploaders — on every keystroke, and left validation scattered across ad-hoc `if` blocks in each
|
||||
* submit handler. `Controller` subscribes only this field to its own value and error, so a keystroke
|
||||
* re-renders one input; the rules travel with the field they validate.
|
||||
*
|
||||
* Reads `control` from `FormProvider` when it isn't passed explicitly, so a form only wires it once.
|
||||
* @component RhfTextField
|
||||
*/
|
||||
const RhfTextField = <TFieldValues extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
transform,
|
||||
helperText,
|
||||
...textFieldProps
|
||||
}: RhfTextFieldProps<TFieldValues>) => {
|
||||
const context = useFormContext<TFieldValues>();
|
||||
const resolvedControl = control ?? context?.control;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={resolvedControl}
|
||||
rules={rules as RegisterOptions<TFieldValues>}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
name={field.name}
|
||||
inputRef={field.ref}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) => field.onChange(transform ? transform(event.target.value) : event.target.value)}
|
||||
onBlur={field.onBlur}
|
||||
error={Boolean(fieldState.error)}
|
||||
helperText={fieldState.error?.message ?? helperText}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RhfTextField;
|
||||
@@ -0,0 +1,13 @@
|
||||
import FormSection from './FormSection';
|
||||
import RhfChipSelect from './RhfChipSelect';
|
||||
import RhfControlGroup from './RhfControlGroup';
|
||||
import RhfJalaliDateField from './RhfJalaliDateField';
|
||||
import RhfTextField from './RhfTextField';
|
||||
|
||||
export { FormSection, RhfChipSelect, RhfControlGroup, RhfJalaliDateField, RhfTextField };
|
||||
export type { FormSectionProps } from './FormSection';
|
||||
export type { ChipOption, RhfChipSelectProps } from './RhfChipSelect';
|
||||
export type { RhfControlGroupProps } from './RhfControlGroup';
|
||||
export type { RhfJalaliDateFieldProps } from './RhfJalaliDateField';
|
||||
export type { RhfTextFieldProps } from './RhfTextField';
|
||||
export type { RhfFieldProps } from './types';
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Control, FieldPath, FieldValues, RegisterOptions } from 'react-hook-form';
|
||||
|
||||
/**
|
||||
* What every `Rhf*` field wrapper in this folder takes to bind itself to one form field.
|
||||
*
|
||||
* `control` is optional on purpose: a form that wraps its subtree in `FormProvider` never has to
|
||||
* thread it through, and one that doesn't (a small local form, a field rendered outside the
|
||||
* provider) can still pass it explicitly.
|
||||
*/
|
||||
export interface RhfFieldProps<TFieldValues extends FieldValues> {
|
||||
/** Dot-path of the field in the form's value shape — type-checked against it. */
|
||||
name: FieldPath<TFieldValues>;
|
||||
/** Falls back to the enclosing `FormProvider`'s control when omitted. */
|
||||
control?: Control<TFieldValues>;
|
||||
/** Validation rules, colocated with the field they govern rather than in the submit handler. */
|
||||
rules?: Omit<RegisterOptions<TFieldValues>, 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'>;
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import FormDialogShell from './FormDialogShell';
|
||||
import RouteFadeIn from './RouteFadeIn';
|
||||
import NavHubList from './NavHubList';
|
||||
|
||||
export * from './form';
|
||||
|
||||
export {
|
||||
ErrorBoundary,
|
||||
AppAlert,
|
||||
|
||||
Reference in New Issue
Block a user