diff --git a/src/generic/TypeXToConfirmModal.test.tsx b/src/generic/TypeXToConfirmModal.test.tsx new file mode 100644 index 0000000000..1d90d5d588 --- /dev/null +++ b/src/generic/TypeXToConfirmModal.test.tsx @@ -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'; + +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( + + + , + ); + +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( + + + , + ); + + rerender( + + + , + ); + + 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( + + + , + ); + + expect(props.setContext).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..f691df13a7 --- /dev/null +++ b/src/generic/TypeXToConfirmModal.tsx @@ -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 | null; + isOpen: boolean; + onConfirm: (context?: Record | null) => void; + onCancel: () => void; + setContext?: (context: Record | null) => void; +} + +const TypeXToConfirmModal: React.FC = ({ + label, + X, + bodyText, + confirmLabel, + cancelLabel, + isOpen, + context, + onConfirm, + onCancel, + setContext, +}) => { + const [confirmedByTyping, setConfirmedByTyping] = React.useState(false); + const intl = useIntl(); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!confirmedByTyping) { return; } + if (e.key === 'Enter') { + onConfirm(context); + } + }; + + const handleConfirm = () => { + if (!confirmedByTyping) { return; } + setConfirmedByTyping(false); + onConfirm(context); + }; + + 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 (setContext) { + // reset onConfirm callback context when modal is closed + setContext(null); + } + } + }, [X, isOpen, context, setContext]); + + return ( + + + {label} + + + + +
+ +
{bodyText}
+
+
+
+
+
+ {(() => { + const messageText = intl.formatMessage(messages.typeToConfirmInstruction, { X }); + const parts = messageText.split(X); + return ( + <> + {parts[0]} + {X} + {parts[1]} + + ); + })()} +
+ +
+ + + + +
+
+ ); +}; + +export default React.memo(TypeXToConfirmModal); diff --git a/src/generic/messages.ts b/src/generic/messages.ts new file mode 100644 index 0000000000..05b8ed160d --- /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/data/api.ts b/src/taxonomy/data/api.ts index f2de425ed5..4d552f9da3 100644 --- a/src/taxonomy/data/api.ts +++ b/src/taxonomy/data/api.ts @@ -99,6 +99,7 @@ export const apiUrls = { tagsPlanImport: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/import/plan/`), createTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`), updateTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`), + deleteTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`), } satisfies Record string>; /** diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index b47ee23f1b..fd40a10097 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -269,3 +269,23 @@ export const useUpdateTag = (taxonomyId: number) => { }, }); }; + +export const useDeleteTag = (taxonomyId: number) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ value, withSubtags }: { value: string; withSubtags: boolean; }) => { + const body = { tags: [value], with_subtags: withSubtags }; + await getAuthenticatedHttpClient().delete(apiUrls.deleteTag(taxonomyId), { + data: body, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), + }); + // In the metadata, 'tagsCount' (and possibly other fields) will have changed: + queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) }); + }, + }); +}; diff --git a/src/taxonomy/tag-list/Actions.tsx b/src/taxonomy/tag-list/Actions.tsx new file mode 100644 index 0000000000..0740ed39ef --- /dev/null +++ b/src/taxonomy/tag-list/Actions.tsx @@ -0,0 +1,138 @@ +import { + Icon, + IconButton, + IconButtonWithTooltip, + Dropdown, +} from '@openedx/paragon'; +import { + AddCircle, + MoreVert, +} from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import type { Row } from '@tanstack/react-table'; + +import type { + RowId, + TreeRowData, +} from '@src/taxonomy/tree-table/types'; +import type { TagListRowData } from './types'; +import messages from './messages'; + +interface ActionsHeaderProps { + onStartDraft: () => void; + setDraftError: (error: string) => void; + setIsCreatingTopRow: (isCreating: boolean) => void; + setEditingRowId: (id: RowId | null) => void; + setActiveActionMenuRowId: (id: RowId | null) => void; + hasOpenDraft: boolean; + draftInProgressHintId: string; + canAddTag: boolean; +} + +const ActionsHeader = ({ + onStartDraft, + setDraftError, + setIsCreatingTopRow, + setEditingRowId, + setActiveActionMenuRowId, + hasOpenDraft, + canAddTag, + draftInProgressHintId, +}: ActionsHeaderProps) => { + const intl = useIntl(); + return ( +
+ {intl.formatMessage(messages.createNewTagTooltip)}
} + src={AddCircle} + alt={intl.formatMessage(messages.createTagButtonLabel)} + size="inline" + onClick={() => { + onStartDraft(); + setDraftError(''); + setIsCreatingTopRow(true); + setEditingRowId(null); + setActiveActionMenuRowId(null); + }} + disabled={hasOpenDraft || !canAddTag} + aria-describedby={hasOpenDraft ? draftInProgressHintId : undefined} + /> + + ); +}; + +interface ActionsMenuProps { + rowData: TagListRowData; + startSubtagDraft: () => void; + disableAddSubtag: boolean; + startEditRow: () => void; + disableEditRow: boolean; + reachedMaxDepth: (row: Row) => boolean; + startDeleteRow: (row: Row) => void; + disableDeleteRow: boolean; + row: Row; +} + +const ActionsMenu = ({ + rowData, + row, + startSubtagDraft, + disableAddSubtag, + startEditRow, + disableEditRow, + reachedMaxDepth, + startDeleteRow, + disableDeleteRow, +}: ActionsMenuProps) => { + const intl = useIntl(); + + const deleteRowMenuItem = ( + startDeleteRow(row)} + disabled={disableDeleteRow} + > + {intl.formatMessage(messages.deleteTag)} + + ); + + const editRowMenuItem = ( + + {intl.formatMessage(messages.renameTag)} + + ); + + return ( + + + + + {intl.formatMessage(messages.addSubtag)} + + {editRowMenuItem} + {deleteRowMenuItem} + + + ); +}; + +const Actions = { + Header: ActionsHeader, + Menu: ActionsMenu, +}; + +export default Actions; diff --git a/src/taxonomy/tag-list/DeleteModal.test.tsx b/src/taxonomy/tag-list/DeleteModal.test.tsx new file mode 100644 index 0000000000..e1293216eb --- /dev/null +++ b/src/taxonomy/tag-list/DeleteModal.test.tsx @@ -0,0 +1,163 @@ +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 { TreeTableContext } from '@src/taxonomy/tree-table'; +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 baseContextValue = (overrides = {}) => ({ + treeData: [], + columns: [], + pageCount: -1, + pagination: { pageIndex: 0, pageSize: 10 }, + handlePaginationChange: jest.fn(), + isLoading: false, + isCreatingTopRow: false, + draftError: '', + createRowMutation: {}, + updateRowMutation: {}, + deleteRowMutation: {}, + toast: { show: false, message: '', variant: 'success' }, + setToast: jest.fn(), + setIsCreatingTopRow: jest.fn(), + exitDraftWithoutSave: jest.fn(), + handleCreateRow: jest.fn(), + creatingParentId: null, + setCreatingParentId: jest.fn(), + setDraftError: jest.fn(), + validate: jest.fn(() => true), + handleUpdateRow: jest.fn(), + editingRowId: null, + setEditingRowId: jest.fn(), + confirmDeleteDialogOpen: true, + setConfirmDeleteDialogOpen: jest.fn(), + confirmDeleteDialogContext: createRow(leafRowData), + setConfirmDeleteDialogContext: jest.fn(), + handleDeleteRow: jest.fn(), + startEditRow: jest.fn(), + startDeleteRow: jest.fn(), + table: null, + ...overrides, +}); + +const renderDeleteModal = (contextValue = baseContextValue()) => + 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(baseContextValue({ confirmDeleteDialogContext: 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(baseContextValue({ confirmDeleteDialogContext: 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 setConfirmDeleteDialogOpen = jest.fn(); + const setConfirmDeleteDialogContext = jest.fn(); + + renderDeleteModal(baseContextValue({ + confirmDeleteDialogContext: row, + handleDeleteRow, + setConfirmDeleteDialogOpen, + setConfirmDeleteDialogContext, + })); + + await user.type(screen.getByRole('textbox'), 'DELETE'); + await user.click(screen.getByRole('button', { name: 'Delete Tag' })); + + expect(handleDeleteRow).toHaveBeenCalledWith(row); + expect(setConfirmDeleteDialogOpen).toHaveBeenCalledWith(false); + expect(setConfirmDeleteDialogContext).toHaveBeenCalledWith(null); + }); + + it('closes and clears the dialog context on cancel without invoking deletion', async () => { + const user = userEvent.setup(); + const handleDeleteRow = jest.fn(); + const setConfirmDeleteDialogOpen = jest.fn(); + const setConfirmDeleteDialogContext = jest.fn(); + + renderDeleteModal(baseContextValue({ + handleDeleteRow, + setConfirmDeleteDialogOpen, + setConfirmDeleteDialogContext, + })); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(handleDeleteRow).not.toHaveBeenCalled(); + expect(setConfirmDeleteDialogOpen).toHaveBeenCalledWith(false); + expect(setConfirmDeleteDialogContext).toHaveBeenCalledWith(null); + }); +}); diff --git a/src/taxonomy/tag-list/DeleteModal.tsx b/src/taxonomy/tag-list/DeleteModal.tsx new file mode 100644 index 0000000000..c0d59b2833 --- /dev/null +++ b/src/taxonomy/tag-list/DeleteModal.tsx @@ -0,0 +1,73 @@ +import { useContext, useMemo } from 'react'; +import { Row } from '@tanstack/react-table'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import TypeXToConfirmModal from '@src/generic/TypeXToConfirmModal'; +import { TreeTableContext } from '@src/taxonomy/tree-table'; +import { TreeRowData } from '@src/taxonomy/tree-table/types'; +import messages from './messages'; +import { getTagListRowData, getTagWithDescendantsCount } from './utils'; + +const DeleteModal = () => { + const { + confirmDeleteDialogOpen, + setConfirmDeleteDialogOpen, + confirmDeleteDialogContext, + setConfirmDeleteDialogContext, + handleDeleteRow, + } = useContext(TreeTableContext); + const intl = useIntl(); + + const handleConfirm = (row: Row) => { + handleDeleteRow(row); + setConfirmDeleteDialogOpen(false); + setConfirmDeleteDialogContext(null); + }; + + const handleCancel = () => { + setConfirmDeleteDialogOpen(false); + setConfirmDeleteDialogContext(null); + }; + + const rowData = confirmDeleteDialogContext ? getTagListRowData(confirmDeleteDialogContext) : null; + const count = useMemo(() => rowData ? getTagWithDescendantsCount(rowData) : 0, [confirmDeleteDialogContext]); + + const hasSubtags = count > 1; + // const bodyText = hasSubtags ? intl.formatMessage(messages.deleteTagWithSubtagsConfirmation, { count }) : intl.formatMessage(messages.deleteTagConfirmation, { count }); + 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={confirmDeleteDialogOpen} + context={confirmDeleteDialogContext} + setContext={setConfirmDeleteDialogContext} + onConfirm={handleConfirm} + onCancel={handleCancel} + /> + ); +}; + +export default DeleteModal; diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index d9022c84fb..717dc8537f 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { AxiosError } from 'axios'; +import userEvent from '@testing-library/user-event'; import { render, waitFor, @@ -86,21 +87,12 @@ const mockTagsResponse = { const mockTagResponseDisallowingEdits = { ...mockTagsResponse, - results: mockTagsResponse.results.map(tag => ({ + results: mockTagsResponse.results.map((tag) => ({ ...tag, can_change_tag: false, can_delete_tag: false, })), }; -const mockTagsPaginationResponse = { - next: null, - previous: null, - count: 103, - num_pages: 2, - current_page: 1, - start: 0, - results: [], -}; const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000&include_counts=true'; const subTagsResponse = { @@ -124,6 +116,7 @@ const subTagsResponse = { const subTagsUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000&parent_tag=root+tag+1'; const createTagUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/'; +const deleteTagUrl = createTagUrl; const renderTagListTable = (maxDepth = 3) => render(); @@ -198,6 +191,46 @@ const openRenameDraftRow = async (tagName = 'root tag 1') => { }; }; +const buildTagsResponse = (results) => ({ + ...mockTagsResponse, + count: results.filter((tag) => tag.depth === 0).length, + results, +}); + +const openDeleteDialogForTag = async ({ + tagName, + actionButtonName = /actions/i, +} = {}) => { + openActionsMenuForTag(tagName, actionButtonName); + fireEvent.click(screen.getByRole('button', { name: /^Delete$/i })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(`Delete "${tagName}"`)).toBeInTheDocument(); + const input = within(dialog).getByRole('textbox'); + const cancelButton = within(dialog).getByRole('button', { name: 'Cancel' }); + const deleteButton = within(dialog).getByRole('button', { + name: /Delete Tag|Delete Tags/i, + }); + return { + dialog, + input, + cancelButton, + deleteButton, + }; +}; + +const expectDeleteRequest = async ({ tagName, withSubtags }) => { + await waitFor(() => { + expect(axiosMock.history.delete.length).toBe(1); + expect(axiosMock.history.delete[0].url).toBe(deleteTagUrl); + expect(axiosMock.history.delete[0].data).toEqual( + JSON.stringify({ + tags: [tagName], + with_subtags: withSubtags, + }), + ); + }); +}; + describe('', () => { beforeEach(async () => { ({ axiosMock } = initializeMocks({ user: adminUser })); @@ -281,24 +314,6 @@ describe('', () => { }); }); - // temporarily skipped because pagination is not implemented yet - it.skip('should render pagination footer', async () => { - axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsPaginationResponse); - renderTagListTable(); - const tableFooter = await screen.findAllByRole('navigation', { - name: /table pagination/i, - }); - expect(tableFooter[0]).toBeInTheDocument(); - }); - - // temporarily skipped because pagination is not implemented yet - it.skip('should render correct number of items in pagination footer', async () => { - axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsPaginationResponse); - renderTagListTable(); - const paginationButtons = await screen.findByText('Page 1 of 2'); - expect(paginationButtons).toBeInTheDocument(); - }); - describe('Create a new top-level tag', () => { it('should disable tag creation buttons if the taxonomy includes `can_add_tag: false`', async () => { axiosMock.onGet(rootTagsListUrl).reply(200, { @@ -460,38 +475,6 @@ describe('', () => { expect(temporaryRow).toBeInTheDocument(); }); - // temporarily skipped because pagination is not implemented yet - it.skip('should refresh the table and remove the temporary row when a pagination button is clicked', async () => { - axiosMock.onPost(createTagUrl).reply(201, { - ...tagDefaults, - value: 'xyz tag', - child_count: 0, - _id: 1234, - }); - const { creatingRow, input } = await openTopLevelDraftRow(); - - fireEvent.change(input, { target: { value: 'xyz tag' } }); - const saveButton = within(creatingRow).getByRole('button', { name: 'Save' }); - fireEvent.click(saveButton); - const temporaryRow = await screen.findByText('xyz tag'); - // temporaryRow should be at the top of the table, that is, the first row after the header - const rows = screen.getAllByRole('row'); - expect(rows[1]).toContainElement(temporaryRow); - - // Simulate clicking a pagination button - const paginationButton = await screen.findByRole('button', { name: 'Go to page 2' }); - fireEvent.click(paginationButton); - - await waitFor(() => { - // A get request should have refreshed the table data - expect(axiosMock.history.get.length).toBeGreaterThan(1); - const xyzTagRow = screen.queryByText('xyz tag'); - expect(xyzTagRow).toBeInTheDocument(); - // expect the row to not be the first row after the header - expect(rows[1]).not.toContainElement(xyzTagRow); - }); - }); - // a bit flaky when ran together with other tests - any way to improve this? it('should allow adding multiple tags consecutively without a page refresh', async () => { // clear axios mock history @@ -991,6 +974,324 @@ describe('', () => { expect(within(grandchildTagRow).getByText('Add Subtag')).toHaveAttribute('aria-disabled', 'true'); }); }); + + describe('Delete Tags', () => { + const tagDepthScenarios = [ + { + description: 'Delete a top-level tag', + tagName: 'root tag 1', + }, + { description: 'Delete a sub-tag', tagName: 'the child tag' }, + { description: 'Delete a grandchild tag', tagName: 'the grandchild tag' }, + ]; + + tagDepthScenarios.forEach(({ description, tagName }) => { + describe(description, () => { + beforeEach(async () => { + axiosMock.resetHistory(); + }); + + it('should disable delete action if tag includes `can_delete: false`', async () => { + axiosMock.reset(); + axiosMock + .onGet(rootTagsListUrl) + .reply(200, mockTagResponseDisallowingEdits); + axiosMock.onGet(subTagsUrl).reply(200, subTagsResponse); + cleanup(); + ({ axiosMock } = initializeMocks({ user: adminUser })); + axiosMock + .onGet(rootTagsListUrl) + .reply(200, mockTagResponseDisallowingEdits); + axiosMock.onGet(subTagsUrl).reply(200, subTagsResponse); + renderTagListTable(); + await waitForRootTag(); + + openActionsMenuForTag(tagName); + const deleteButton = screen.getByRole('button', { name: /Delete/i }); + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); + }); + }); + }); + + it('opens the delete confirmation dialog for a leaf tag from the actions menu and requires typing "DELETE" before deletion can proceed', async () => { + fireEvent.click(screen.getAllByText('Expand All')[0]); + await screen.findByText('the grandchild tag'); + + const { dialog, input, cancelButton, deleteButton } = await openDeleteDialogForTag({ + tagName: 'the grandchild tag', + actionButtonName: /more actions for tag "the grandchild tag"/i, + }); + + expect(dialog).toHaveTextContent( + 'Warning! You are about to delete 1 tag(s).', + ); + expect(dialog).toHaveTextContent( + 'Any tags applied to course content will be removed across all assigned organizations.', + ); + expect(dialog).toHaveTextContent('Type DELETE to confirm'); + expect(cancelButton).toBeInTheDocument(); + expect(deleteButton).toHaveTextContent('Delete Tag'); + expect(deleteButton).toBeDisabled(); + + fireEvent.change(input, { target: { value: 'DELETE ALL 2 TAGS' } }); + expect(deleteButton).toBeDisabled(); + + fireEvent.change(input, { target: { value: 'DELETE' } }); + expect(deleteButton).toBeEnabled(); + }); + + it('opens the stronger delete confirmation dialog for a parent tag and requires the exact descendant-aware confirmation phrase before enabling delete', async () => { + fireEvent.click(screen.getAllByText('Expand All')[0]); + await screen.findByText('the child tag'); + + const { dialog, input, deleteButton } = await openDeleteDialogForTag({ + tagName: 'the child tag', + actionButtonName: /more actions for tag "the child tag"/i, + }); + + expect(dialog).toHaveTextContent( + 'Warning! You are about to delete a tag containing sub-tags. If you proceed, 2 tags will be deleted.', + ); + expect(dialog).toHaveTextContent('Type DELETE ALL 2 TAGS to confirm'); + expect(deleteButton).toHaveTextContent('Delete Tags'); + expect(deleteButton).toBeDisabled(); + + fireEvent.change(input, { target: { value: 'DELETE' } }); + expect(deleteButton).toBeDisabled(); + + fireEvent.change(input, { target: { value: 'DELETE ALL 2 TAGS' } }); + expect(deleteButton).toBeEnabled(); + }); + + it('shows the descendant deletion warning and the total recursive tag count when deleting a tag with nested subtags', async () => { + const { dialog, deleteButton } = await openDeleteDialogForTag({ + tagName: 'root tag 1', + }); + + expect(dialog).toHaveTextContent( + 'Warning! You are about to delete a tag containing sub-tags. If you proceed, 3 tags will be deleted.', + ); + expect(dialog).toHaveTextContent('Type DELETE ALL 3 TAGS to confirm'); + expect(deleteButton).toHaveTextContent('Delete Tags'); + }); + + it('deletes a leaf tag after typed confirmation and shows the success toast describing that tagged content will be updated', async () => { + axiosMock.reset(); + axiosMock.onDelete(deleteTagUrl).reply(204); + axiosMock + .onGet(rootTagsListUrl) + .reply( + 200, + buildTagsResponse( + mockTagsResponse.results.filter( + (tag) => tag.value !== 'root tag 2', + ), + ), + ); + + expect(screen.queryByText('root tag 2')).toBeInTheDocument(); + + const { input, deleteButton } = await openDeleteDialogForTag({ + tagName: 'root tag 2', + }); + fireEvent.change(input, { target: { value: 'DELETE' } }); + fireEvent.click(deleteButton); + + await expectDeleteRequest({ tagName: 'root tag 2', withSubtags: false }); + expect( + await screen.findByText( + '1 tag(s) deleted. This change will be applied across all tagged content.', + ), + ).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText('root tag 2')).not.toBeInTheDocument(); + }); + }); + + it('deletes a parent tag together with all rendered descendants after typed confirmation and shows the success toast with the total deleted count', async () => { + fireEvent.click(screen.getAllByText('Expand All')[0]); + await screen.findByText('the child tag'); + await screen.findByText('the grandchild tag'); + + axiosMock.reset(); + axiosMock.onDelete(deleteTagUrl).reply(204); + axiosMock + .onGet(rootTagsListUrl) + .reply( + 200, + buildTagsResponse( + mockTagsResponse.results.filter( + (tag) => + !['root tag 1', 'the child tag', 'the grandchild tag'].includes( + tag.value, + ), + ), + ), + ); + + const { input, deleteButton } = await openDeleteDialogForTag({ + tagName: 'root tag 1', + }); + fireEvent.change(input, { target: { value: 'DELETE ALL 3 TAGS' } }); + fireEvent.click(deleteButton); + + await expectDeleteRequest({ tagName: 'root tag 1', withSubtags: true }); + expect( + await screen.findByText( + '3 tag(s) deleted. This change will be applied across all tagged content.', + ), + ).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText('root tag 1')).not.toBeInTheDocument(); + expect(screen.queryByText('the child tag')).not.toBeInTheDocument(); + expect( + screen.queryByText('the grandchild tag'), + ).not.toBeInTheDocument(); + expect(screen.getByText('root tag 2')).toBeInTheDocument(); + }); + }); + + it('does not issue a delete request when the dialog is canceled and leaves the table unchanged', async () => { + const { cancelButton } = await openDeleteDialogForTag({ + tagName: 'root tag 1', + }); + fireEvent.click(cancelButton); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + expect(axiosMock.history.delete.length).toBe(0); + expect(screen.getByText('root tag 1')).toBeInTheDocument(); + }); + + it('does not keep a previous typed confirmation when the delete dialog is closed and reopened for the same tag', async () => { + const firstOpen = await openDeleteDialogForTag({ tagName: 'root tag 1' }); + fireEvent.change(firstOpen.input, { + target: { value: 'DELETE ALL 3 TAGS' }, + }); + expect(firstOpen.deleteButton).toBeEnabled(); + fireEvent.click(firstOpen.cancelButton); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + const secondOpen = await openDeleteDialogForTag({ + tagName: 'root tag 1', + }); + expect(secondOpen.input.value).toEqual(''); + expect(secondOpen.deleteButton).toBeDisabled(); + }); + + it('completes the delete workflow with keyboard only by opening the menu, selecting Delete, typing the confirmation phrase, and pressing Enter', async () => { + const user = userEvent.setup(); + axiosMock.reset(); + axiosMock.onDelete(deleteTagUrl).reply(204); + axiosMock + .onGet(rootTagsListUrl) + .reply( + 200, + buildTagsResponse( + mockTagsResponse.results.filter( + (tag) => tag.value !== 'root tag 2', + ), + ), + ); + + const row = screen.getByText('root tag 2').closest('tr'); + const actionsButton = within(row).getByRole('button', { + name: /more actions for tag "root tag 2"/i, + }); + actionsButton.focus(); + expect(actionsButton).toHaveFocus(); + + await user.keyboard('{Enter}'); + + const deleteMenuItem = await screen.findByRole('button', { + name: /^Delete$/i, + }); + deleteMenuItem.focus(); + expect(deleteMenuItem).toHaveFocus(); + await user.keyboard('{Enter}'); + + const dialog = await screen.findByRole('dialog'); + const input = within(dialog).getByRole('textbox'); + await user.type(input, 'DELETE'); + await user.keyboard('{Enter}'); + + await expectDeleteRequest({ tagName: 'root tag 2', withSubtags: false }); + expect( + await screen.findByText( + '1 tag(s) deleted. This change will be applied across all tagged content.', + ), + ).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText('root tag 2')).not.toBeInTheDocument(); + }); + }); + + it('cancels the delete workflow with keyboard only by pressing Escape and does not send a delete request', async () => { + const user = userEvent.setup(); + const row = screen.getByText('root tag 1').closest('tr'); + const actionsButton = within(row).getByRole('button', { + name: /more actions for tag "root tag 1"/i, + }); + actionsButton.focus(); + expect(actionsButton).toHaveFocus(); + + await user.keyboard('{Enter}'); + + const deleteMenuItem = await screen.findByRole('button', { + name: /^Delete$/i, + }); + deleteMenuItem.focus(); + expect(deleteMenuItem).toHaveFocus(); + await user.keyboard('{Enter}'); + + const dialog = await screen.findByRole('dialog'); + expect( + within(dialog).getByText('Delete "root tag 1"'), + ).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + expect(axiosMock.history.delete.length).toBe(0); + expect(screen.getByText('root tag 1')).toBeInTheDocument(); + }); + + it('surfaces a failed delete through both the persistent error alert and the delete-error toast while leaving the row visible', async () => { + axiosMock.reset(); + axiosMock.onDelete(deleteTagUrl).reply(() => { + const error = new AxiosError('Request failed with status code 500'); + error.response = { + data: { + value: ['Delete failed'], + }, + }; + return Promise.reject(error); + }); + + const { input, deleteButton } = await openDeleteDialogForTag({ + tagName: 'root tag 1', + }); + fireEvent.change(input, { target: { value: 'DELETE ALL 3 TAGS' } }); + fireEvent.click(deleteButton); + + await expectDeleteRequest({ tagName: 'root tag 1', withSubtags: true }); + expect( + await screen.findByText('Error saving changes'), + ).toBeInTheDocument(); + expect( + await screen.findByText('Error deleting tag: Delete failed'), + ).toBeInTheDocument(); + expect(screen.getByText('root tag 1')).toBeInTheDocument(); + }); + }); }); // These async creation flows are intentionally isolated because they pass individually @@ -1210,8 +1511,8 @@ describe(' pagination transition behavior', () => { handleUpdateTag: jest.fn(), validate: jest.fn(() => true), }); - jest.spyOn(treeTableModule, 'TableView').mockImplementation((props) => { - tableViewProps = props; + jest.spyOn(treeTableModule, 'TableView').mockImplementation(() => { + tableViewProps = React.useContext(treeTableModule.TreeTableContext); return
; }); }); diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index c2d00319d8..e388448d56 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -3,20 +3,20 @@ import React, { useMemo, useEffect, } from 'react'; -import type { PaginationState } from '@tanstack/react-table'; -import { TableView } from '@src/taxonomy/tree-table'; -import { useTagListData, useCreateTag, useUpdateTag } from '@src/taxonomy/data/apiHooks'; -import { TagTree } from './tagTree'; +import type { PaginationState, Row } from '@tanstack/react-table'; +import { useTagListData, useCreateTag, useUpdateTag, useDeleteTag } from '@src/taxonomy/data/apiHooks'; +import { TableView, TreeTableContext } from '@src/taxonomy/tree-table'; import type { RowId, - TreeColumnDef, TreeRowData, } from '../tree-table/types'; +import { TagTree } from './tagTree'; +import { getColumns } from './tagColumns'; import { TABLE_MODES, } from './constants'; -import { getColumns } from './tagColumns'; import { useTableModes, useEditActions } from './hooks'; +import DeleteModal from './DeleteModal'; interface TagListTableProps { taxonomyId: number; @@ -47,10 +47,12 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { const [toast, setToast] = useState({ show: false, message: '', variant: 'success' }); const [tagTree, setTagTree] = useState(null); const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); - const [activeActionMenuRowId, setActiveActionMenuRowId] = useState(null); + const [, setActiveActionMenuRowId] = useState(null); const [draftError, setDraftError] = useState(''); const treeData = (tagTree?.getAllAsDeepCopy() || []) as unknown as TreeRowData[]; const hasOpenDraft = isCreatingTopTag || creatingParentId !== null || editingRowId !== null; + const [confirmDeleteDialogOpen, setConfirmDeleteDialogOpen] = useState(false); + const [confirmDeleteDialogContext, setConfirmDeleteDialogContext] = useState | null>(null); // TABLE MODES const { @@ -83,59 +85,31 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { }); const createTagMutation = useCreateTag(taxonomyId); const updateTagMutation = useUpdateTag(taxonomyId); + const deleteTagMutation = useDeleteTag(taxonomyId); const pageCount = tagList?.numPages ?? -1; - - // TODO: to make this more readable, introduce a React context for the TagListTable instead of passing props. + const canAddTag = tagList?.canAddTag !== false; // Custom Edit Actions Hook - handles table mode transitions, API calls, // and updating the table without a full data reload when creating or editing tags. - const { handleCreateTag, handleUpdateTag, validate } = useEditActions({ - setTagTree, - setDraftError, - createTagMutation, - updateTagMutation, - enterPreviewMode, - setToast, - setIsCreatingTopTag, - setCreatingParentId, - exitDraftWithoutSave, - setEditingRowId, - }); - - const columns = useMemo( - () => - getColumns({ - setIsCreatingTopTag, - setCreatingParentId, - handleUpdateTag, - setEditingRowId, - onStartDraft: enterDraftMode, - setActiveActionMenuRowId, - hasOpenDraft, - canAddTag: tagList?.canAddTag !== false, - draftError, - setDraftError, - isSavingDraft: createTagMutation.isPending, - maxDepth, - }), - [ - isCreatingTopTag, - tableMode, - activeActionMenuRowId, - hasOpenDraft, - creatingParentId, - tagList?.canAddTag, - draftError, - createTagMutation.isPending, - maxDepth, + const editActions = useEditActions( + { + enterDraftMode, + enterPreviewMode, + enterViewMode, + setTagTree, + setDraftError, + createTagMutation, + updateTagMutation, + setToast, setIsCreatingTopTag, setCreatingParentId, - handleUpdateTag, + exitDraftWithoutSave, setEditingRowId, - enterDraftMode, setActiveActionMenuRowId, - setDraftError, - ], + setConfirmDeleteDialogOpen, + setConfirmDeleteDialogContext, + deleteTagMutation, + }, ); // RELOAD DATA IN VIEW MODE @@ -150,33 +124,49 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { } }, [tagList?.results, tableMode]); + // TreeTable context + const contextValueArgs = { + ...editActions, + treeData, + pageCount, + pagination, + handlePaginationChange, + isLoading, + isCreatingTopRow: isCreatingTopTag, + draftError, + createRowMutation: createTagMutation, + updateRowMutation: updateTagMutation, + deleteRowMutation: deleteTagMutation, + toast, + setToast, + setIsCreatingTopRow: setIsCreatingTopTag, + exitDraftWithoutSave, + creatingParentId, + setCreatingParentId, + setDraftError, + editingRowId, + setEditingRowId, + onStartDraft: enterDraftMode, + setActiveActionMenuRowId, + hasOpenDraft, + canAddTag, + maxDepth, + confirmDeleteDialogOpen, + setConfirmDeleteDialogOpen, + confirmDeleteDialogContext, + setConfirmDeleteDialogContext, + }; + const contextValue = { + ...contextValueArgs, + columns: getColumns(contextValueArgs), + table: null, + }; + return ( - + + + + ); }; diff --git a/src/taxonomy/tag-list/hooks.test.tsx b/src/taxonomy/tag-list/hooks.test.tsx index 89fb70c0c6..d3ea744ed4 100644 --- a/src/taxonomy/tag-list/hooks.test.tsx +++ b/src/taxonomy/tag-list/hooks.test.tsx @@ -122,7 +122,7 @@ describe('useEditActions', () => { } = buildActions(); await act(async () => { - await actions.handleUpdateTag(' same value ', 'same value'); + await actions.handleUpdateRow(' same value ', 'same value'); }); expect(enterPreviewMode).not.toHaveBeenCalled(); @@ -139,7 +139,7 @@ describe('useEditActions', () => { } = buildActions(); await act(async () => { - await actions.handleUpdateTag('updated', 'original'); + await actions.handleUpdateRow('updated', 'original'); }); await waitFor(() => { @@ -152,17 +152,12 @@ describe('useEditActions', () => { expect(setEditingRowId).toHaveBeenCalledWith(null); }); - it('keeps draft open and shows failure toast when createTag request fails', async () => { - const { - actions, - createTagMutation, - setDraftError, - setToast, - } = buildActions(); + it('keeps draft open and shows failure toast when createRow request fails', async () => { + const { actions, createTagMutation, setDraftError, setToast } = buildActions(); createTagMutation.mutateAsync.mockRejectedValue(new Error('server failed')); await act(async () => { - await actions.handleCreateTag('new tag'); + await actions.handleCreateRow('new tag'); }); expect(setDraftError).toHaveBeenCalledWith('server failed'); diff --git a/src/taxonomy/tag-list/hooks.ts b/src/taxonomy/tag-list/hooks.ts index a1518b9de9..9ff5540e16 100644 --- a/src/taxonomy/tag-list/hooks.ts +++ b/src/taxonomy/tag-list/hooks.ts @@ -3,8 +3,8 @@ import { AxiosError } from 'axios'; import { useIntl } from '@edx/frontend-platform/i18n'; import globalMessages from '@src/messages'; -import { useCreateTag, useUpdateTag } from '@src/taxonomy/data/apiHooks'; -import type { RowId } from '@src/taxonomy/tree-table/types'; +import { useCreateTag, useDeleteTag, useUpdateTag } from '@src/taxonomy/data/apiHooks'; +import type { RowId, TreeRowData } from '@src/taxonomy/tree-table/types'; import { TagTree } from './tagTree'; import { TagListTableError } from './errors'; import { @@ -15,6 +15,8 @@ import { } from './constants'; import messages from './messages'; +import { getTagListRowData, getTagWithDescendantsCount } from './utils'; +import { Row } from '@tanstack/react-table'; /** Interface for table mode actions for React's `useReducer` hook. * @@ -41,13 +43,19 @@ interface UseEditActionsParams { setTagTree: React.Dispatch>; setDraftError: React.Dispatch>; createTagMutation: ReturnType; + enterDraftMode: () => void; enterPreviewMode: () => void; + enterViewMode: () => void; setToast: React.Dispatch>; setIsCreatingTopTag: React.Dispatch>; setCreatingParentId: React.Dispatch>; exitDraftWithoutSave: () => void; setEditingRowId: React.Dispatch>; updateTagMutation: ReturnType; + setActiveActionMenuRowId: React.Dispatch>; + setConfirmDeleteDialogOpen: React.Dispatch>; + setConfirmDeleteDialogContext: React.Dispatch | null>>; + deleteTagMutation: ReturnType; } const getInlineValidationMessage = (value: string, intl: ReturnType): string => { @@ -111,16 +119,21 @@ const useTableModes = (): UseTableModesReturn => { }; const useEditActions = ({ + enterDraftMode, + enterPreviewMode, + enterViewMode, setTagTree, setDraftError, createTagMutation, - enterPreviewMode, setToast, setIsCreatingTopTag, setCreatingParentId, exitDraftWithoutSave, setEditingRowId, updateTagMutation, + deleteTagMutation, + setConfirmDeleteDialogOpen, + setConfirmDeleteDialogContext, }: UseEditActionsParams) => { const intl = useIntl(); @@ -255,11 +268,58 @@ const useEditActions = ({ } }; + const startSubtagDraft = (row: Row) => { + const rowData = getTagListRowData(row); + enterDraftMode(); + setDraftError(''); + setCreatingParentId(rowData.id); + row.toggleExpanded(true); + }; + + const startEditTag = (row: Row) => { + const rowData = getTagListRowData(row); + enterDraftMode(); + setDraftError(''); + setEditingRowId(`${rowData.id}:${rowData.value}`); + }; + + const startDeleteTag = (row: Row) => { + setConfirmDeleteDialogOpen(true); + setConfirmDeleteDialogContext(row); + }; + + const handleDeleteTag = async (row: Row) => { + const rowData = getTagListRowData(row); + const count = getTagWithDescendantsCount(rowData); + // If the tag in the frontend state does not have subtags, + // don't allow the backend to delete subtags. + // That prevents problems in case of stale frontend state. + const shouldDeleteSubtags = count > 1; + try { + // In view mode, the table reloads on change, reflecting the deletion + // without needing to manually update the table state + enterViewMode(); + await deleteTagMutation.mutateAsync({ value: rowData.value, withSubtags: shouldDeleteSubtags }); + setToast({ + show: true, + message: intl.formatMessage(messages.tagsDeleteSuccessMessage, { count }), + }); + } catch (error) { + const errorMessage = getErrorMessage(error); + setDraftError(errorMessage); + setToast({ show: true, message: intl.formatMessage(messages.tagDeleteErrorMessage, { errorMessage }) }); + } + }; + return { updateTableWithoutDataReload, - handleCreateTag, - handleUpdateTag, + handleCreateRow: handleCreateTag, + handleUpdateRow: handleUpdateTag, + startSubtagDraft, + startEditRow: startEditTag, + startDeleteRow: startDeleteTag, validate, + handleDeleteRow: handleDeleteTag, }; }; diff --git a/src/taxonomy/tag-list/messages.ts b/src/taxonomy/tag-list/messages.ts index 69e74221ea..a34609db12 100644 --- a/src/taxonomy/tag-list/messages.ts +++ b/src/taxonomy/tag-list/messages.ts @@ -65,6 +65,70 @@ const messages = defineMessages({ id: 'course-authoring.tag-list.rename-tag', defaultMessage: 'Rename', }, + deleteTag: { + id: 'course-authoring.tag-list.delete-tag', + defaultMessage: 'Delete', + }, + deleteTagDisabledTooltip: { + id: 'course-authoring.tag-list.delete-tag-disabled-tooltip', + defaultMessage: 'This tag does not allow deletion', + }, + tagEditForbidden: { + id: 'course-authoring.tag-list.system-defined-tag-edit-disabled', + defaultMessage: 'Disabled because this is not allowed to be changed', + }, + tagDeleteForbidden: { + id: 'course-authoring.tag-list.system-defined-tag-delete-disabled', + defaultMessage: 'Disabled because this is not allowed to be deleted', + }, + hasOpenDraft: { + id: 'course-authoring.tag-list.has-open-draft', + defaultMessage: 'Disabled because tag creation or edit is in progress', + }, + tagsDeleteSuccessMessage: { + id: 'course-authoring.tag-list.delete-success', + defaultMessage: '{count} tag(s) deleted. This change will be applied across all tagged content.', + }, + tagDeleteErrorMessage: { + id: 'course-authoring.tag-list.delete-error', + defaultMessage: 'Error deleting tag: {errorMessage}', + }, + 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/tagColumns.tsx b/src/taxonomy/tag-list/tagColumns.tsx index 7b8874331f..800355a761 100644 --- a/src/taxonomy/tag-list/tagColumns.tsx +++ b/src/taxonomy/tag-list/tagColumns.tsx @@ -1,14 +1,4 @@ -import { - Icon, - IconButton, - IconButtonWithTooltip, - Dropdown, -} from '@openedx/paragon'; -import { - AddCircle, - MoreVert, -} from '@openedx/paragon/icons'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; import type { Row } from '@tanstack/react-table'; import type { @@ -16,128 +6,35 @@ import type { TreeColumnDef, TreeRowData, } from '@src/taxonomy/tree-table/types'; -import type { TagListRowData } from './types'; import messages from './messages'; import OptionalExpandLink from './OptionalExpandLink'; import UsageCountDisplay from './UsageCountDisplay'; import { getTagListRowData } from './utils'; +import Actions from './Actions'; const EDITABLE_COLUMNS = ['value']; interface GetColumnsArgs { - setIsCreatingTopTag: (isCreating: boolean) => void; - setCreatingParentId: (id: RowId | null) => void; - handleUpdateTag: (value: string, originalValue: string) => void; + setIsCreatingTopRow: (isCreating: boolean) => void; setEditingRowId: (id: RowId | null) => void; onStartDraft: () => void; setActiveActionMenuRowId: (id: RowId | null) => void; hasOpenDraft: boolean; canAddTag: boolean; - draftError: string; setDraftError: (error: string) => void; - isSavingDraft: boolean; maxDepth: number; + startSubtagDraft: (row: Row) => void; + startEditRow: (row: Row) => void; + startDeleteRow: (row: Row) => void; } -interface ActionsHeaderProps { - onStartDraft: () => void; - setDraftError: (error: string) => void; - setIsCreatingTopTag: (isCreating: boolean) => void; - setEditingRowId: (id: RowId | null) => void; - setActiveActionMenuRowId: (id: RowId | null) => void; - hasOpenDraft: boolean; - draftInProgressHintId: string; - canAddTag: boolean; -} - -const ActionsHeader = ({ - onStartDraft, - setDraftError, - setIsCreatingTopTag, - setEditingRowId, - setActiveActionMenuRowId, - hasOpenDraft, - canAddTag, - draftInProgressHintId, -}: ActionsHeaderProps) => { - const intl = useIntl(); - return ( -
- {intl.formatMessage(messages.createNewTagTooltip)}
} - src={AddCircle} - alt={intl.formatMessage(messages.createTagButtonLabel)} - size="inline" - onClick={() => { - onStartDraft(); - setDraftError(''); - setIsCreatingTopTag(true); - setEditingRowId(null); - setActiveActionMenuRowId(null); - }} - disabled={hasOpenDraft || !canAddTag} - aria-describedby={hasOpenDraft ? draftInProgressHintId : undefined} - /> -
- ); -}; - -interface ActionsMenuProps { - rowData: TagListRowData; - startSubtagDraft: () => void; - disableAddSubtag: boolean; - editTag: () => void; - disableEditTag: boolean; - reachedMaxDepth: (row: Row) => boolean; - row: Row; -} - -const ActionsMenu = ({ - rowData, - row, - startSubtagDraft, - disableAddSubtag, - editTag, - disableEditTag, - reachedMaxDepth, -}: ActionsMenuProps) => { - const intl = useIntl(); - - return ( - - - - - {intl.formatMessage(messages.addSubtag)} - - - {intl.formatMessage(messages.renameTag)} - - - - ); -}; - function getColumns({ - setIsCreatingTopTag, - setCreatingParentId, + setIsCreatingTopRow, setEditingRowId, onStartDraft, + startSubtagDraft, + startEditRow, + startDeleteRow, setActiveActionMenuRowId, hasOpenDraft, canAddTag, @@ -172,10 +69,10 @@ function getColumns({ { id: 'actions', header: () => ( - { - onStartDraft(); - setDraftError(''); - setCreatingParentId(rowData.id); - setEditingRowId(null); - setIsCreatingTopTag(false); - setActiveActionMenuRowId(null); - row.toggleExpanded(true); - }; - - const editTag = () => { - onStartDraft(); - setDraftError(''); - setEditingRowId(`${rowData.id}:${rowData.value}`); - setCreatingParentId(null); - setIsCreatingTopTag(false); - setActiveActionMenuRowId(null); - }; + const disableEditRow = hasOpenDraft || rowData.canChangeTag === false; + const disableDeleteRow = hasOpenDraft || rowData.canDeleteTag === false; return (
- startSubtagDraft(row)} disableAddSubtag={disableAddSubtag} - editTag={editTag} - disableEditTag={disableEditTag} + startEditRow={() => startEditRow(row)} + disableEditRow={disableEditRow} reachedMaxDepth={reachedMaxDepth} + startDeleteRow={() => startDeleteRow(row)} + disableDeleteRow={disableDeleteRow} />
); diff --git a/src/taxonomy/tag-list/utils.test.ts b/src/taxonomy/tag-list/utils.test.ts new file mode 100644 index 0000000000..923016d60a --- /dev/null +++ b/src/taxonomy/tag-list/utils.test.ts @@ -0,0 +1,33 @@ +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; + + expect(getTagWithDescendantsCount(rowData)).toBe(7); + }); +}); diff --git a/src/taxonomy/tag-list/utils.ts b/src/taxonomy/tag-list/utils.ts index 012ca2b824..9f0cbbbc19 100644 --- a/src/taxonomy/tag-list/utils.ts +++ b/src/taxonomy/tag-list/utils.ts @@ -10,3 +10,10 @@ import { TagListRowData } from './types'; export const getTagListRowData = (row: Row): TagListRowData => ( row.original as unknown as TagListRowData ); + +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); +}; diff --git a/src/taxonomy/tree-table/CreateRow.test.tsx b/src/taxonomy/tree-table/CreateRow.test.tsx index 57a7840441..33144603c1 100644 --- a/src/taxonomy/tree-table/CreateRow.test.tsx +++ b/src/taxonomy/tree-table/CreateRow.test.tsx @@ -3,30 +3,57 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { fireEvent, render, screen } from '@testing-library/react'; import CreateRow from './CreateRow'; +import { TreeTableContext } from './TreeTableContext'; const wrapper = ({ children }: { children: React.ReactNode; }) => ( {children} ); -const baseProps = () => ({ +const baseContextValue = () => ({ + treeData: [], + columns: [], + pageCount: -1, + pagination: { pageIndex: 0, pageSize: 10 }, + handlePaginationChange: jest.fn(), + isLoading: false, + isCreatingTopRow: false, draftError: '', setDraftError: jest.fn(), handleCreateRow: jest.fn(), setIsCreatingTopRow: jest.fn(), exitDraftWithoutSave: jest.fn(), createRowMutation: { isPending: false }, + updateRowMutation: {}, + deleteRowMutation: {}, + toast: { show: false, message: '', variant: 'success' }, + setToast: jest.fn(), + creatingParentId: null, + setCreatingParentId: jest.fn(), validate: jest.fn((value: string) => value.trim().length > 0), + handleUpdateRow: jest.fn(), + editingRowId: null, + setEditingRowId: jest.fn(), + confirmDeleteDialogOpen: false, + setConfirmDeleteDialogOpen: jest.fn(), + confirmDeleteDialogContext: null, + setConfirmDeleteDialogContext: jest.fn(), + handleDeleteRow: jest.fn(), + startEditRow: jest.fn(), + startDeleteRow: jest.fn(), + table: null, }); describe('CreateRow', () => { it('saves on Enter when value is valid', () => { - const props = baseProps(); + const contextValue = baseContextValue(); render( - - - - -
, + + + + + +
+
, { wrapper }, ); @@ -34,19 +61,21 @@ describe('CreateRow', () => { fireEvent.change(input, { target: { value: ' new tag ' } }); fireEvent.keyDown(input, { key: 'Enter' }); - expect(props.handleCreateRow).toHaveBeenCalledWith('new tag'); + expect(contextValue.handleCreateRow).toHaveBeenCalledWith('new tag'); }); it('does not save on Enter when mutation is pending', () => { - const props = baseProps(); - props.createRowMutation = { isPending: true }; + const contextValue = baseContextValue(); + contextValue.createRowMutation = { isPending: true }; render( - - - - -
, + + + + + +
+
, { wrapper }, ); @@ -54,18 +83,20 @@ describe('CreateRow', () => { fireEvent.change(input, { target: { value: 'pending tag' } }); fireEvent.keyDown(input, { key: 'Enter' }); - expect(props.handleCreateRow).not.toHaveBeenCalled(); + expect(contextValue.handleCreateRow).not.toHaveBeenCalled(); }); it('cancels on Escape and resets draft state', () => { - const props = baseProps(); + const contextValue = baseContextValue(); render( - - - - -
, + + + + + +
+
, { wrapper }, ); @@ -73,8 +104,8 @@ describe('CreateRow', () => { fireEvent.change(input, { target: { value: 'will cancel' } }); fireEvent.keyDown(input, { key: 'Escape' }); - expect(props.setDraftError).toHaveBeenCalledWith(''); - expect(props.setIsCreatingTopRow).toHaveBeenCalledWith(false); - expect(props.exitDraftWithoutSave).toHaveBeenCalled(); + expect(contextValue.setDraftError).toHaveBeenCalledWith(''); + expect(contextValue.setIsCreatingTopRow).toHaveBeenCalledWith(false); + expect(contextValue.exitDraftWithoutSave).toHaveBeenCalled(); }); }); diff --git a/src/taxonomy/tree-table/CreateRow.tsx b/src/taxonomy/tree-table/CreateRow.tsx index f130560b02..d16399f77b 100644 --- a/src/taxonomy/tree-table/CreateRow.tsx +++ b/src/taxonomy/tree-table/CreateRow.tsx @@ -1,42 +1,41 @@ -import React from 'react'; -import type { CreateRowMutationState } from './types'; +import React, { useContext } from 'react'; import DraftRow from './DraftRow'; +import { TreeTableContext } from './TreeTableContext'; interface CreateRowProps { - draftError: string; - setDraftError: (error: string) => void; - handleCreateRow: (value: string) => void; - setIsCreatingTopRow: (isCreating: boolean) => void; - exitDraftWithoutSave: () => void; - createRowMutation: CreateRowMutationState; + handleCreateRow?: (value: string) => void; + exitDraftWithoutSave?: () => void; indent?: number; - validate: (value: string, mode?: 'soft' | 'hard') => boolean; } const CreateRow: React.FC = ({ - draftError, - setDraftError, handleCreateRow, - setIsCreatingTopRow, exitDraftWithoutSave, - createRowMutation, indent = 0, - validate, }) => { + const { + setDraftError, + handleCreateRow: contextHandleCreateRow, + setIsCreatingTopRow, + exitDraftWithoutSave: contextExitDraftWithoutSave, + createRowMutation, + } = useContext(TreeTableContext); + + const onCreateRow = handleCreateRow ?? contextHandleCreateRow; + const onExitDraftWithoutSave = exitDraftWithoutSave ?? contextExitDraftWithoutSave; + const handleCancel = () => { setDraftError(''); setIsCreatingTopRow(false); - exitDraftWithoutSave(); + onExitDraftWithoutSave(); }; return ( diff --git a/src/taxonomy/tree-table/DraftRow.tsx b/src/taxonomy/tree-table/DraftRow.tsx index 1be3b01a2c..57b0b7ef1f 100644 --- a/src/taxonomy/tree-table/DraftRow.tsx +++ b/src/taxonomy/tree-table/DraftRow.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Spinner } from '@openedx/paragon'; import { Row } from '@tanstack/react-table'; @@ -7,15 +7,14 @@ import UsageCountDisplay from '@src/taxonomy/tag-list/UsageCountDisplay'; import { EditableCell } from './EditableCell'; import type { CreateRowMutationState, TreeRowData } from './types'; import messages from './messages'; +import { TreeTableContext } from './TreeTableContext'; interface DraftRowProps { - draftError: string; initialValue?: string; onSave: (value: string) => void; onCancel: () => void; mutationState: CreateRowMutationState; indent?: number; - validate: (value: string, mode?: 'soft' | 'hard') => boolean; requireValueChangeToEnableSave?: boolean; rowTestId?: string; rowId?: string; @@ -23,13 +22,11 @@ interface DraftRowProps { } const DraftRow: React.FC = ({ - draftError, initialValue = '', onSave, onCancel, mutationState, indent = 0, - validate, requireValueChangeToEnableSave = false, rowTestId, rowId, @@ -39,6 +36,8 @@ const DraftRow: React.FC = ({ const [saveDisabled, setSaveDisabled] = useState(true); const intl = useIntl(); + const { draftError, validate } = useContext(TreeTableContext); + const updateSaveDisabled = (value: string) => { const trimmedValue = value.trim(); const isValid = validate(value, 'soft'); diff --git a/src/taxonomy/tree-table/EditRow.tsx b/src/taxonomy/tree-table/EditRow.tsx index 12a83fa055..d4ef94d9c3 100644 --- a/src/taxonomy/tree-table/EditRow.tsx +++ b/src/taxonomy/tree-table/EditRow.tsx @@ -1,31 +1,29 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { Row } from '@tanstack/react-table'; -import type { CreateRowMutationState, TreeRowData } from './types'; +import type { TreeRowData } from './types'; import DraftRow from './DraftRow'; +import { TreeTableContext } from './TreeTableContext'; interface EditRowProps { - draftError: string; - setDraftError: (error: string) => void; initialValue: string; handleUpdateRow: (value: string) => void; cancelEditRow: () => void; - updateRowMutation: CreateRowMutationState; indent?: number; - validate: (value: string, mode?: 'soft' | 'hard') => boolean; row: Row; } const EditRow: React.FC = ({ - draftError, - setDraftError, initialValue, handleUpdateRow, cancelEditRow, - updateRowMutation, indent = 0, - validate, row, }) => { + const { + setDraftError, + updateRowMutation, + } = useContext(TreeTableContext); + const handleCancel = () => { setDraftError(''); cancelEditRow(); @@ -33,13 +31,11 @@ const EditRow: React.FC = ({ return ( diff --git a/src/taxonomy/tree-table/NestedRows.test.tsx b/src/taxonomy/tree-table/NestedRows.test.tsx index ceeecf0395..994a495000 100644 --- a/src/taxonomy/tree-table/NestedRows.test.tsx +++ b/src/taxonomy/tree-table/NestedRows.test.tsx @@ -3,21 +3,45 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { fireEvent, render, screen } from '@testing-library/react'; import NestedRows from './NestedRows'; +import { TreeTableContext } from './TreeTableContext'; const wrapper = ({ children }: { children: React.ReactNode; }) => ( {children} ); -const defaultRequiredProps = { - setIsCreatingTopRow: jest.fn(), +const baseContextValue: any = () => ({ + treeData: [], + columns: [], + pageCount: -1, + pagination: { pageIndex: 0, pageSize: 10 }, + handlePaginationChange: jest.fn(), + isLoading: false, + isCreatingTopRow: false, + draftError: '', createRowMutation: {}, updateRowMutation: {}, + deleteRowMutation: {}, + toast: { show: false, message: '', variant: 'success' }, + setToast: jest.fn(), + setIsCreatingTopRow: jest.fn(), + exitDraftWithoutSave: jest.fn(), + handleCreateRow: jest.fn(), + creatingParentId: null, + setCreatingParentId: jest.fn(), + setDraftError: jest.fn(), + validate: jest.fn(() => true), handleUpdateRow: jest.fn(), editingRowId: null, setEditingRowId: jest.fn(), - exitDraftWithoutSave: jest.fn(), - validate: () => true, -}; + confirmDeleteDialogOpen: false, + setConfirmDeleteDialogOpen: jest.fn(), + confirmDeleteDialogContext: null, + setConfirmDeleteDialogContext: jest.fn(), + handleDeleteRow: jest.fn(), + startEditRow: jest.fn(), + startDeleteRow: jest.fn(), + table: null, +}); const makeCell = (id: string, content: string) => ({ id, @@ -46,16 +70,18 @@ const makeRow = ({ describe('NestedRows', () => { it('renders nothing when parent row is collapsed', () => { const parent = makeRow({ id: 1, value: 'parent', expanded: false }); + const contextValue = baseContextValue(); const { container } = render( - - - - -
, + + + + + +
+
, { wrapper }, ); @@ -70,30 +96,31 @@ describe('NestedRows', () => { expanded: true, subRows: [nestedChild], }); - const setCreatingParentId = jest.fn(); + const contextValue = baseContextValue(); + contextValue.setCreatingParentId = jest.fn(); + contextValue.creatingParentId = 2; + contextValue.createRowMutation = { isPending: false }; const onCancelCreation = jest.fn(); render( - - - - -
, + + + + + +
+
, { wrapper }, ); fireEvent.click(screen.getByText('Cancel')); - expect(setCreatingParentId).toHaveBeenCalledWith(null); + expect(contextValue.setCreatingParentId).toHaveBeenCalledWith(null); expect(onCancelCreation).toHaveBeenCalled(); }); @@ -106,18 +133,21 @@ describe('NestedRows', () => { subRows: [nestedChild], }); + const contextValue = baseContextValue(); + contextValue.editingRowId = '2:child'; + render( - - - - -
, + + + + + +
+
, { wrapper }, ); diff --git a/src/taxonomy/tree-table/NestedRows.tsx b/src/taxonomy/tree-table/NestedRows.tsx index c2647f9f98..25f3669d02 100644 --- a/src/taxonomy/tree-table/NestedRows.tsx +++ b/src/taxonomy/tree-table/NestedRows.tsx @@ -1,14 +1,10 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { flexRender } from '@tanstack/react-table'; -import type { - RowId, - TreeRow, - TreeColumnDef, - CreateRowMutationState, -} from './types'; +import type { TreeRow } from './types'; import CreateRow from './CreateRow'; import EditRow from './EditRow'; +import { TreeTableContext } from './TreeTableContext'; interface NestedRowsProps { /** The parent row object from TanStack React Table */ @@ -25,33 +21,6 @@ interface NestedRowsProps { childRowsData?: TreeRow[]; /** Current nesting depth level (used for indentation calculation) */ depth?: number; - /** Error message to display in draft creation form */ - draftError?: string; - /** Setter function for draft error state */ - setDraftError?: (error: string) => void; - /** ID of the row currently in creation mode */ - creatingParentId?: RowId | null; - /** Setter function for which row is in creation mode */ - setCreatingParentId?: (value: RowId | null) => void; - /** Callback to set whether top-level row creation is active */ - setIsCreatingTopRow: (isCreating: boolean) => void; - /** State object for the row creation mutation (isPending, isError, error) */ - createRowMutation: CreateRowMutationState; - /** State object for the row update mutation (isPending, isError, error) */ - updateRowMutation: CreateRowMutationState; - /** Callback when an existing row is updated (receives new value and original value) */ - handleUpdateRow: (value: string, originalValue: string) => void; - /** ID of the row currently in edit mode */ - editingRowId: RowId | null; - /** Setter function for which row is in edit mode */ - setEditingRowId: (id: RowId | null) => void; - /** Callback to exit draft mode without saving changes */ - exitDraftWithoutSave: () => void; - /** Column definitions for rendering cells in edit mode */ - columns?: TreeColumnDef[]; - /** Validation function for new row values (receives value and optional 'soft' or 'hard' mode; - * in 'hard' mode an exception is thrown on validation failure) */ - validate: (value: string, mode?: 'soft' | 'hard') => boolean; } /** @@ -75,20 +44,16 @@ const NestedRows = ({ onCancelCreation = () => {}, childRowsData = [], depth = 1, - draftError = '', - setDraftError = () => {}, - creatingParentId = null, - setCreatingParentId = () => {}, - setIsCreatingTopRow, - createRowMutation, - updateRowMutation, - handleUpdateRow, - editingRowId, - setEditingRowId, - exitDraftWithoutSave, - columns = [], - validate, }: NestedRowsProps) => { + const { + creatingParentId, + setCreatingParentId, + handleUpdateRow, + editingRowId, + setEditingRowId, + exitDraftWithoutSave, + } = useContext(TreeTableContext); + if (!parentRow.getIsExpanded()) { return null; } @@ -98,14 +63,9 @@ const NestedRows = ({ <> {isCreating && ( onSaveNewChildRow(value, parentRowValue)} - setIsCreatingTopRow={setIsCreatingTopRow} exitDraftWithoutSave={onCancelCreation} - createRowMutation={createRowMutation} indent={indent} - validate={validate} /> )} {childRowsData?.map(row => { @@ -115,17 +75,13 @@ const NestedRows = ({ {editingRowId === `${row.original.id}:${String(row.original.value)}` ? ( handleUpdateRow(value, String(row.original.value))} cancelEditRow={() => { setEditingRowId(null); exitDraftWithoutSave(); }} - updateRowMutation={updateRowMutation} indent={indent} - validate={validate} row={row} /> ) : @@ -159,20 +115,7 @@ const NestedRows = ({ setCreatingParentId(null); onCancelCreation(); }} - creatingParentId={creatingParentId} - setCreatingParentId={setCreatingParentId} depth={depth + 1} - draftError={draftError} - setDraftError={setDraftError} - setIsCreatingTopRow={setIsCreatingTopRow} - createRowMutation={createRowMutation} - updateRowMutation={updateRowMutation} - handleUpdateRow={handleUpdateRow} - editingRowId={editingRowId} - setEditingRowId={setEditingRowId} - exitDraftWithoutSave={exitDraftWithoutSave} - columns={columns} - validate={validate} /> ); diff --git a/src/taxonomy/tree-table/SaveErrorAlert.tsx b/src/taxonomy/tree-table/SaveErrorAlert.tsx index ef0788140f..c283151ca7 100644 --- a/src/taxonomy/tree-table/SaveErrorAlert.tsx +++ b/src/taxonomy/tree-table/SaveErrorAlert.tsx @@ -5,6 +5,7 @@ import { import { Info } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; +// @ts-ignore import './TableView.scss'; import messages from './messages'; @@ -12,15 +13,16 @@ interface SaveErrorAlertProps { draftError: string | undefined; isError: boolean | undefined; isUpdateError: boolean | undefined; + isDeleteError: boolean | undefined; } -const SaveErrorAlert = ({ draftError, isError, isUpdateError }: SaveErrorAlertProps) => { +const SaveErrorAlert = ({ draftError, isError, isUpdateError, isDeleteError }: SaveErrorAlertProps) => { const intl = useIntl(); - const hasError: boolean = Boolean((isError || isUpdateError) && !!draftError); + const hasError: boolean = Boolean((isError || isUpdateError || isDeleteError) && !!draftError); const [alertOpen, setAlertOpen] = React.useState(hasError); useEffect(() => { setAlertOpen(hasError); - }, [hasError, isError, isUpdateError, draftError]); + }, [hasError, isError, isUpdateError, isDeleteError, draftError]); if (!alertOpen) { return null; } diff --git a/src/taxonomy/tree-table/TableBody.tsx b/src/taxonomy/tree-table/TableBody.tsx index 0962c4c6c7..c46df26d10 100644 --- a/src/taxonomy/tree-table/TableBody.tsx +++ b/src/taxonomy/tree-table/TableBody.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { flexRender } from '@tanstack/react-table'; @@ -6,56 +6,31 @@ import { LoadingSpinner } from '@src/generic/Loading'; import NestedRows from './NestedRows'; import messages from './messages'; +import { TreeTableContext } from './TreeTableContext'; -import type { - CreateRowMutationState, - RowId, - TreeColumnDef, - TreeTable, -} from './types'; import CreateRow from './CreateRow'; import EditRow from './EditRow'; -interface TableBodyProps { - columns: TreeColumnDef[]; - isCreatingTopRow: boolean; - draftError: string; - setIsCreatingTopRow: (isCreating: boolean) => void; - exitDraftWithoutSave: () => void; - handleCreateRow: (value: string, parentRowValue?: string) => void; - creatingParentId: RowId | null; - setCreatingParentId: (id: RowId | null) => void; - setDraftError: (error: string) => void; - createRowMutation: CreateRowMutationState; - updateRowMutation: CreateRowMutationState; - table: TreeTable; - isLoading: boolean; - validate: (value: string, mode?: 'soft' | 'hard') => boolean; - handleUpdateRow: (value: string, originalValue: string) => void; - editingRowId: RowId | null; - setEditingRowId: (id: RowId | null) => void; -} - -const TableBody = ({ - columns, - isCreatingTopRow, - draftError, - handleCreateRow, - setIsCreatingTopRow, - exitDraftWithoutSave, - creatingParentId, - setCreatingParentId, - setDraftError, - createRowMutation, - updateRowMutation, - table, - isLoading, - validate, - handleUpdateRow, - editingRowId, - setEditingRowId, -}: TableBodyProps) => { +const TableBody = () => { const intl = useIntl(); + const { + columns, + isCreatingTopRow, + handleCreateRow, + exitDraftWithoutSave, + creatingParentId, + setCreatingParentId, + setDraftError, + table, + isLoading, + handleUpdateRow, + editingRowId, + setEditingRowId, + } = useContext(TreeTableContext); + + if (!table) { + return null; + } if (isLoading) { return ( @@ -79,33 +54,19 @@ const TableBody = ({ )} - {isCreatingTopRow && ( - - )} + {isCreatingTopRow && } {table.getRowModel().rows.filter(row => row.depth === 0).map(row => ( {editingRowId === `${row.original.id}:${String(row.original.value)}` ? ( handleUpdateRow(value, String(row.original.value))} cancelEditRow={() => { setEditingRowId(null); exitDraftWithoutSave(); }} - updateRowMutation={updateRowMutation} - validate={validate} row={row} /> ) : @@ -130,20 +91,6 @@ const TableBody = ({ setCreatingParentId(null); exitDraftWithoutSave(); }} - creatingParentId={creatingParentId} - setCreatingParentId={setCreatingParentId} - depth={1} - draftError={draftError} - createRowMutation={createRowMutation} - setDraftError={setDraftError} - setIsCreatingTopRow={setIsCreatingTopRow} - validate={validate} - updateRowMutation={updateRowMutation} - handleUpdateRow={handleUpdateRow} - editingRowId={editingRowId} - setEditingRowId={setEditingRowId} - exitDraftWithoutSave={exitDraftWithoutSave} - columns={columns} /> ))} diff --git a/src/taxonomy/tree-table/TableView.test.tsx b/src/taxonomy/tree-table/TableView.test.tsx index eca55131b7..1edbf5991d 100644 --- a/src/taxonomy/tree-table/TableView.test.tsx +++ b/src/taxonomy/tree-table/TableView.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { fireEvent, render, screen } from '@testing-library/react'; +import { TreeTableContext } from './TreeTableContext'; import { TableView } from './TableView'; jest.mock('./TableBody', () => { @@ -19,7 +20,7 @@ const wrapper = ({ children }: { children: React.ReactNode; }) => ( {children} ); -const baseProps = () => ({ +const baseContextValue = () => ({ treeData: [{ id: 1, value: 'root' }], columns: [{ accessorKey: 'value', header: 'Tag name', cell: (info: any) => info.getValue() }], pageCount: 3, @@ -30,6 +31,7 @@ const baseProps = () => ({ draftError: '', createRowMutation: { isPending: false, isError: false }, updateRowMutation: { isPending: false, isError: false }, + deleteRowMutation: { isPending: false, isError: false }, toast: { show: false, message: '', variant: 'success' }, setToast: jest.fn(), setIsCreatingTopRow: jest.fn(), @@ -42,15 +44,34 @@ const baseProps = () => ({ handleUpdateRow: jest.fn(), editingRowId: null, setEditingRowId: jest.fn(), + confirmDeleteDialogOpen: false, + setConfirmDeleteDialogOpen: jest.fn(), + confirmDeleteDialogContext: null, + setConfirmDeleteDialogContext: jest.fn(), + handleDeleteRow: jest.fn(), + startEditRow: jest.fn(), + startDeleteRow: jest.fn(), + table: null, }); +const renderTableView = ( + contextValue = baseContextValue(), + props: React.ComponentProps = {}, +) => + render( + + + , + { wrapper }, + ); + describe('TableView', () => { it('shows and dismisses save error banner', () => { - const props = baseProps(); - props.createRowMutation = { isPending: false, isError: true }; - props.draftError = 'Request failed with status code 500'; + const contextValue = baseContextValue(); + contextValue.createRowMutation = { isPending: false, isError: true }; + contextValue.draftError = 'Request failed with status code 500'; - render(, { wrapper }); + renderTableView(contextValue); expect(screen.getByText('Error saving changes')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); @@ -58,42 +79,76 @@ describe('TableView', () => { }); it('keeps pagination hidden by default even when multiple pages are reported', () => { - const props = baseProps(); - render(, { wrapper }); + renderTableView(); expect(screen.queryByRole('navigation', { name: /table pagination/i })).not.toBeInTheDocument(); }); it('renders pagination and updates page selection when explicitly enabled', () => { - const props = baseProps(); - render(, { wrapper }); + const contextValue = baseContextValue(); + renderTableView(contextValue, { enablePagination: true }); expect(screen.getByText('Page 1 of 3')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /^page 2$/i })); - expect(props.handlePaginationChange).toHaveBeenCalled(); + expect(contextValue.handlePaginationChange).toHaveBeenCalled(); }); it('hides pagination when there is only one page', () => { - const props = baseProps(); - props.pageCount = 1; - render(, { wrapper }); + const contextValue = baseContextValue(); + contextValue.pageCount = 1; + renderTableView(contextValue); expect(screen.queryByRole('navigation', { name: /table pagination/i })).not.toBeInTheDocument(); }); it('closes toast by setting show to false', () => { - const props = baseProps(); - props.toast = { show: true, message: 'created', variant: 'success' }; + const contextValue = baseContextValue(); + contextValue.toast = { show: true, message: 'created', variant: 'success' }; - render(, { wrapper }); + renderTableView(contextValue); fireEvent.click(screen.getByRole('button', { name: /close/i })); - expect(props.setToast).toHaveBeenCalled(); - const updater = props.setToast.mock.calls[0][0]; + expect(contextValue.setToast).toHaveBeenCalled(); + const updater = contextValue.setToast.mock.calls[0][0]; expect(updater({ show: true, message: 'created', variant: 'success' })).toEqual({ show: false, message: 'created', variant: 'success', }); }); + + it('shows the save error alert when a delete mutation fails and draftError is present', () => { + const contextValue = baseContextValue(); + contextValue.deleteRowMutation = { isPending: false, isError: true }; + contextValue.draftError = 'Delete request failed'; + + renderTableView(contextValue); + + expect(screen.getByText('Error saving changes')).toBeInTheDocument(); + expect(screen.getByText('Delete request failed. Please try again.')).toBeInTheDocument(); + }); + + it('reopens the save error alert when a new delete error arrives after the user previously dismissed it', () => { + const contextValue = baseContextValue(); + contextValue.deleteRowMutation = { isPending: false, isError: true }; + contextValue.draftError = 'First delete failure'; + + const { rerender } = renderTableView(contextValue); + + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); + expect(screen.queryByText('Error saving changes')).not.toBeInTheDocument(); + + const nextContextValue = baseContextValue(); + nextContextValue.deleteRowMutation = { isPending: false, isError: true }; + nextContextValue.draftError = 'Second delete failure'; + + rerender( + + + , + ); + + expect(screen.getByText('Error saving changes')).toBeInTheDocument(); + expect(screen.getByText('Second delete failure. Please try again.')).toBeInTheDocument(); + }); }); diff --git a/src/taxonomy/tree-table/TableView.tsx b/src/taxonomy/tree-table/TableView.tsx index 707792f452..c442279d0a 100644 --- a/src/taxonomy/tree-table/TableView.tsx +++ b/src/taxonomy/tree-table/TableView.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useContext, useMemo } from 'react'; import { Button, Toast, @@ -13,78 +13,44 @@ import { getCoreRowModel, getExpandedRowModel, flexRender, - type OnChangeFn, - type PaginationState, } from '@tanstack/react-table'; import { ArrowDropUpDown } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import TableBody from './TableBody'; +// @ts-ignore import './TableView.scss'; -import type { - CreateRowMutationState, - RowId, - ToastState, - TreeColumnDef, - TreeRowData, -} from './types'; import messages from './messages'; import SaveErrorAlert from './SaveErrorAlert'; +import { TreeTableContext } from './TreeTableContext'; +import { TreeTable } from './types'; interface TableViewProps { - treeData: TreeRowData[]; - columns: TreeColumnDef[]; - pageCount: number; enablePagination?: boolean; - pagination: PaginationState; - handlePaginationChange: OnChangeFn; - isLoading: boolean; - isCreatingTopRow: boolean; - draftError: string; - createRowMutation: CreateRowMutationState; - updateRowMutation: CreateRowMutationState; - toast: ToastState; - setToast: React.Dispatch>; - setIsCreatingTopRow: (isCreating: boolean) => void; - exitDraftWithoutSave: () => void; - handleCreateRow: (value: string, parentRowValue?: string) => void; - creatingParentId: RowId | null; - setCreatingParentId: (id: RowId | null) => void; - setDraftError: (error: string) => void; - validate: (value: string, mode?: 'soft' | 'hard') => boolean; - handleUpdateRow: (value: string, originalValue: string) => void; - editingRowId: RowId | null; - setEditingRowId: (id: RowId | null) => void; } const TableView = ({ - treeData, - columns, - pageCount, enablePagination = false, - pagination, - handlePaginationChange, - isLoading, - isCreatingTopRow, - draftError, - createRowMutation, - updateRowMutation, - handleCreateRow, - toast, - setToast, - setIsCreatingTopRow, - exitDraftWithoutSave, - creatingParentId, - setCreatingParentId, - setDraftError, - validate, - handleUpdateRow, - editingRowId, - setEditingRowId, }: TableViewProps) => { const intl = useIntl(); - const table = useReactTable({ + const contextValue = useContext(TreeTableContext); + + const { + treeData, + columns, + pageCount, + pagination, + handlePaginationChange, + draftError, + createRowMutation, + updateRowMutation, + deleteRowMutation, + toast, + setToast, + } = contextValue; + + const table: TreeTable = useReactTable({ data: treeData, columns, getCoreRowModel: getCoreRowModel(), @@ -98,14 +64,28 @@ const TableView = ({ getSubRows: (row) => row?.subRows || undefined, }); + const nestedContextValue = useMemo(() => ({ + ...contextValue, + table, + }), [contextValue, table]); + const currentPageIndex = table.getState().pagination.pageIndex + 1; const { isError } = createRowMutation; const { isError: isUpdateError } = updateRowMutation; + const { isError: isDeleteError } = deleteRowMutation; return ( - <> - + // This is a nested context provider. It is a valid pattern in React even if it looks odd, + // and the purpose here is to add the react-table instance to the overall context. + // Note that there is an outer context provider higher up in `TagListTable` as well. + +
@@ -145,25 +125,7 @@ const TableView = ({ ))} - + @@ -200,7 +162,7 @@ const TableView = ({ {toast.message} - + ); }; diff --git a/src/taxonomy/tree-table/TreeTableContext.tsx b/src/taxonomy/tree-table/TreeTableContext.tsx new file mode 100644 index 0000000000..e004956064 --- /dev/null +++ b/src/taxonomy/tree-table/TreeTableContext.tsx @@ -0,0 +1,80 @@ +import { createContext } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import type { OnChangeFn, PaginationState, Row } from '@tanstack/react-table'; + +import type { + CreateRowMutationState, + RowId, + ToastState, + TreeColumnDef, + TreeTable, + TreeRowData, +} from './types'; + +export interface TreeTableContextValue { + treeData: TreeRowData[]; + columns: TreeColumnDef[]; + pageCount: number; + pagination: PaginationState; + handlePaginationChange: OnChangeFn; + isLoading: boolean; + isCreatingTopRow: boolean; + draftError: string; + createRowMutation: CreateRowMutationState; + updateRowMutation: CreateRowMutationState; + toast: ToastState; + setToast: Dispatch>; + setIsCreatingTopRow: (isCreating: boolean) => void; + exitDraftWithoutSave: () => void; + handleCreateRow: (value: string, parentRowValue?: string) => void; + creatingParentId: RowId | null; + setCreatingParentId: (id: RowId | null) => void; + setDraftError: (error: string) => void; + deleteRowMutation: CreateRowMutationState; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; + handleUpdateRow: (value: string, originalValue: string) => void; + editingRowId: RowId | null; + setEditingRowId: (id: RowId | null) => void; + confirmDeleteDialogOpen: boolean; + setConfirmDeleteDialogOpen: Dispatch>; + confirmDeleteDialogContext: Row | null; + setConfirmDeleteDialogContext: Dispatch | null>>; + handleDeleteRow: (row: Row) => void; + startEditRow: (row: Row) => void; + startDeleteRow: (row: Row) => void; + table: TreeTable | null; +} + +export const TreeTableContext = createContext({ + treeData: [], + columns: [], + pageCount: -1, + pagination: { pageIndex: 0, pageSize: 0 }, + handlePaginationChange: () => {}, + isLoading: false, + isCreatingTopRow: false, + draftError: '', + createRowMutation: {}, + updateRowMutation: {}, + deleteRowMutation: {}, + toast: { show: false, message: '', variant: 'success' }, + setToast: () => {}, + setIsCreatingTopRow: () => {}, + exitDraftWithoutSave: () => {}, + handleCreateRow: () => {}, + creatingParentId: null, + setCreatingParentId: () => {}, + setDraftError: () => {}, + validate: () => true, + handleUpdateRow: () => {}, + editingRowId: null, + setEditingRowId: () => {}, + confirmDeleteDialogOpen: false, + setConfirmDeleteDialogOpen: () => {}, + confirmDeleteDialogContext: null, + setConfirmDeleteDialogContext: () => {}, + handleDeleteRow: () => {}, + startEditRow: () => {}, + startDeleteRow: () => {}, + table: null, +}); diff --git a/src/taxonomy/tree-table/index.ts b/src/taxonomy/tree-table/index.ts index eca12d684c..44b27a7709 100644 --- a/src/taxonomy/tree-table/index.ts +++ b/src/taxonomy/tree-table/index.ts @@ -1,2 +1,3 @@ export { EditableCell } from './EditableCell'; export { TableView } from './TableView'; +export { TreeTableContext } from './TreeTableContext';