Skip to content

Commit 2e77e19

Browse files
authored
feat: Add tags to a taxonomy (#2872)
1 parent 9630215 commit 2e77e19

39 files changed

Lines changed: 5234 additions & 200 deletions

package-lock.json

Lines changed: 101 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"@openedx/paragon": "^23.5.0",
6767
"@redux-devtools/extension": "^3.3.0",
6868
"@reduxjs/toolkit": "2.11.2",
69+
"@tanstack/react-table": "^8.21.3",
6970
"@tanstack/react-query": "5.90.21",
7071
"@tinymce/tinymce-react": "^6.0.0",
7172
"classnames": "2.5.1",

src/taxonomy/data/api.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getTaxonomyListData,
88
getTaxonomy,
99
deleteTaxonomy,
10+
getApiErrorMessage,
1011
} from './api';
1112

1213
describe('taxonomy api calls', () => {
@@ -57,4 +58,82 @@ describe('taxonomy api calls', () => {
5758
// Restore the location object of window:
5859
window.location = origLocation;
5960
});
61+
62+
describe('getApiErrorMessage', () => {
63+
it('returns first non-empty string when response data is an array', () => {
64+
const err = {
65+
response: {
66+
data: ['', 'Array error message', 'Another message'],
67+
},
68+
};
69+
70+
expect(getApiErrorMessage(err)).toEqual('Array error message');
71+
});
72+
73+
it('returns response data when it is a non-empty string', () => {
74+
const err = {
75+
response: {
76+
data: 'String error message',
77+
},
78+
};
79+
80+
expect(getApiErrorMessage(err)).toEqual('String error message');
81+
});
82+
83+
it('prefers object.error over detail and message fields', () => {
84+
const err = {
85+
response: {
86+
data: {
87+
error: 'Error field message',
88+
detail: 'Detail field message',
89+
message: 'Message field message',
90+
},
91+
},
92+
};
93+
94+
expect(getApiErrorMessage(err)).toEqual('Error field message');
95+
});
96+
97+
it('falls back to object.message then object.detail when needed', () => {
98+
const messageErr = {
99+
response: {
100+
data: {
101+
detail: 'Detail field message',
102+
message: 'Message field message',
103+
},
104+
},
105+
};
106+
const detailErr = {
107+
response: {
108+
data: {
109+
detail: 'Detail field message',
110+
},
111+
},
112+
};
113+
114+
expect(getApiErrorMessage(messageErr)).toEqual('Message field message');
115+
expect(getApiErrorMessage(detailErr)).toEqual('Detail field message');
116+
});
117+
118+
it('falls back to top-level error message when response data is unparseable', () => {
119+
const err = {
120+
message: 'Top level error message',
121+
response: {
122+
data: [null, {}, ' '],
123+
},
124+
};
125+
126+
expect(getApiErrorMessage(err)).toEqual('Top level error message');
127+
});
128+
129+
it('returns default message when no message is available', () => {
130+
const err = {
131+
response: {
132+
data: null,
133+
},
134+
};
135+
136+
expect(getApiErrorMessage(err)).toEqual('Unknown error');
137+
});
138+
});
60139
});

src/taxonomy/data/api.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
22
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
33
import type { TaxonomyData, TaxonomyListData } from './types';
4+
import { MAX_TAXONOMY_ITEMS } from './constants';
5+
import messages from '../messages';
46

57
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
68
const getTaxonomiesV1Endpoint = () => new URL('api/content_tagging/v1/taxonomies/', getApiBaseUrl()).href;
@@ -53,19 +55,32 @@ export const apiUrls = {
5355
/** Get the URL for a Taxonomy */
5456
taxonomy: (taxonomyId: number) => makeUrl(`${taxonomyId}/`),
5557
/**
56-
* Get the URL for listing the tags of a taxonomy
58+
* Get the URL for listing the tags of a taxonomy.
59+
* The max response size is 10,000 items, as set in the `MAX_TAXONOMY_ITEMS` constant.
60+
* The backend does not support larger responses.
5761
* @param pageIndex Zero-indexed page number
5862
* @param pageSize How many tags per page to load
63+
* @param fullDepth Whether to return max levels of child tags,
64+
* with results limited by the MAX_TAXONOMY_ITEMS constant.
5965
*/
60-
tagList: (taxonomyId: number, pageIndex: number, pageSize: number) => makeUrl(`${taxonomyId}/tags/`, {
61-
page: (pageIndex + 1), page_size: pageSize,
62-
}),
66+
tagList: (taxonomyId: number, {
67+
pageIndex, pageSize, fullDepth, disablePagination,
68+
}: { pageIndex: number | null; pageSize: number | null; fullDepth?: boolean; disablePagination?: boolean }) => {
69+
if (disablePagination) {
70+
return makeUrl(`${taxonomyId}/tags/`, { full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0 });
71+
}
72+
return makeUrl(`${taxonomyId}/tags/`, {
73+
page: (pageIndex ?? 0) + 1,
74+
page_size: pageSize ?? 10,
75+
full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0,
76+
});
77+
},
6378
/**
6479
* Get _all_ tags below a given parent tag. This may be replaced with something more scalable in the future.
6580
*/
6681
allSubtagsOf: (taxonomyId: number, parentTagValue: string) => makeUrl(`${taxonomyId}/tags/`, {
6782
// Load as deeply as we can
68-
full_depth_threshold: 10000,
83+
full_depth_threshold: MAX_TAXONOMY_ITEMS,
6984
parent_tag: parentTagValue,
7085
}),
7186
/** URL to create a new taxonomy from an import file. */
@@ -74,6 +89,7 @@ export const apiUrls = {
7489
tagsImport: (taxonomyId) => makeUrl(`${taxonomyId}/tags/import/`),
7590
/** URL to plan (preview what would happen) a taxonomy import */
7691
tagsPlanImport: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/import/plan/`),
92+
createTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`),
7793
} satisfies Record<string, (...args: any[]) => string>;
7894

