Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
70bece4
feat: add a new tag from frontend
jesperhodge Feb 12, 2026
e6d6cb7
feat: Add table control bar with expand button
jesperhodge Feb 12, 2026
e70becf
feat: create tags
jesperhodge Feb 19, 2026
3d9188b
feat: use react-table and get full depth of tags
jesperhodge Feb 19, 2026
39b8438
feat: support nested subrows in tag list table
jesperhodge Feb 20, 2026
ba08de0
feat: can create new tags with a subtag as parent
jesperhodge Feb 23, 2026
f11e813
feat: show add row conditionally on table depth
jesperhodge Feb 23, 2026
54b1175
test: make existing tag list table tests work again
jesperhodge Feb 23, 2026
de4e8c5
test: create tags
jesperhodge Feb 23, 2026
3ea6bed
test: generate tests from acceptance criteria
jesperhodge Feb 23, 2026
0f4dfda
test: add tests for nested sub-tags and taxonomy editability
jesperhodge Feb 23, 2026
15e9de1
feat: keep table working state with new row at top
jesperhodge Feb 24, 2026
ec926db
feat: add tag tree data structure
jesperhodge Feb 26, 2026
a617cb8
feat: create tag tree
jesperhodge Feb 26, 2026
f5f1ffc
fix: creating top tags
jesperhodge Feb 26, 2026
6021b82
feat: add card style
jesperhodge Feb 26, 2026
38df0b7
feat: add plus icon
jesperhodge Feb 26, 2026
9688d35
feat: add button styling
jesperhodge Feb 26, 2026
07c54d9
test: fix tests that are implemented
jesperhodge Feb 27, 2026
a459888
feat: add reducer for table modes
jesperhodge Feb 27, 2026
8db41e1
feat: enable preview mode
jesperhodge Feb 27, 2026
1081bfa
fix: mode transitions
jesperhodge Feb 27, 2026
cb2b3be
feat: add row options menu
jesperhodge Feb 27, 2026
7913057
test: skip anything thats not implemented yet
jesperhodge Feb 28, 2026
2f5202f
test: fix test
jesperhodge Feb 28, 2026
ee92b6c
refactor: change table mode name to preview
jesperhodge Mar 2, 2026
875ce1e
refactor: extract subcomponents
jesperhodge Mar 2, 2026
62a34d0
fix: tests
jesperhodge Mar 2, 2026
ddc5271
refactor: extract table display component
jesperhodge Mar 2, 2026
a522402
refactor: make table components reusable
jesperhodge Mar 2, 2026
f51f3ae
refactor: simplify and extract components
jesperhodge Mar 2, 2026
2b9aad0
refactor: extract reusable tree table components
jesperhodge Mar 2, 2026
3e7ac04
refactor: convert to typescript
jesperhodge Mar 2, 2026
e6caaa6
refactor: make tree table more readable
jesperhodge Mar 3, 2026
c2de45c
Merge remote-tracking branch 'upstream/master' into jhodge/create-tags
jesperhodge Mar 3, 2026
15d3c78
fix: delete duplicate file
jesperhodge Mar 4, 2026
1b630bb
feat: add expand icon
jesperhodge Mar 4, 2026
7c9c69b
fix: show columns with correct width
jesperhodge Mar 4, 2026
25c0397
fix: expand rows style
jesperhodge Mar 4, 2026
8fc888e
feat: tag list table expand and row UI
jesperhodge Mar 5, 2026
6bf6400
feat: add dropdown menu
jesperhodge Mar 5, 2026
9e3114a
feat: attempt to make editable row
jesperhodge Mar 5, 2026
784bf49
feat: move create top row buttons to right column
jesperhodge Mar 6, 2026
86522b1
feat: save create rows
jesperhodge Mar 6, 2026
1ca3c5a
feat: prettify expand all
jesperhodge Mar 6, 2026
cb093c5
fix: transitions and styles
jesperhodge Mar 6, 2026
6e887d5
feat: UI alignments
jesperhodge Mar 6, 2026
b3c298e
fix: lint
jesperhodge Mar 9, 2026
7cba21e
fix: lint
jesperhodge Mar 9, 2026
20ee272
fix: lint and types
jesperhodge Mar 9, 2026
3565051
fix: lint
jesperhodge Mar 9, 2026
0c645c7
refactor: remove unused code
jesperhodge Mar 9, 2026
694f5b9
Merge remote-tracking branch 'upstream/master' into jhodge/create-tags
jesperhodge Mar 9, 2026
4e1191f
feat: add Enter/Exit and spacing
jesperhodge Mar 9, 2026
27d47dc
fix: key press escape functionality
jesperhodge Mar 10, 2026
6a9e1db
fix: expand link
jesperhodge Mar 10, 2026
1dbf276
fix: style
jesperhodge Mar 10, 2026
bd6bbab
refactor: replace hardcoded pixel values with paragon/bootstrap sizes
jesperhodge Mar 10, 2026
674af1a
feat: improve accessibility
jesperhodge Mar 10, 2026
391ca25
fix: ui
jesperhodge Mar 10, 2026
3022311
fix: test
jesperhodge Mar 10, 2026
8b3d766
fix: tests
jesperhodge Mar 10, 2026
f7f2aaa
temp: disable pagination for tag list
jesperhodge Mar 10, 2026
d1ae67f
fix: tests
jesperhodge Mar 10, 2026
98aab9d
fix: lint
jesperhodge Mar 10, 2026
9da80a3
fix: pr review comments
jesperhodge Mar 10, 2026
afef299
fix: pr review comments
jesperhodge Mar 10, 2026
711a9b9
fix: pr review comments
jesperhodge Mar 10, 2026
4100fd6
fix: correct forbidden chars
jesperhodge Mar 10, 2026
e9c1c27
fix: lint
jesperhodge Mar 11, 2026
01120b0
fix: visual indent
jesperhodge Mar 11, 2026
a86679f
refactor: tests
jesperhodge Mar 11, 2026
9a9e3e2
refactor: tests
jesperhodge Mar 11, 2026
25af304
fix: test warnings
jesperhodge Mar 11, 2026
6e0366a
fix: disable behavior
jesperhodge Mar 11, 2026
a78e047
fix: test
jesperhodge Mar 11, 2026
e614c16
test: increase coverage
jesperhodge Mar 12, 2026
1f1e366
fix: lint
jesperhodge Mar 12, 2026
1ff082f
Merge remote-tracking branch 'upstream/master' into jhodge/create-tags
jesperhodge Mar 12, 2026
dc2db61
test: coverage
jesperhodge Mar 12, 2026
1cd7a44
fix: tests
jesperhodge Mar 12, 2026
9bdd5a4
fix: show correct number of taxonomy levels
jesperhodge Mar 17, 2026
a3f8899
refactor: extract constants
jesperhodge Mar 17, 2026
631d22d
fix: PR comments
jesperhodge Mar 17, 2026
407835d
fix: tests
jesperhodge Mar 18, 2026
faea987
fix: url parameter breaking things
jesperhodge Mar 18, 2026
b3d25bd
fix: PR comments and cleanup
jesperhodge Mar 25, 2026
6f2f6b9
fix: typo
jesperhodge Mar 25, 2026
3a96b58
fix: types
jesperhodge Mar 25, 2026
f193edb
fix: apply github code review suggestions
jesperhodge Mar 26, 2026
a8f8297
fix: PR comments
jesperhodge Mar 26, 2026
0971da4
fix: PR comments
jesperhodge Mar 26, 2026
825bab3
fix: tests
jesperhodge Mar 26, 2026
443221c
fix: tests
jesperhodge Mar 26, 2026
40f409e
fix: PR comment
jesperhodge Mar 26, 2026
4a4deef
fix: PR comments
jesperhodge Mar 26, 2026
38f87b8
fix: remove unused code
jesperhodge Mar 27, 2026
e8820a2
Merge remote-tracking branch 'upstream/master' into jhodge/create-tags
jesperhodge Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 101 additions & 15 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@openedx/paragon": "^23.5.0",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "2.11.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-query": "5.90.21",
"@tinymce/tinymce-react": "^6.0.0",
"classnames": "2.5.1",
Expand Down
79 changes: 79 additions & 0 deletions src/taxonomy/data/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getTaxonomyListData,
getTaxonomy,
deleteTaxonomy,
getApiErrorMessage,
} from './api';

