Skip to content

Commit 8fe6577

Browse files
mgwozdz-uniconjesperhodgeCopilot
authored
feat: delete confirm modal (#3033)
* feat: delete confirm modal * docs: add todo to remove code * fix: lint * fix: address comments on PR #3024 Co-authored-by: Copilot <[email protected]> * feat: Remove testing artifacts * fix: Remove unused variable --------- Co-authored-by: Jesper Hodge <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 9d96d2e commit 8fe6577

8 files changed

Lines changed: 550 additions & 3 deletions

File tree

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