Skip to content

Commit 4b3cde8

Browse files
mgwozdz-uniconCopilot
andcommitted
fix: address comments on PR openedx#3024
Co-authored-by: Copilot <[email protected]>
1 parent e7228cb commit 4b3cde8

6 files changed

Lines changed: 64 additions & 51 deletions

File tree

src/generic/TypeXToConfirmModal.test.tsx

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import userEvent from '@testing-library/user-event';
33
import { IntlProvider } from '@edx/frontend-platform/i18n';
4-
import { fireEvent, render, screen } from '@testing-library/react';
4+
import { render, screen } from '@testing-library/react';
55

66
import TypeXToConfirmModal from './TypeXToConfirmModal';
77

@@ -11,11 +11,11 @@ const defaultProps = () => ({
1111
confirmLabel: 'Delete',
1212
cancelLabel: 'Cancel',
1313
X: 'DELETE',
14-
context: { id: 7 },
14+
confirmPayload: { id: 7 },
1515
isOpen: true,
1616
onConfirm: jest.fn(),
1717
onCancel: jest.fn(),
18-
setContext: jest.fn(),
18+
setConfirmPayload: jest.fn(),
1919
});
2020

2121
const renderModal = (props = defaultProps()) =>
@@ -26,45 +26,58 @@ const renderModal = (props = defaultProps()) =>
2626
);
2727