describe('taxonomy api calls', () => {
Expand Down Expand Up @@ -57,4 +58,82 @@ describe('taxonomy api calls', () => {
// Restore the location object of window:
window.location = origLocation;
});

describe('getApiErrorMessage', () => {
it('returns first non-empty string when response data is an array', () => {
const err = {
response: {
data: ['', 'Array error message', 'Another message'],
},
};

expect(getApiErrorMessage(err)).toEqual('Array error message');
});

it('returns response data when it is a non-empty string', () => {
const err = {
response: {
data: 'String error message',
},
};

expect(getApiErrorMessage(err)).toEqual('String error message');
});

it('prefers object.error over detail and message fields', () => {
const err = {
response: {
data: {
error: 'Error field message',
detail: 'Detail field message',
message: 'Message field message',
},
},
};

expect(getApiErrorMessage(err)).toEqual('Error field message');
});

it('falls back to object.message then object.detail when needed', () => {
const messageErr = {
response: {
data: {
detail: 'Detail field message',
message: 'Message field message',
},
},
};
const detailErr = {
response: {
data: {
detail: 'Detail field message',
},
},
};

expect(getApiErrorMessage(messageErr)).toEqual('Message field message');
expect(getApiErrorMessage(detailErr)).toEqual('Detail field message');
});

it('falls back to top-level error message when response data is unparseable', () => {
const err = {
message: 'Top level error message',
response: {
data: [null, {}, ' '],
},
};

expect(getApiErrorMessage(err)).toEqual('Top level error message');
});

it('returns default message when no message is available', () => {
const err = {
response: {
data: null,
},
};

expect(getApiErrorMessage(err)).toEqual('Unknown error');
});
});
});
69 changes: 64 additions & 5 deletions src/taxonomy/data/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import type { TaxonomyData, TaxonomyListData } from './types';
import { MAX_TAXONOMY_ITEMS } from './constants';
import messages from '../messages';

