ui phase 1
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
|
||||
describe('<ConfirmDialog/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
const base = {
|
||||
title: 'Run batch?',
|
||||
confirmLabel: 'Run',
|
||||
cancelLabel: 'Cancel',
|
||||
onClose: jest.fn(),
|
||||
};
|
||||
|
||||
it('renders the title and body when open', () => {
|
||||
wrap(<ConfirmDialog open body="This moves money" onConfirm={jest.fn()} {...base} />);
|
||||
expect(screen.getByText('Run batch?')).toBeInTheDocument();
|
||||
expect(screen.getByText('This moves money')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onConfirm with no reason for a plain confirm', () => {
|
||||
const onConfirm = jest.fn();
|
||||
wrap(<ConfirmDialog open onConfirm={onConfirm} {...base} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
|
||||
expect(onConfirm).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('disables confirm until a required reason is entered, then passes it', () => {
|
||||
const onConfirm = jest.fn();
|
||||
wrap(<ConfirmDialog open requireReason reasonLabel="Reason" onConfirm={onConfirm} {...base} />);
|
||||
const confirmBtn = screen.getByRole('button', { name: 'Run' });
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText('Reason'), { target: { value: 'bad docs' } });
|
||||
expect(confirmBtn).not.toBeDisabled();
|
||||
fireEvent.click(confirmBtn);
|
||||
expect(onConfirm).toHaveBeenCalledWith('bad docs');
|
||||
});
|
||||
|
||||
it('calls onClose from cancel', () => {
|
||||
const onClose = jest.fn();
|
||||
wrap(<ConfirmDialog open onConfirm={jest.fn()} {...base} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode, useState } from 'react';
|
||||
import {
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import AppButton from '../AppButton';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
/** Already-translated title. */
|
||||
title: string;
|
||||
/** Already-translated body (string or node). */
|
||||
body?: ReactNode;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
/** Called with the entered reason (undefined when `requireReason` is false). */
|
||||
onConfirm: (reason?: string) => void;
|
||||
onClose: () => void;
|
||||
loading?: boolean;
|
||||
/** When true a required reason field shows; confirm is disabled until it is non-empty. */
|
||||
requireReason?: boolean;
|
||||
reasonLabel?: string;
|
||||
reasonPlaceholder?: string;
|
||||
/** MUI color for the confirm button — `error` for a destructive action. */
|
||||
confirmColor?: 'primary' | 'error' | 'secondary';
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared confirmation dialog behind every audited/irreversible action — approve/reject a
|
||||
* verification, run/retry a payout, save a config, resolve an alert, archive a patient, delete an
|
||||
* address. Optionally collects a **required reason** (reject/hide/resolve) and disables confirm until it
|
||||
* is provided. The dialog owns the reason field only; the caller owns the mutation and closes on success.
|
||||
* `loading` disables the buttons and shows a spinner so a double-submit is impossible. Promoted from
|
||||
* `components/admin` (kept there as a thin alias) — the contract is unchanged.
|
||||
* @component ConfirmDialog
|
||||
*/
|
||||
const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
|
||||
open,
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
onClose,
|
||||
loading = false,
|
||||
requireReason = false,
|
||||
reasonLabel,
|
||||
reasonPlaceholder,
|
||||
confirmColor = 'primary',
|
||||
}) => {
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const close = () => {
|
||||
setReason('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
onConfirm(requireReason ? reason.trim() : undefined);
|
||||
setReason('');
|
||||
};
|
||||
|
||||
const confirmDisabled = loading || (requireReason && reason.trim().length === 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
{body ? (
|
||||
typeof body === 'string' ? (
|
||||
<DialogContentText sx={{ color: 'text.secondary' }}>{body}</DialogContentText>
|
||||
) : (
|
||||
body
|
||||
)
|
||||
) : null}
|
||||
{requireReason ? (
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
label={reasonLabel}
|
||||
placeholder={reasonPlaceholder}
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={close} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color={confirmColor}
|
||||
onClick={confirm}
|
||||
disabled={confirmDisabled}
|
||||
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : undefined}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
|
||||
export default ConfirmDialog;
|
||||
export type { ConfirmDialogProps } from './ConfirmDialog';
|
||||
Reference in New Issue
Block a user