7995
/**
@@ -109,3 +125,46 @@ export async function getTaxonomy(taxonomyId: number): Promise<TaxonomyData> {
109125
export function getTaxonomyExportFile(taxonomyId: number, format: 'json' | 'csv'): void {
110126
window.location.href = apiUrls.exportTaxonomy(taxonomyId, format);
111127
}
128+
129+
/**
130+
* Extracts a human-readable error message from the API response.
131+
*
132+
* While most endpoints return an object (e.g., `{ error: "msg" }`), this specific
133+
* backend call may return a raw array of strings: `["error1", "error2"]`. This function normalizes those
134+
* edge cases by returning the first available error message.
135+
* @param {unknown} err - The caught error object from the API.
136+
* @param {Object} intl - The internationalization object to format default messages.
137+
* @returns {string} The first detected error string or a default message if unparseable.
138+
*/
139+
export const getApiErrorMessage = (err: unknown, intl?: any): string => {
140+
const error = err as { message?: string; response?: { data?: unknown } };
141+
const responseData = error?.response?.data;
142+
143+
// `POST /api/content_tagging/v1/taxonomies/:id/tags/ with a duplicate tag name returns
144+
// `["Tag with value 'abblue' already exists for taxonomy."]` as response body.
145+
if (Array.isArray(responseData)) {
146+
const firstMessage = responseData.find((item): item is string => typeof item === 'string' && item.trim().length > 0);
147+
if (firstMessage) {
148+
return firstMessage;
149+
}
150+
}
151+
152+
if (typeof responseData === 'string' && responseData.trim().length > 0) {
153+
return responseData;
154+
}
155+
156+
if (responseData && typeof responseData === 'object') {
157+
const objectData = responseData as { error?: string; detail?: string; message?: string };
158+
if (typeof objectData.error === 'string' && objectData.error.trim().length > 0) {
159+
return objectData.error;
160+
}
161+
if (typeof objectData.message === 'string' && objectData.message.trim().length > 0) {
162+
return objectData.message;
163+
}
164+
if (typeof objectData.detail === 'string' && objectData.detail.trim().length > 0) {
165+
return objectData.detail;
166+
}
167+
}
168+
169+
return error?.message || (intl ? intl.formatMessage(messages.unknownErrorMessage) : 'Unknown error');
170+
};

src/taxonomy/data/apiHooks.test.jsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React from 'react'; // Required to use JSX syntax without type errors
33

44
import { initializeMockApp } from '@edx/frontend-platform';
55
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
6+
import { IntlProvider } from '@edx/frontend-platform/i18n';
67
import { renderHook, waitFor } from '@testing-library/react';
78
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
89

@@ -11,6 +12,7 @@ import MockAdapter from 'axios-mock-adapter';
1112
import { apiUrls } from './api';
1213

1314
import {
15+
useCreateTag,
1416
useImportPlan,
1517
useImportTags,
1618
useImportNewTaxonomy,
@@ -28,7 +30,9 @@ const queryClient = new QueryClient({
2830

2931
const wrapper = ({ children }) => (
3032
<QueryClientProvider client={queryClient}>
31-
{children}
33+
<IntlProvider locale="en">
34+
{children}
35+
</IntlProvider>
3236
</QueryClientProvider>
3337
);
3438

@@ -105,4 +109,12 @@ describe('import taxonomy api calls', () => {
105109
expect(result.current.error).toEqual(Error('test error'));
106110
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsPlanImport(1));
107111
});
112+
113+
it('should surface duplicate tag error returned as an array', async () => {
114+
const duplicateError = "Tag with value 'ab' already exists for taxonomy.";
115+
axiosMock.onPost(apiUrls.createTag(1)).reply(400, [duplicateError]);
116+
const { result } = renderHook(() => useCreateTag(1), { wrapper });
117+
118+
await expect(result.current.mutateAsync({ value: 'ab' })).rejects.toEqual(Error(duplicateError));
119+
});
108120
});

src/taxonomy/data/apiHooks.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
1414
import { camelCaseObject } from '@edx/frontend-platform';
1515
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
16-
import { apiUrls, ALL_TAXONOMIES } from './api';
16+
import { apiUrls, ALL_TAXONOMIES, getApiErrorMessage } from './api';
1717
import * as api from './api';
1818
import type { QueryOptions, TagListData } from './types';
19+
import { useIntl } from '@edx/frontend-platform/i18n';
1920

