Skip to content

Commit bce8843

Browse files
authored
feat: rename tags in the taxonomy editor (openedx#2939)
1 parent aa92d74 commit bce8843

15 files changed

Lines changed: 755 additions & 246 deletions

src/taxonomy/data/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export const apiUrls = {
9191
/** URL to plan (preview what would happen) a taxonomy import */
9292
tagsPlanImport: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/import/plan/`),
9393
createTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`),
94+
updateTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`),
9495
} satisfies Record<string, (...args: any[]) => string>;
9596

9697
/**

src/taxonomy/data/apiHooks.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,27 @@ export const useCreateTag = (taxonomyId: number) => {
240240
},
241241
});
242242
};
243+
244+
export const useUpdateTag = (taxonomyId: number) => {
245+
const queryClient = useQueryClient();
246+
247+
return useMutation({
248+
mutationFn: async ({ value, originalValue }: { value: string, originalValue: string }) => {
249+
try {
250+
await getAuthenticatedHttpClient().patch(
251+
apiUrls.updateTag(taxonomyId),
252+
{ tag: originalValue, updated_tag_value: value },
253+
);
254+
} catch (err) {
255+
throw new Error(getApiErrorMessage(err));
256+
}
257+
},
258+
onSuccess: () => {
259+
queryClient.invalidateQueries({
260+
queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId),
261+
});
262+
// In the metadata, 'tagsCount' (and possibly other fields) will have changed:
263+
queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) });
264+
},
265+
});
266+
};

src/taxonomy/data/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export interface TagListData {
5858
next: string;
5959
numPages: number;
6060
previous: string;
61+
canAddTag?: boolean;
6162
results: TagData[];
6263
start: number;
6364
}

src/taxonomy/tag-list/TagListTable.test.jsx

Lines changed: 336 additions & 161 deletions
Large diffs are not rendered by default.

src/taxonomy/tag-list/TagListTable.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, {
44
useEffect,
55
} from 'react';
66
import type { PaginationState } from '@tanstack/react-table';
7-
import { useTagListData, useCreateTag } from '../data/apiHooks';
7+
import { useTagListData, useCreateTag, useUpdateTag } from '../data/apiHooks';
88
import { TagTree } from './tagTree';
99
import { TableView } from '../tree-table';
1010
import type {
@@ -78,6 +78,7 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => {
7878
enabled: tableMode === TABLE_MODES.VIEW,
7979
});
8080
const createTagMutation = useCreateTag(taxonomyId);
81+
const updateTagMutation = useUpdateTag(taxonomyId);
8182
const pageCount = tagList?.numPages ?? -1;
8283

8384
// TODO: to make this more readable, introduce a React context for the TagListTable instead of passing props.
@@ -88,6 +89,7 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => {
8889
setTagTree,
8990
setDraftError,
9091
createTagMutation,
92+
updateTagMutation,
9193
enterPreviewMode,
9294
setToast,
9395
setIsCreatingTopTag,
@@ -105,19 +107,19 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => {
105107
onStartDraft: enterDraftMode,
106108
setActiveActionMenuRowId,
107109
hasOpenDraft,
110+
canAddTag: tagList?.canAddTag !== false,
108111
draftError,
109112
setDraftError,
110113
isSavingDraft: createTagMutation.isPending,
111114
maxDepth,
112-
creatingParentId,
113115
}),
114116
[
115117
isCreatingTopTag,
116-
editingRowId,
117118
tableMode,
118119
activeActionMenuRowId,
119120
hasOpenDraft,
120121
creatingParentId,
122+
tagList?.canAddTag,
121123
draftError,
122124
createTagMutation.isPending,
123125
maxDepth,
@@ -155,7 +157,9 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => {
155157
isCreatingTopRow: isCreatingTopTag,
156158
draftError,
157159
createRowMutation: createTagMutation,
160+
updateRowMutation: updateTagMutation,
158161
handleCreateRow: handleCreateTag,
162+
handleUpdateRow: handleUpdateTag,
159163
toast,
160164
setToast,
161165
setIsCreatingTopRow: setIsCreatingTopTag,
@@ -164,6 +168,8 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => {
164168
setCreatingParentId,
165169
setDraftError,
166170
validate,
171+
editingRowId,
172+
setEditingRowId,
167173
}}
168174
/>
169175
);

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import { IntlProvider } from '@edx/frontend-platform/i18n';
3-
import { act, renderHook } from '@testing-library/react';
3+
import { act, renderHook, waitFor } from '@testing-library/react';
44

55
import { TagTree } from './tagTree';
66
import { useEditActions, useTableModes } from './hooks';
@@ -44,6 +44,8 @@ describe('useTableModes', () => {
4444
describe('useEditActions', () => {
4545
const buildActions = (overrides = {}) => {
4646
const createTagMutation = { mutateAsync: jest.fn() };
47+
// mock updateTagMutation to have a function `mutateAsync` that returns a resolved promise
48+
const updateTagMutation = { mutateAsync: jest.fn() };
4749
const setTagTree = jest.fn();
4850
const setDraftError = jest.fn();
4951
const enterPreviewMode = jest.fn();
@@ -63,6 +65,7 @@ describe('useEditActions', () => {
6365
setCreatingParentId,
6466
exitDraftWithoutSave,
6567
setEditingRowId,
68+
updateTagMutation: updateTagMutation as any,
6669
...(overrides as any),
6770
};
6871

@@ -139,7 +142,9 @@ describe('useEditActions', () => {
139142
await actions.handleUpdateTag('updated', 'original');
140143
});
141144

142-
expect(enterPreviewMode).toHaveBeenCalled();
145+
await waitFor(() => {
146+
expect(enterPreviewMode).toHaveBeenCalled();
147+
});
143148
expect(setToast).toHaveBeenCalledWith({
144149
show: true,
145150
message: 'Tag "updated" updated successfully',

src/taxonomy/tag-list/hooks.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useReducer } from 'react';
22
import { useIntl } from '@edx/frontend-platform/i18n';
33

4-
import { useCreateTag } from '../data/apiHooks';
4+
import { useCreateTag, useUpdateTag } from '../data/apiHooks';
55
import { TagTree } from './tagTree';
66
import { TagListTableError } from './errors';
77
import type { RowId } from '../tree-table/types';
@@ -45,6 +45,7 @@ interface UseEditActionsParams {
4545
setCreatingParentId: React.Dispatch<React.SetStateAction<RowId | null>>;
4646
exitDraftWithoutSave: () => void;
4747
setEditingRowId: React.Dispatch<React.SetStateAction<RowId | null>>;
48+
updateTagMutation: ReturnType<typeof useUpdateTag>;
4849
}
4950

5051
const getInlineValidationMessage = (value: string, intl: ReturnType<typeof useIntl>): string => {
@@ -111,9 +112,20 @@ const useEditActions = ({
111112
setToast,
112113
setIsCreatingTopTag,
113114
setCreatingParentId,
115+
exitDraftWithoutSave,
114116
setEditingRowId,
117+
updateTagMutation,
115118
}: UseEditActionsParams) => {
116119
const intl = useIntl();
120+
121+
const updateTableAfterRename = (oldValue: string, newValue: string) => {
122+
setTagTree((currentTagTree) => {
123+
const nextTree = currentTagTree || new TagTree([]);
124+
nextTree.editTagValue(oldValue, newValue);
125+
return nextTree;
126+
});
127+
};
128+
117129
const updateTableWithoutDataReload = (value: string, parentTagValue: string | null = null) => {
118130
setTagTree((currentTagTree) => {
119131
const nextTree = currentTagTree || new TagTree([]);
@@ -178,14 +190,31 @@ const useEditActions = ({
178190

179191
const handleUpdateTag = async (value: string, originalValue: string) => {
180192
const trimmed = value.trim();
181-
if (trimmed && trimmed !== originalValue) {
193+
if (!validate(trimmed, 'soft')) {
194+
return;
195+
}
196+
197+
if (trimmed === originalValue) {
198+
setEditingRowId(null);
199+
exitDraftWithoutSave();
200+
return;
201+
}
202+
203+
try {
204+
setDraftError('');
205+
await updateTagMutation.mutateAsync({ value: trimmed, originalValue });
206+
updateTableAfterRename(originalValue, trimmed);
182207
enterPreviewMode();
208+
setEditingRowId(null);
183209
setToast({
184210
show: true,
185211
message: intl.formatMessage(messages.tagUpdateSuccessMessage, { name: trimmed }),
186212
});
213+
} catch (error) {
214+
const message = intl.formatMessage(messages.tagUpdateErrorMessage, { errorMessage: (error as Error)?.message });
215+
setDraftError((error as Error)?.message || intl.formatMessage(messages.tagUpdateErrorMessage, { errorMessage: '' }));
216+
setToast({ show: true, message });
187217
}
188-
setEditingRowId(null);
189218
};
190219

191220
return {

src/taxonomy/tag-list/messages.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ const messages = defineMessages({
5757
id: 'course-authoring.tag-list.hide-subtags.button-label',
5858
defaultMessage: 'Hide Subtags',
5959
},
60+
tagUpdateErrorMessage: {
61+
id: 'course-authoring.tag-list.update-error',
62+
defaultMessage: 'Error updating tag: {errorMessage}',
63+
},
64+
renameTag: {
65+
id: 'course-authoring.tag-list.rename-tag',
66+
defaultMessage: 'Rename',
67+
},
6068
});
6169

6270
export default messages;

0 commit comments

Comments
 (0)