Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 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
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
14 changes: 11 additions & 3 deletions src/taxonomy/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,16 @@ export const apiUrls = {
* @param pageIndex Zero-indexed page number
* @param pageSize How many tags per page to load
*/
tagList: (taxonomyId: number, pageIndex: number, pageSize: number) => makeUrl(`${taxonomyId}/tags/`, {
page: (pageIndex + 1), page_size: pageSize,
}),
tagList: (taxonomyId: number, pageIndex: number | null, pageSize: number | null, fullDepthThreshold?: number) => {
if (pageIndex === null) {
return makeUrl(`${taxonomyId}/tags/`, { full_depth_threshold: fullDepthThreshold || 0 });
}
return makeUrl(`${taxonomyId}/tags/`, {
page: (pageIndex ?? 0) + 1,
page_size: pageSize ?? 10,
full_depth_threshold: fullDepthThreshold || 0,
});
},
/**
* Get _all_ tags below a given parent tag. This may be replaced with something more scalable in the future.
*/
Expand All @@ -74,6 +81,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
9 changes: 9 additions & 0 deletions src/taxonomy/data/apiHooks.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import MockAdapter from 'axios-mock-adapter';
import { apiUrls } from './api';

import {
useCreateTag,
useImportPlan,
useImportTags,
useImportNewTaxonomy,
Expand Down Expand Up @@ -105,4 +106,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));
});
});
70 changes: 65 additions & 5 deletions src/taxonomy/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { apiUrls, ALL_TAXONOMIES } from './api';
import * as api from './api';
import type { QueryOptions, TagListData } from './types';
import { EXPECTED_MAX_TAXONOMY_ITEMS } from './constants';

// 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 @@ -65,6 +66,37 @@ export const taxonomyQueryKeys = {
importPlan: (taxonomyId: number, fileId: string) => [...taxonomyQueryKeys.all, 'importPlan', taxonomyId, fileId],
} satisfies Record<string, (string | number)[] | ((...args: any[]) => (string | number)[])>;

const getApiErrorMessage = (err: unknown): string => {
const error = err as { message?: string; response?: { data?: unknown } };
const responseData = error?.response?.data;

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.detail === 'string' && objectData.detail.trim().length > 0) {
return objectData.detail;
}
if (typeof objectData.message === 'string' && objectData.message.trim().length > 0) {
return objectData.message;
}
}

return error?.message || 'Unexpected error';
};

/**
* Builds the query to get the taxonomy list
* @param {string} [org] Filter the list to only show taxonomies assigned to this org
Expand Down Expand Up @@ -139,7 +171,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));
}
},
onSuccess: (data) => {
Expand Down Expand Up @@ -170,7 +202,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 +212,17 @@ 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 } = 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, null, null, EXPECTED_MAX_TAXONOMY_ITEMS),
);
return camelCaseObject(data) as TagListData;
},
enabled,
});
};

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

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

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));
}
},
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.
* This is used to set `full_depth_threshold` for the tag list API endpoint,
* which determines when to include the `full_depth` field in the response.
* Right now we expect to load all tags for a taxonomy in one request,
* and we just set this number really high to avoid any edge cases.
*/
export const EXPECTED_MAX_TAXONOMY_ITEMS = 10000;
1 change: 1 addition & 0 deletions src/taxonomy/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface TaxonomyListData {
export interface QueryOptions {
pageIndex: number;
pageSize: number;
enabled?: boolean;
}

export interface TagData {
Expand Down
50 changes: 50 additions & 0 deletions src/taxonomy/tag-list/OptionalExpandLink.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { fireEvent, render, screen } from '@testing-library/react';

import OptionalExpandLink from './OptionalExpandLink';

const wrapper = ({ children }: { children: React.ReactNode }) => (
<IntlProvider locale="en" messages={{}}>{children}</IntlProvider>
);

const createMockRow = ({
canExpand = true,
isExpanded = false,
toggleHandler = jest.fn(),
} = {}) => ({
getCanExpand: () => canExpand,
getIsExpanded: () => isExpanded,
getToggleExpandedHandler: () => toggleHandler,
}) as any;

describe('OptionalExpandLink', () => {
it('hides expand button when row cannot expand', () => {
render(<OptionalExpandLink row={createMockRow({ canExpand: false })} />, { wrapper });
const button = screen.getByRole('button', { hidden: true });

expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-hidden', 'true');
});

it('renders show subtags control and toggles for collapsed row', () => {
const toggleHandler = jest.fn();
const row = createMockRow({ canExpand: true, isExpanded: false, toggleHandler });

render(<OptionalExpandLink row={row} />, { wrapper });
const button = screen.getByRole('button', { name: 'Show Subtags' });

expect(button).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(button);
expect(toggleHandler).toHaveBeenCalled();
});

it('renders hide subtags control for expanded row', () => {
const row = createMockRow({ canExpand: true, isExpanded: true });

render(<OptionalExpandLink row={row} />, { wrapper });
const button = screen.getByRole('button', { name: 'Hide Subtags' });

expect(button).toHaveAttribute('aria-expanded', 'true');
});
});
57 changes: 57 additions & 0 deletions src/taxonomy/tag-list/OptionalExpandLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { IconButton } from '@openedx/paragon';
import { ExpandLess, ExpandMore } from '@openedx/paragon/icons';
import { Row } from '@tanstack/react-table';
import { useIntl } from '@edx/frontend-platform/i18n';

import type { TreeRowData } from '../tree-table/types';
import messages from './messages';

interface OptionalExpandLinkProps {
row?: Row<TreeRowData>;
forceHide?: boolean;
}

/** OptionalExpandLink
* Renders an optional expand/collapse button for a tanstack/react-table row.
*
* For simplicity, this just hides the button if the row can't be expanded,
* in order to maintain a correctly-sized placeholder.
*/
const OptionalExpandLink = ({ row, forceHide = false }: OptionalExpandLinkProps) => {
const intl = useIntl();
const canExpand = !!row?.getCanExpand() && !forceHide;

if (!canExpand) {
return (
<IconButton
src={ExpandMore}
alt=""
size="sm"
className="mr-1 invisible"
disabled
tabIndex={-1}
aria-hidden
/>
);
}

const isExpanded = !!row?.getIsExpanded();
const buttonLabel = isExpanded
? intl.formatMessage(messages.hideSubtagsButtonLabel)
: intl.formatMessage(messages.showSubtagsButtonLabel);

return (
<IconButton
src={isExpanded ? ExpandLess : ExpandMore}
onClick={row?.getToggleExpandedHandler()}
alt={buttonLabel}
aria-label={buttonLabel}
aria-expanded={isExpanded}
size="sm"
className="mr-1"
/>
);
};

export default OptionalExpandLink;
Loading
Loading