diff --git a/src/generic/TypeXToConfirmModal.test.tsx b/src/generic/TypeXToConfirmModal.test.tsx new file mode 100644 index 0000000000..3da664ae6a --- /dev/null +++ b/src/generic/TypeXToConfirmModal.test.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { render, screen } from '@testing-library/react'; + +import TypeXToConfirmModal from './TypeXToConfirmModal'; + +const defaultProps = () => ({ + label: 'Delete item', + bodyText: 'Dangerous action', + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + X: 'DELETE', + confirmPayload: { id: 7 }, + isOpen: true, + onConfirm: jest.fn(), + onCancel: jest.fn(), + setConfirmPayload: jest.fn(), +}); + +const renderModal = (props = defaultProps()) => + render( + + + , + ); + +describe('TypeXToConfirmModal', () => { + it('renders the required confirmation phrase with strong emphasis', () => { + renderModal(); + + expect(screen.getByText('DELETE', { selector: 'strong' })).toBeInTheDocument(); + }); + + it('keeps the destructive confirm button disabled until the typed value exactly matches the required confirmation phrase', async () => { + const user = userEvent.setup(); + renderModal(); + + const input = screen.getByRole('textbox'); + const confirmButton = screen.getByRole('button', { name: 'Delete' }); + + expect(confirmButton).toBeDisabled(); + await user.type(input, 'DEL'); + expect(confirmButton).toBeDisabled(); + await user.type(input, 'ETE'); + expect(confirmButton).toBeEnabled(); + }); + + it('does not enable confirmation for partial, differently cased, or whitespace-padded confirmation text', async () => { + const user = userEvent.setup(); + renderModal(); + + const input = screen.getByRole('textbox'); + const confirmButton = screen.getByRole('button', { name: 'Delete' }); + + for (const value of ['DEL', 'delete', ' DELETE', 'DELETE ', ' Delete ']) { + await user.clear(input); + await user.type(input, value); + expect(confirmButton).toBeDisabled(); + } + }); + + it('requires explicit activation of the enabled destructive confirm button', async () => { + const user = userEvent.setup(); + const props = defaultProps(); + renderModal(props); + + const input = screen.getByRole('textbox'); + const confirmButton = screen.getByRole('button', { name: 'Delete' }); + + 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).not.toHaveBeenCalled(); + + await user.click(confirmButton); + expect(props.onConfirm).toHaveBeenCalledWith(props.confirmPayload); + }); + + 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( + + + , + ); + + rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled(); + }); + + it('clears the provided confirm payload when the modal is closed without confirming', () => { + const props = defaultProps(); + const { rerender } = renderModal(props); + + rerender( + + + , + ); + + expect(props.setConfirmPayload).toHaveBeenCalledWith(null); + expect(props.onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/generic/TypeXToConfirmModal.tsx b/src/generic/TypeXToConfirmModal.tsx new file mode 100644 index 0000000000..a8f2f4ec02 --- /dev/null +++ b/src/generic/TypeXToConfirmModal.tsx @@ -0,0 +1,117 @@ +import React, { useEffect } from 'react'; +import { + ActionRow, + 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; + confirmPayload?: Record | null; + isOpen: boolean; + onConfirm: (confirmPayload?: Record | null) => void; + onCancel: () => void; + setConfirmPayload?: (confirmPayload: Record | null) => void; +} + +const TypeXToConfirmModal: React.FC = ({ + label, + X, + bodyText, + confirmLabel, + cancelLabel, + isOpen, + confirmPayload, + onConfirm, + onCancel, + setConfirmPayload, +}) => { + const [confirmedByTyping, setConfirmedByTyping] = React.useState(false); + const intl = useIntl(); + + const handleConfirm = () => { + if (!confirmedByTyping) { return; } + setConfirmedByTyping(false); + onConfirm(confirmPayload); + }; + + const handleCancel = () => { + setConfirmedByTyping(false); + onCancel(); + }; + + const handleChange = (e: React.ChangeEvent) => { + 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 (setConfirmPayload) { + setConfirmPayload(null); + } + } + }, [X, isOpen, confirmPayload, setConfirmPayload]); + + return ( + + + {label} + + + + +
+ +
{bodyText}
+
+
+
+
+
+ {intl.formatMessage(messages.typeToConfirmInstruction, { + X, + strong: (chunks: React.ReactNode) => {chunks}, + })} +
+ +
+
+ + + + + + +
+ ); +}; + +export default React.memo(TypeXToConfirmModal); diff --git a/src/generic/messages.ts b/src/generic/messages.ts new file mode 100644 index 0000000000..0d35def2a3 --- /dev/null +++ b/src/generic/messages.ts @@ -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; diff --git a/src/taxonomy/tag-list/DeleteModal.test.tsx b/src/taxonomy/tag-list/DeleteModal.test.tsx new file mode 100644 index 0000000000..3d41bb172a --- /dev/null +++ b/src/taxonomy/tag-list/DeleteModal.test.tsx @@ -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( + + + , + ); + +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); + }); +}); diff --git a/src/taxonomy/tag-list/DeleteModal.tsx b/src/taxonomy/tag-list/DeleteModal.tsx new file mode 100644 index 0000000000..a31966186a --- /dev/null +++ b/src/taxonomy/tag-list/DeleteModal.tsx @@ -0,0 +1,83 @@ +import { useMemo } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import type { Row } from '@tanstack/react-table'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import TypeXToConfirmModal from '@src/generic/TypeXToConfirmModal'; +import type { TreeRowData } from '@src/taxonomy/tree-table/types'; +import messages from './messages'; +import { getTagListRowData, getTagWithDescendantsCount } from './utils'; + +interface DeleteModalProps { + isOpen: boolean; + row: Row | null; + setIsOpen: Dispatch>; + setRow: Dispatch | null>>; + handleDeleteRow: (row: Row) => void | Promise; +} + +const DeleteModal = ({ + isOpen, + row, + setIsOpen, + setRow, + handleDeleteRow, +}: DeleteModalProps) => { + const intl = useIntl(); + + const handleConfirm = (row: Row) => { + handleDeleteRow(row); + setIsOpen(false); + setRow(null); + }; + + const handleCancel = () => { + setIsOpen(false); + setRow(null); + }; + + const rowData = row ? getTagListRowData(row) : null; + const count = useMemo(() => (rowData ? getTagWithDescendantsCount(rowData) : 0), [rowData]); + + if (!row) { + return null; + } + + const hasSubtags = count > 1; + const typeToDeleteText = hasSubtags + ? intl.formatMessage(messages.typeToConfirmDeleteTagWithSubtags, { count }) + : intl.formatMessage(messages.typeToConfirmDeleteOneTag); + const messageText = hasSubtags + ? intl.formatMessage(messages.deleteTagWithSubtagsConfirmation, { count }) + : intl.formatMessage(messages.deleteTagConfirmation, { count }); + const parts = messageText.split(String(count)); + const bodyText = ( + <> +
+ {parts[0]} + {count} + {parts[1]} +
+
+ {intl.formatMessage(messages.deleteTagConfirmationEmphasizedPart)} +
+ + ); + + return ( + 1 ? messages.deleteLabelPlural : messages.deleteLabelSingular)} + cancelLabel={intl.formatMessage(messages.cancelLabel)} + isOpen={isOpen} + confirmPayload={row} + setConfirmPayload={setRow} + onConfirm={handleConfirm} + onCancel={handleCancel} + /> + ); +}; + +export default DeleteModal; diff --git a/src/taxonomy/tag-list/messages.ts b/src/taxonomy/tag-list/messages.ts index 69e74221ea..4ba77837d9 100644 --- a/src/taxonomy/tag-list/messages.ts +++ b/src/taxonomy/tag-list/messages.ts @@ -65,6 +65,42 @@ const messages = defineMessages({ id: 'course-authoring.tag-list.rename-tag', defaultMessage: 'Rename', }, + confirmDeleteTitle: { + id: 'course-authoring.tag-list.confirm-delete-title', + defaultMessage: 'Delete "{tagName}"', + }, + typeToConfirmDeleteOneTag: { + id: 'course-authoring.tag-list.delete-one-tag-type-to-confirm', + defaultMessage: 'DELETE', + }, + deleteTagConfirmation: { + id: 'course-authoring.tag-list.delete-tag-confirmation', + defaultMessage: 'Warning! You are about to delete {count} tag(s).', + }, + deleteLabelPlural: { + id: 'course-authoring.tag-list.delete-label', + defaultMessage: 'Delete Tags', + }, + deleteLabelSingular: { + id: 'course-authoring.tag-list.delete-label-singular', + defaultMessage: 'Delete Tag', + }, + cancelLabel: { + id: 'course-authoring.tag-list.cancel-label', + defaultMessage: 'Cancel', + }, + typeToConfirmDeleteTagWithSubtags: { + id: 'course-authoring.tag-list.delete-tag-with-subtags-type-to-confirm', + defaultMessage: 'DELETE ALL {count} TAGS', + }, + deleteTagWithSubtagsConfirmation: { + id: 'course-authoring.tag-list.delete-tag-with-subtags-confirmation', + defaultMessage: 'Warning! You are about to delete a tag containing sub-tags. If you proceed, {count} tags will be deleted.', + }, + deleteTagConfirmationEmphasizedPart: { + id: 'course-authoring.tag-list.delete-tag-confirmation-bold-part', + defaultMessage: 'Any tags applied to course content will be removed across all assigned organizations.', + }, }); export default messages; diff --git a/src/taxonomy/tag-list/utils.test.ts b/src/taxonomy/tag-list/utils.test.ts new file mode 100644 index 0000000000..cf94b40cc0 --- /dev/null +++ b/src/taxonomy/tag-list/utils.test.ts @@ -0,0 +1,35 @@ +import { getTagWithDescendantsCount } from './utils'; + +describe('getTagsWithDescendantCount', () => { + it('returns 1 for a leaf tag', () => { + expect(getTagWithDescendantsCount({ value: 'leaf', subRows: [] } as any)).toBe(1); + }); + + it('counts all descendants across multiple nested levels', () => { + const rowData = { + value: 'root', + subRows: [ + { + value: 'child 1', + subRows: [ + { value: 'grandchild 1', subRows: [] }, + { value: 'grandchild 2', subRows: [] }, + ], + }, + { + value: 'child 2', + subRows: [ + { + value: 'grandchild 3', + subRows: [{ value: 'great grandchild 1', subRows: [] }], + }, + ], + }, + ], + } as any; + + // The total includes the root tag itself: + // root + child 1 + 2 grandchildren + child 2 + grandchild 3 + great grandchild 1 = 7. + expect(getTagWithDescendantsCount(rowData)).toBe(7); + }); +}); diff --git a/src/taxonomy/tag-list/utils.ts b/src/taxonomy/tag-list/utils.ts index 012ca2b824..a5ea28df3a 100644 --- a/src/taxonomy/tag-list/utils.ts +++ b/src/taxonomy/tag-list/utils.ts @@ -1,6 +1,6 @@ -import { Row } from '@openedx/paragon'; -import { TreeRowData } from '@src/taxonomy/tree-table/types'; -import { TagListRowData } from './types'; +import type { Row } from '@tanstack/react-table'; +import type { TreeRowData } from '@src/taxonomy/tree-table/types'; +import type { TagListRowData } from './types'; /** getTagListRowData * @@ -10,3 +10,16 @@ import { TagListRowData } from './types'; export const getTagListRowData = (row: Row): TagListRowData => ( row.original as unknown as TagListRowData ); + +/** + * Counts this tag and every nested descendant below it. + * + * A leaf tag counts as 1. For parent tags, start with 1 for the tag itself, + * then recursively add the same count for each child in `subRows`. + */ +export const getTagWithDescendantsCount = (rowData: TreeRowData): number => { + if (!rowData.subRows || rowData.subRows.length === 0) { + return 1; + } + return rowData.subRows.reduce((count, subRow) => count + getTagWithDescendantsCount(subRow), 1); +};