2021
// Query key patterns. Allows an easy way to clear all data related to a given taxonomy.
2122
// https://github.com/openedx/frontend-app-admin-portal/blob/2ba315d/docs/decisions/0006-tanstack-react-query.rst
@@ -139,7 +140,7 @@ export const useImportTags = () => {
139140
const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsImport(taxonomyId), formData);
140141
return camelCaseObject(data);
141142
} catch (err) {
142-
throw new Error((err as any).response?.data?.error || (err as any).message);
143+
throw new Error(getApiErrorMessage(err));
143144
}
144145
},
145146
onSuccess: (data) => {
@@ -170,7 +171,7 @@ export const useImportPlan = (taxonomyId: number, file: File | null) => useQuery
170171
const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsPlanImport(taxonomyId), formData);
171172
return data.plan as string;
172173
} catch (err) {
173-
throw new Error((err as any).response?.data?.error || (err as any).message);
174+
throw new Error(getApiErrorMessage(err));
174175
}
175176
},
176177
retry: false, // If there's an error, it's probably a real problem with the file. Don't try again several times!
@@ -180,13 +181,19 @@ export const useImportPlan = (taxonomyId: number, file: File | null) => useQuery
180181
* Use the list of tags in a taxonomy.
181182
*/
182183
export const useTagListData = (taxonomyId: number, options: QueryOptions) => {
183-
const { pageIndex, pageSize } = options;
184+
const { pageIndex, pageSize, enabled = true, disablePagination = false } = options; // eslint-disable-line
184185
return useQuery({
185-
queryKey: taxonomyQueryKeys.taxonomyTagListPage(taxonomyId, pageIndex, pageSize),
186+
// queryKey: taxonomyQueryKeys.taxonomyTagListPage(taxonomyId, pageIndex, pageSize),
187+
queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), // For now, ignore pagination in the query key.
186188
queryFn: async () => {
187-
const { data } = await getAuthenticatedHttpClient().get(apiUrls.tagList(taxonomyId, pageIndex, pageSize));
189+
const { data } = await getAuthenticatedHttpClient().get(
190+
apiUrls.tagList(taxonomyId, {
191+
pageIndex, pageSize, fullDepth: true, disablePagination,
192+
}),
193+
);
188194
return camelCaseObject(data) as TagListData;
189195
},
196+
enabled,
190197
});
191198
};
192199

@@ -202,3 +209,28 @@ export const useSubTags = (taxonomyId: number, parentTagValue: string) => useQue
202209
return camelCaseObject(response.data) as TagListData;
203210
},
204211
});
212+
213+
export const useCreateTag = (taxonomyId: number) => {
214+
const queryClient = useQueryClient();
215+
const intl = useIntl();
216+
217+
return useMutation({
218+
mutationFn: async ({ value, parentTagValue }: { value: string, parentTagValue?: string }) => {
219+
try {
220+
await getAuthenticatedHttpClient().post(
221+
apiUrls.createTag(taxonomyId),
222+
{ tag: value, parent_tag_value: parentTagValue },
223+
);
224+
} catch (err) {
225+
throw new Error(getApiErrorMessage(err, intl));
226+
}
227+
},
228+
onSuccess: () => {
229+
queryClient.invalidateQueries({
230+
queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId),
231+
});
232+
// In the metadata, 'tagsCount' (and possibly other fields) will have changed:
233+
queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) });
234+
},
235+
});
236+
};

src/taxonomy/data/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* The maximum number of taxonomy items expected.
3+
* Used to ensure that we load all nested subtags.
4+
* This is set to the maximum value allowed by the backend.
5+
* However, if the taxonomy size exceeds this value, the results
6+
* will be incomplete because the backend only supports a taxonomy size of 10,000 items or fewer.
7+
*/
8+
export const MAX_TAXONOMY_ITEMS = 10000;

src/taxonomy/data/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export interface TaxonomyListData {
3232
export interface QueryOptions {
3333
pageIndex: number;
3434
pageSize: number;
35+
enabled?: boolean;
36+
disablePagination?: boolean;
3537
}
3638

3739
export interface TagData {
@@ -42,6 +44,8 @@ export interface TagData {
4244
id: number;
4345
parentValue: string | null;
4446
subTagsUrl: string | null;
47+
canChangeTag?: boolean;
48+
canDeleteTag?: boolean;
4549
/** Unique ID for this tag, also its display text */
4650
value: string;
4751
usageCount?: number;

src/taxonomy/messages.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ const messages = defineMessages({
5050
defaultMessage: 'Please keep this window open. We\'ll let you know when it\'s done.',
5151
description: 'Alert message when the taxonomy import is in progress.',
5252
},
53+
unknownErrorMessage: {
54+
id: 'course-authoring.taxonomy-list.error.unknown',
55+
defaultMessage: 'Unknown error',
56+
},
5357
});
5458

5559
export default messages;

0 commit comments

Comments
 (0)