Skip to content

Commit a19a0ba

Browse files
committed
feat: delete confirm modal
1 parent 57b770b commit a19a0ba

10 files changed

Lines changed: 631 additions & 30 deletions
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import React from 'react';
2+
import userEvent from '@testing-library/user-event';
3+
import { IntlProvider } from '@edx/frontend-platform/i18n';
4+
import { fireEvent, render, screen } from '@testing-library/react';
5+
6+
import TypeXToConfirmModal from './TypeXToConfirmModal';
7+
8+
const defaultProps = () => ({
9+
label: 'Delete item',
10+
bodyText: 'Dangerous action',
11+
confirmLabel: 'Delete',
12+
cancelLabel: 'Cancel',
13+
X: 'DELETE',
14+
context: { id: 7 },
15+
isOpen: true,
16+
onConfirm: jest.fn(),
17+
onCancel: jest.fn(),
18+
setContext: jest.fn(),
19+
});
20+
21+
const renderModal = (props = defaultProps()) =>
22+
render(
23+
<IntlProvider locale="en" messages={{}}>
24+
<TypeXToConfirmModal {...props} />
25+
</IntlProvider>,
26+
);
27+
28+
describe('TypeXToConfirmModal', () => {
29+
it('keeps the destructive confirm button disabled until the typed value exactly matches the required confirmation phrase', () => {
30+
renderModal();
31+
32+
const input = screen.getByRole('textbox');
33+
const confirmButton = screen.getByRole('button', { name: 'Delete' });
34+
35+
expect(confirmButton).toBeDisabled();
36+
fireEvent.change(input, { target: { value: 'DEL' } });
37+
expect(confirmButton).toBeDisabled();
38+
fireEvent.change(input, { target: { value: 'DELETE' } });
39+
expect(confirmButton).toBeEnabled();
40+
});
41+
42+
it('does not enable confirmation for partial, differently cased, or whitespace-padded confirmation text', () => {
43+
renderModal();
44+
45+
const input = screen.getByRole('textbox');
46+
const confirmButton = screen.getByRole('button', { name: 'Delete' });
47+
48+
['DEL', 'delete', ' DELETE', 'DELETE ', ' Delete '].forEach(value => {
49+
fireEvent.change(input, { target: { value } });
50+
expect(confirmButton).toBeDisabled();
51+
});
52+
});
53+
54+
it('submits on Enter only after the exact confirmation phrase has been entered', async () => {
55+
const user = userEvent.setup();
56+
const props = defaultProps();
57+
renderModal(props);
58+
59+
const input = screen.getByRole('textbox');
60+
61+
await user.click(input);
62+
await user.keyboard('{Enter}');
63+
expect(props.onConfirm).not.toHaveBeenCalled();
64+
65+
await user.type(input, 'DELETE');
66+
await user.keyboard('{Enter}');
67+
expect(props.onConfirm).toHaveBeenCalledWith(props.context);
68+
});
69+
70+
it('resets confirmation state when the modal closes so a reopened dialog starts disabled again', async () => {
71+
const user = userEvent.setup();
72+
const props = defaultProps();
73+
const { rerender } = renderModal(props);
74+
75+
await user.type(screen.getByRole('textbox'), 'DELETE');
76+
expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled();
77+
78+
rerender(
79+
<IntlProvider locale="en" messages={{}}>
80+
<TypeXToConfirmModal {...props} isOpen={false} />
81+
</IntlProvider>,
82+
);
83+
84+
rerender(
85+
<IntlProvider locale="en" messages={{}}>
86+
<TypeXToConfirmModal {...props} isOpen />
87+
</IntlProvider>,
88+
);
89+
90+
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
91+
});
92+
93+
it('clears the provided context when the modal is closed without confirming', () => {
94+
const props = defaultProps();
95+
const { rerender } = renderModal(props);
96+
97+
rerender(
98+
<IntlProvider locale="en" messages={{}}>
99+
<TypeXToConfirmModal {...props} isOpen={false} />
100+
</IntlProvider>,
101+
);
102+
103+
expect(props.setContext).toHaveBeenCalledWith(null);
104+
expect(props.onConfirm).not.toHaveBeenCalled();
105+
});
106+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import React, { useEffect } from 'react';
2+
import { Button, Card, Form, Icon, ModalDialog } from '@openedx/paragon';
3+
import { WarningFilled } from '@openedx/paragon/icons';
4+
import { useIntl } from '@edx/frontend-platform/i18n';
5+
import messages from './messages';
6+
7+
interface TypeXToConfirmModalProps {
8+
label: string;
9+
bodyText: string | React.ReactNode;
10+
confirmLabel: string;
11+
cancelLabel: string;
12+
X: string;
13+
// any additional context that the caller wants to pass to the onConfirm callback; not a React context.
14+
context?: Record<string, any> | null;
15+
isOpen: boolean;
16+
onConfirm: (context?: Record<string, any> | null) => void;
17+
onCancel: () => void;
18+
setContext?: (context: Record<string, any> | null) => void;
19+
}
20+
21+
const TypeXToConfirmModal: React.FC<TypeXToConfirmModalProps> = ({
22+
label,
23+
X,
24+
bodyText,
25+
confirmLabel,
26+
cancelLabel,
27+
isOpen,
28+
context,
29+
onConfirm,
30+
onCancel,
31+
setContext,
32+
}) => {
33+
const [confirmedByTyping, setConfirmedByTyping] = React.useState(false);
34+
const intl = useIntl();
35+
36+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
37+
if (!confirmedByTyping) { return; }
38+
if (e.key === 'Enter') {
39+
onConfirm(context);
40+
}
41+
};
42+
43+
const handleConfirm = () => {
44+
if (!confirmedByTyping) { return; }
45+
setConfirmedByTyping(false);
46+
onConfirm(context);
47+
};
48+
49+
const handleCancel = () => {
50+
setConfirmedByTyping(false);
51+
onCancel();
52+
};
53+
54+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
55+
if (e.target.value === X) {
56+
setConfirmedByTyping(true);
57+
} else {
58+
setConfirmedByTyping(false);
59+
}
60+
};
61+
62+
// Don't remove. This is necessary to prevent an old state from erroneously enabling the confirm button
63+
useEffect(() => {
64+
if (!isOpen) {
65+
setConfirmedByTyping(false);
66+
if (setContext) {
67+
// reset onConfirm callback context when modal is closed
68+
setContext(null);
69+
}
70+
}
71+
}, [X, isOpen, context, setContext]);
72+
73+
return (
74+
<ModalDialog
75+
title={label}
76+
isOpen={isOpen}
77+
onClose={handleCancel}
78+
isOverflowVisible
79+
>
80+
<ModalDialog.Header>
81+
<ModalDialog.Title>{label}</ModalDialog.Title>
82+
</ModalDialog.Header>
83+
<ModalDialog.Body>
84+
<Card className="bg-warning-100">
85+
<Card.Section>
86+
<div className="d-flex align-items-start mb-2">
87+
<Icon src={WarningFilled} className="text-warning-500 mr-2" />
88+
<div className="small">{bodyText}</div>
89+
</div>
90+
</Card.Section>
91+
</Card>
92+
<div className="mt-3">
93+
<div>
94+
{(() => {
95+
const messageText = intl.formatMessage(messages.typeToConfirmInstruction, { X });
96+
const parts = messageText.split(X);
97+
return (
98+
<>
99+
{parts[0]}
100+
<strong>{X}</strong>
101+
{parts[1]}
102+
</>
103+
);
104+
})()}
105+
</div>
106+
<Form.Control
107+
onKeyDown={handleKeyDown}
108+
onChange={handleChange}
109+
className="mt-4"
110+
/>
111+
</div>
112+
<ModalDialog.Footer>
113+
<Button variant="tertiary" onClick={handleCancel}>
114+
{cancelLabel}
115+
</Button>
116+
<Button onClick={handleConfirm} disabled={!confirmedByTyping} variant="danger">
117+
{confirmLabel}
118+
</Button>
119+
</ModalDialog.Footer>
120+
</ModalDialog.Body>
121+
</ModalDialog>
122+
);
123+
};
124+
125+
export default React.memo(TypeXToConfirmModal);

