manual improvement 2 & add telegram bot

This commit is contained in:
hamid
2026-07-27 23:58:16 +03:30
parent baa3cc63cd
commit e6a8f93a1e
57 changed files with 4181 additions and 2484 deletions
@@ -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 controls 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'>;
}
+2
View File
@@ -25,6 +25,8 @@ import FormDialogShell from './FormDialogShell';
import RouteFadeIn from './RouteFadeIn';
import NavHubList from './NavHubList';
export * from './form';
export {
ErrorBoundary,
AppAlert,