const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
const getTaxonomiesV1Endpoint = () => new URL('api/content_tagging/v1/taxonomies/', getApiBaseUrl()).href;
Expand Down Expand Up @@ -53,19 +55,32 @@ export const apiUrls = {
/** Get the URL for a Taxonomy */
taxonomy: (taxonomyId: number) => makeUrl(`${taxonomyId}/`),
/**
* Get the URL for listing the tags of a taxonomy
* Get the URL for listing the tags of a taxonomy.
* The max response size is 10,000 items, as set in the `MAX_TAXONOMY_ITEMS` constant.
* The backend does not support larger responses.
* @param pageIndex Zero-indexed page number
* @param pageSize How many tags per page to load
* @param fullDepth Whether to return max levels of child tags,
* with results limited by the MAX_TAXONOMY_ITEMS constant.
*/
tagList: (taxonomyId: number, pageIndex: number, pageSize: number) => makeUrl(`${taxonomyId}/tags/`, {
page: (pageIndex + 1), page_size: pageSize,
}),
tagList: (taxonomyId: number, {
pageIndex, pageSize, fullDepth, disablePagination,
}: { pageIndex: number | null; pageSize: number | null; fullDepth?: boolean; disablePagination?: boolean }) => {
if (disablePagination) {
return makeUrl(`${taxonomyId}/tags/`, { full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0 });
}
return makeUrl(`${taxonomyId}/tags/`, {
page: (pageIndex ?? 0) + 1,
page_size: pageSize ?? 10,
full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0,
});
},
/**
* Get _all_ tags below a given parent tag. This may be replaced with something more scalable in the future.
*/
allSubtagsOf: (taxonomyId: number, parentTagValue: string) => makeUrl(`${taxonomyId}/tags/`, {
// Load as deeply as we can
full_depth_threshold: 10000,
full_depth_threshold: MAX_TAXONOMY_ITEMS,
parent_tag: parentTagValue,
}),
/** URL to create a new taxonomy from an import file. */
Expand All @@ -74,6 +89,7 @@ export const apiUrls = {
tagsImport: (taxonomyId) => makeUrl(`${taxonomyId}/tags/import/`),
/** URL to plan (preview what would happen) a taxonomy import */
tagsPlanImport: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/import/plan/`),
createTag: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/`),
} satisfies Record<string, (...args: any[]) => string>;