src/generic/messages.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
const messages = defineMessages({
4+
typeToConfirmInstruction: {
5+
id: 'course-authoring.generic.type-to-confirm-instruction',
6+
defaultMessage: 'Type {X} to confirm',
7+
},
8+
});
9+
10+
export default messages;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import React from 'react';
2+
import userEvent from '@testing-library/user-event';
3+
import { IntlProvider } from '@edx/frontend-platform/i18n';
4+
import { render, screen, within } from '@testing-library/react';
5+
6+
import DeleteModal from './DeleteModal';
7+
8+
const createRow = (rowData) =>
9+
({
10+
id: String(rowData.id),
11+
original: rowData,
12+
}) as any;
13+
14+
const leafRowData = {
15+
id: 101,
16+
value: 'leaf tag',
17+
depth: 0,
18+
childCount: 0,
19+
subRows: [],
20+
};
21+
22+
const nestedRowData = {
23+
id: 201,
24+
value: 'parent tag',
25+
depth: 0,
26+
childCount: 1,
27+
subRows: [
28+
{
29+
id: 202,
30+
value: 'child tag',
31+
depth: 1,
32+
childCount: 1,
33+
subRows: [
34+
{
35+
id: 203,
36+
value: 'grandchild tag',
37+
depth: 2,
38+
childCount: 0,
39+
subRows: [],
40+
},
41+
],
42+
},
43+
],
44+
};
45+
46+
const defaultProps = (overrides = {}) => ({
47+
isOpen: true,
48+
row: createRow(leafRowData),
49+
setIsOpen: jest.fn(),
50+
setRow: jest.fn(),
51+
handleDeleteRow: jest.fn(),
52+
...overrides,
53+
});
54+
55+
const renderDeleteModal = (props = defaultProps()) =>
56+
render(
57+
<IntlProvider locale="en" messages={{}}>
58+
<DeleteModal {...(props as any)} />
59+
</IntlProvider>,
60+
);
61+
62+
describe('DeleteModal', () => {
63+
it('renders a singular delete title and "Delete Tag" action label when the selected row has no descendants', () => {
64+
renderDeleteModal();
65+
66+
const dialog = screen.getByRole('dialog');
67+
expect(dialog).toHaveTextContent('Delete "leaf tag"');
68+
expect(dialog).toHaveTextContent('Warning! You are about to delete 1 tag(s).');
69+
expect(dialog).toHaveTextContent('Type DELETE to confirm');
70+
expect(within(dialog).getByRole('button', { name: 'Delete Tag' })).toBeDisabled();
71+
expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
72+
});
73+
74+
it('renders a plural delete action label and descendant warning copy when the selected row has one or more descendants', () => {
75+
renderDeleteModal(defaultProps({ row: createRow(nestedRowData) }));
76+
77+
const dialog = screen.getByRole('dialog');
78+
expect(dialog).toHaveTextContent('Delete "parent tag"');
79+
expect(dialog).toHaveTextContent('Warning! You are about to delete a tag containing sub-tags.');
80+
expect(dialog).toHaveTextContent('If you proceed, 3 tags will be deleted.');
81+
expect(dialog).toHaveTextContent('Type DELETE ALL 3 TAGS to confirm');
82+
expect(within(dialog).getByRole('button', { name: 'Delete Tags' })).toBeDisabled();
83+
});
84+
85+
it('computes the required confirmation phrase from the recursive descendant count instead of only the immediate child count', () => {
86+
renderDeleteModal(defaultProps({ row: createRow(nestedRowData) }));
87+
88+
const dialog = screen.getByRole('dialog');
89+
expect(dialog).toHaveTextContent('If you proceed, 3 tags will be deleted.');
90+
expect(dialog).toHaveTextContent('Type DELETE ALL 3 TAGS to confirm');
91+
expect(dialog).not.toHaveTextContent('DELETE ALL 2 TAGS');
92+
});
93+
94+
it('calls handleDeleteRow with the dialog row context and then closes and clears the dialog state on confirm', async () => {
95+
const user = userEvent.setup();
96+
const row = createRow(leafRowData);
97+
const handleDeleteRow = jest.fn();
98+
const setIsOpen = jest.fn();
99+
const setRow = jest.fn();
100+
101+
renderDeleteModal(defaultProps({
102+
row,
103+
handleDeleteRow,
104+
setIsOpen,
105+
setRow,
106+
}));
107+
108+
await user.type(screen.getByRole('textbox'), 'DELETE');
109+
await user.click(screen.getByRole('button', { name: 'Delete Tag' }));
110+
111+
expect(handleDeleteRow).toHaveBeenCalledWith(row);
112+
expect(setIsOpen).toHaveBeenCalledWith(false);
113+
expect(setRow).toHaveBeenCalledWith(null);
114+
});
115+
116+
it('closes and clears the dialog context on cancel without invoking deletion', async () => {
117+
const user = userEvent.setup();
118+
const handleDeleteRow = jest.fn();
119+
const setIsOpen = jest.fn();
120+
const setRow = jest.fn();
121+
122+
renderDeleteModal(defaultProps({
123+
handleDeleteRow,
124+
setIsOpen,
125+
setRow,
126+
}));
127+
128+
await user.click(screen.getByRole('button', { name: 'Cancel' }));
129+
130+
expect(handleDeleteRow).not.toHaveBeenCalled();
131+
expect(setIsOpen).toHaveBeenCalledWith(false);
132+
expect(setRow).toHaveBeenCalledWith(null);
133+
});
134+
});

0 commit comments

Comments
 (0)