Skip to content

Commit 31b6ea7

Browse files
feat: delete tag (#3035)
* feat: delete tag * fix: lint * fix: cleanup from comments on PR #3025 * fix: implement disableTagActions logic * fix: remove unused variable * fix: fix unsafe optional chaining lint * feat: Add delete modal to delete functionality * fix: code coverage * fix: lint formatting --------- Co-authored-by: Jesper Hodge <[email protected]>
1 parent 3a8eb99 commit 31b6ea7

27 files changed

Lines changed: 1347 additions & 650 deletions

src/taxonomy/data/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export const apiUrls = {
9999
tagsPlanImport: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/import/plan/`),
100100
createTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`),
101101
updateTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`),
102+
deleteTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`),
102103
} satisfies Record<string, (...args: any[]) => string>;
103104

104105
/**

src/taxonomy/data/apiHooks.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,3 +269,23 @@ export const useUpdateTag = (taxonomyId: number) => {
269269
},
270270
});
271271
};
272+
273+
export const useDeleteTag = (taxonomyId: number) => {
274+
const queryClient = useQueryClient();
275+
276+
return useMutation({
277+
mutationFn: async ({ value, withSubtags }: { value: string; withSubtags: boolean; }) => {
278+
const body = { tags: [value], with_subtags: withSubtags };
279+
await getAuthenticatedHttpClient().delete(apiUrls.deleteTag(taxonomyId), {
280+
data: body,
281+
});
282+
},
283+
onSuccess: () => {
284+
queryClient.invalidateQueries({
285+
queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId),
286+
});
287+
// In the metadata, 'tagsCount' (and possibly other fields) will have changed:
288+
queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) });
289+
},
290+
});
291+
};

src/taxonomy/tag-list/Actions.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import {
2+
Icon,
3+
IconButton,
4+
IconButtonWithTooltip,
5+
Dropdown,
6+
} from '@openedx/paragon';
7+
import {
8+
AddCircle,
9+
MoreVert,
10+
} from '@openedx/paragon/icons';
11+
import { useIntl } from '@edx/frontend-platform/i18n';
12+
import type { Row } from '@tanstack/react-table';
13+
14+
import type {
15+
RowId,
16+
TreeRowData,
17+
} from '@src/taxonomy/tree-table/types';
18+
import type { TagListRowData } from './types';
19+
import messages from './messages';
20+
21+
interface ActionsHeaderProps {
22+
onStartDraft: () => void;
23+
setDraftError: (error: string) => void;
24+
setIsCreatingTopRow: (isCreating: boolean) => void;
25+
setEditingRowId: (id: RowId | null) => void;
26+
setActiveActionMenuRowId: (id: RowId | null) => void;
27+
hasOpenDraft: boolean;
28+
disableTagActions: boolean;
29+
draftInProgressHintId: string;
30+
canAddTag: boolean;
31+
}
32+
33+
const ActionsHeader = ({
34+
onStartDraft,
35+
setDraftError,
36+
setIsCreatingTopRow,
37+
setEditingRowId,
38+
setActiveActionMenuRowId,
39+
hasOpenDraft,
40+
disableTagActions,
41+
canAddTag,
42+
draftInProgressHintId,
43+
}: ActionsHeaderProps) => {
44+
const intl = useIntl();
45+
return (
46+
<div className="d-flex justify-content-end">
47+
<IconButtonWithTooltip
48+
tooltipPlacement="top"
49+
tooltipContent={<div>{intl.formatMessage(messages.createNewTagTooltip)}</div>}
50+
src={AddCircle}
51+
alt={intl.formatMessage(messages.createTagButtonLabel)}
52+
size="inline"
53+
onClick={() => {
54+
onStartDraft();
55+
setDraftError('');
56+
setIsCreatingTopRow(true);
57+
setEditingRowId(null);
58+
setActiveActionMenuRowId(null);
59+
}}
60+
disabled={disableTagActions || !canAddTag}
61+
aria-describedby={hasOpenDraft ? draftInProgressHintId : undefined}
62+
/>
63+
</div>
64+
);
65+
};
66+
67+
interface ActionsMenuProps {
68+
rowData: TagListRowData;
69+
startSubtagDraft: () => void;
70+
disableAddSubtag: boolean;
71+
startEditRow: () => void;
72+
disableEditRow: boolean;
73+
reachedMaxDepth: (row: Row<TreeRowData>) => boolean;
74+
startDeleteRow: (row: Row<TreeRowData>) => void;
75+
disableDeleteRow: boolean;
76+
row: Row<TreeRowData>;
77+
}
78+
79+
const ActionsMenu = ({
80+
rowData,
81+
row,
82+
startSubtagDraft,
83+
disableAddSubtag,
84+
startEditRow,
85+
disableEditRow,
86+
reachedMaxDepth,
87+
startDeleteRow,
88+
disableDeleteRow,
89+
}: ActionsMenuProps) => {
90+
const intl = useIntl();
91+
92+
const deleteRowMenuItem = (
93+
<Dropdown.Item
94+
onClick={() => startDeleteRow(row)}
95+
disabled={disableDeleteRow}
96+
>
97+
{intl.formatMessage(messages.deleteTag)}
98+
</Dropdown.Item>
99+
);
100+
101+
const editRowMenuItem = (
102+
<Dropdown.Item
103+
onClick={startEditRow}
104+
disabled={disableEditRow}
105+
>
106+
{intl.formatMessage(messages.renameTag)}
107+
</Dropdown.Item>
108+
);
109+
110+
return (
111+
<Dropdown>
112+
<Dropdown.Toggle
113+
id={`dropdown-toggle-for-tag-${rowData.id}`}
114+
as={IconButton}
115+
src={MoreVert}
116+
iconAs={Icon}
117+
variant="primary"
118+
aria-label={intl.formatMessage(messages.moreActionsForTag, { tagName: rowData.value })}
119+
size="sm"
120+
/>
121+
<Dropdown.Menu>
122+
<Dropdown.Item
123+
onClick={startSubtagDraft}
124+
disabled={reachedMaxDepth(row) || disableAddSubtag}
125+
>
126+
{intl.formatMessage(messages.addSubtag)}
127+
</Dropdown.Item>
128+
{editRowMenuItem}
129+
{deleteRowMenuItem}
130+
</Dropdown.Menu>
131+
</Dropdown>
132+
);
133+
};
134+
135+
const Actions = {
136+
Header: ActionsHeader,
137+
Menu: ActionsMenu,
138+
};
139+
140+
export default Actions;

src/taxonomy/tag-list/DeleteModal.test.tsx

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

66
import DeleteModal from './DeleteModal';
77

@@ -94,7 +94,7 @@ describe('DeleteModal', () => {
9494
it('calls handleDeleteRow with the dialog row context and then closes and clears the dialog state on confirm', async () => {
9595
const user = userEvent.setup();
9696
const row = createRow(leafRowData);
97-
const handleDeleteRow = jest.fn();
97+
const handleDeleteRow = jest.fn().mockResolvedValue(undefined);
9898
const setIsOpen = jest.fn();
9999
const setRow = jest.fn();
100100

@@ -108,9 +108,11 @@ describe('DeleteModal', () => {
108108
await user.type(screen.getByRole('textbox'), 'DELETE');
109109
await user.click(screen.getByRole('button', { name: 'Delete Tag' }));
110110

111-
expect(handleDeleteRow).toHaveBeenCalledWith(row);
112-
expect(setIsOpen).toHaveBeenCalledWith(false);
113-
expect(setRow).toHaveBeenCalledWith(null);
111+
await waitFor(() => {
112+
expect(handleDeleteRow).toHaveBeenCalledWith(row);
113+
expect(setIsOpen).toHaveBeenCalledWith(false);
114+
expect(setRow).toHaveBeenCalledWith(null);
115+
});
114116
});
115117

116118
it('closes and clears the dialog context on cancel without invoking deletion', async () => {

src/taxonomy/tag-list/DeleteModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ const DeleteModal = ({
2525
}: DeleteModalProps) => {
2626
const intl = useIntl();
2727

28-
const handleConfirm = (row: Row<TreeRowData>) => {
29-
handleDeleteRow(row);
28+
const handleConfirm = async (row: Row<TreeRowData>) => {
29+
await handleDeleteRow(row);
3030
setIsOpen(false);
3131
setRow(null);
3232
};

0 commit comments

Comments
 (0)