Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/generic/TypeXToConfirmModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { fireEvent, render, screen } from '@testing-library/react';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's generally best practice to use userEvent instead of fireEvent. See https://testing-library.com/docs/user-event/intro/#differences-from-fireevent


import TypeXToConfirmModal from './TypeXToConfirmModal';

const defaultProps = () => ({
label: 'Delete item',
bodyText: 'Dangerous action',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
X: 'DELETE',
context: { id: 7 },
isOpen: true,
onConfirm: jest.fn(),
onCancel: jest.fn(),
setContext: jest.fn(),
});

const renderModal = (props = defaultProps()) =>
render(
<IntlProvider locale="en" messages={{}}>
<TypeXToConfirmModal {...props} />
</IntlProvider>,
);

describe('TypeXToConfirmModal', () => {
it('keeps the destructive confirm button disabled until the typed value exactly matches the required confirmation phrase', () => {
renderModal();

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

expect(confirmButton).toBeDisabled();
fireEvent.change(input, { target: { value: 'DEL' } });
expect(confirmButton).toBeDisabled();
fireEvent.change(input, { target: { value: 'DELETE' } });
expect(confirmButton).toBeEnabled();
});

it('does not enable confirmation for partial, differently cased, or whitespace-padded confirmation text', () => {
renderModal();

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

['DEL', 'delete', ' DELETE', 'DELETE ', ' Delete '].forEach(value => {
fireEvent.change(input, { target: { value } });
expect(confirmButton).toBeDisabled();
});
});

it('submits on Enter only after the exact confirmation phrase has been entered', async () => {
const user = userEvent.setup();
const props = defaultProps();
renderModal(props);

const input = screen.getByRole('textbox');

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

await user.type(input, 'DELETE');
await user.keyboard('{Enter}');
expect(props.onConfirm).toHaveBeenCalledWith(props.context);
});

it('resets confirmation state when the modal closes so a reopened dialog starts disabled again', async () => {
const user = userEvent.setup();
const props = defaultProps();
const { rerender } = renderModal(props);

await user.type(screen.getByRole('textbox'), 'DELETE');
expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled();

rerender(
<IntlProvider locale="en" messages={{}}>
<TypeXToConfirmModal {...props} isOpen={false} />
</IntlProvider>,
);

rerender(
<IntlProvider locale="en" messages={{}}>
<TypeXToConfirmModal {...props} isOpen />
</IntlProvider>,
);

expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
});

it('clears the provided context when the modal is closed without confirming', () => {
const props = defaultProps();
const { rerender } = renderModal(props);

rerender(
<IntlProvider locale="en" messages={{}}>
<TypeXToConfirmModal {...props} isOpen={false} />
</IntlProvider>,
);

expect(props.setContext).toHaveBeenCalledWith(null);
expect(props.onConfirm).not.toHaveBeenCalled();
});
});
125 changes: 125 additions & 0 deletions src/generic/TypeXToConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import React, { useEffect } from 'react';
import { Button, Card, Form, Icon, ModalDialog } from '@openedx/paragon';
import { WarningFilled } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';

interface TypeXToConfirmModalProps {
label: string;
bodyText: string | React.ReactNode;
confirmLabel: string;
cancelLabel: string;
X: string;
// any additional context that the caller wants to pass to the onConfirm callback; not a React context.
context?: Record<string, any> | null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that this needs a descriptive comment makes me think we could probably use a better name here, maybe something like callbackParams?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to go with confirmPayload since that's where this context is getting passed.

isOpen: boolean;
onConfirm: (context?: Record<string, any> | null) => void;
onCancel: () => void;
setContext?: (context: Record<string, any> | null) => void;
}

const TypeXToConfirmModal: React.FC<TypeXToConfirmModalProps> = ({
label,
X,
bodyText,
confirmLabel,
cancelLabel,
isOpen,
context,
onConfirm,
onCancel,
setContext,
}) => {
const [confirmedByTyping, setConfirmedByTyping] = React.useState(false);
const intl = useIntl();

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!confirmedByTyping) { return; }
if (e.key === 'Enter') {
onConfirm(context);
}
Comment on lines +38 to +40
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't closing the modal.

