ui phase 11
This commit is contained in:
@@ -41,4 +41,58 @@ describe('<ConfirmDialog/>', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
|
||||
wrap(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onConfirm={jest.fn()}
|
||||
{...base}
|
||||
requireTypedConfirmation={['CONFIRM', '150000']}
|
||||
typedConfirmationLabel="Type to confirm"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
|
||||
const onConfirm = jest.fn();
|
||||
wrap(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onConfirm={onConfirm}
|
||||
{...base}
|
||||
requireTypedConfirmation={['CONFIRM', '150000']}
|
||||
typedConfirmationLabel="Type to confirm"
|
||||
/>,
|
||||
);
|
||||
const confirmBtn = screen.getByRole('button', { name: 'Run' });
|
||||
const field = screen.getByLabelText('Type to confirm');
|
||||
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
|
||||
fireEvent.change(field, { target: { value: 'nope' } });
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
|
||||
fireEvent.change(field, { target: { value: ' confirm ' } });
|
||||
expect(confirmBtn).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(confirmBtn);
|
||||
expect(onConfirm).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('enables confirm on an exact match against a different allowed value (e.g. an amount)', () => {
|
||||
wrap(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onConfirm={jest.fn()}
|
||||
{...base}
|
||||
requireTypedConfirmation={['CONFIRM', '150000']}
|
||||
typedConfirmationLabel="Type to confirm"
|
||||
/>,
|
||||
);
|
||||
const confirmBtn = screen.getByRole('button', { name: 'Run' });
|
||||
fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
|
||||
expect(confirmBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,15 @@ export interface ConfirmDialogProps {
|
||||
requireReason?: boolean;
|
||||
reasonLabel?: string;
|
||||
reasonPlaceholder?: string;
|
||||
/**
|
||||
* When set, confirm stays disabled until the typed value case-insensitively matches ONE of these
|
||||
* (e.g. the literal word «تایید» OR the exact amount digit-string) — the guard for an irreversible
|
||||
* action like running a payout batch. A gate, not a payload: `onConfirm`'s signature is unchanged, the
|
||||
* typed value itself is never passed to the caller.
|
||||
*/
|
||||
requireTypedConfirmation?: string[];
|
||||
typedConfirmationLabel?: string;
|
||||
typedConfirmationPlaceholder?: string;
|
||||
/** MUI color for the confirm button — `error` for a destructive action. */
|
||||
confirmColor?: 'primary' | 'error' | 'secondary';
|
||||
}
|
||||
@@ -52,21 +61,31 @@ const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
|
||||
requireReason = false,
|
||||
reasonLabel,
|
||||
reasonPlaceholder,
|
||||
requireTypedConfirmation,
|
||||
typedConfirmationLabel,
|
||||
typedConfirmationPlaceholder,
|
||||
confirmColor = 'primary',
|
||||
}) => {
|
||||
const [reason, setReason] = useState('');
|
||||
const [typedValue, setTypedValue] = useState('');
|
||||
|
||||
const close = () => {
|
||||
setReason('');
|
||||
setTypedValue('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
onConfirm(requireReason ? reason.trim() : undefined);
|
||||
setReason('');
|
||||
setTypedValue('');
|
||||
};
|
||||
|
||||
const confirmDisabled = loading || (requireReason && reason.trim().length === 0);
|
||||
const confirmDisabled =
|
||||
loading ||
|
||||
(requireReason && reason.trim().length === 0) ||
|
||||
(requireTypedConfirmation != null &&
|
||||
!requireTypedConfirmation.some((v) => v.trim().toLowerCase() === typedValue.trim().toLowerCase()));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
|
||||
@@ -92,6 +111,17 @@ const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
{requireTypedConfirmation ? (
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
value={typedValue}
|
||||
onChange={(e) => setTypedValue(e.target.value)}
|
||||
label={typedConfirmationLabel}
|
||||
placeholder={typedConfirmationPlaceholder}
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={close} disabled={loading}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import PageHeader from './PageHeader';
|
||||
|
||||
@@ -16,6 +16,11 @@ describe('<PageHeader/>', () => {
|
||||
expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the meta slot below the title when provided', () => {
|
||||
wrap(<PageHeader title="T" meta={<span>Open</span>} />);
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the back button when backTo is not given', () => {
|
||||
wrap(<PageHeader title="T" />);
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
@@ -25,4 +30,12 @@ describe('<PageHeader/>', () => {
|
||||
wrap(<PageHeader title="T" backTo="/patients" backLabel="Back" />);
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', '/patients');
|
||||
});
|
||||
|
||||
it('renders a back button that calls onBack, not a link, when onBack is given', () => {
|
||||
const onBack = jest.fn();
|
||||
wrap(<PageHeader title="T" onBack={onBack} backLabel="Back" />);
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,24 +11,53 @@ export interface PageHeaderProps {
|
||||
subtitle?: string;
|
||||
/** Optional action node (a button / filter) rendered end-aligned on desktop, wrapping on mobile. */
|
||||
actions?: ReactNode;
|
||||
/**
|
||||
* Optional node rendered below the title/subtitle — a chip row (status/category/linked-record chips)
|
||||
* or any other short meta line that isn't a button (ui-phase-11: the admin ticket thread's category/
|
||||
* status/linked-booking/linked-refund chips). Kept distinct from `actions`, which is end-aligned and
|
||||
* reserved for buttons.
|
||||
*/
|
||||
meta?: ReactNode;
|
||||
/** Optional back-navigation target — renders an RTL-flippable chevron before the title. */
|
||||
backTo?: string;
|
||||
/** Already-translated accessible label for the back button (required when `backTo` is set). */
|
||||
/** Already-translated accessible label for the back button (required when `backTo` or `onBack` is set). */
|
||||
backLabel?: string;
|
||||
/**
|
||||
* Optional back-navigation handler — an alternative to `backTo` for a caller that needs `router.back()`
|
||||
* semantics (e.g. `useAdminBackToList`) rather than a fixed link target. Takes precedence over `backTo`
|
||||
* when both are given (they shouldn't be).
|
||||
*/
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard page header — title + optional subtitle, an end-aligned actions slot, and an optional
|
||||
* back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin alias); every area
|
||||
* page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead. Presentational,
|
||||
* caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under `dir="rtl"`).
|
||||
* The standard page header — title + optional subtitle + optional meta row, an end-aligned actions slot,
|
||||
* and an optional back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin
|
||||
* alias); every area page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead.
|
||||
* Presentational, caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under
|
||||
* `dir="rtl"`).
|
||||
* @component PageHeader
|
||||
*/
|
||||
const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actions, backTo, backLabel }) => (
|
||||
const PageHeader: FunctionComponent<PageHeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
meta,
|
||||
backTo,
|
||||
backLabel,
|
||||
onBack,
|
||||
}) => (
|
||||
<Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
|
||||
{backTo ? (
|
||||
<AppIconButton icon="back" to={backTo} title={backLabel} sx={{ mt: 0.25 }} iconProps={{ size: 20 }} />
|
||||
{backTo || onBack ? (
|
||||
<AppIconButton
|
||||
icon="back"
|
||||
to={onBack ? undefined : backTo}
|
||||
onClick={onBack}
|
||||
title={backLabel}
|
||||
sx={{ mt: 0.25 }}
|
||||
iconProps={{ size: 20 }}
|
||||
/>
|
||||
) : null}
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 700 }}>
|
||||
@@ -39,6 +68,7 @@ const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actio
|
||||
{subtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
{meta ? <Box sx={{ mt: 1 }}>{meta}</Box> : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}
|
||||
|
||||
Reference in New Issue
Block a user