/**
Expand Down Expand Up @@ -109,3 +125,46 @@ export async function getTaxonomy(taxonomyId: number): Promise<TaxonomyData> {
export function getTaxonomyExportFile(taxonomyId: number, format: 'json' | 'csv'): void {
window.location.href = apiUrls.exportTaxonomy(taxonomyId, format);
}

/**
* Extracts a human-readable error message from the API response.
*
* While most endpoints return an object (e.g., `{ error: "msg" }`), this specific
* backend call may return a raw array of strings: `["error1", "error2"]`. This function normalizes those
* edge cases by returning the first available error message.
* @param {unknown} err - The caught error object from the API.
* @param {Object} intl - The internationalization object to format default messages.
* @returns {string} The first detected error string or a default message if unparseable.
*/
export const getApiErrorMessage = (err: unknown, intl?: any): string => {
const error = err as { message?: string; response?: { data?: unknown } };
const responseData = error?.response?.data;

// `POST /api/content_tagging/v1/taxonomies/:id/tags/ with a duplicate tag name returns
// `["Tag with value 'abblue' already exists for taxonomy."]` as response body.
if (Array.isArray(responseData)) {
const firstMessage = responseData.find((item): item is string => typeof item === 'string' && item.trim().length > 0);
if (firstMessage) {
return firstMessage;
}
}

if (typeof responseData === 'string' && responseData.trim().length > 0) {
return responseData;
}

if (responseData && typeof responseData === 'object') {
const objectData = responseData as { error?: string; detail?: string; message?: string };
if (typeof objectData.error === 'string' && objectData.error.trim().length > 0) {
return objectData.error;
}
if (typeof objectData.message === 'string' && objectData.message.trim().length > 0) {
return objectData.message;
}
if (typeof objectData.detail === 'string' && objectData.detail.trim().length > 0) {
return objectData.detail;
}
}

return error?.message || (intl ? intl.formatMessage(messages.unknownErrorMessage) : 'Unknown error');
};
14 changes: 13 additions & 1 deletion src/taxonomy/data/apiHooks.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react'; // Required to use JSX syntax without type errors

import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

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

import {
useCreateTag,
useImportPlan,
useImportTags,
useImportNewTaxonomy,
Expand All @@ -28,7 +30,9 @@ const queryClient = new QueryClient({

const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
<IntlProvider locale="en">
{children}
</IntlProvider>
</QueryClientProvider>
);

Expand Down Expand Up @@ -105,4 +109,12 @@ describe('import taxonomy api calls', () => {
expect(result.current.error).toEqual(Error('test error'));
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsPlanImport(1));
});

it('should surface duplicate tag error returned as an array', async () => {
const duplicateError = "Tag with value 'ab' already exists for taxonomy.";
axiosMock.onPost(apiUrls.createTag(1)).reply(400, [duplicateError]);
const { result } = renderHook(() => useCreateTag(1), { wrapper });

await expect(result.current.mutateAsync({ value: 'ab' })).rejects.toEqual(Error(duplicateError));
});
});
44 changes: 38 additions & 6 deletions src/taxonomy/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { apiUrls, ALL_TAXONOMIES } from './api';
import { apiUrls, ALL_TAXONOMIES, getApiErrorMessage } from './api';
import * as api from './api';
import type { QueryOptions, TagListData } from './types';
import { useIntl } from '@edx/frontend-platform/i18n';