Screencast.From.2026-04-27.14-52-35.mp4

Copy link
Copy Markdown
Contributor

@brian-smith-tcril brian-smith-tcril Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the other question is, "do we want this functionality?" Should hitting enter while in the input box count as confirmation? I'd think needing to tab over to the "Delete Tags" button would be reasonable (and less likely to result in unexpected behavior)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Since the acceptance criteria do not require Enter from the text input to confirm, I’m going to remove that shortcut and require users to explicitly activate the enabled Delete Tags button. Keyboard users can still tab to the button and press Enter, and this keeps the destructive action a little more intentional.

};

const handleConfirm = () => {
if (!confirmedByTyping) { return; }
setConfirmedByTyping(false);
onConfirm(context);
};

const handleCancel = () => {
setConfirmedByTyping(false);
onCancel();
};

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === X) {
setConfirmedByTyping(true);
} else {
setConfirmedByTyping(false);
}
};

// Don't remove. This is necessary to prevent an old state from erroneously enabling the confirm button
useEffect(() => {
if (!isOpen) {
setConfirmedByTyping(false);
if (setContext) {
// reset onConfirm callback context when modal is closed
setContext(null);
}
}
}, [X, isOpen, context, setContext]);

return (
<ModalDialog
title={label}
isOpen={isOpen}
onClose={handleCancel}
isOverflowVisible
>
<ModalDialog.Header>
<ModalDialog.Title>{label}</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<Card className="bg-warning-100">
<Card.Section>
<div className="d-flex align-items-start mb-2">
<Icon src={WarningFilled} className="text-warning-500 mr-2" />
<div className="small">{bodyText}</div>
</div>
</Card.Section>
</Card>
<div className="mt-3">
<div>
{(() => {
const messageText = intl.formatMessage(messages.typeToConfirmInstruction, { X });
const parts = messageText.split(X);
return (
<>
{parts[0]}
<strong>{X}</strong>
{parts[1]}
</>
);
})()}
Comment on lines +95 to +104
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a time when we'd use the "Type X to confirm" without <strong>? The "with rich text formatting" example in the formatjs api docs https://formatjs.github.io/docs/react-intl/api/ has

const messages = defineMessages({
  greeting: {
    id: 'app.greeting',
    defaultMessage: 'Hello, <bold>{name}</bold>!',
    description: 'Greeting to welcome the user to the app',
  },
})

intl.formatMessage(messages.greeting, {name: 'Eric', bold: str => <b>{str}</b>})

and in the live code editor there I tested

const messages = defineMessages({
  greeting: {
    id: 'app.greeting',
    defaultMessage: 'Hello, <strong>{name}</strong>!',
    description: 'Greeting to welcome the user to the app',
  },
})

intl.formatMessage(messages.greeting, {name: 'Eric', strong: str => <strong>{str}</strong>})

and it worked

</div>
<Form.Control
onKeyDown={handleKeyDown}
onChange={handleChange}
className="mt-4"
/>
</div>
<ModalDialog.Footer>
<Button variant="tertiary" onClick={handleCancel}>
{cancelLabel}
</Button>
<Button onClick={handleConfirm} disabled={!confirmedByTyping} variant="danger">
{confirmLabel}
</Button>
</ModalDialog.Footer>
Comment on lines +112 to +119
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spacing in here is off, the examples in https://paragon-openedx.netlify.app/components/modal/modal-dialog/ have the footer buttons in an ActionRow, I think that'll likely fix the spacing.

Image

</ModalDialog.Body>
</ModalDialog>
);
};

export default React.memo(TypeXToConfirmModal);
10 changes: 10 additions & 0 deletions src/generic/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineMessages } from '@edx/frontend-platform/i18n';

const messages = defineMessages({
typeToConfirmInstruction: {
id: 'course-authoring.generic.type-to-confirm-instruction',
defaultMessage: 'Type {X} to confirm',
},
});

export default messages;
134 changes: 134 additions & 0 deletions src/taxonomy/tag-list/DeleteModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, screen, within } from '@testing-library/react';