2828
describe('TypeXToConfirmModal', () => {
29-
it('keeps the destructive confirm button disabled until the typed value exactly matches the required confirmation phrase', () => {
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();
3037
renderModal();
3138

3239
const input = screen.getByRole('textbox');
3340
const confirmButton = screen.getByRole('button', { name: 'Delete' });
3441

3542
expect(confirmButton).toBeDisabled();
36-
fireEvent.change(input, { target: { value: 'DEL' } });
43+
await user.type(input, 'DEL');
3744
expect(confirmButton).toBeDisabled();
38-
fireEvent.change(input, { target: { value: 'DELETE' } });
45+
await user.type(input, 'ETE');
3946
expect(confirmButton).toBeEnabled();
4047
});
4148

42-
it('does not enable confirmation for partial, differently cased, or whitespace-padded confirmation text', () => {
49+
it('does not enable confirmation for partial, differently cased, or whitespace-padded confirmation text', async () => {
50+
const user = userEvent.setup();
4351
renderModal();
4452

4553
const input = screen.getByRole('textbox');
4654
const confirmButton = screen.getByRole('button', { name: 'Delete' });
4755

48-
['DEL', 'delete', ' DELETE', 'DELETE ', ' Delete '].forEach(value => {
49-
fireEvent.change(input, { target: { value } });
56+
for (const value of ['DEL', 'delete', ' DELETE', 'DELETE ', ' Delete ']) {
57+
await user.clear(input);
58+
await user.type(input, value);
5059
expect(confirmButton).toBeDisabled();
51-
});
60+
}
5261
});
5362

54-
it('submits on Enter only after the exact confirmation phrase has been entered', async () => {
63+
it('requires explicit activation of the enabled destructive confirm button', async () => {
5564
const user = userEvent.setup();
5665
const props = defaultProps();
5766
renderModal(props);
5867

5968
const input = screen.getByRole('textbox');
69+
const confirmButton = screen.getByRole('button', { name: 'Delete' });
6070

6171
await user.click(input);
6272
await user.keyboard('{Enter}');
6373
expect(props.onConfirm).not.toHaveBeenCalled();
6474

6575
await user.type(input, 'DELETE');
6676
await user.keyboard('{Enter}');
67-
expect(props.onConfirm).toHaveBeenCalledWith(props.context);
77+
expect(props.onConfirm).not.toHaveBeenCalled();
78+
79+
await user.click(confirmButton);
80+
expect(props.onConfirm).toHaveBeenCalledWith(props.confirmPayload);
6881
});
6982

7083
it('resets confirmation state when the modal closes so a reopened dialog starts disabled again', async () => {
@@ -90,7 +103,7 @@ describe('TypeXToConfirmModal', () => {
90103
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
91104
});
92105

93-
it('clears the provided context when the modal is closed without confirming', () => {
106+
it('clears the provided confirm payload when the modal is closed without confirming', () => {
94107
const props = defaultProps();
95108
const { rerender } = renderModal(props);
96109

@@ -100,7 +113,7 @@ describe('TypeXToConfirmModal', () => {
100113
</IntlProvider>,
101114
);
102115

103-
expect(props.setContext).toHaveBeenCalledWith(null);
116+
expect(props.setConfirmPayload).toHaveBeenCalledWith(null);
104117
expect(props.onConfirm).not.toHaveBeenCalled();
105118
});
106119
});

src/generic/TypeXToConfirmModal.tsx

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import React, { useEffect } from 'react';
2-
import { Button, Card, Form, Icon, ModalDialog } from '@openedx/paragon';
2+
import {
3+
ActionRow,
4+
Button,
5+
Card,
6+
Form,
7+
Icon,
8+
ModalDialog,
9+
} from '@openedx/paragon';
310
import { WarningFilled } from '@openedx/paragon/icons';
411
import { useIntl } from '@edx/frontend-platform/i18n';
512
import messages from './messages';
@@ -10,12 +17,11 @@ interface TypeXToConfirmModalProps {
1017
confirmLabel: string;
1118
cancelLabel: string;
1219
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;
20+
confirmPayload?: Record<string, any> | null;
1521
isOpen: boolean;
16-
onConfirm: (context?: Record<string, any> | null) => void;
22+
onConfirm: (confirmPayload?: Record<string, any> | null) => void;
1723
onCancel: () => void;
18-
setContext?: (context: Record<string, any> | null) => void;
24+
setConfirmPayload?: (confirmPayload: Record<string, any> | null) => void;
1925
}
2026

2127
const TypeXToConfirmModal: React.FC<TypeXToConfirmModalProps> = ({
@@ -25,25 +31,18 @@ const TypeXToConfirmModal: React.FC<TypeXToConfirmModalProps> = ({
2531
confirmLabel,
2632
cancelLabel,
2733
isOpen,
28-
context,
34+
confirmPayload,
2935
onConfirm,
3036
onCancel,
31-
setContext,
37+
setConfirmPayload,
3238
}) => {
3339
const [confirmedByTyping, setConfirmedByTyping] = React.useState(false);
3440
const intl = useIntl();
3541

36-
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
37-
if (!confirmedByTyping) { return; }
38-
if (e.key === 'Enter') {
39-
onConfirm(context);
40-
}
41-
};
42-
4342
const handleConfirm = () => {
4443
if (!confirmedByTyping) { return; }
4544
setConfirmedByTyping(false);
46-
onConfirm(context);
45+
onConfirm(confirmPayload);
4746
};
4847

4948
const handleCancel = () => {
@@ -63,12 +62,11 @@ const TypeXToConfirmModal: React.FC<TypeXToConfirmModalProps> = ({
6362
useEffect(() => {
6463
if (!isOpen) {
6564
setConfirmedByTyping(false);
66-
if (setContext) {
67-
// reset onConfirm callback context when modal is closed
68-
setContext(null);
65+
if (setConfirmPayload) {
66+
setConfirmPayload(null);
6967
}
7068
}
71-
}, [X, isOpen, context, setContext]);
69+
}, [X, isOpen, confirmPayload, setConfirmPayload]);
7270

7371
return (
7472
<ModalDialog
@@ -91,33 +89,27 @@ const TypeXToConfirmModal: React.FC<TypeXToConfirmModalProps> = ({
9189
</Card>
9290
<div className="mt-3">
9391
<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-
})()}
92+
{intl.formatMessage(messages.typeToConfirmInstruction, {
93+
X,
94+
strong: (chunks: React.ReactNode) => <strong>{chunks}</strong>,
95+
})}
10596
</div>
10697
<Form.Control
107-
onKeyDown={handleKeyDown}
10898
onChange={handleChange}
10999
className="mt-4"
110100
/>
111101
</div>
112-
<ModalDialog.Footer>
102+
</ModalDialog.Body>
103+
<ModalDialog.Footer>
104+
<ActionRow>
113105
<Button variant="tertiary" onClick={handleCancel}>
114106
{cancelLabel}
115107
</Button>
116108
<Button onClick={handleConfirm} disabled={!confirmedByTyping} variant="danger">
117109
{confirmLabel}
118110
</Button>
119-
</ModalDialog.Footer>
120-
</ModalDialog.Body>
111+
</ActionRow>
112+
</ModalDialog.Footer>
121113
</ModalDialog>
122114
);
123115
};

src/generic/messages.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
33
const messages = defineMessages({
44
typeToConfirmInstruction: {
55
id: 'course-authoring.generic.type-to-confirm-instruction',
6-
defaultMessage: 'Type {X} to confirm',
6+
defaultMessage: 'Type <strong>{X}</strong> to confirm',
77
},
88
});
99

src/taxonomy/tag-list/DeleteModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ const DeleteModal = ({
7272
confirmLabel={intl.formatMessage(count > 1 ? messages.deleteLabelPlural : messages.deleteLabelSingular)}
7373
cancelLabel={intl.formatMessage(messages.cancelLabel)}
7474
isOpen={isOpen}
75-
context={row}
76-
setContext={setRow}
75+
confirmPayload={row}
76+
setConfirmPayload={setRow}
7777
onConfirm={handleConfirm}
7878
onCancel={handleCancel}
7979
/>

src/taxonomy/tag-list/utils.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ describe('getTagsWithDescendantCount', () => {
2828
],
2929
} as any;
3030

31+
// The total includes the root tag itself:
32+
// root + child 1 + 2 grandchildren + child 2 + grandchild 3 + great grandchild 1 = 7.
3133
expect(getTagWithDescendantsCount(rowData)).toBe(7);
3234
});
3335
});

src/taxonomy/tag-list/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ export const getTagListRowData = (row: Row<TreeRowData>): TagListRowData => (
1111
row.original as unknown as TagListRowData
1212
);
1313

14+
/**
15+
* Counts this tag and every nested descendant below it.
16+
*
17+
* A leaf tag counts as 1. For parent tags, start with 1 for the tag itself,
18+
* then recursively add the same count for each child in `subRows`.
19+
*/
1420
export const getTagWithDescendantsCount = (rowData: TreeRowData): number => {
1521
if (!rowData.subRows || rowData.subRows.length === 0) {
1622
return 1;

0 commit comments

Comments
 (0)