// Query key patterns. Allows an easy way to clear all data related to a given taxonomy.
// https://github.com/openedx/frontend-app-admin-portal/blob/2ba315d/docs/decisions/0006-tanstack-react-query.rst
Expand Down Expand Up @@ -139,7 +140,7 @@ export const useImportTags = () => {
const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsImport(taxonomyId), formData);
return camelCaseObject(data);
} catch (err) {
throw new Error((err as any).response?.data?.error || (err as any).message);
throw new Error(getApiErrorMessage(err));
Comment thread
bradenmacdonald marked this conversation as resolved.
}
},
onSuccess: (data) => {
Expand Down Expand Up @@ -170,7 +171,7 @@ export const useImportPlan = (taxonomyId: number, file: File | null) => useQuery
const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsPlanImport(taxonomyId), formData);
return data.plan as string;
} catch (err) {
throw new Error((err as any).response?.data?.error || (err as any).message);
throw new Error(getApiErrorMessage(err));
}
},
retry: false, // If there's an error, it's probably a real problem with the file. Don't try again several times!
Expand All @@ -180,13 +181,19 @@ export const useImportPlan = (taxonomyId: number, file: File | null) => useQuery
* Use the list of tags in a taxonomy.
*/
export const useTagListData = (taxonomyId: number, options: QueryOptions) => {
const { pageIndex, pageSize } = options;
const { pageIndex, pageSize, enabled = true, disablePagination = false } = options; // eslint-disable-line
return useQuery({
queryKey: taxonomyQueryKeys.taxonomyTagListPage(taxonomyId, pageIndex, pageSize),
// queryKey: taxonomyQueryKeys.taxonomyTagListPage(taxonomyId, pageIndex, pageSize),
queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), // For now, ignore pagination in the query key.
queryFn: async () => {
const { data } = await getAuthenticatedHttpClient().get(apiUrls.tagList(taxonomyId, pageIndex, pageSize));
const { data } = await getAuthenticatedHttpClient().get(
apiUrls.tagList(taxonomyId, {
pageIndex, pageSize, fullDepth: true, disablePagination,
}),
);
return camelCaseObject(data) as TagListData;
},
enabled,
});
};

Expand All @@ -202,3 +209,28 @@ export const useSubTags = (taxonomyId: number, parentTagValue: string) => useQue
return camelCaseObject(response.data) as TagListData;
},
});

export const useCreateTag = (taxonomyId: number) => {
const queryClient = useQueryClient();
const intl = useIntl();

return useMutation({
mutationFn: async ({ value, parentTagValue }: { value: string, parentTagValue?: string }) => {
try {
await getAuthenticatedHttpClient().post(
apiUrls.createTag(taxonomyId),
{ tag: value, parent_tag_value: parentTagValue },
);
} catch (err) {
throw new Error(getApiErrorMessage(err, intl));
}
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId),
});
// In the metadata, 'tagsCount' (and possibly other fields) will have changed:
queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) });
},
});
};
8 changes: 8 additions & 0 deletions src/taxonomy/data/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* The maximum number of taxonomy items expected.
* Used to ensure that we load all nested subtags.
* This is set to the maximum value allowed by the backend.
* However, if the taxonomy size exceeds this value, the results
* will be incomplete because the backend only supports a taxonomy size of 10,000 items or fewer.
*/
export const MAX_TAXONOMY_ITEMS = 10000;
4 changes: 4 additions & 0 deletions src/taxonomy/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface TaxonomyListData {
export interface QueryOptions {
pageIndex: number;
pageSize: number;
enabled?: boolean;
disablePagination?: boolean;
}

export interface TagData {
Expand All @@ -42,6 +44,8 @@ export interface TagData {
id: number;
parentValue: string | null;
subTagsUrl: string | null;
canChangeTag?: boolean;
canDeleteTag?: boolean;
/** Unique ID for this tag, also its display text */
value: string;
usageCount?: number;
Expand Down
4 changes: 4 additions & 0 deletions src/taxonomy/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ const messages = defineMessages({
defaultMessage: 'Please keep this window open. We\'ll let you know when it\'s done.',
description: 'Alert message when the taxonomy import is in progress.',
},
unknownErrorMessage: {
id: 'course-authoring.taxonomy-list.error.unknown',
defaultMessage: 'Unknown error',
},
});

export default messages;
Loading
Loading