import DeleteModal from './DeleteModal';

const createRow = (rowData) =>
({
id: String(rowData.id),
original: rowData,
}) as any;

const leafRowData = {
id: 101,
value: 'leaf tag',
depth: 0,
childCount: 0,
subRows: [],
};

const nestedRowData = {
id: 201,
value: 'parent tag',
depth: 0,
childCount: 1,
subRows: [
{
id: 202,
value: 'child tag',
depth: 1,
childCount: 1,
subRows: [
{
id: 203,
value: 'grandchild tag',
depth: 2,
childCount: 0,
subRows: [],
},
],
},
],
};

const defaultProps = (overrides = {}) => ({
isOpen: true,
row: createRow(leafRowData),
setIsOpen: jest.fn(),
setRow: jest.fn(),
handleDeleteRow: jest.fn(),
...overrides,
});

const renderDeleteModal = (props = defaultProps()) =>
render(
<IntlProvider locale="en" messages={{}}>
<DeleteModal {...(props as any)} />
</IntlProvider>,
);

describe('DeleteModal', () => {
it('renders a singular delete title and "Delete Tag" action label when the selected row has no descendants', () => {
renderDeleteModal();

const dialog = screen.getByRole('dialog');
expect(dialog).toHaveTextContent('Delete "leaf tag"');
expect(dialog).toHaveTextContent('Warning! You are about to delete 1 tag(s).');
expect(dialog).toHaveTextContent('Type DELETE to confirm');
expect(within(dialog).getByRole('button', { name: 'Delete Tag' })).toBeDisabled();
expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});

it('renders a plural delete action label and descendant warning copy when the selected row has one or more descendants', () => {
renderDeleteModal(defaultProps({ row: createRow(nestedRowData) }));

const dialog = screen.getByRole('dialog');
expect(dialog).toHaveTextContent('Delete "parent tag"');
expect(dialog).toHaveTextContent('Warning! You are about to delete a tag containing sub-tags.');
expect(dialog).toHaveTextContent('If you proceed, 3 tags will be deleted.');
expect(dialog).toHaveTextContent('Type DELETE ALL 3 TAGS to confirm');
expect(within(dialog).getByRole('button', { name: 'Delete Tags' })).toBeDisabled();
});

it('computes the required confirmation phrase from the recursive descendant count instead of only the immediate child count', () => {
renderDeleteModal(defaultProps({ row: createRow(nestedRowData) }));

const dialog = screen.getByRole('dialog');
expect(dialog).toHaveTextContent('If you proceed, 3 tags will be deleted.');
expect(dialog).toHaveTextContent('Type DELETE ALL 3 TAGS to confirm');
expect(dialog).not.toHaveTextContent('DELETE ALL 2 TAGS');
});

it('calls handleDeleteRow with the dialog row context and then closes and clears the dialog state on confirm', async () => {
const user = userEvent.setup();
const row = createRow(leafRowData);
const handleDeleteRow = jest.fn();
const setIsOpen = jest.fn();
const setRow = jest.fn();

renderDeleteModal(defaultProps({
row,
handleDeleteRow,
setIsOpen,
setRow,
}));

await user.type(screen.getByRole('textbox'), 'DELETE');
await user.click(screen.getByRole('button', { name: 'Delete Tag' }));

expect(handleDeleteRow).toHaveBeenCalledWith(row);
expect(setIsOpen).toHaveBeenCalledWith(false);
expect(setRow).toHaveBeenCalledWith(null);
});

it('closes and clears the dialog context on cancel without invoking deletion', async () => {
const user = userEvent.setup();
const handleDeleteRow = jest.fn();
const setIsOpen = jest.fn();
const setRow = jest.fn();

renderDeleteModal(defaultProps({
handleDeleteRow,
setIsOpen,
setRow,
}));

await user.click(screen.getByRole('button', { name: 'Cancel' }));

expect(handleDeleteRow).not.toHaveBeenCalled();
expect(setIsOpen).toHaveBeenCalledWith(false);
expect(setRow).toHaveBeenCalledWith(null);
});
});
Loading