From 4d5c5838a8cc01ea9a58c52a4f882ee469424a11 Mon Sep 17 00:00:00 2001 From: Jacobo Dominguez Date: Wed, 18 Mar 2026 11:00:31 -0600 Subject: [PATCH 01/62] feat: moving allow public read switch from admin console (#2942) --- .../components/PublicReadToggle.test.tsx | 117 ++++++++++++++++++ .../components/PublicReadToggle.tsx | 42 +++++++ src/library-authoring/components/messages.ts | 15 +++ .../library-info/LibraryInfo.test.tsx | 27 +++- .../library-info/LibraryInfo.tsx | 16 +++ .../library-info/messages.ts | 5 + 6 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/library-authoring/components/PublicReadToggle.test.tsx create mode 100644 src/library-authoring/components/PublicReadToggle.tsx diff --git a/src/library-authoring/components/PublicReadToggle.test.tsx b/src/library-authoring/components/PublicReadToggle.test.tsx new file mode 100644 index 0000000000..e3dd9d8fbf --- /dev/null +++ b/src/library-authoring/components/PublicReadToggle.test.tsx @@ -0,0 +1,117 @@ +import userEvent from '@testing-library/user-event'; +import { initializeMocks, render, screen } from '@src/testUtils'; +import PublicReadToggle from './PublicReadToggle'; +import messages from './messages'; + +jest.mock('../data/apiHooks', () => ({ + useContentLibrary: jest.fn(), + useUpdateLibraryMetadata: jest.fn(), +})); + +const mockUseContentLibrary = require('../data/apiHooks').useContentLibrary; +const mockUseUpdateLibraryMetadata = require('../data/apiHooks').useUpdateLibraryMetadata; + +let mockShowToast; + +describe('PublicReadToggle', () => { + beforeEach(() => { + const mocks = initializeMocks(); + mockShowToast = mocks.mockShowToast; + }); + + it('renders toggle when allowPublicRead is true and canEditToggle is true', () => { + mockUseContentLibrary.mockReturnValue({ data: { allowPublicRead: true } }); + mockUseUpdateLibraryMetadata.mockReturnValue({ mutateAsync: jest.fn(), isPending: false }); + + render( + , + ); + expect(screen.getByText(messages.publicReadToggleLabel.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.publicReadToggleSubtext.defaultMessage)).toBeInTheDocument(); + }); + + it('toggle is disabled when canEditToggle is false', () => { + mockUseContentLibrary.mockReturnValue({ data: { allowPublicRead: true } }); + mockUseUpdateLibraryMetadata.mockReturnValue({ mutateAsync: jest.fn(), isPending: false }); + + render( + , + ); + expect(screen.getByRole('switch')).toBeDisabled(); + }); + + it('calls updateLibrary when toggle is changed', async () => { + const user = userEvent.setup(); + const mockMutateAsync = jest.fn().mockImplementation(() => Promise.resolve()); + mockUseContentLibrary.mockReturnValue({ data: { allowPublicRead: false } }); + mockUseUpdateLibraryMetadata.mockReturnValue({ mutateAsync: mockMutateAsync, isPending: false }); + + render( + , + ); + await user.click(screen.getByRole('switch')); + expect(mockMutateAsync).toHaveBeenCalledWith( + { + id: 'lib1', + allow_public_read: true, + }, + ); + }); + + it('shows error toast when updateLibrary fails', async () => { + const user = userEvent.setup(); + const mockMutateAsync = jest.fn(); + + const error = { + customAttributes: { + httpErrorStatus: 500, + }, + }; + + mockMutateAsync.mockImplementation((_, options) => { + if (options?.onError) { + options.onError(error); + } + return Promise.reject(error); + }); + + mockUseContentLibrary.mockReturnValue({ data: { allowPublicRead: false } }); + mockUseUpdateLibraryMetadata.mockReturnValue({ mutateAsync: mockMutateAsync, isPending: false }); + + render( + , + ); + + await user.click(screen.getByRole('switch')); + + expect(mockMutateAsync).toHaveBeenCalledWith( + { + id: 'lib1', + allow_public_read: true, + }, + ); + + expect(mockShowToast).toHaveBeenCalledWith(messages.publicReadToggleDefaultError.defaultMessage); + }); + + it('shows error toast when updateLibrary promise is rejected', async () => { + const user = userEvent.setup(); + const mockMutateAsync = jest.fn().mockRejectedValue(new Error('Network error')); + + mockUseContentLibrary.mockReturnValue({ data: { allowPublicRead: false } }); + mockUseUpdateLibraryMetadata.mockReturnValue({ mutateAsync: mockMutateAsync, isPending: false }); + + render( + , + ); + + await user.click(screen.getByRole('switch')); + expect(mockMutateAsync).toHaveBeenCalledWith( + { + id: 'lib1', + allow_public_read: true, + }, + ); + expect(mockShowToast).toHaveBeenCalledWith(messages.publicReadToggleDefaultError.defaultMessage); + }); +}); diff --git a/src/library-authoring/components/PublicReadToggle.tsx b/src/library-authoring/components/PublicReadToggle.tsx new file mode 100644 index 0000000000..635a0a0902 --- /dev/null +++ b/src/library-authoring/components/PublicReadToggle.tsx @@ -0,0 +1,42 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Form } from '@openedx/paragon'; +import { ToastContext } from '@src/generic/toast-context'; +import { useContext } from 'react'; +import messages from './messages'; +import { useContentLibrary, useUpdateLibraryMetadata } from '../data/apiHooks'; + +type PublicReadToggleProps = { + libraryId: string; + canEditToggle: boolean; +}; + +const PublicReadToggle = ({ libraryId, canEditToggle }: PublicReadToggleProps) => { + const { formatMessage } = useIntl(); + const { data: library } = useContentLibrary(libraryId); + const { mutateAsync: updateLibrary, isPending } = useUpdateLibraryMetadata(); + const { showToast } = useContext(ToastContext); + + const onChangeToggle = async () => { + await updateLibrary({ + id: libraryId, + allow_public_read: !library?.allowPublicRead, + }).catch(() => { + showToast(formatMessage(messages.publicReadToggleDefaultError)); + }); + }; + + return ( + {formatMessage(messages.publicReadToggleSubtext)} + } + > + {formatMessage(messages.publicReadToggleLabel)} + + ); +}; + +export default PublicReadToggle; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index f1e3b89d71..baf0c6b0f0 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -246,5 +246,20 @@ const messages = defineMessages({ defaultMessage: 'Remove', description: 'Button to confirm removal of a container from its parent', }, + publicReadToggleLabel: { + id: 'course-authoring.library-authoring.public.read.toggle.label', + defaultMessage: 'Allow public read', + description: 'Library label toggle to allow public read', + }, + publicReadToggleSubtext: { + id: 'course-authoring.library-authoring.public.read.toggle.subtext', + defaultMessage: 'Allows reuse of library content in courses.', + description: 'Library description toggle to allow public read', + }, + publicReadToggleDefaultError: { + id: 'course-authoring.library-authoring.public.read.toggle.default.error.message', + defaultMessage: 'Something went wrong on our end. Please try again later.', + description: 'Public read toggle default error message', + }, }); export default messages; diff --git a/src/library-authoring/library-info/LibraryInfo.test.tsx b/src/library-authoring/library-info/LibraryInfo.test.tsx index da7870f05a..1ef5ed6f12 100644 --- a/src/library-authoring/library-info/LibraryInfo.test.tsx +++ b/src/library-authoring/library-info/LibraryInfo.test.tsx @@ -45,7 +45,7 @@ describe('', () => { mockShowToast = mocks.mockShowToast; validateUserPermissionsMock = mocks.validateUserPermissionsMock; - validateUserPermissionsMock.mockResolvedValue({ canPublish: true }); + validateUserPermissionsMock.mockResolvedValue({ canPublish: true, canManageTeam: true }); }); afterEach(() => { @@ -285,4 +285,29 @@ describe('', () => { expect(manageTeam).toBeInTheDocument(); expect(manageTeam).toHaveAttribute('href', `${ADMIN_CONSOLE_URL}/authz/libraries/${libraryData.id}`); }); + + it('renders settings section title', () => { + render(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('renders PublicReadToggle when user can manage team', async () => { + render(); + const allowSwitch = await screen.findByRole('switch', { name: /allow public read/i }); + expect(allowSwitch).toBeInTheDocument(); + await waitFor(() => { + expect(allowSwitch).toBeEnabled(); + }); + }); + + it('renders PublicReadToggle in disabled mode when user can not manage team', async () => { + validateUserPermissionsMock.mockResolvedValue({ canPublish: true, canManageTeam: false }); + + render(); + const allowSwitch = await screen.findByRole('switch', { name: /allow public read/i }); + expect(allowSwitch).toBeInTheDocument(); + await waitFor(() => { + expect(allowSwitch).toBeDisabled(); + }); + }); }); diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx index 562e511e72..6337f7389e 100644 --- a/src/library-authoring/library-info/LibraryInfo.tsx +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -3,15 +3,24 @@ import { Button, Hyperlink, Stack } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; +import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants'; +import { useUserPermissions } from '@src/authz/data/apiHooks'; import messages from './messages'; import LibraryPublishStatus from './LibraryPublishStatus'; import { useLibraryContext } from '../common/context/LibraryContext'; import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; +import PublicReadToggle from '../components/PublicReadToggle'; const LibraryInfo = () => { const intl = useIntl(); const { libraryId, libraryData, readOnly } = useLibraryContext(); const { setSidebarAction } = useSidebarContext(); + const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({ + canManageTeam: { + action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, + scope: libraryId, + }, + }, typeof libraryId !== 'undefined'); const adminConsoleUrl = getConfig().ADMIN_CONSOLE_URL; // always show link to admin console MFE if it is being used @@ -28,6 +37,13 @@ const LibraryInfo = () => { + + {intl.formatMessage(messages.settingsSectionTitle)} + + {intl.formatMessage(messages.organizationSectionTitle)} diff --git a/src/library-authoring/library-info/messages.ts b/src/library-authoring/library-info/messages.ts index 75e3b46f4b..9633de1279 100644 --- a/src/library-authoring/library-info/messages.ts +++ b/src/library-authoring/library-info/messages.ts @@ -11,6 +11,11 @@ const messages = defineMessages({ defaultMessage: 'Organization', description: 'Title for Organization section in Library info sidebar.', }, + settingsSectionTitle: { + id: 'course-authoring.library-authoring.sidebar.info.settings.title', + defaultMessage: 'Settings', + description: 'Title for Settings section in Library info sidebar.', + }, libraryTeamButtonTitle: { id: 'course-authoring.library-authoring.sidebar.info.library-team.button.title', defaultMessage: 'Library Team', From b2b5bc8ce5622458dd4e0123fbf6417876ef5dff Mon Sep 17 00:00:00 2001 From: edX requirements bot <49161187+edx-requirements-bot@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:29:38 -0400 Subject: [PATCH 02/62] chore: update browserslist DB (#2952) Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index b27cc1e7f4..e2a8a29400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9189,9 +9189,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.8", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", - "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -9572,9 +9572,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001779", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", - "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", "funding": [ { "type": "opencollective", From 0c59b78e02cf3f37324d84e818c739406c6a28c4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:10:48 -0700 Subject: [PATCH 03/62] chore(deps): update dependency oxlint-tsgolint to ^0.17.0 (#2953) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 56 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index e2a8a29400..1774875140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,7 +95,7 @@ "jest-canvas-mock": "^2.5.2", "jest-expect-message": "^1.1.3", "oxlint": "^1.42.0", - "oxlint-tsgolint": "^0.16.0", + "oxlint-tsgolint": "^0.17.0", "react-test-renderer": "^18.3.1", "redux-mock-store": "^1.5.4" } @@ -5574,9 +5574,9 @@ } }, "node_modules/@oxlint-tsgolint/darwin-arm64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.16.0.tgz", - "integrity": "sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.17.2.tgz", + "integrity": "sha512-1/QmWTRB8g5273wUnmmQxQz+kEFLJq8MYS82uFdxulUa2sdggWEQphnRhDRAzcbOCtrsya8+xGohv41dqRM/hQ==", "cpu": [ "arm64" ], @@ -5588,9 +5588,9 @@ ] }, "node_modules/@oxlint-tsgolint/darwin-x64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.16.0.tgz", - "integrity": "sha512-VJo29XOzdkalvCTiE2v6FU3qZlgHaM8x8hUEVJGPU2i5W+FlocPpmn00+Ld2n7Q0pqIjyD5EyvZ5UmoIEJMfqg==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.17.2.tgz", + "integrity": "sha512-GjEvcZPm8e9N2QtRlH5ttRr4II1ph86iR+gj7P7u47NuxKs099GivV0ISAsRlG09uYgRG3lTe2x5JrnMknuI0Q==", "cpu": [ "x64" ], @@ -5602,9 +5602,9 @@ ] }, "node_modules/@oxlint-tsgolint/linux-arm64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.16.0.tgz", - "integrity": "sha512-MPfqRt1+XRHv9oHomcBMQ3KpTE+CSkZz14wUxDQoqTNdUlV0HWdzwIE9q65I3D9YyxEnqpM7j4qtDQ3apqVvbQ==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.17.2.tgz", + "integrity": "sha512-Ybo4npjDMXQ15MBoftOBut9/gOdHhbnIhRmphx9owBQcZBmwrIy1+PfLqHRBuTCJ8diUmxQxSRkvXrGb+ogGqQ==", "cpu": [ "arm64" ], @@ -5616,9 +5616,9 @@ ] }, "node_modules/@oxlint-tsgolint/linux-x64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.16.0.tgz", - "integrity": "sha512-XQSwVUsnwLokMhe1TD6IjgvW5WMTPzOGGkdFDtXWQmlN2YeTw94s/NN0KgDrn2agM1WIgAenEkvnm0u7NgwEyw==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.17.2.tgz", + "integrity": "sha512-bU3A7bg9qa1VeWUwYwbXaAcUCOW+fl+SndNMNoYpm2+nhsAzzr9k9jz5Qr7NeKwbYet3qETjmhCmmfqe1syiPA==", "cpu": [ "x64" ], @@ -5630,9 +5630,9 @@ ] }, "node_modules/@oxlint-tsgolint/win32-arm64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.16.0.tgz", - "integrity": "sha512-EWdlspQiiFGsP2AiCYdhg5dTYyAlj6y1nRyNI2dQWq4Q/LITFHiSRVPe+7m7K7lcsZCEz2icN/bCeSkZaORqIg==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.17.2.tgz", + "integrity": "sha512-MeM1tyeg8J4DoHxAO3geDllM0Zm0tQDieQ701OXiS/vFA4QK+v+qBEJALqUys5obbIlLR2scmhzGor89bOr2ug==", "cpu": [ "arm64" ], @@ -5644,9 +5644,9 @@ ] }, "node_modules/@oxlint-tsgolint/win32-x64": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.16.0.tgz", - "integrity": "sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.17.2.tgz", + "integrity": "sha512-XfmGnyosL9jDGPwZcoDqdYADQNXjzH5hZs0xoZFodBbQhI1oAuItw/XR6tgga6grjusPSMS7j373sSGLLrE3yg==", "cpu": [ "x64" ], @@ -18113,21 +18113,21 @@ } }, "node_modules/oxlint-tsgolint": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.16.0.tgz", - "integrity": "sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.17.2.tgz", + "integrity": "sha512-W3gmZSOzNFGs9EwU8i3xlDpC0aqynQNtoDnaftdAZ3FE8cR7W625pPRbSmtsUOtTC0MPixx1i08R6uRVLfPp7g==", "dev": true, "license": "MIT", "bin": { "tsgolint": "bin/tsgolint.js" }, "optionalDependencies": { - "@oxlint-tsgolint/darwin-arm64": "0.16.0", - "@oxlint-tsgolint/darwin-x64": "0.16.0", - "@oxlint-tsgolint/linux-arm64": "0.16.0", - "@oxlint-tsgolint/linux-x64": "0.16.0", - "@oxlint-tsgolint/win32-arm64": "0.16.0", - "@oxlint-tsgolint/win32-x64": "0.16.0" + "@oxlint-tsgolint/darwin-arm64": "0.17.2", + "@oxlint-tsgolint/darwin-x64": "0.17.2", + "@oxlint-tsgolint/linux-arm64": "0.17.2", + "@oxlint-tsgolint/linux-x64": "0.17.2", + "@oxlint-tsgolint/win32-arm64": "0.17.2", + "@oxlint-tsgolint/win32-x64": "0.17.2" } }, "node_modules/p-limit": { diff --git a/package.json b/package.json index 2b279b711d..f838811307 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "jest-canvas-mock": "^2.5.2", "jest-expect-message": "^1.1.3", "oxlint": "^1.42.0", - "oxlint-tsgolint": "^0.16.0", + "oxlint-tsgolint": "^0.17.0", "react-test-renderer": "^18.3.1", "redux-mock-store": "^1.5.4" } From 652efb5a4a8e0227594c84a88000086c50b728ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 25 Mar 2026 16:43:30 -0300 Subject: [PATCH 04/62] feat: remove lib v2 beta badge (#2951) --- .../tabs-section/TabsSection.test.tsx | 25 ++++++------------- src/studio-home/tabs-section/index.tsx | 2 -- src/studio-home/tabs-section/messages.ts | 13 ---------- 3 files changed, 8 insertions(+), 32 deletions(-) diff --git a/src/studio-home/tabs-section/TabsSection.test.tsx b/src/studio-home/tabs-section/TabsSection.test.tsx index 590c870c42..8a4a5d6dfb 100644 --- a/src/studio-home/tabs-section/TabsSection.test.tsx +++ b/src/studio-home/tabs-section/TabsSection.test.tsx @@ -33,9 +33,6 @@ let store; const courseApiLinkV2 = `${getApiBaseUrl()}/api/contentstore/v2/home/courses`; const libraryApiLink = `${getStudioHomeApiUrl()}/libraries`; -// The Libraries v2 tab title contains a badge, so we need to use regex to match its tab text. -const librariesBetaTabTitle = /Libraries Beta/; - const tabSectionComponent = (overrideProps) => ( ', () => { expect(screen.getByRole('tab', { name: tabMessages.coursesTabTitle.defaultMessage })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: librariesBetaTabTitle })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: tabMessages.legacyLibrariesTabTitle.defaultMessage })).toBeInTheDocument(); }); @@ -120,7 +117,7 @@ describe('', () => { await executeThunk(fetchStudioHomeData(), store.dispatch); expect(screen.getByText(tabMessages.librariesTabTitle.defaultMessage)).toBeInTheDocument(); - const librariesTab = screen.getByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = screen.getByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); expect(librariesTab).toBeInTheDocument(); // Check Tab.eventKey expect(librariesTab).toHaveAttribute('data-rb-event-key', 'libraries'); @@ -352,7 +349,7 @@ describe('', () => { await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); fireEvent.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -377,9 +374,6 @@ describe('', () => { const user = userEvent.setup(); await act(async () => executeThunk(fetchStudioHomeData(), store.dispatch)); - // Libraries v2 tab should not be shown - expect(screen.queryByRole('tab', { name: librariesBetaTabTitle })).toBeNull(); - const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); await user.click(librariesTab); @@ -399,9 +393,6 @@ describe('', () => { const user = userEvent.setup(); await act(async () => executeThunk(fetchStudioHomeData(), store.dispatch)); - // Libraries v2 tab should not be shown - expect(screen.queryByRole('tab', { name: librariesBetaTabTitle })).toBeNull(); - const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); await user.click(librariesTab); @@ -430,7 +421,7 @@ describe('', () => { render(); await executeThunk(fetchStudioHomeData(), store.dispatch); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); await user.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -453,7 +444,7 @@ describe('', () => { // Libraries v1 tab should not be shown expect(screen.queryByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).toBeNull(); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); fireEvent.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -478,7 +469,7 @@ describe('', () => { await axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); fireEvent.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -522,7 +513,7 @@ describe('', () => { const user = userEvent.setup(); await act(async () => executeThunk(fetchStudioHomeData(), store.dispatch)); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); await user.click(librariesTab); expect(librariesTab).toHaveClass('active'); @@ -546,7 +537,7 @@ describe('', () => { render(); await executeThunk(fetchStudioHomeData(), store.dispatch); - const librariesTab = await screen.findByRole('tab', { name: librariesBetaTabTitle }); + const librariesTab = await screen.findByRole('tab', { name: tabMessages.librariesTabTitle.defaultMessage }); await user.click(librariesTab); expect(librariesTab).toHaveClass('active'); diff --git a/src/studio-home/tabs-section/index.tsx b/src/studio-home/tabs-section/index.tsx index 9a07c7c2c3..e4eb63223f 100644 --- a/src/studio-home/tabs-section/index.tsx +++ b/src/studio-home/tabs-section/index.tsx @@ -1,7 +1,6 @@ import { useMemo, useState, useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { - Badge, Stack, Tab, Tabs, @@ -91,7 +90,6 @@ const TabsSection = ({ title={( {intl.formatMessage(messages.librariesTabTitle)} - {intl.formatMessage(messages.librariesV2TabBetaBadge)} )} > diff --git a/src/studio-home/tabs-section/messages.ts b/src/studio-home/tabs-section/messages.ts index 763a384187..723a202102 100644 --- a/src/studio-home/tabs-section/messages.ts +++ b/src/studio-home/tabs-section/messages.ts @@ -50,19 +50,6 @@ const messages = defineMessages({ defaultMessage: 'Taxonomies', description: 'Title of Taxonomies tab on the home page', }, - libraryV2PlaceholderTitle: { - id: 'course-authoring.studio-home.libraries.placeholder.title', - defaultMessage: 'Library V2 Placeholder', - }, - libraryV2PlaceholderBody: { - id: 'course-authoring.studio-home.libraries.placeholder.body', - defaultMessage: 'This is a placeholder page, as the Library Authoring MFE is not enabled.', - }, - librariesV2TabBetaBadge: { - id: 'course-authoring.studio-home.libraries.tab.library.beta-badge', - defaultMessage: 'Beta', - description: 'Text used to mark the Libraries v2 feature as "in beta"', - }, librariesV2TabLibrarySearchPlaceholder: { id: 'course-authoring.studio-home.libraries.tab.library.search-placeholder', defaultMessage: 'Search', From b3d25bd1e9d0f80e349f3167f1e1821e44ca4339 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 25 Mar 2026 17:19:38 -0400 Subject: [PATCH 05/62] fix: PR comments and cleanup --- src/taxonomy/data/api.test.ts | 79 +++++++++++++++++++++++ src/taxonomy/data/api.ts | 63 ++++++++++++++++-- src/taxonomy/data/apiHooks.ts | 44 ++----------- src/taxonomy/data/constants.ts | 10 +-- src/taxonomy/data/types.ts | 1 + src/taxonomy/messages.ts | 4 ++ src/taxonomy/tag-list/TagListTable.tsx | 14 +++- src/taxonomy/tag-list/tagTree.ts | 30 +++++++++ src/taxonomy/taxonomy-detail/constants.ts | 7 +- src/taxonomy/tree-table/EditableCell.tsx | 29 ++++++--- src/taxonomy/tree-table/NestedRows.tsx | 34 ++++++++++ 11 files changed, 255 insertions(+), 60 deletions(-) diff --git a/src/taxonomy/data/api.test.ts b/src/taxonomy/data/api.test.ts index ed6ef8cdce..1a0cbf2df3 100644 --- a/src/taxonomy/data/api.test.ts +++ b/src/taxonomy/data/api.test.ts @@ -7,6 +7,7 @@ import { getTaxonomyListData, getTaxonomy, deleteTaxonomy, + getApiErrorMessage, } from './api'; describe('taxonomy api calls', () => { @@ -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'); + }); + }); }); diff --git a/src/taxonomy/data/api.ts b/src/taxonomy/data/api.ts index 4d1f473e66..063971ee08 100644 --- a/src/taxonomy/data/api.ts +++ b/src/taxonomy/data/api.ts @@ -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; @@ -53,18 +55,24 @@ 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 | null, pageSize: number | null, fullDepthThreshold?: number) => { - if (pageIndex === null) { - return makeUrl(`${taxonomyId}/tags/`, { full_depth_threshold: fullDepthThreshold || 0 }); + 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: fullDepthThreshold || 0, + full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0, }); }, /** @@ -72,7 +80,7 @@ export const apiUrls = { */ 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. */ @@ -117,3 +125,46 @@ export async function getTaxonomy(taxonomyId: number): Promise { 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'); +}; diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index 5178ecbf24..3e15b04110 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -13,10 +13,9 @@ 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 { 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 @@ -66,37 +65,6 @@ export const taxonomyQueryKeys = { importPlan: (taxonomyId: number, fileId: string) => [...taxonomyQueryKeys.all, 'importPlan', taxonomyId, fileId], } satisfies Record (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 @@ -212,13 +180,15 @@ 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, enabled = true } = options; // eslint-disable-line + const { pageIndex, pageSize, enabled = true, disablePagination = false } = options; // eslint-disable-line return useQuery({ // 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, null, null, EXPECTED_MAX_TAXONOMY_ITEMS), + apiUrls.tagList(taxonomyId, { + pageIndex, pageSize, fullDepth: true, disablePagination, + }), ); return camelCaseObject(data) as TagListData; }, @@ -239,7 +209,7 @@ export const useSubTags = (taxonomyId: number, parentTagValue: string) => useQue }, }); -export const useCreateTag = (taxonomyId: number) => { +export const useCreateTag = (taxonomyId: number, intl: any) => { const queryClient = useQueryClient(); return useMutation({ @@ -250,7 +220,7 @@ export const useCreateTag = (taxonomyId: number) => { { tag: value, parent_tag_value: parentTagValue }, ); } catch (err) { - throw new Error(getApiErrorMessage(err)); + throw new Error(getApiErrorMessage(err, intl)); } }, onSuccess: () => { diff --git a/src/taxonomy/data/constants.ts b/src/taxonomy/data/constants.ts index 399418fbb8..dc205768a2 100644 --- a/src/taxonomy/data/constants.ts +++ b/src/taxonomy/data/constants.ts @@ -1,8 +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. + * 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 EXPECTED_MAX_TAXONOMY_ITEMS = 10000; +export const MAX_TAXONOMY_ITEMS = 10000; diff --git a/src/taxonomy/data/types.ts b/src/taxonomy/data/types.ts index d0e0192d36..0686bed900 100644 --- a/src/taxonomy/data/types.ts +++ b/src/taxonomy/data/types.ts @@ -33,6 +33,7 @@ export interface QueryOptions { pageIndex: number; pageSize: number; enabled?: boolean; + disablePagination?: boolean; } export interface TagData { diff --git a/src/taxonomy/messages.ts b/src/taxonomy/messages.ts index c904346dd3..0fca895539 100644 --- a/src/taxonomy/messages.ts +++ b/src/taxonomy/messages.ts @@ -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; diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index 22575736f2..b9147859df 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -24,6 +24,9 @@ interface TagListTableProps { maxDepth: number; } +// TODO: Fix and enable pagination on backend and frontend.For now, disable pagination by showing all tags on one page. +const DISABLE_PAGINATION = true; + const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { // The table has a VIEW, DRAFT, and a PREVIEW mode. It starts in VIEW mode. // It switches to DRAFT mode when a user edits or creates a tag. @@ -33,6 +36,10 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { // success or failure responses. // However, the table does not refresh to show the updated data from the backend. // This allows us to show the newly created or updated tag in the same place without reordering. + // + // TODO: Simpler approaches have been suggested. Two options are to just use simple React state: + // `isCurrentlyEditingTag` and `lastCreatedTag`, or to use optimistic updates. + // For reference, see https://github.com/openedx/frontend-app-authoring/pull/2872#discussion_r2880965005. const intl = useIntl(); const [creatingParentId, setCreatingParentId] = useState(null); @@ -51,10 +58,10 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { } = useTableModes(); // PAGINATION - // TODO: Fix and enable pagination. For now, disable pagination on the api hook side. + // TODO: Fix and enable pagination. For now, disable pagination. const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 0, - pageSize: 100, + pageSize: 50, }); const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); const handlePaginationChange = (updater: React.SetStateAction) => { @@ -67,9 +74,10 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { // API HOOKS const { isLoading, data: tagList } = useTagListData(taxonomyId, { ...pagination, + disablePagination: DISABLE_PAGINATION, enabled: tableMode === TABLE_MODES.VIEW, }); - const createTagMutation = useCreateTag(taxonomyId); + const createTagMutation = useCreateTag(taxonomyId, intl); const pageCount = tagList?.numPages ?? -1; // Custom Edit Actions Hook - handles table mode transitions, API calls, diff --git a/src/taxonomy/tag-list/tagTree.ts b/src/taxonomy/tag-list/tagTree.ts index 992a5f2503..4982faae7e 100644 --- a/src/taxonomy/tag-list/tagTree.ts +++ b/src/taxonomy/tag-list/tagTree.ts @@ -37,6 +37,36 @@ export class TagTree { this.buildTree(); } + /** Returns a flattened copy of all nodes in the tree. For example, + * this array is not nested even though it contains a parent and a child tag: + * [ + * { + * value: 'parent tag name', + * externalId: null, + * childCount: 2, + * descendantCount: 4, + * depth: 0, + * parentValue: null, + * id: 1, + * subTagsUrl: 'http://example.com', + * canChangeTag: true, + * canDeleteTag: true, + * }, + * { + * value: 'child tag name', + * externalId: null, + * childCount: 0, + * descendantCount: 0, + * depth: 1, + * parentValue: 'parent tag name', + * id: 2, + * subTagsUrl: 'http://example.com', + * canChangeTag: true, + * canDeleteTag: true, + * }, + * // ... more tags + * ] + */ getAllFlattenedAsCopy(): TagTreeNode[] { const flatten = (nodes: TagTreeNode[], accumulator: TagTreeNode[] = []): TagTreeNode[] => { for (const node of nodes) { diff --git a/src/taxonomy/taxonomy-detail/constants.ts b/src/taxonomy/taxonomy-detail/constants.ts index ae59021c7a..442573c7c3 100644 --- a/src/taxonomy/taxonomy-detail/constants.ts +++ b/src/taxonomy/taxonomy-detail/constants.ts @@ -1,5 +1,10 @@ /** - * Warning: This must reflect the `TAXONOMY_MAX_DEPTH` used in the openedx-core backend. + * The maximum allowable depth for any tag in the taxonomy (0-indexed). + * * **Constraint**: A value of 3 allows levels 0, 1, 2, and 3. Creation of new subtags + * is disabled for any tag already at this depth to prevent exceeding the limit. + * * **Data Handling**: This is a UI safety gate, not a filter. If the backend returns + * tags exceeding this depth, they will still be rendered, but further nesting will be blocked. + * * **Sync Required**: This must match `TAXONOMY_MAX_DEPTH` in the openedx-core backend. */ const TAXONOMY_MAX_DEPTH = 3; diff --git a/src/taxonomy/tree-table/EditableCell.tsx b/src/taxonomy/tree-table/EditableCell.tsx index 507976e826..bbb9aee658 100644 --- a/src/taxonomy/tree-table/EditableCell.tsx +++ b/src/taxonomy/tree-table/EditableCell.tsx @@ -10,6 +10,19 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import OptionalExpandLink from '../tag-list/OptionalExpandLink'; +/** + * Props for the EditableCell component. + * @interface EditableCellProps + * @property {string} [initialValue] - The initial value to display in the cell + * @property {function} [onKeyDown] - Callback function triggered on keyboard events + * @property {function} [onChange] - Callback function triggered when the input value changes + * @property {string} [errorMessage] - Error message to display if validation fails + * @property {boolean} [isSaving] - Indicates whether the cell value is currently being saved to the server + * @property {boolean} [autoFocus] - If true, the input field will automatically receive focus when the cell + * enters edit mode + * @property {function} [getInlineValidationMessage] - Function that returns a validation message to be displayed + * based on the current input value. + */ interface EditableCellProps { initialValue?: string; onKeyDown?: (event: React.KeyboardEvent) => void; @@ -30,24 +43,23 @@ const EditableCell = ({ autoFocus = false, }: EditableCellProps) => { const [value, setValue] = useState(initialValue); + const [validationMessage, setValidationMessage] = useState('');'' const inputId = useId(); const inputRef = useRef(null); const intl = useIntl(); useEffect(() => { - if (autoFocus) { - if (inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } + if (autoFocus && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); } - }, [autoFocus]); + }, [inputRef.current]); // autoFocus explicitly not a dependency, to avoid unexpected focus change. useEffect(() => { setValue(initialValue); - }, [initialValue]); + setValidationMessage(getInlineValidationMessage(initialValue)); + }, []); // initialValue explicitly not a dependency, to avoid overwriting user input. - const validationMessage = getInlineValidationMessage(value); const effectiveErrorMessage = errorMessage || validationMessage; const errorMessageId = `${inputId}-error`; @@ -61,6 +73,7 @@ const EditableCell = ({ value={value} onChange={(e) => { setValue(e.target.value); + setValidationMessage(getInlineValidationMessage(e.target.value)); onChange(e); }} size="sm" diff --git a/src/taxonomy/tree-table/NestedRows.tsx b/src/taxonomy/tree-table/NestedRows.tsx index bed055302e..d93a3f7dee 100644 --- a/src/taxonomy/tree-table/NestedRows.tsx +++ b/src/taxonomy/tree-table/NestedRows.tsx @@ -8,6 +8,27 @@ import type { } from './types'; import { CreateRow } from './CreateRow'; +/** + * Props for the NestedRows component + * @interface NestedRowsProps + * @property {TreeRow} parentRow - The parent row object from TanStack React Table + * @property {string} parentRowValue - The value identifier of the parent row + * @property {boolean} [isCreating] - Whether a new child row is currently being created for this parent + * @property {function} [onSaveNewChildRow] - Callback when a new child row is saved (receives value and parentRowValue) + * @property {function} [onCancelCreation] - Callback when child row creation is cancelled + * @property {TreeRow[]} [childRowsData] - Array of child row objects to render + * @property {number} [depth] - Current nesting depth level (used for indentation calculation) + * @property {string} [draftError] - Error message to display in draft creation form + * @property {function} [setDraftError] - Setter function for draft error state + * @property {RowId | null} [creatingParentId] - ID of the row currently in creation mode + * @property {function} [setCreatingParentId] - Setter function for which row is in creation mode + * @property {function} setIsCreatingTopRow - Callback to set whether top-level row creation is active + * @property {CreateRowMutationState} createRowMutation - State object for the row creation mutation + * (isPending, isError, error) + * @property {function} validate - Validation function for new row values + * (receives value and optional 'soft' or 'hard' mode; in 'hard' mode an exception is thrown on validation failure) + */ + interface NestedRowsProps { parentRow: TreeRow; parentRowValue: string; @@ -25,6 +46,19 @@ interface NestedRowsProps { validate: (value: string, mode?: 'soft' | 'hard') => boolean; } +/** + * NestedRows + * + * Recursively renders nested child rows within a tree table structure. This component handles: + * - Display of child rows when a parent row is expanded + * - Indentation based on nesting depth + * - Creation of new child rows with validation + * - Management of draft state during row creation + * - Recursive rendering of grandchild rows and deeper levels + * + * The component uses the TanStack React Table library to render table cells and manages + * the creation flow by displaying a CreateRow form when a parent is in creation mode. + */ const NestedRows = ({ parentRow, parentRowValue, From 6f2f6b9fffdce1689f5dd8a45ff2691ba7e44d68 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 25 Mar 2026 17:24:53 -0400 Subject: [PATCH 06/62] fix: typo --- src/taxonomy/tree-table/EditableCell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taxonomy/tree-table/EditableCell.tsx b/src/taxonomy/tree-table/EditableCell.tsx index bbb9aee658..569c881676 100644 --- a/src/taxonomy/tree-table/EditableCell.tsx +++ b/src/taxonomy/tree-table/EditableCell.tsx @@ -43,7 +43,7 @@ const EditableCell = ({ autoFocus = false, }: EditableCellProps) => { const [value, setValue] = useState(initialValue); - const [validationMessage, setValidationMessage] = useState('');'' + const [validationMessage, setValidationMessage] = useState(''); const inputId = useId(); const inputRef = useRef(null); const intl = useIntl(); From 3a96b58a8665a9e02f276bed322b837425b47c21 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 25 Mar 2026 17:37:50 -0400 Subject: [PATCH 07/62] fix: types --- src/taxonomy/data/apiHooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index 3e15b04110..19a3f0a5d6 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -209,7 +209,7 @@ export const useSubTags = (taxonomyId: number, parentTagValue: string) => useQue }, }); -export const useCreateTag = (taxonomyId: number, intl: any) => { +export const useCreateTag = (taxonomyId: number, intl?: any) => { const queryClient = useQueryClient(); return useMutation({ From 9076a09ddc76318ca3356879a45a406dca2d969d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 25 Mar 2026 19:11:49 -0300 Subject: [PATCH 08/62] fix: remove deprecated settings waffle flags (#2959) --- .../ChecklistSection/ChecklistItemBody.jsx | 11 ++- .../ChecklistSection.test.jsx | 7 +- .../status-bar/LegacyStatusBar.tsx | 8 +-- src/course-outline/status-bar/StatusBar.tsx | 12 ++-- src/custom-pages/CustomPages.test.tsx | 2 - src/data/api.ts | 5 -- ...pSidebar.test.jsx => HelpSidebar.test.tsx} | 6 +- .../{HelpSidebar.jsx => HelpSidebar.tsx} | 67 ++++++------------- src/header/hooks.tsx | 11 ++- .../pages/PageCard.test.jsx | 7 +- 10 files changed, 47 insertions(+), 89 deletions(-) rename src/generic/help-sidebar/{HelpSidebar.test.jsx => HelpSidebar.test.tsx} (98%) rename src/generic/help-sidebar/{HelpSidebar.jsx => HelpSidebar.tsx} (64%) diff --git a/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx b/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx index d42a5cb0d4..5d0234d0df 100644 --- a/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx +++ b/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx @@ -5,24 +5,21 @@ import { ActionRow, Button, Icon } from '@openedx/paragon'; import { CheckCircle, RadioButtonUnchecked } from '@openedx/paragon/icons'; import { getConfig } from '@edx/frontend-platform'; -import { useWaffleFlags } from '../../data/apiHooks'; +import { useWaffleFlags } from '@src/data/apiHooks'; + import messages from './messages'; const getUpdateLinks = (courseId, waffleFlags) => { const baseUrl = getConfig().STUDIO_BASE_URL; - const isLegacyGradingUrl = !waffleFlags.useNewGradingPage; const isLegacyCertificateUrl = !waffleFlags.useNewCertificatesPage; - const isLegacyCourseDatesUrl = !waffleFlags.useNewScheduleDetailsPage; const isLegacyOutlineUrl = !waffleFlags.useNewCourseOutlinePage; return { welcomeMessage: `/course/${courseId}/course_info`, - gradingPolicy: isLegacyGradingUrl - ? `${baseUrl}/settings/grading/${courseId}` : `/course/${courseId}/settings/grading`, + gradingPolicy: `/course/${courseId}/settings/grading`, certificate: isLegacyCertificateUrl ? `${baseUrl}/certificates/${courseId}` : `/course/${courseId}/certificates`, - courseDates: isLegacyCourseDatesUrl - ? `${baseUrl}/settings/details/${courseId}#schedule` : `/course/${courseId}/settings/details/#schedule`, + courseDates: `/course/${courseId}/settings/details/#schedule`, proctoringEmail: `${baseUrl}/pages-and-resources/proctoring/settings`, outline: isLegacyOutlineUrl ? `${baseUrl}/course/${courseId}` : `/course/${courseId}`, }; diff --git a/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx b/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx index 2fc73e66d6..d6baed767d 100644 --- a/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx +++ b/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx @@ -2,8 +2,9 @@ import { camelCaseObject } from '@edx/frontend-platform'; import { initializeMocks, render, screen, within, -} from '../../testUtils'; -import { getApiWaffleFlagsUrl } from '../../data/api'; +} from '@src/testUtils'; +import { getApiWaffleFlagsUrl } from '@src/data/api'; + import { generateCourseLaunchData } from '../factories/mockApiResponses'; import { checklistItems } from './utils/courseChecklistData'; import messages from './messages'; @@ -36,9 +37,7 @@ describe('ChecklistSection', () => { axiosMock .onGet(getApiWaffleFlagsUrl(courseId)) .reply(200, { - useNewGradingPage: true, useNewCertificatesPage: true, - useNewScheduleDetailsPage: true, useNewCourseOutlinePage: true, }); }); diff --git a/src/course-outline/status-bar/LegacyStatusBar.tsx b/src/course-outline/status-bar/LegacyStatusBar.tsx index 2673dd2143..5eb5449d14 100644 --- a/src/course-outline/status-bar/LegacyStatusBar.tsx +++ b/src/course-outline/status-bar/LegacyStatusBar.tsx @@ -5,16 +5,16 @@ import { Button, Hyperlink, Form, Stack, useToggle, } from '@openedx/paragon'; import { Link } from 'react-router-dom'; +import { type ReactNode } from 'react'; -import { ReactNode } from 'react'; import { CourseOutlineStatusBar } from '@src/course-outline/data/types'; import { ContentTagsDrawerSheet } from '@src/content-tags-drawer'; import TagCount from '@src/generic/tag-count'; import { useHelpUrls } from '@src/help-urls/hooks'; -import { useWaffleFlags } from '@src/data/apiHooks'; import { VIDEO_SHARING_OPTIONS } from '@src/course-outline/constants'; import { useContentTagsCount } from '@src/generic/data/apiHooks'; import { getVideoSharingOptionText } from '@src/course-outline/utils'; + import messages from './messages'; interface StatusBarItemProps { @@ -47,7 +47,6 @@ export const LegacyStatusBar = ({ handleVideoSharingOptionChange, }: LegacyStatusBarProps) => { const intl = useIntl(); - const waffleFlags = useWaffleFlags(courseId); const { courseReleaseDate, @@ -67,7 +66,6 @@ export const LegacyStatusBar = ({ const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true); const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`; - const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, getConfig().STUDIO_BASE_URL).href; const { contentHighlights: contentHighlightsUrl, @@ -88,7 +86,7 @@ export const LegacyStatusBar = ({ {courseReleaseDateObj.isValid() ? ( { - const waffleFlags = useWaffleFlags(courseId); - const { endDate, courseReleaseDate, @@ -189,7 +186,6 @@ export const StatusBar = ({ const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true); const endDateObj = moment.utc(endDate); - const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, getConfig().STUDIO_BASE_URL).href; if (isLoading) { return null; @@ -203,7 +199,7 @@ export const StatusBar = ({ startDate={courseReleaseDateObj} endDate={endDateObj} startDateRaw={courseReleaseDate} - datesLink={waffleFlags.useNewScheduleDetailsPage ? `/course/${courseId}/settings/details/#schedule` : scheduleDestination()} + datesLink={`/course/${courseId}/settings/details/#schedule`} /> diff --git a/src/custom-pages/CustomPages.test.tsx b/src/custom-pages/CustomPages.test.tsx index fec0ebc220..f8acd09b1f 100644 --- a/src/custom-pages/CustomPages.test.tsx +++ b/src/custom-pages/CustomPages.test.tsx @@ -62,9 +62,7 @@ describe('CustomPages', () => { axiosMock .onGet(getApiWaffleFlagsUrl(courseId)) .reply(200, { - useNewGradingPage: true, useNewCertificatesPage: true, - useNewScheduleDetailsPage: true, useNewCourseOutlinePage: true, }); }); diff --git a/src/data/api.ts b/src/data/api.ts index 4f74a7196b..102da1dc13 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -76,9 +76,6 @@ export const waffleFlagDefaults = { enableCourseOptimizerCheckPrevRunLinks: false, useNewHomePage: true, useNewCustomPages: true, - useNewScheduleDetailsPage: true, - useNewAdvancedSettingsPage: true, - useNewGradingPage: true, useNewUpdatesPage: true, useNewImportPage: false, useNewExportPage: true, @@ -86,10 +83,8 @@ export const waffleFlagDefaults = { useNewVideoUploadsPage: true, useNewCourseOutlinePage: true, useNewUnitPage: false, - useNewCourseTeamPage: true, useNewCertificatesPage: true, useNewTextbooksPage: true, - useNewGroupConfigurationsPage: true, useReactMarkdownEditor: true, useVideoGalleryFlow: false, enableAuthzCourseAuthoring: false, diff --git a/src/generic/help-sidebar/HelpSidebar.test.jsx b/src/generic/help-sidebar/HelpSidebar.test.tsx similarity index 98% rename from src/generic/help-sidebar/HelpSidebar.test.jsx rename to src/generic/help-sidebar/HelpSidebar.test.tsx index 8f97c4478e..64eb6d9345 100644 --- a/src/generic/help-sidebar/HelpSidebar.test.jsx +++ b/src/generic/help-sidebar/HelpSidebar.test.tsx @@ -1,9 +1,9 @@ -// @ts-check - import { waitFor } from '@testing-library/react'; + import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import { useUserPermissions } from '@src/authz/data/apiHooks'; -import { initializeMocks, render } from '../../testUtils'; +import { initializeMocks, render } from '@src/testUtils'; + import messages from './messages'; import { HelpSidebar } from '.'; diff --git a/src/generic/help-sidebar/HelpSidebar.jsx b/src/generic/help-sidebar/HelpSidebar.tsx similarity index 64% rename from src/generic/help-sidebar/HelpSidebar.jsx rename to src/generic/help-sidebar/HelpSidebar.tsx index 5a9745e8a8..074ab955e1 100644 --- a/src/generic/help-sidebar/HelpSidebar.jsx +++ b/src/generic/help-sidebar/HelpSidebar.tsx @@ -1,8 +1,7 @@ -import PropTypes from 'prop-types'; +import { type ReactNode } from 'react'; import { useLocation } from 'react-router-dom'; import classNames from 'classnames'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { getConfig } from '@edx/frontend-platform'; import { useUserPermissions } from '@src/authz/data/apiHooks'; import { COURSE_PERMISSIONS } from '@src/authz/constants'; @@ -11,13 +10,21 @@ import { otherLinkURLParams } from './constants'; import messages from './messages'; import HelpSidebarLink from './HelpSidebarLink'; +interface HelpSidebarProps { + courseId: string; + showOtherSettings?: boolean; + proctoredExamSettingsUrl?: string; + children: ReactNode; + className?: string; +} + const HelpSidebar = ({ courseId, - showOtherSettings, - proctoredExamSettingsUrl, + showOtherSettings = false, + proctoredExamSettingsUrl = '', children, className, -}) => { +}: HelpSidebarProps) => { const intl = useIntl(); const { pathname } = useLocation(); const { @@ -30,16 +37,6 @@ const HelpSidebar = ({ const waffleFlags = useWaffleFlags(courseId); const showOtherLink = (params) => !pathname.includes(params); - const generateLegacyURL = (urlParameter) => { - const referObj = new URL(`${urlParameter}/${courseId}`, getConfig().STUDIO_BASE_URL); - return referObj.href; - }; - - const scheduleAndDetailsDestination = generateLegacyURL(scheduleAndDetails); - const gradingDestination = generateLegacyURL(grading); - const courseTeamDestination = generateLegacyURL(courseTeam); - const advancedSettingsDestination = generateLegacyURL(advancedSettings); - const groupConfigurationsDestination = generateLegacyURL(groupConfigurations); /* AuthZ for Course Authoring @@ -78,46 +75,41 @@ const HelpSidebar = ({
    {showOtherLink(scheduleAndDetails) && ( )} {showOtherLink(grading) && ( )} {showOtherLink(courseTeam) && ( )} {showOtherLink(groupConfigurations) && ( )} {showOtherLink(advancedSettings) && canManageAdvancedSettings && ( )} {proctoredExamSettingsUrl && ( @@ -137,19 +129,4 @@ const HelpSidebar = ({ ); }; -HelpSidebar.defaultProps = { - proctoredExamSettingsUrl: '', - className: undefined, - courseId: undefined, - showOtherSettings: false, -}; - -HelpSidebar.propTypes = { - courseId: PropTypes.string, - showOtherSettings: PropTypes.bool, - proctoredExamSettingsUrl: PropTypes.string, - children: PropTypes.node.isRequired, - className: PropTypes.string, -}; - export default HelpSidebar; diff --git a/src/header/hooks.tsx b/src/header/hooks.tsx index 5c12bcfe9f..6b8112b175 100644 --- a/src/header/hooks.tsx +++ b/src/header/hooks.tsx @@ -57,7 +57,6 @@ export const useContentMenuItems = (courseId: string) => { export const useSettingMenuItems = (courseId: string) => { const intl = useIntl(); - const studioBaseUrl = getConfig().STUDIO_BASE_URL; const { canAccessAdvancedSettings: legacyCanAccessAdvancedSettings } = useSelector(getStudioHomeData); const waffleFlags = useWaffleFlags(courseId); @@ -84,24 +83,24 @@ export const useSettingMenuItems = (courseId: string) => { const items = [ { - href: waffleFlags.useNewScheduleDetailsPage ? `/course/${courseId}/settings/details` : `${studioBaseUrl}/settings/details/${courseId}`, + href: `/course/${courseId}/settings/details`, title: intl.formatMessage(messages['header.links.scheduleAndDetails']), }, { - href: waffleFlags.useNewGradingPage ? `/course/${courseId}/settings/grading` : `${studioBaseUrl}/settings/grading/${courseId}`, + href: `/course/${courseId}/settings/grading`, title: intl.formatMessage(messages['header.links.grading']), }, { - href: waffleFlags.useNewCourseTeamPage ? `/course/${courseId}/course_team` : `${studioBaseUrl}/course_team/${courseId}`, + href: `/course/${courseId}/course_team`, title: intl.formatMessage(messages['header.links.courseTeam']), }, { - href: waffleFlags.useNewGroupConfigurationsPage ? `/course/${courseId}/group_configurations` : `${studioBaseUrl}/group_configurations/${courseId}`, + href: `/course/${courseId}/group_configurations`, title: intl.formatMessage(messages['header.links.groupConfigurations']), }, ...(canAccessAdvancedSettings ? [{ - href: waffleFlags.useNewAdvancedSettingsPage ? `/course/${courseId}/settings/advanced` : `${studioBaseUrl}/settings/advanced/${courseId}`, + href: `/course/${courseId}/settings/advanced`, title: intl.formatMessage(messages['header.links.advancedSettings']), }] : [] ), diff --git a/src/pages-and-resources/pages/PageCard.test.jsx b/src/pages-and-resources/pages/PageCard.test.jsx index c5d641789e..19c6247f58 100644 --- a/src/pages-and-resources/pages/PageCard.test.jsx +++ b/src/pages-and-resources/pages/PageCard.test.jsx @@ -1,13 +1,14 @@ import { getConfig } from '@edx/frontend-platform'; +import { getApiWaffleFlagsUrl } from '@src/data/api'; import { initializeMocks, screen, render, waitFor, -} from '../../testUtils'; +} from '@src/testUtils'; + import PageGrid from './PageGrid'; -import { getApiWaffleFlagsUrl } from '../../data/api'; import PagesAndResourcesProvider from '../PagesAndResourcesProvider'; @@ -49,9 +50,7 @@ describe('LiveSettings', () => { axiosMock .onGet(getApiWaffleFlagsUrl(courseId)) .reply(200, { - useNewGradingPage: true, useNewCertificatesPage: true, - useNewScheduleDetailsPage: true, useNewCourseOutlinePage: true, }); }); From f193edb02535df9e0c8099906daf798c1c6c2eae Mon Sep 17 00:00:00 2001 From: Jesper Hodge <19345795+jesperhodge@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:12:27 -0400 Subject: [PATCH 09/62] fix: apply github code review suggestions --- src/taxonomy/tag-list/constants.ts | 1 + src/taxonomy/tag-list/tagTree.ts | 1 + src/taxonomy/tree-table/EditableCell.tsx | 17 +++++++---------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/taxonomy/tag-list/constants.ts b/src/taxonomy/tag-list/constants.ts index 5ac09eb66b..63c3041f27 100644 --- a/src/taxonomy/tag-list/constants.ts +++ b/src/taxonomy/tag-list/constants.ts @@ -1,3 +1,4 @@ +/** Tag list table modes - see explanation in `` component (`src/taxonomy/tag-list/TagListTable.tsx`) */ const TABLE_MODES = { VIEW: 'view', DRAFT: 'draft', diff --git a/src/taxonomy/tag-list/tagTree.ts b/src/taxonomy/tag-list/tagTree.ts index 4982faae7e..872c3b6132 100644 --- a/src/taxonomy/tag-list/tagTree.ts +++ b/src/taxonomy/tag-list/tagTree.ts @@ -95,6 +95,7 @@ export class TagTree { } } + /** For extra robustness, we verify that there are no cycles in the data. (The backend also guarantees this.) */ private validateNoCycles(items: TagData[]) { const parentByValue: { [key: string]: string | null } = {}; for (const item of items) { diff --git a/src/taxonomy/tree-table/EditableCell.tsx b/src/taxonomy/tree-table/EditableCell.tsx index 569c881676..09a660c159 100644 --- a/src/taxonomy/tree-table/EditableCell.tsx +++ b/src/taxonomy/tree-table/EditableCell.tsx @@ -12,24 +12,21 @@ import OptionalExpandLink from '../tag-list/OptionalExpandLink'; /** * Props for the EditableCell component. - * @interface EditableCellProps - * @property {string} [initialValue] - The initial value to display in the cell - * @property {function} [onKeyDown] - Callback function triggered on keyboard events - * @property {function} [onChange] - Callback function triggered when the input value changes - * @property {string} [errorMessage] - Error message to display if validation fails - * @property {boolean} [isSaving] - Indicates whether the cell value is currently being saved to the server - * @property {boolean} [autoFocus] - If true, the input field will automatically receive focus when the cell - * enters edit mode - * @property {function} [getInlineValidationMessage] - Function that returns a validation message to be displayed - * based on the current input value. */ interface EditableCellProps { + /** The initial value to display in the cell */ initialValue?: string; + /** Callback function triggered on keyboard events */ onKeyDown?: (event: React.KeyboardEvent) => void; + /** Callback function triggered when the input value changes */ onChange?: (event: React.ChangeEvent) => void; + /** Error message to display if validation fails */ errorMessage?: string; + /** Indicates whether the cell value is currently being saved to the server */ isSaving?: boolean; + /** If true, the input field will automatically receive focus when the cell enters edit mode */ autoFocus?: boolean; + /** Function that returns a validation message to be displayed based on the current input value. */ getInlineValidationMessage?: (value: string) => string; } From a8f8297d9e48222ddb9bd0691f7f058f43e90267 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Thu, 26 Mar 2026 16:25:04 -0400 Subject: [PATCH 10/62] fix: PR comments --- src/taxonomy/data/apiHooks.ts | 4 ++- src/taxonomy/tag-list/TagListTable.tsx | 3 +- src/taxonomy/tag-list/constants.ts | 6 ++++ src/taxonomy/tag-list/errors.ts | 16 +++++++++++ src/taxonomy/tag-list/hooks.test.tsx | 2 -- src/taxonomy/tag-list/hooks.ts | 39 +++++++++++++++++++------ src/taxonomy/tag-list/tagTree.test.ts | 2 +- src/taxonomy/tag-list/tagTree.ts | 2 +- src/taxonomy/tag-list/tagTreeError.ts | 6 ---- src/utils.tsx | 40 ++++++++++++++++++++++++++ 10 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 src/taxonomy/tag-list/errors.ts delete mode 100644 src/taxonomy/tag-list/tagTreeError.ts diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index 19a3f0a5d6..753503cf3f 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -16,6 +16,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; 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 @@ -209,8 +210,9 @@ export const useSubTags = (taxonomyId: number, parentTagValue: string) => useQue }, }); -export const useCreateTag = (taxonomyId: number, intl?: any) => { +export const useCreateTag = (taxonomyId: number) => { const queryClient = useQueryClient(); + const intl = useIntl(); return useMutation({ mutationFn: async ({ value, parentTagValue }: { value: string, parentTagValue?: string }) => { diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index b9147859df..91a5f562ce 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -77,7 +77,7 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { disablePagination: DISABLE_PAGINATION, enabled: tableMode === TABLE_MODES.VIEW, }); - const createTagMutation = useCreateTag(taxonomyId, intl); + const createTagMutation = useCreateTag(taxonomyId); const pageCount = tagList?.numPages ?? -1; // Custom Edit Actions Hook - handles table mode transitions, API calls, @@ -88,7 +88,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { createTagMutation, enterPreviewMode, setToast, - intl, setIsCreatingTopTag, setCreatingParentId, exitDraftWithoutSave, diff --git a/src/taxonomy/tag-list/constants.ts b/src/taxonomy/tag-list/constants.ts index 63c3041f27..33b0dc058d 100644 --- a/src/taxonomy/tag-list/constants.ts +++ b/src/taxonomy/tag-list/constants.ts @@ -5,12 +5,18 @@ const TABLE_MODES = { PREVIEW: 'preview', }; +/** Allowed transitions for table mode. + * An invalid transition is mainly an illegal switch from DRAFT mode to VIEW mode, + * which would refresh data and suddenly reorder the table and disrupt the user's workflow. + * Refreshing data is only allowed in VIEW mode. + */ const TRANSITION_TABLE = { [TABLE_MODES.VIEW]: [TABLE_MODES.VIEW, TABLE_MODES.DRAFT], [TABLE_MODES.DRAFT]: [TABLE_MODES.DRAFT, TABLE_MODES.PREVIEW], [TABLE_MODES.PREVIEW]: [TABLE_MODES.PREVIEW, TABLE_MODES.DRAFT, TABLE_MODES.VIEW], }; +/** Table mode action types for the React's `useReducer` hook */ const TABLE_MODE_ACTIONS = { TRANSITION: 'transition', }; diff --git a/src/taxonomy/tag-list/errors.ts b/src/taxonomy/tag-list/errors.ts new file mode 100644 index 0000000000..389621388c --- /dev/null +++ b/src/taxonomy/tag-list/errors.ts @@ -0,0 +1,16 @@ +/** Custom error classes for the Tag List feature. */ +/* eslint-disable max-classes-per-file */ + +export class TagTreeError extends Error { + constructor(message: string) { + super(message); + this.name = 'TagTreeError'; + } +} + +export class TagListTableError extends Error { + constructor(message: string) { + super(message); + this.name = 'TagListTableError'; + } +} diff --git a/src/taxonomy/tag-list/hooks.test.tsx b/src/taxonomy/tag-list/hooks.test.tsx index dbb76b3f8f..e03f563278 100644 --- a/src/taxonomy/tag-list/hooks.test.tsx +++ b/src/taxonomy/tag-list/hooks.test.tsx @@ -48,7 +48,6 @@ describe('useTableModes', () => { describe('useEditActions', () => { const buildActions = (overrides = {}) => { - const intl = getIntl(); const createTagMutation = { mutateAsync: jest.fn() }; const setTagTree = jest.fn(); const setDraftError = jest.fn(); @@ -65,7 +64,6 @@ describe('useEditActions', () => { createTagMutation: createTagMutation as any, enterPreviewMode, setToast, - intl, setIsCreatingTopTag, setCreatingParentId, exitDraftWithoutSave, diff --git a/src/taxonomy/tag-list/hooks.ts b/src/taxonomy/tag-list/hooks.ts index b90b4d1e50..4211ad9fdb 100644 --- a/src/taxonomy/tag-list/hooks.ts +++ b/src/taxonomy/tag-list/hooks.ts @@ -3,6 +3,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useCreateTag } from '../data/apiHooks'; import { TagTree } from './tagTree'; +import { TagListTableError } from './errors'; import type { RowId } from '../tree-table/types'; import { TABLE_MODES, @@ -13,6 +14,12 @@ import { import messages from './messages'; +/** Interface for table mode actions for React's `useReducer` hook. + * + * `type`: Action type. + * `targetMode`: The table mode to transition to. Must be one of the allowed transitions defined in `TRANSITION_TABLE`. + * An invalid transition (e.g. from DRAFT to VIEW) will throw an error to prevent disruptive data refreshes. +*/ export interface TableModeAction { type: string; targetMode: string; @@ -31,8 +38,7 @@ interface UseEditActionsParams { setDraftError: React.Dispatch>; createTagMutation: ReturnType; enterPreviewMode: () => void; - setToast: React.Dispatch>; - intl: ReturnType; + setToast: React.Dispatch>; setIsCreatingTopTag: React.Dispatch>; setCreatingParentId: React.Dispatch>; exitDraftWithoutSave: () => void; @@ -50,9 +56,17 @@ const getInlineValidationMessage = (value: string, intl: ReturnType { if (action?.type !== TABLE_MODE_ACTIONS.TRANSITION) { - throw new Error(`Unknown table mode action: ${action?.type}`); + throw new TagListTableError(`Unknown table mode action: ${action?.type}`); } const { targetMode } = action; @@ -60,9 +74,16 @@ const tableModeReducer = (currentMode: string, action: TableModeAction): string return targetMode; } - throw new Error(`Invalid table mode transition from ${currentMode} to ${targetMode}`); + throw new TagListTableError(`Invalid table mode transition from ${currentMode} to ${targetMode}`); }; +/** Simple custom hook providing table modes. + * The main purpose of this hook is to manage allowed transitions between table modes + * to prevent disruptive data refreshes. + * This allows a component to check the current mode and switch to a different mode without risking invalid transitions. + * Transitions are defined separately in the `TRANSITION_TABLE` constant, + * which makes it easy to understand and update allowed transitions in one place. + */ const useTableModes = (): UseTableModesReturn => { const [tableMode, dispatchTableMode] = useReducer(tableModeReducer, TABLE_MODES.VIEW); @@ -86,11 +107,11 @@ const useEditActions = ({ createTagMutation, enterPreviewMode, setToast, - intl, setIsCreatingTopTag, setCreatingParentId, setEditingRowId, }: UseEditActionsParams) => { + const intl = useIntl(); const updateTableWithoutDataReload = (value: string, parentTagValue: string | null = null) => { setTagTree((currentTagTree) => { const nextTree = currentTagTree || new TagTree([]); @@ -111,6 +132,10 @@ const useEditActions = ({ }); }; + /** Validates a tag value and sets a draft error message if invalid. + * In 'hard' mode, it will throw an error instead of setting a draft error message; + * in 'soft' mode, it will set a draft error message and return false. + */ const validate = (value: string, mode: 'soft' | 'hard' = 'hard'): boolean => { const validationError = getInlineValidationMessage(value, intl); if (validationError) { @@ -140,14 +165,13 @@ const useEditActions = ({ setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), - variant: 'success', }); setIsCreatingTopTag(false); setCreatingParentId(null); } catch (error) { const message = intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage: (error as Error)?.message }); setDraftError((error as Error)?.message || intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage: '' })); - setToast({ show: true, message, variant: 'danger' }); + setToast({ show: true, message }); } }; @@ -158,7 +182,6 @@ const useEditActions = ({ setToast({ show: true, message: intl.formatMessage(messages.tagUpdateSuccessMessage, { name: trimmed }), - variant: 'success', }); } setEditingRowId(null); diff --git a/src/taxonomy/tag-list/tagTree.test.ts b/src/taxonomy/tag-list/tagTree.test.ts index f19885faf1..3650597f7d 100644 --- a/src/taxonomy/tag-list/tagTree.test.ts +++ b/src/taxonomy/tag-list/tagTree.test.ts @@ -1,6 +1,6 @@ import { rawData, treeRowData } from './mockData'; import { TagTree } from './tagTree'; -import TagTreeError from './tagTreeError'; +import { TagTreeError } from './errors'; const newSubtagChildRow = { value: 'newChild', diff --git a/src/taxonomy/tag-list/tagTree.ts b/src/taxonomy/tag-list/tagTree.ts index 872c3b6132..fbe9d02ddd 100644 --- a/src/taxonomy/tag-list/tagTree.ts +++ b/src/taxonomy/tag-list/tagTree.ts @@ -1,4 +1,4 @@ -import TagTreeError from './tagTreeError'; +import { TagTreeError } from './errors'; export interface TagData { childCount: number; diff --git a/src/taxonomy/tag-list/tagTreeError.ts b/src/taxonomy/tag-list/tagTreeError.ts deleted file mode 100644 index 5e1615f257..0000000000 --- a/src/taxonomy/tag-list/tagTreeError.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default class TagTreeError extends Error { - constructor(message: string) { - super(message); - this.name = 'TagTreeError'; - } -} diff --git a/src/utils.tsx b/src/utils.tsx index 42fc1c4561..b034654bde 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -350,6 +350,46 @@ export const skipIfUnwantedTarget = ( onClick(e); }; +/** + * Error message handling for Django-Rest-Framework API responses. + * Attempts to extract an error message as a string from an unknown error object. + * + * DRF errors can come in many different formats, and this is a best-effort attempt to handle them, + * but we cannot guarantee to support every possible format. + * + * Attempts to extract an error message as a string from an unknown error object. + */ +export 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'; +}; + export const BoldText = (chunk: string[]) => {chunk}; export const Div = (chunk: string[]) =>
    {chunk}
    ; export const Paragraph = (chunk: string[]) =>

    {chunk}

    ; From 0971da49316bf739184e72cb2db0c1578a8b992c Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Thu, 26 Mar 2026 17:22:59 -0400 Subject: [PATCH 11/62] fix: PR comments --- src/taxonomy/data/types.ts | 2 + src/taxonomy/tag-list/TagListTable.tsx | 4 + src/taxonomy/tag-list/hooks.ts | 2 + src/taxonomy/tag-list/mockData.ts | 115 +++++++++++++------------ src/taxonomy/tag-list/tagTree.test.ts | 48 ++++++----- src/taxonomy/tag-list/tagTree.ts | 42 ++++----- src/taxonomy/tree-table/NestedRows.tsx | 36 ++++---- 7 files changed, 122 insertions(+), 127 deletions(-) diff --git a/src/taxonomy/data/types.ts b/src/taxonomy/data/types.ts index 0686bed900..d8ca63d1e7 100644 --- a/src/taxonomy/data/types.ts +++ b/src/taxonomy/data/types.ts @@ -44,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; diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index 91a5f562ce..a471b34afb 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -44,6 +44,8 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { const [creatingParentId, setCreatingParentId] = useState(null); const [editingRowId, setEditingRowId] = useState(null); + + // TODO: change to use the global ToastContext (waiting for UX refinement on that). const [toast, setToast] = useState({ show: false, message: '', variant: 'success' }); const [tagTree, setTagTree] = useState(null); const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); @@ -80,6 +82,8 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { const createTagMutation = useCreateTag(taxonomyId); const pageCount = tagList?.numPages ?? -1; + // TODO: to make this more readable, introduce a React context for the TagListTable instead of passing props. + // Custom Edit Actions Hook - handles table mode transitions, API calls, // and updating the table without a full data reload when creating or editing tags. const { handleCreateTag, handleUpdateTag, validate } = useEditActions({ diff --git a/src/taxonomy/tag-list/hooks.ts b/src/taxonomy/tag-list/hooks.ts index 4211ad9fdb..de58a1bef4 100644 --- a/src/taxonomy/tag-list/hooks.ts +++ b/src/taxonomy/tag-list/hooks.ts @@ -19,6 +19,8 @@ import messages from './messages'; * `type`: Action type. * `targetMode`: The table mode to transition to. Must be one of the allowed transitions defined in `TRANSITION_TABLE`. * An invalid transition (e.g. from DRAFT to VIEW) will throw an error to prevent disruptive data refreshes. + * + * For examples, see: https://react.dev/learn/extracting-state-logic-into-a-reducer#writing-reducers-well */ export interface TableModeAction { type: string; diff --git a/src/taxonomy/tag-list/mockData.ts b/src/taxonomy/tag-list/mockData.ts index 695bee619b..77d88cb60a 100644 --- a/src/taxonomy/tag-list/mockData.ts +++ b/src/taxonomy/tag-list/mockData.ts @@ -1,9 +1,10 @@ -import { TagData, TagTreeNode } from './tagTree'; +import { TagTreeNode } from './tagTree'; +import { TagData } from '../data/types'; export const rawData: TagData[] = [ { value: 'ab', - externalId: null, + externalId: 'some-external-id', childCount: 2, descendantCount: 4, depth: 0, @@ -15,7 +16,7 @@ export const rawData: TagData[] = [ }, { value: 'aaa', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -27,7 +28,7 @@ export const rawData: TagData[] = [ }, { value: 'aa', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -39,7 +40,7 @@ export const rawData: TagData[] = [ }, { value: 'ab2', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -51,7 +52,7 @@ export const rawData: TagData[] = [ }, { value: 'S3', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -63,7 +64,7 @@ export const rawData: TagData[] = [ }, { value: 'Brass2', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -75,7 +76,7 @@ export const rawData: TagData[] = [ }, { value: 'Celli', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 2, depth: 0, @@ -87,7 +88,7 @@ export const rawData: TagData[] = [ }, { value: 'ViolaDaGamba', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -99,7 +100,7 @@ export const rawData: TagData[] = [ }, { value: 'Soprano', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -111,7 +112,7 @@ export const rawData: TagData[] = [ }, { value: 'Contrabass', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -123,7 +124,7 @@ export const rawData: TagData[] = [ }, { value: 'Electrodrum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -171,7 +172,7 @@ export const rawData: TagData[] = [ }, { value: 'Fiddle', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -183,7 +184,7 @@ export const rawData: TagData[] = [ }, { value: 'grand piano', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -195,7 +196,7 @@ export const rawData: TagData[] = [ }, { value: 'Horns', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 2, depth: 0, @@ -207,7 +208,7 @@ export const rawData: TagData[] = [ }, { value: 'English Horn', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -219,7 +220,7 @@ export const rawData: TagData[] = [ }, { value: 'Small English Horn', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -231,7 +232,7 @@ export const rawData: TagData[] = [ }, { value: 'Keyboard', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -243,7 +244,7 @@ export const rawData: TagData[] = [ }, { value: 'Kid drum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -255,7 +256,7 @@ export const rawData: TagData[] = [ }, { value: 'Mezzosopranocello', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -267,7 +268,7 @@ export const rawData: TagData[] = [ }, { value: 'Oriental', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -315,7 +316,7 @@ export const rawData: TagData[] = [ }, { value: 'Drum', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -327,7 +328,7 @@ export const rawData: TagData[] = [ }, { value: 'bass drum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -411,7 +412,7 @@ export const rawData: TagData[] = [ }, { value: 'Recorder', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -459,7 +460,7 @@ export const rawData: TagData[] = [ }, { value: 'Viola', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -483,7 +484,7 @@ export const rawData: TagData[] = [ }, { value: 'Other strings', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 1, @@ -543,7 +544,7 @@ export const rawData: TagData[] = [ }, { value: 'Subbass', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -555,7 +556,7 @@ export const rawData: TagData[] = [ }, { value: 'Trumpets', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -663,7 +664,7 @@ export const rawData: TagData[] = [ }, { value: 'Xyllophones', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -678,7 +679,7 @@ export const rawData: TagData[] = [ export const treeRowData: TagTreeNode[] = [ { value: 'ab', - externalId: null, + externalId: 'some-external-id', childCount: 2, descendantCount: 4, depth: 0, @@ -690,7 +691,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'aaa', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -702,7 +703,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'aa', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -716,7 +717,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'ab2', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -728,7 +729,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'S3', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -744,7 +745,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Brass2', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -756,7 +757,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Celli', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 2, depth: 0, @@ -768,7 +769,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'ViolaDaGamba', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -780,7 +781,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'Soprano', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -796,7 +797,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Contrabass', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -808,7 +809,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Electrodrum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -858,7 +859,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Fiddle', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -870,7 +871,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'grand piano', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -882,7 +883,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Horns', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 2, depth: 0, @@ -894,7 +895,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'English Horn', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -906,7 +907,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'Small English Horn', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -922,7 +923,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Keyboard', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -934,7 +935,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Kid drum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -946,7 +947,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Mezzosopranocello', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -958,7 +959,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Oriental', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -1008,7 +1009,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Drum', - externalId: null, + externalId: 'some-external-id', childCount: 1, descendantCount: 1, depth: 1, @@ -1020,7 +1021,7 @@ export const treeRowData: TagTreeNode[] = [ subRows: [ { value: 'bass drum', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -1112,7 +1113,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Recorder', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -1160,7 +1161,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Viola', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 2, @@ -1186,7 +1187,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Other strings', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 1, @@ -1250,7 +1251,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Subbass', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -1262,7 +1263,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Trumpets', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, @@ -1376,7 +1377,7 @@ export const treeRowData: TagTreeNode[] = [ }, { value: 'Xyllophones', - externalId: null, + externalId: 'some-external-id', childCount: 0, descendantCount: 0, depth: 0, diff --git a/src/taxonomy/tag-list/tagTree.test.ts b/src/taxonomy/tag-list/tagTree.test.ts index 3650597f7d..288facfc31 100644 --- a/src/taxonomy/tag-list/tagTree.test.ts +++ b/src/taxonomy/tag-list/tagTree.test.ts @@ -1,10 +1,12 @@ import { rawData, treeRowData } from './mockData'; import { TagTree } from './tagTree'; import { TagTreeError } from './errors'; +import { TagData } from '../data/types'; -const newSubtagChildRow = { +// For testing purposes, we define a new child node that can be added to the tree in various test cases. +const newChildNode: TagData = { value: 'newChild', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 8, @@ -42,13 +44,13 @@ describe('TagTree', () => { it('gets a deep copy when getting a node so that direct mutations do not affect the original tree', () => { const tree = new TagTree(rawData); const node = tree.getTagAsDeepCopy('ab'); - expect(node?.externalId).toBeNull(); + expect(node?.externalId).toBe('some-external-id'); if (node) { node.externalId = 'modified'; } const originalNode = tree.getTagAsDeepCopy('ab'); - expect(originalNode?.externalId).toBeNull(); + expect(originalNode?.externalId).toBe('some-external-id'); }); it('returns null for non-existent node', () => { @@ -61,7 +63,7 @@ describe('TagTree', () => { const tree = new TagTree(rawData); const newRow = { value: 'newTopLevel', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 7, @@ -77,24 +79,24 @@ describe('TagTree', () => { it('creates a new child row', () => { const tree = new TagTree(rawData); - tree.addNode(newSubtagChildRow, 'ab'); + tree.addNode(newChildNode, 'ab'); const parentNode = tree.getTagAsDeepCopy('ab'); - expect(parentNode?.subRows).toContainEqual(newSubtagChildRow); + expect(parentNode?.subRows).toContainEqual(newChildNode); }); it('edits a node value', () => { const tree = new TagTree(rawData); - tree.addNode(newSubtagChildRow, 'ab'); + tree.addNode(newChildNode, 'ab'); tree.editTagValue('ab', 'editedAb'); expect(tree.getTagAsDeepCopy('editedAb')).not.toBeNull(); expect(tree.getTagAsDeepCopy('ab')).toBeNull(); expect(tree.getTagAsDeepCopy('editedAb')?.value).toBe('editedAb'); - expect(tree.getTagAsDeepCopy('editedAb')?.subRows).toContainEqual(newSubtagChildRow); + expect(tree.getTagAsDeepCopy('editedAb')?.subRows).toContainEqual(newChildNode); }); it('deletes a top-level node and its children', () => { const tree = new TagTree(rawData); - tree.addNode(newSubtagChildRow, 'ab'); + tree.addNode(newChildNode, 'ab'); tree.removeNode('ab'); expect(tree.getTagAsDeepCopy('ab')).toBeNull(); expect(tree.getTagAsDeepCopy('newChild')).toBeNull(); @@ -102,10 +104,10 @@ describe('TagTree', () => { it('deletes a child node', () => { const tree = new TagTree(rawData); - tree.addNode(newSubtagChildRow, 'ab'); + tree.addNode(newChildNode, 'ab'); tree.removeNode('newChild', 'ab'); const parentNode = tree.getTagAsDeepCopy('ab'); - expect(parentNode?.subRows).not.toContainEqual(newSubtagChildRow); + expect(parentNode?.subRows).not.toContainEqual(newChildNode); }); it('returns null and leaves tree unchanged when removing a non-existent node', () => { @@ -132,7 +134,7 @@ describe('TagTree', () => { const tree = new TagTree(rawData); const rowCountBefore = tree.getAllAsDeepCopy().length; - tree.addNode(newSubtagChildRow, 'missing-parent'); + tree.addNode(newChildNode, 'missing-parent'); expect(tree.getAllAsDeepCopy()).toHaveLength(rowCountBefore); expect(tree.getTagAsDeepCopy('newChild')).toBeNull(); @@ -142,7 +144,7 @@ describe('TagTree', () => { const orphanData = [ { value: 'orphan', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 900, @@ -164,7 +166,7 @@ describe('TagTree', () => { const duplicateValueData = [ { value: 'dup', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1001, @@ -176,7 +178,7 @@ describe('TagTree', () => { }, { value: 'dup', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1002, @@ -195,7 +197,7 @@ describe('TagTree', () => { const cyclicData = [ { value: 'a', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1101, @@ -207,7 +209,7 @@ describe('TagTree', () => { }, { value: 'b', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1102, @@ -232,7 +234,7 @@ describe('TagTree', () => { const tree = new TagTree(rawData); const newNode = { value: 'ab', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 999, @@ -250,7 +252,7 @@ describe('TagTree', () => { const tree = new TagTree(rawData); const newNode = { value: 'new row', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1000, @@ -266,7 +268,7 @@ describe('TagTree', () => { expect(tree.getAllAsDeepCopy()[0]).toEqual(newNode); const nextNewNode = { value: 'another new row', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1001, @@ -285,7 +287,7 @@ describe('TagTree', () => { const tree = new TagTree(rawData); const newChild = { value: 'new child', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1002, @@ -303,7 +305,7 @@ describe('TagTree', () => { const nextNewChild = { value: 'another new child', - externalId: null, + externalId: 'some-external-id', canChangeTag: true, canDeleteTag: true, id: 1003, diff --git a/src/taxonomy/tag-list/tagTree.ts b/src/taxonomy/tag-list/tagTree.ts index fbe9d02ddd..881e3dc623 100644 --- a/src/taxonomy/tag-list/tagTree.ts +++ b/src/taxonomy/tag-list/tagTree.ts @@ -1,19 +1,5 @@ import { TagTreeError } from './errors'; - -export interface TagData { - childCount: number; - descendantCount: number; - depth: number; - externalId?: string | null; - canChangeTag?: boolean; - canDeleteTag?: boolean; - id: number; - parentValue: string | null; - subTagsUrl: string | null; - value: string; - usageCount?: number; - _id?: string; -} +import type { TagData } from '../data/types'; export interface TagTreeNode extends TagData { subRows?: TagTreeNode[]; @@ -37,8 +23,8 @@ export class TagTree { this.buildTree(); } - /** Returns a flattened copy of all nodes in the tree. For example, - * this array is not nested even though it contains a parent and a child tag: + /** Returns a flattened copy of all nodes in the tree. + * For example, this array is not nested even though it contains a parent and a child tag: * [ * { * value: 'parent tag name', @@ -67,10 +53,11 @@ export class TagTree { * // ... more tags * ] */ - getAllFlattenedAsCopy(): TagTreeNode[] { - const flatten = (nodes: TagTreeNode[], accumulator: TagTreeNode[] = []): TagTreeNode[] => { + getAllFlattenedAsCopy(): TagData[] { + const flatten = (nodes: TagTreeNode[], accumulator: TagData[] = []): TagData[] => { for (const node of nodes) { - accumulator.push({ ...node, subRows: undefined }); + const { subRows, ...tagData } = node; + accumulator.push({ ...tagData }); // Create a shallow copy of the tag data without subRows if (node.subRows) { flatten(node.subRows, accumulator); } @@ -84,14 +71,17 @@ export class TagTree { return JSON.parse(JSON.stringify(this.rows)); } + /** For extra robustness, we verify that there are no duplicate values + * in the data. (The backend also guarantees this.) + */ private validateNoDuplicateValues(items: TagData[]) { - // this should be case-sensitive to account for conceivable duplicates that have different cases in the backend. const seenValues = new Set(); for (const item of items) { - if (seenValues.has(item.value)) { - throw new TagTreeError(`Duplicate tag value found: ${item.value}`); + const lowerCaseValue = item.value.toLowerCase(); + if (seenValues.has(lowerCaseValue)) { + throw new TagTreeError(`Duplicate tag value found: ${lowerCaseValue}`); } - seenValues.add(item.value); + seenValues.add(lowerCaseValue); } } @@ -99,7 +89,7 @@ export class TagTree { private validateNoCycles(items: TagData[]) { const parentByValue: { [key: string]: string | null } = {}; for (const item of items) { - parentByValue[item.value] = item.parentValue; + parentByValue[item.value.toLowerCase()] = item.parentValue ? item.parentValue.toLowerCase() : null; } const visitStatus: { [key: string]: number } = {}; @@ -125,7 +115,7 @@ export class TagTree { }; for (const item of items) { - if (detectCycle(item.value)) { + if (detectCycle(item.value.toLowerCase())) { throw new TagTreeError('Cycle detected in tag hierarchy.'); } } diff --git a/src/taxonomy/tree-table/NestedRows.tsx b/src/taxonomy/tree-table/NestedRows.tsx index d93a3f7dee..8facb825b7 100644 --- a/src/taxonomy/tree-table/NestedRows.tsx +++ b/src/taxonomy/tree-table/NestedRows.tsx @@ -8,41 +8,35 @@ import type { } from './types'; import { CreateRow } from './CreateRow'; -/** - * Props for the NestedRows component - * @interface NestedRowsProps - * @property {TreeRow} parentRow - The parent row object from TanStack React Table - * @property {string} parentRowValue - The value identifier of the parent row - * @property {boolean} [isCreating] - Whether a new child row is currently being created for this parent - * @property {function} [onSaveNewChildRow] - Callback when a new child row is saved (receives value and parentRowValue) - * @property {function} [onCancelCreation] - Callback when child row creation is cancelled - * @property {TreeRow[]} [childRowsData] - Array of child row objects to render - * @property {number} [depth] - Current nesting depth level (used for indentation calculation) - * @property {string} [draftError] - Error message to display in draft creation form - * @property {function} [setDraftError] - Setter function for draft error state - * @property {RowId | null} [creatingParentId] - ID of the row currently in creation mode - * @property {function} [setCreatingParentId] - Setter function for which row is in creation mode - * @property {function} setIsCreatingTopRow - Callback to set whether top-level row creation is active - * @property {CreateRowMutationState} createRowMutation - State object for the row creation mutation - * (isPending, isError, error) - * @property {function} validate - Validation function for new row values - * (receives value and optional 'soft' or 'hard' mode; in 'hard' mode an exception is thrown on validation failure) - */ - interface NestedRowsProps { + /** The parent row object from TanStack React Table */ parentRow: TreeRow; + /** The value identifier of the parent row */ parentRowValue: string; + /** Whether a new child row is currently being created for this parent */ isCreating?: boolean; + /** Callback when a new child row is saved (receives value and parentRowValue) */ onSaveNewChildRow?: (value: string, parentRowValue: string) => void; + /** Callback when child row creation is cancelled */ onCancelCreation?: () => void; + /** Array of child row objects to render */ childRowsData?: TreeRow[]; + /** Current nesting depth level (used for indentation calculation) */ depth?: number; + /** Error message to display in draft creation form */ draftError?: string; + /** Setter function for draft error state */ setDraftError?: (error: string) => void; + /** ID of the row currently in creation mode */ creatingParentId?: RowId | null; + /** Setter function for which row is in creation mode */ setCreatingParentId?: (value: RowId | null) => void; + /** Callback to set whether top-level row creation is active */ setIsCreatingTopRow: (isCreating: boolean) => void; + /** State object for the row creation mutation (isPending, isError, error) */ createRowMutation: CreateRowMutationState; + /** Validation function for new row values (receives value and optional 'soft' or 'hard' mode; + * in 'hard' mode an exception is thrown on validation failure) */ validate: (value: string, mode?: 'soft' | 'hard') => boolean; } From 825bab372d4c8850520b1480d1ce08814d3be3f3 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Thu, 26 Mar 2026 17:33:04 -0400 Subject: [PATCH 12/62] fix: tests --- src/taxonomy/tag-list/TagListTable.test.jsx | 2 +- src/taxonomy/tag-list/hooks.test.tsx | 33 +++++++++++---------- src/taxonomy/tag-list/hooks.ts | 2 +- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index 9a5dde7a3c..a7616a2ab7 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -45,7 +45,7 @@ RootWrapper.propTypes = { maxDepth: PropTypes.number, }; -const tagDefaults = { depth: 0, external_id: null, parent_value: null }; +const tagDefaults = { depth: 0, external_id: '', parent_value: null }; const mockTagsResponse = { next: null, previous: null, diff --git a/src/taxonomy/tag-list/hooks.test.tsx b/src/taxonomy/tag-list/hooks.test.tsx index e03f563278..7d799afceb 100644 --- a/src/taxonomy/tag-list/hooks.test.tsx +++ b/src/taxonomy/tag-list/hooks.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { IntlProvider, useIntl } from '@edx/frontend-platform/i18n'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import { act, renderHook } from '@testing-library/react'; import { TagTree } from './tagTree'; @@ -9,11 +9,6 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); -const getIntl = () => { - const { result } = renderHook(() => useIntl(), { wrapper }); - return result.current; -}; - describe('useTableModes', () => { it('supports valid transitions from view to draft to preview', () => { const { result } = renderHook(() => useTableModes()); @@ -58,7 +53,7 @@ describe('useEditActions', () => { const exitDraftWithoutSave = jest.fn(); const setEditingRowId = jest.fn(); - const actions = useEditActions({ // eslint-disable-line react-hooks/rules-of-hooks + const params = { setTagTree, setDraftError, createTagMutation: createTagMutation as any, @@ -69,10 +64,12 @@ describe('useEditActions', () => { exitDraftWithoutSave, setEditingRowId, ...(overrides as any), - }); + }; + + const { result } = renderHook(() => useEditActions(params), { wrapper }); return { - actions, + actions: result.current, createTagMutation, setTagTree, setDraftError, @@ -106,7 +103,9 @@ describe('useEditActions', () => { }); const { actions } = buildActions({ setTagTree }); - actions.updateTableWithoutDataReload('brand new root'); + act(() => { + actions.updateTableWithoutDataReload('brand new root'); + }); expect(updatedTree.getTagAsDeepCopy('brand new root')).not.toBeNull(); }); @@ -119,7 +118,9 @@ describe('useEditActions', () => { setEditingRowId, } = buildActions(); - await actions.handleUpdateTag(' same value ', 'same value'); + await act(async () => { + await actions.handleUpdateTag(' same value ', 'same value'); + }); expect(enterPreviewMode).not.toHaveBeenCalled(); expect(setToast).not.toHaveBeenCalled(); @@ -134,13 +135,14 @@ describe('useEditActions', () => { setEditingRowId, } = buildActions(); - await actions.handleUpdateTag('updated', 'original'); + await act(async () => { + await actions.handleUpdateTag('updated', 'original'); + }); expect(enterPreviewMode).toHaveBeenCalled(); expect(setToast).toHaveBeenCalledWith({ show: true, message: 'Tag "updated" updated successfully', - variant: 'success', }); expect(setEditingRowId).toHaveBeenCalledWith(null); }); @@ -154,13 +156,14 @@ describe('useEditActions', () => { } = buildActions(); createTagMutation.mutateAsync.mockRejectedValue(new Error('server failed')); - await actions.handleCreateTag('new tag'); + await act(async () => { + await actions.handleCreateTag('new tag'); + }); expect(setDraftError).toHaveBeenCalledWith('server failed'); expect(setToast).toHaveBeenCalledWith({ show: true, message: 'Error creating tag: server failed', - variant: 'danger', }); }); }); diff --git a/src/taxonomy/tag-list/hooks.ts b/src/taxonomy/tag-list/hooks.ts index de58a1bef4..a350e3eabe 100644 --- a/src/taxonomy/tag-list/hooks.ts +++ b/src/taxonomy/tag-list/hooks.ts @@ -127,7 +127,7 @@ const useEditActions = ({ childCount: 0, descendantCount: 0, subTagsUrl: null, - externalId: null, + externalId: '', }, parentTagValue); return nextTree; From 443221c6d6c2f5fc9befcb9e858deebd478b888f Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Thu, 26 Mar 2026 17:36:24 -0400 Subject: [PATCH 13/62] fix: tests --- src/taxonomy/data/apiHooks.test.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/taxonomy/data/apiHooks.test.jsx b/src/taxonomy/data/apiHooks.test.jsx index c4454acc49..e968ac6a38 100644 --- a/src/taxonomy/data/apiHooks.test.jsx +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -16,6 +16,7 @@ import { useImportTags, useImportNewTaxonomy, } from './apiHooks'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; let axiosMock; @@ -29,7 +30,9 @@ const queryClient = new QueryClient({ const wrapper = ({ children }) => ( - {children} + + {children} + ); From 40f409e823e50fc9f38773436ef513ea2cd621cb Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Thu, 26 Mar 2026 17:40:25 -0400 Subject: [PATCH 14/62] fix: PR comment --- src/taxonomy/data/apiHooks.test.jsx | 2 +- src/taxonomy/tag-list/tagTree.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/taxonomy/data/apiHooks.test.jsx b/src/taxonomy/data/apiHooks.test.jsx index e968ac6a38..1f012ee946 100644 --- a/src/taxonomy/data/apiHooks.test.jsx +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -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'; @@ -16,7 +17,6 @@ import { useImportTags, useImportNewTaxonomy, } from './apiHooks'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; let axiosMock; diff --git a/src/taxonomy/tag-list/tagTree.test.ts b/src/taxonomy/tag-list/tagTree.test.ts index 288facfc31..068005adb5 100644 --- a/src/taxonomy/tag-list/tagTree.test.ts +++ b/src/taxonomy/tag-list/tagTree.test.ts @@ -61,7 +61,7 @@ describe('TagTree', () => { it('creates a new top-level row', () => { const tree = new TagTree(rawData); - const newRow = { + const newRow: TagData = { value: 'newTopLevel', externalId: 'some-external-id', canChangeTag: true, From 4a4deefc623581016cc78818313b69b8b55c55bf Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Thu, 26 Mar 2026 17:57:27 -0400 Subject: [PATCH 15/62] fix: PR comments --- src/taxonomy/tag-list/TagListTable.tsx | 2 - src/taxonomy/tag-list/tagColumns.tsx | 130 +++++++++++++++++-------- 2 files changed, 88 insertions(+), 44 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index a471b34afb..01795b8917 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -100,7 +100,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { const columns = useMemo( () => getColumns({ - intl, setIsCreatingTopTag, setCreatingParentId, handleUpdateTag, @@ -115,7 +114,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { creatingParentId, }), [ - intl, isCreatingTopTag, editingRowId, tableMode, diff --git a/src/taxonomy/tag-list/tagColumns.tsx b/src/taxonomy/tag-list/tagColumns.tsx index 01fbcfc860..c70b415a69 100644 --- a/src/taxonomy/tag-list/tagColumns.tsx +++ b/src/taxonomy/tag-list/tagColumns.tsx @@ -9,8 +9,8 @@ import { AddCircle, MoreVert, } from '@openedx/paragon/icons'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import type { Row } from '@tanstack/react-table'; -import type { IntlShape } from 'react-intl'; import messages from './messages'; import type { @@ -33,7 +33,6 @@ const asTagListRowData = (row: Row): TagListRowData => ( ); interface GetColumnsArgs { - intl: IntlShape; setIsCreatingTopTag: (isCreating: boolean) => void; setCreatingParentId: (id: RowId | null) => void; handleUpdateTag: (value: string, originalValue: string) => void; @@ -48,8 +47,82 @@ interface GetColumnsArgs { creatingParentId: RowId | null; } +interface ActionsHeaderProps { + onStartDraft: () => void; + setDraftError: (error: string) => void; + setIsCreatingTopTag: (isCreating: boolean) => void; + setEditingRowId: (id: RowId | null) => void; + setActiveActionMenuRowId: (id: RowId | null) => void; + hasOpenDraft: boolean; + draftInProgressHintId: string; +} + +const ActionsHeader = ({ + onStartDraft, + setDraftError, + setIsCreatingTopTag, + setEditingRowId, + setActiveActionMenuRowId, + hasOpenDraft, + draftInProgressHintId, +}: ActionsHeaderProps) => { + const intl = useIntl(); + return ( +
    + {intl.formatMessage(messages.createNewTagTooltip)}
    } + src={AddCircle} + alt={intl.formatMessage(messages.createTagButtonLabel)} + size="inline" + onClick={() => { + onStartDraft(); + setDraftError(''); + setIsCreatingTopTag(true); + setEditingRowId(null); + setActiveActionMenuRowId(null); + }} + disabled={hasOpenDraft} + aria-describedby={hasOpenDraft ? draftInProgressHintId : undefined} + /> + + ); +}; + +interface ActionsMenuProps { + rowData: TagListRowData; + startSubtagDraft: () => void; + disableAddSubtag: boolean; +} + +const ActionsMenu = ({ rowData, startSubtagDraft, disableAddSubtag }: ActionsMenuProps) => { + const intl = useIntl(); + + return ( + + + + + {intl.formatMessage(messages.addSubtag)} + + + + ); +} + function getColumns({ - intl, setIsCreatingTopTag, setCreatingParentId, setEditingRowId, @@ -65,7 +138,8 @@ function getColumns({ return [ { - header: intl.formatMessage(messages.tagListColumnValueHeader), + id: 'valueColumn', + header: () => , cell: ({ row }) => { const { value, @@ -82,24 +156,15 @@ function getColumns({ { id: 'actions', header: () => ( -
    - {intl.formatMessage(messages.createNewTagTooltip)}
    } - src={AddCircle} - alt={intl.formatMessage(messages.createTagButtonLabel)} - size="inline" - onClick={() => { - onStartDraft(); - setDraftError(''); - setIsCreatingTopTag(true); - setEditingRowId(null); - setActiveActionMenuRowId(null); - }} - disabled={hasOpenDraft} - aria-describedby={hasOpenDraft ? draftInProgressHintId : undefined} - /> - + ), cell: ({ row }) => { const rowData = asTagListRowData(row); @@ -121,26 +186,7 @@ function getColumns({ return (
    - - - - - {intl.formatMessage(messages.addSubtag)} - - - +
    ); }, From 96302150bd385dc15f75e2b96d644eac82abc90e Mon Sep 17 00:00:00 2001 From: "kshitij.sobti" Date: Wed, 28 Jan 2026 15:46:41 +0530 Subject: [PATCH 16/62] feat: adds agreement-gated feature with support across files and videos pages Adds new generic components for gating certain features based on acceptance or acknowledgement of user agreements. It adds one alert that can be displayed where a feature (such as uploading) is blocked based on user agreeement, and it adds a wrapper component that disables the components inside it till the agreement has been accepted. --- src/constants.ts | 6 + src/course-outline/page-alerts/PageAlerts.jsx | 5 + src/data/api.ts | 22 +++ src/data/apiHooks.ts | 63 +++++++- src/data/types.ts | 16 ++ .../files-page/CourseFilesTable.tsx | 47 +++--- src/files-and-videos/files-page/FilesPage.jsx | 7 +- .../videos-page/CourseVideosTable.tsx | 56 +++---- .../videos-page/VideosPage.tsx | 7 +- .../AlertAgreementGatedFeature.test.tsx | 142 ++++++++++++++++++ .../AlertAgreementGatedFeature.tsx | 66 ++++++++ .../GatedComponentWrapper.test.tsx | 79 ++++++++++ .../GatedComponentWrapper.tsx | 30 ++++ src/generic/agreement-gated-feature/index.ts | 2 + .../agreement-gated-feature/messages.ts | 16 ++ 15 files changed, 506 insertions(+), 58 deletions(-) create mode 100644 src/generic/agreement-gated-feature/AlertAgreementGatedFeature.test.tsx create mode 100644 src/generic/agreement-gated-feature/AlertAgreementGatedFeature.tsx create mode 100644 src/generic/agreement-gated-feature/GatedComponentWrapper.test.tsx create mode 100644 src/generic/agreement-gated-feature/GatedComponentWrapper.tsx create mode 100644 src/generic/agreement-gated-feature/index.ts create mode 100644 src/generic/agreement-gated-feature/messages.ts diff --git a/src/constants.ts b/src/constants.ts index 12e65d401d..be5eeca609 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -116,3 +116,9 @@ export const BROKEN = 'broken'; export const LOCKED = 'locked'; export const MANUAL = 'manual'; + +export enum AgreementGated { + UPLOAD = 'upload', + UPLOAD_VIDEOS = 'upload.videos', + UPLOAD_FILES = 'upload.files', +} diff --git a/src/course-outline/page-alerts/PageAlerts.jsx b/src/course-outline/page-alerts/PageAlerts.jsx index 7389263044..dad989010e 100644 --- a/src/course-outline/page-alerts/PageAlerts.jsx +++ b/src/course-outline/page-alerts/PageAlerts.jsx @@ -15,6 +15,8 @@ import { useState } from 'react'; import { useDispatch } from 'react-redux'; import { Link, useNavigate } from 'react-router-dom'; import { usePasteFileNotices } from '@src/course-outline/data/apiHooks'; +import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature'; +import { AgreementGated } from '../../constants'; import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot'; import advancedSettingsMessages from '../../advanced-settings/messages'; import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert'; @@ -441,6 +443,9 @@ const PageAlerts = ({ {conflictingFilesPasteAlert()} {newFilesPasteAlert()} {renderOutOfSyncAlert()} + ); diff --git a/src/data/api.ts b/src/data/api.ts index 102da1dc13..b3cfe0d2a4 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -203,3 +203,25 @@ export async function getPreviewModulestoreMigration( const { data } = await client.get(getPreviewModulestoreMigrationUrl(), { params }); return camelCaseObject(data); } + +export const getUserAgreementRecordApi = (agreementType: string) => `${getConfig().LMS_BASE_URL}/api/agreements/v1/agreement_record/${agreementType}`; + +export async function getUserAgreementRecord(agreementType: string) { + const client = getAuthenticatedHttpClient(); + const { data } = await client.get(getUserAgreementRecordApi(agreementType)); + return camelCaseObject(data); +} + +export async function updateUserAgreementRecord(agreementType: string) { + const client = getAuthenticatedHttpClient(); + const { data } = await client.post(getUserAgreementRecordApi(agreementType)); + return camelCaseObject(data); +} + +export const getUserAgreementApi = (agreementType: string) => `${getConfig().LMS_BASE_URL}/api/agreements/v1/agreement/${agreementType}/`; + +export async function getUserAgreement(agreementType: string) { + const client = getAuthenticatedHttpClient(); + const { data } = await client.get(getUserAgreementApi(agreementType)); + return camelCaseObject(data); +} diff --git a/src/data/apiHooks.ts b/src/data/apiHooks.ts index 211b9eede5..b55d98143b 100644 --- a/src/data/apiHooks.ts +++ b/src/data/apiHooks.ts @@ -1,16 +1,19 @@ -import { - skipToken, useMutation, useQuery, useQueryClient, -} from '@tanstack/react-query'; +import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { UserAgreement, UserAgreementRecord } from '@src/data/types'; import { libraryAuthoringQueryKeys } from '@src/library-authoring/data/apiHooks'; import { - getWaffleFlags, - waffleFlagDefaults, - bulkModulestoreMigrate, - getModulestoreMigrationStatus, + skipToken, useMutation, useQueries, useQuery, useQueryClient, UseQueryOptions, +} from '@tanstack/react-query'; +import { BulkMigrateRequestData, + bulkModulestoreMigrate, getCourseDetails, - getPreviewModulestoreMigration, + getModulestoreMigrationStatus, + getPreviewModulestoreMigration, getUserAgreement, + getUserAgreementRecord, + getWaffleFlags, updateUserAgreementRecord, + waffleFlagDefaults, } from './api'; import { RequestStatus, RequestStatusType } from './constants'; @@ -165,3 +168,47 @@ export function createGlobalState( return { data, setData, resetData }; }; } + +export const getGatingAgreementTypes = (gatingTypes: string[]): string[] => ( + [...new Set( + gatingTypes + .flatMap(gatingType => getConfig().AGREEMENT_GATING?.[gatingType]) + .filter(item => Boolean(item)), + )] +); + +export const useUserAgreementRecord = (agreementType:string) => ( + useQuery({ + queryKey: ['agreement-record', agreementType], + queryFn: () => getUserAgreementRecord(agreementType), + retry: false, + }) +); + +export const useUserAgreementRecords = (agreementTypes:string[]) => ( + useQueries({ + queries: agreementTypes.map>(agreementType => ({ + queryKey: ['agreement-record', agreementType], + queryFn: () => getUserAgreementRecord(agreementType), + retry: false, + })), + }) +); + +export const useUserAgreementRecordUpdater = (agreementType:string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => updateUserAgreementRecord(agreementType), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agreement-record', agreementType] }); + }, + }); +}; + +export const useUserAgreement = (agreementType:string) => ( + useQuery({ + queryKey: ['agreements', agreementType], + queryFn: () => getUserAgreement(agreementType), + retry: false, + }) +); diff --git a/src/data/types.ts b/src/data/types.ts index c13205a6a0..7dd267f4f8 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -201,3 +201,19 @@ export type AccessManagedXBlockDataTypes = { onlineProctoringRules?: string; discussionEnabled?: boolean; }; + +export interface UserAgreementRecord { + username: string; + agreementType: string; + acceptedAt: string | null; + isCurrent: boolean; +} + +export interface UserAgreement { + type: string; + name: string; + summary: string; + hasText: boolean; + url: string; + updated: string; +} diff --git a/src/files-and-videos/files-page/CourseFilesTable.tsx b/src/files-and-videos/files-page/CourseFilesTable.tsx index d71b90476e..1fc9e885eb 100644 --- a/src/files-and-videos/files-page/CourseFilesTable.tsx +++ b/src/files-and-videos/files-page/CourseFilesTable.tsx @@ -1,5 +1,6 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { CheckboxFilter } from '@openedx/paragon'; +import { AgreementGated, UPLOAD_FILE_MAX_SIZE } from '@src/constants'; import { addAssetFile, deleteAssetFile, @@ -20,13 +21,13 @@ import { FileTable, ThumbnailColumn, } from '@src/files-and-videos/generic'; +import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature'; import { useModels } from '@src/generic/model-store'; import { DeprecatedReduxState } from '@src/store'; import { getFileSizeToClosestByte } from '@src/utils'; import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { UPLOAD_FILE_MAX_SIZE } from '@src/constants'; export const CourseFilesTable = () => { const intl = useIntl(); @@ -159,26 +160,28 @@ export const CourseFilesTable = () => { return null; } return ( - <> - - - + + <> + + + + ); }; diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 41af98f34e..210c43bdc1 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -1,7 +1,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Container } from '@openedx/paragon'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; @@ -10,6 +10,8 @@ import Placeholder from '@src/editors/Placeholder'; import { RequestStatus } from '@src/data/constants'; import getPageHeadTitle from '@src/generic/utils'; import EditFileAlertsSlot from '@src/plugin-slots/EditFileAlertsSlot'; +import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature'; +import { AgreementGated } from '@src/constants'; import { EditFileErrors } from '../generic'; import { fetchAssets, resetErrors } from './data/thunks'; @@ -55,6 +57,9 @@ const FilesPage = () => { updateFileStatus={updateAssetStatus} loadingStatus={loadingStatus} /> +
    {intl.formatMessage(messages.heading)} diff --git a/src/files-and-videos/videos-page/CourseVideosTable.tsx b/src/files-and-videos/videos-page/CourseVideosTable.tsx index d1667ef13f..fe14b2645b 100644 --- a/src/files-and-videos/videos-page/CourseVideosTable.tsx +++ b/src/files-and-videos/videos-page/CourseVideosTable.tsx @@ -2,6 +2,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, CheckboxFilter, useToggle, } from '@openedx/paragon'; +import { AgreementGated } from '@src/constants'; import { RequestStatus } from '@src/data/constants'; import { ActiveColumn, @@ -29,6 +30,7 @@ import messages from '@src/files-and-videos/videos-page/messages'; import TranscriptSettings from '@src/files-and-videos/videos-page/transcript-settings'; import UploadModal from '@src/files-and-videos/videos-page/upload-modal'; import VideoThumbnail from '@src/files-and-videos/videos-page/VideoThumbnail'; +import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature'; import { useModels } from '@src/generic/model-store'; import { DeprecatedReduxState } from '@src/store'; import React, { useEffect, useRef } from 'react'; @@ -224,23 +226,24 @@ export const CourseVideosTable = () => { ]; return ( - <> - - - {isVideoTranscriptEnabled ? ( - - ) : null} - - { + + <> + + + {isVideoTranscriptEnabled ? ( + + ) : null} + + { loadingStatus !== RequestStatus.FAILED && ( <> {isVideoTranscriptEnabled && ( @@ -275,14 +278,15 @@ export const CourseVideosTable = () => { ) } - - + + + ); }; diff --git a/src/files-and-videos/videos-page/VideosPage.tsx b/src/files-and-videos/videos-page/VideosPage.tsx index 53d32322c8..b2faa26e07 100644 --- a/src/files-and-videos/videos-page/VideosPage.tsx +++ b/src/files-and-videos/videos-page/VideosPage.tsx @@ -1,4 +1,6 @@ -import { useEffect } from 'react'; +import { AgreementGated } from '@src/constants'; +import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature'; +import React, { useEffect } from 'react'; import { Helmet } from 'react-helmet'; import { useDispatch, useSelector } from 'react-redux'; @@ -57,6 +59,9 @@ const VideosPage = () => { updateFileStatus={updateVideoStatus} loadingStatus={loadingStatus} /> +

    {intl.formatMessage(messages.heading)}

    diff --git a/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.test.tsx b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.test.tsx new file mode 100644 index 0000000000..78c525b66f --- /dev/null +++ b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.test.tsx @@ -0,0 +1,142 @@ +import { initializeMockApp, mergeConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { AgreementGated } from '@src/constants'; +import { getUserAgreementApi, getUserAgreementRecordApi } from '@src/data/api'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MockAdapter from 'axios-mock-adapter'; +import React from 'react'; +import { AlertAgreementGatedFeature } from './AlertAgreementGatedFeature'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +async function renderComponent(gatingTypes: AgreementGated[]) { + return render( + + + + , + , + ); +} + +describe('AlertAgreementGatedFeature', () => { + let axiosMock; + beforeAll(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + beforeEach(() => { + axiosMock.onGet(getUserAgreementApi('agreement1')).reply(200, { + type: 'agreement1', + name: 'agreement1', + summary: 'summary1', + has_text: true, + url: 'https://example.com/agreement1', + updated: '2023-01-01T00:00:00Z', + }); + axiosMock.onGet(getUserAgreementApi('agreement2')).reply(200, { + type: 'agreement2', + name: 'agreement2', + summary: 'summary2', + has_text: true, + url: 'https://example.com/agreement2', + }); + axiosMock.onGet(getUserAgreementApi('agreement3')).reply(404); + axiosMock.onGet(getUserAgreementRecordApi('agreement1')).reply(200, {}); + axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, {}); + mergeConfig({ + AGREEMENT_GATING: { + [AgreementGated.UPLOAD]: ['agreement1', 'agreement2'], + [AgreementGated.UPLOAD_VIDEOS]: ['agreement2'], + }, + }); + }); + afterEach(() => { + axiosMock.reset(); + }); + + it('renders no alerts when gatingTypes is empty', async () => { + await renderComponent([]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders no alerts when gatingTypes have no associated agreement', async () => { + await renderComponent([AgreementGated.UPLOAD_FILES]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders no alerts when associated agreement does not exist', async () => { + mergeConfig({ + AGREEMENT_GATING: { + [AgreementGated.UPLOAD_FILES]: ['agreement3'], + }, + }); + await renderComponent([AgreementGated.UPLOAD_FILES]); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders an alert for each agreement type associated with the gating types', async () => { + const gatingTypes = [AgreementGated.UPLOAD]; + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + + expect(screen.queryAllByRole('alert')).toHaveLength(2); + expect(screen.getByText('agreement1')).toBeInTheDocument(); + expect(screen.getByText('summary1')).toBeInTheDocument(); + expect(screen.getByText('agreement2')).toBeInTheDocument(); + expect(screen.getByText('summary2')).toBeInTheDocument(); + }); + + it('renders skips alerts for agreements that have already been accepted', async () => { + const gatingTypes = [AgreementGated.UPLOAD]; + axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, { is_current: true }); + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + + expect(screen.queryAllByRole('alert')).toHaveLength(1); + expect(screen.getByText('agreement1')).toBeInTheDocument(); + expect(screen.getByText('summary1')).toBeInTheDocument(); + expect(screen.queryByText('agreement2')).not.toBeInTheDocument(); + expect(screen.queryByText('summary2')).not.toBeInTheDocument(); + }); + + it('does not duplicate alert if multiple gating types have the same agreement type', async () => { + const gatingTypes = [AgreementGated.UPLOAD, AgreementGated.UPLOAD_FILES]; + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + + expect(screen.queryAllByRole('alert')).toHaveLength(2); + expect(screen.getByText('agreement1')).toBeInTheDocument(); + expect(screen.getByText('summary1')).toBeInTheDocument(); + expect(screen.getByText('agreement2')).toBeInTheDocument(); + expect(screen.getByText('summary2')).toBeInTheDocument(); + }); + + it('posts a request to mark acceptance when user clicks Agree', async () => { + const user = userEvent.setup(); + const gatingTypes = [AgreementGated.UPLOAD_VIDEOS]; + await renderComponent(gatingTypes); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + axiosMock.onPost(new RegExp(getUserAgreementRecordApi('*'))).reply(201, {}); + await user.click(screen.getByRole('button', { name: 'Agree' })); + expect(axiosMock.history.post[0].url).toBe(getUserAgreementRecordApi('agreement2')); + }); +}); diff --git a/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.tsx b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.tsx new file mode 100644 index 0000000000..6df1f30321 --- /dev/null +++ b/src/generic/agreement-gated-feature/AlertAgreementGatedFeature.tsx @@ -0,0 +1,66 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Alert, Button, Hyperlink } from '@openedx/paragon'; +import { Policy } from '@openedx/paragon/icons'; +import { AgreementGated } from '@src/constants'; +import { + getGatingAgreementTypes, + useUserAgreement, + useUserAgreementRecord, + useUserAgreementRecordUpdater, +} from '@src/data/apiHooks'; +import messages from './messages'; + +const AlertAgreement = ({ agreementType }: { agreementType: string }) => { + const intl = useIntl(); + const { data, isLoading, isError } = useUserAgreement(agreementType); + const mutation = useUserAgreementRecordUpdater(agreementType); + const showAlert = data && !isLoading && !isError; + const handleAcceptAgreement = async () => { + try { + await mutation.mutateAsync(); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error accepting agreement', e); + } + }; + if (!showAlert) { return null; } + const { url, name, summary } = data; + return ( + {intl.formatMessage(messages.learnMoreLinkLabel)}, + , + ]} + > + {name} + {summary} + + ); +}; + +const AlertAgreementWrapper = ( + { agreementType }: { agreementType: string }, +) => { + const { data, isLoading, isError } = useUserAgreementRecord(agreementType); + const showAlert = !data?.isCurrent && !isLoading && !isError; + if (!showAlert) { return null; } + return ; +}; + +export const AlertAgreementGatedFeature = ( + { gatingTypes }: { gatingTypes: AgreementGated[] }, +) => { + const agreementTypes = getGatingAgreementTypes(gatingTypes); + return ( + <> + {agreementTypes.map((agreementType) => ( + + ))} + + ); +}; diff --git a/src/generic/agreement-gated-feature/GatedComponentWrapper.test.tsx b/src/generic/agreement-gated-feature/GatedComponentWrapper.test.tsx new file mode 100644 index 0000000000..bd00233f92 --- /dev/null +++ b/src/generic/agreement-gated-feature/GatedComponentWrapper.test.tsx @@ -0,0 +1,79 @@ +import { initializeMockApp, mergeConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { AgreementGated } from '@src/constants'; +import { getUserAgreementRecordApi } from '@src/data/api'; +import { GatedComponentWrapper } from '@src/generic/agreement-gated-feature/GatedComponentWrapper'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import React from 'react'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +async function renderComponent(gatingTypes: AgreementGated[]) { + return render( + + + + + + , + , + ); +} + +describe('GatedComponentWrapper', () => { + let axiosMock; + beforeAll(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + beforeEach(() => { + axiosMock.onGet(getUserAgreementRecordApi('agreement1')).reply(200, {}); + axiosMock.onGet(getUserAgreementRecordApi('agreement2')).reply(200, { is_current: true }); + mergeConfig({ + AGREEMENT_GATING: { + [AgreementGated.UPLOAD]: ['agreement1', 'agreement2'], + [AgreementGated.UPLOAD_VIDEOS]: ['agreement2'], + [AgreementGated.UPLOAD_FILES]: ['agreement1'], + }, + }); + }); + afterEach(() => { + axiosMock.reset(); + }); + + it('applies no gating when gatingTypes is empty', async () => { + await renderComponent([]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.getByRole('button').parentNode).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('applies no gating when associated agreement has been accepted', async () => { + await renderComponent([AgreementGated.UPLOAD_VIDEOS]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.getByRole('button').parentNode).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('applies gating when associated agreement has not been accepted', async () => { + await renderComponent([AgreementGated.UPLOAD_FILES]); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + expect(screen.getByRole('button').parentNode).toHaveAttribute('aria-disabled', 'true'); + }); +}); diff --git a/src/generic/agreement-gated-feature/GatedComponentWrapper.tsx b/src/generic/agreement-gated-feature/GatedComponentWrapper.tsx new file mode 100644 index 0000000000..bddd352e40 --- /dev/null +++ b/src/generic/agreement-gated-feature/GatedComponentWrapper.tsx @@ -0,0 +1,30 @@ +import { AgreementGated } from '@src/constants'; +import { + getGatingAgreementTypes, + useUserAgreementRecords, +} from '@src/data/apiHooks'; + +interface GatedComponentWrapperProps { + gatingTypes: AgreementGated[]; + children: React.ReactElement; +} + +export const GatedComponentWrapper = ( + { gatingTypes, children }: GatedComponentWrapperProps, +) => { + const agreementTypes = getGatingAgreementTypes(gatingTypes); + const results = useUserAgreementRecords(agreementTypes); + const isNotGated = results.every((result) => !!result?.data?.isCurrent); + return isNotGated ? children : ( +
    + {children} +
    + ); +}; diff --git a/src/generic/agreement-gated-feature/index.ts b/src/generic/agreement-gated-feature/index.ts new file mode 100644 index 0000000000..9c3265f27e --- /dev/null +++ b/src/generic/agreement-gated-feature/index.ts @@ -0,0 +1,2 @@ +export { AlertAgreementGatedFeature } from './AlertAgreementGatedFeature'; +export { GatedComponentWrapper } from './GatedComponentWrapper'; diff --git a/src/generic/agreement-gated-feature/messages.ts b/src/generic/agreement-gated-feature/messages.ts new file mode 100644 index 0000000000..c17ac47c75 --- /dev/null +++ b/src/generic/agreement-gated-feature/messages.ts @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + agreeButtonLabel: { + id: 'authoring.agreement-gated-feature.agree', + defaultMessage: 'Agree', + description: 'The label for the Agree button on an alert asking users to agree with terms.', + }, + learnMoreLinkLabel: { + id: 'authoring.agreement-gated-feature.learn-more', + defaultMessage: 'Learn more', + description: 'The label for a "learn more" link on an alert asking users to agree with terms.', + }, +}); + +export default messages; From 38f87b801e409e12c484a8aa2fe94c89e5df0a7c Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 27 Mar 2026 09:56:40 -0400 Subject: [PATCH 17/62] fix: remove unused code --- src/utils.tsx | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/src/utils.tsx b/src/utils.tsx index b034654bde..42fc1c4561 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -350,46 +350,6 @@ export const skipIfUnwantedTarget = ( onClick(e); }; -/** - * Error message handling for Django-Rest-Framework API responses. - * Attempts to extract an error message as a string from an unknown error object. - * - * DRF errors can come in many different formats, and this is a best-effort attempt to handle them, - * but we cannot guarantee to support every possible format. - * - * Attempts to extract an error message as a string from an unknown error object. - */ -export 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'; -}; - export const BoldText = (chunk: string[]) => {chunk}; export const Div = (chunk: string[]) =>
    {chunk}
    ; export const Paragraph = (chunk: string[]) =>

    {chunk}

    ; From 726c5a7b49016b5256f9c50513e2c6ad3c318c8f Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Thu, 26 Feb 2026 15:29:39 -0500 Subject: [PATCH 18/62] feat: add button styling --- src/taxonomy/tag-list/TagListTable.jsx | 545 +++++++++++++++++++++++++ 1 file changed, 545 insertions(+) create mode 100644 src/taxonomy/tag-list/TagListTable.jsx diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx new file mode 100644 index 0000000000..61fcb3334f --- /dev/null +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -0,0 +1,545 @@ +// @ts-check +import React, { useState, useMemo, useEffect } from 'react'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Toast, Card, ActionRow, Icon, IconButton, IconButtonWithTooltip } from '@openedx/paragon'; +import { Add, AddCircle } from '@openedx/paragon/icons'; +import { isEqual, set } from 'lodash'; +import Proptypes from 'prop-types'; + +import { + useReactTable, + getCoreRowModel, + getExpandedRowModel, + flexRender, +} from '@tanstack/react-table'; + +import { LoadingSpinner } from '../../generic/Loading'; +import messages from './messages'; +import { useTagListData, useSubTags, useCreateTag } from '../data/apiHooks'; +import { TagTree } from './tagTree'; + +// State machine for table modes + +const TABLE_MODES = { + VIEW: 'view', + DRAFT: 'draft', + WRITE: 'write', +} + +const TRANSITION_TABLE = { + [TABLE_MODES.VIEW]: [TABLE_MODES.DRAFT], + [TABLE_MODES.DRAFT]: [TABLE_MODES.WRITE], + [TABLE_MODES.WRITE]: [TABLE_MODES.DRAFT, TABLE_MODES.VIEW], +} + +const switchMode = (currentMode, targetMode) => { + if (TRANSITION_TABLE[currentMode].includes(targetMode)) { + return targetMode; + } + throw new Error(`Invalid table mode transition from ${currentMode} to ${targetMode}`); +}; + +/** + * 1. Reusable Editable Cell + */ +const EditableCell = ({ initialValue, onSave, onCancel }) => { + const [value, setValue] = useState(initialValue); + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.target.blur(); // Trigger onBlur to save + } else if (e.key === 'Escape') { + onCancel(); + } + }; + + return ( + + + setValue(e.target.value)} + onKeyDown={handleKeyDown} + onClick={(e) => e.stopPropagation()} + placeholder='Type tag name' + /> + + + + + + + + + ); +}; + +EditableCell.propTypes = { + initialValue: Proptypes.string, + onSave: Proptypes.func.isRequired, + onCancel: Proptypes.func.isRequired, +}; + +EditableCell.defaultProps = { + initialValue: '', +}; + +/** + * SubTagsExpanded Component + */ +const SubTagsExpanded = ({ + parentTagValue, + isCreating, + onSaveNewSubTag, + onCancelCreation, + subTagsData, + visibleColumnCount, + createTagMutation, + creatingParentId, + editingRowId, + setCreatingParentId, + setEditingRowId, + maxDepth, +}) => { + const columnCount = subTagsData?.[0]?.getVisibleCells?.().length || visibleColumnCount || 1; + const showAddSubTagButton = maxDepth > 0; + + return ( + <> + {isCreating && ( + + + onSaveNewSubTag(val, parentTagValue)} + onCancel={onCancelCreation} + /> + + + )} + {subTagsData?.map(row => { + const tagData = row.original || row; // Handle both raw and table row data + return ( + + + {row.getVisibleCells() + .filter(cell => showAddSubTagButton || cell.column.id !== 'add') + .map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + + + {/* colSpan stretches the sub-row across the whole table */} + + setCreatingParentId(null)} + createTagMutation={createTagMutation} + creatingParentId={creatingParentId} + editingRowId={editingRowId} + setCreatingParentId={setCreatingParentId} + setEditingRowId={setEditingRowId} + maxDepth={maxDepth - 1} + /> + + + + ); + })} + + ); +}; + +SubTagsExpanded.propTypes = { + subTagsData: Proptypes.array.isRequired, + visibleColumnCount: Proptypes.number, + parentTagValue: Proptypes.string.isRequired, + parentTagId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]).isRequired, + isCreating: Proptypes.bool, + onSaveNewSubTag: Proptypes.func, + onCancelCreation: Proptypes.func, + createTagMutation: Proptypes.object, + creatingParentId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]), + editingRowId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]), + setCreatingParentId: Proptypes.func, + setEditingRowId: Proptypes.func, + maxDepth: Proptypes.number, +}; + +/** + * Expand toggle for rows with children (Updated for v8 API) + */ +const OptionalExpandLink = ({ row }) => { + return ( + row.depth === 0 && row.original.childCount > 0 ? ( + + Expand row + + ) : null + ) +}; +OptionalExpandLink.propTypes = { row: Proptypes.object.isRequired }; + +function getColumns({ intl, handleCreateTopTag, setIsCreatingTopTag, setCreatingParentId, handleUpdateTag, setEditingRowId, setToast }) { + return [ + { + header: intl.formatMessage(messages.tagListColumnValueHeader), + cell: ({ row }) => { + const { isNew, isEditing, value, descendantCount, id } = row.original; + + if (isNew) { + return ( + handleCreateTopTag(value, setToast)} + onCancel={() => setIsCreatingTopTag(false)} /> + ); + } + + if (isEditing) { + return ( + handleUpdateTag(id, newVal, value)} + onCancel={() => setEditingRowId(null)} /> + ); + } + + return ( + <> + {value} + {` (${descendantCount})`} + + ); + }, + }, + { + id: 'expander', + header: () => <>, + cell: OptionalExpandLink, + }, + { + id: 'add', + header: () => ( + Create a new tag
    } + src={AddCircle} + alt="Create Tag" + size="inline" + onClick={() => { + setIsCreatingTopTag(true); + setEditingRowId(null); + }} + /> + ), + cell: ({ row }) => { + if (row.original.isNew) { + return
    ; + } + + return ( +
    + { + setCreatingParentId(row.original.id); + setEditingRowId(null); + row.toggleExpanded(true); + } } + > + Add Subtag + +
    + ); + } + }, + // { + // id: 'edit', + // cell: ({ row }) => { + // if (row.original.isNew) { + // return
    ; + // } + + // return ( + //
    + // { + // setEditingRowId(row.original.id); + // setCreatingParentId(null); + // } } + // > + // Edit + // + //
    + // ); + // } + // }, + ]; +} + +// function addEditRow(data, editingRowId) { +// if (!data) return [] +// const augmentedData = data.map(item => ({ +// ...item, +// isEditing: item.id === editingRowId, +// })); +// const tree = new TagTree(augmentedData); + +// return tree.getAllAsDeepCopy(); +// } + +// function getDisplayData(data, editingRowId, creatingParentId, tableMode) { +// if (tableMode === TABLE_MODES.DRAFT && creatingParentId === 'top') { +// data.unshift({ +// id: 'draft-top-row', +// isNew: true, +// value: '', +// descendantCount: 0, +// childCount: 0, +// }); +// } +// return data; +// } + +const TagListTable = ({ taxonomyId, maxDepth }) => { + // The table has a VIEW and a WRITE mode. It starts in VIEW mode. + // It switches to WRITE mode when a user edits or creates a tag. It remains in WRITE mode even after saving changes, + // and only switches to VIEW when the user refreshes the page, orders a column, or navigates to a different page of the table. + // During WRITE mode, the table makes POST requests to the backend and receives success or failure responses. + // However, the table does not refresh to show the updated data from the backend. + // This allows us to show the newly created or updated tag in the same place without reordering. + const intl = useIntl(); + + // Standardizing pagination state for TanStack v8 + const [{ pageIndex, pageSize }, setPagination] = useState({ + pageIndex: 0, + pageSize: 100, + }); + + const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); + + const [creatingParentId, setCreatingParentId] = useState(null); + const [editingRowId, setEditingRowId] = useState(null); + const [toast, setToast] = useState({ show: false, message: '', variant: 'success' }); + + const [tableMode, setTableMode] = useState(TABLE_MODES.VIEW); + const [tagTree, setTagTree] = useState(null); + const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); + + const { isLoading, data: tagList } = useTagListData(taxonomyId, pagination); + const createTagMutation = useCreateTag(taxonomyId); + + useMemo(() => { + // get row data if table is in VIEW mode, otherwise keep current data to avoid disrupting user while they are editing or creating a tag + if (tableMode === TABLE_MODES.VIEW && tagList?.results) { + console.log('tagList results: ', tagList?.results); + const tree = new TagTree(tagList?.results); + console.log('tree rows: ', tree.getAllAsDeepCopy()); + if (tree) { + setTagTree(tree); + } + } + }, [tagList?.results, editingRowId, pagination, tableMode]); + + + + const remainingDepth = maxDepth - 1 + const showAddSubTagButton = remainingDepth > 0; + + const handleCreateTopTag = async (value, setToast) => { + console.log('Creating top-level tag with value:', value); + if (value.trim()) { + await createTagMutation.mutateAsync({ value }); + setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: value }), variant: 'success' }); + } + setIsCreatingTopTag(false); + }; + + const handleCreateSubTag = async (value, parentTagValue) => { + if (value.trim()) { + await createTagMutation.mutateAsync({ value, parentTagValue }); + setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: value }), variant: 'success' }); + } + setCreatingParentId(null); + }; + + const handleUpdateTag = async (id, value, originalValue) => { + if (value.trim() && value !== originalValue) { + console.log('Update backend here', id, value); + } + setEditingRowId(null); + }; + + const columns = useMemo(() => getColumns({ + intl, handleCreateTopTag, setIsCreatingTopTag, setCreatingParentId,handleUpdateTag, setEditingRowId, setToast }), + [intl, isCreatingTopTag, editingRowId] + ); + + console.log('rowData for table: ', tagTree?.getAllAsDeepCopy()); + + // Initialize TanStack Table + const table = useReactTable({ + data: tagTree?.getAllAsDeepCopy() || [], + columns, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + // Manual pagination config + manualPagination: true, + pageCount: tagList?.numPages ?? -1, + state: { + pagination, + }, + onPaginationChange: setPagination, + getSubRows: (row) => row.subRows || null, + }); + + return ( + + + + + } /> + + {isLoading ? ( + + ) : ( + + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + + {table.getRowModel().rows.length === 0 && ( + + + + )} + + {isCreatingTopTag && ( + + + + )} + {table.getRowModel().rows.filter(row => row.depth === 0).map(row => ( + + {/* Main Row */} + + {row.getVisibleCells() + .filter(cell => showAddSubTagButton || cell.column.id !== 'add') + .map(cell => ( + + ))} + + + {/* Subcomponent Rendering */} + {row.getIsExpanded() && ( + + {/* colSpan stretches the sub-row across the whole table */} + + + )} + + ))} + +
    + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} +
    + {intl.formatMessage(messages.noResultsFoundMessage)} +
    + handleCreateTopTag(value, setToast)} + onCancel={() => setIsCreatingTopTag(false)} /> +
    + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
    + setCreatingParentId(null)} + createTagMutation={createTagMutation} + creatingParentId={creatingParentId} + editingRowId={editingRowId} + setCreatingParentId={setCreatingParentId} + setEditingRowId={setEditingRowId} + maxDepth={remainingDepth - 1} + /> +
    +
    + )} + + {/* Basic Pagination Controls */} + {(tagList?.numPages || 0) > 1 && ( +
    + + + Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} + + +
    + )} + { setToast({ show: false })} } + delay={15000} + className="bg-success-100 border-success" + > + {toast.message} + +
    + ); +}; + +TagListTable.propTypes = { + taxonomyId: Proptypes.number.isRequired, +}; + +export default TagListTable; From 2e77e19a3a57b625ccc91cedbdbc6127c125fe9b Mon Sep 17 00:00:00 2001 From: Jesper Hodge <19345795+jesperhodge@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:30:58 -0400 Subject: [PATCH 19/62] feat: Add tags to a taxonomy (#2872) --- package-lock.json | 116 +- package.json | 1 + src/taxonomy/data/api.test.ts | 79 + src/taxonomy/data/api.ts | 69 +- src/taxonomy/data/apiHooks.test.jsx | 14 +- src/taxonomy/data/apiHooks.ts | 44 +- src/taxonomy/data/constants.ts | 8 + src/taxonomy/data/types.ts | 4 + src/taxonomy/messages.ts | 4 + .../tag-list/OptionalExpandLink.test.tsx | 50 + src/taxonomy/tag-list/OptionalExpandLink.tsx | 57 + src/taxonomy/tag-list/TagListTable.jsx | 121 -- src/taxonomy/tag-list/TagListTable.scss | 11 +- src/taxonomy/tag-list/TagListTable.test.jsx | 935 ++++++++++- src/taxonomy/tag-list/TagListTable.tsx | 174 +++ src/taxonomy/tag-list/constants.ts | 32 + src/taxonomy/tag-list/errors.ts | 16 + src/taxonomy/tag-list/hooks.test.tsx | 169 ++ src/taxonomy/tag-list/hooks.ts | 200 +++ src/taxonomy/tag-list/messages.ts | 48 +- src/taxonomy/tag-list/mockData.ts | 1390 +++++++++++++++++ src/taxonomy/tag-list/tagColumns.tsx | 197 +++ src/taxonomy/tag-list/tagTree.test.ts | 330 ++++ src/taxonomy/tag-list/tagTree.ts | 238 +++ .../taxonomy-detail/TaxonomyDetailPage.jsx | 3 +- src/taxonomy/taxonomy-detail/constants.ts | 11 + src/taxonomy/tree-table/CreateRow.test.tsx | 81 + src/taxonomy/tree-table/CreateRow.tsx | 112 ++ src/taxonomy/tree-table/EditableCell.test.tsx | 61 + src/taxonomy/tree-table/EditableCell.tsx | 96 ++ src/taxonomy/tree-table/NestedRows.test.tsx | 91 ++ src/taxonomy/tree-table/NestedRows.tsx | 144 ++ src/taxonomy/tree-table/TableBody.tsx | 122 ++ src/taxonomy/tree-table/TableView.scss | 21 + src/taxonomy/tree-table/TableView.test.tsx | 94 ++ src/taxonomy/tree-table/TableView.tsx | 204 +++ src/taxonomy/tree-table/index.ts | 2 + src/taxonomy/tree-table/messages.ts | 54 + src/taxonomy/tree-table/types.ts | 31 + 39 files changed, 5234 insertions(+), 200 deletions(-) create mode 100644 src/taxonomy/data/constants.ts create mode 100644 src/taxonomy/tag-list/OptionalExpandLink.test.tsx create mode 100644 src/taxonomy/tag-list/OptionalExpandLink.tsx delete mode 100644 src/taxonomy/tag-list/TagListTable.jsx create mode 100644 src/taxonomy/tag-list/TagListTable.tsx create mode 100644 src/taxonomy/tag-list/constants.ts create mode 100644 src/taxonomy/tag-list/errors.ts create mode 100644 src/taxonomy/tag-list/hooks.test.tsx create mode 100644 src/taxonomy/tag-list/hooks.ts create mode 100644 src/taxonomy/tag-list/mockData.ts create mode 100644 src/taxonomy/tag-list/tagColumns.tsx create mode 100644 src/taxonomy/tag-list/tagTree.test.ts create mode 100644 src/taxonomy/tag-list/tagTree.ts create mode 100644 src/taxonomy/taxonomy-detail/constants.ts create mode 100644 src/taxonomy/tree-table/CreateRow.test.tsx create mode 100644 src/taxonomy/tree-table/CreateRow.tsx create mode 100644 src/taxonomy/tree-table/EditableCell.test.tsx create mode 100644 src/taxonomy/tree-table/EditableCell.tsx create mode 100644 src/taxonomy/tree-table/NestedRows.test.tsx create mode 100644 src/taxonomy/tree-table/NestedRows.tsx create mode 100644 src/taxonomy/tree-table/TableBody.tsx create mode 100644 src/taxonomy/tree-table/TableView.scss create mode 100644 src/taxonomy/tree-table/TableView.test.tsx create mode 100644 src/taxonomy/tree-table/TableView.tsx create mode 100644 src/taxonomy/tree-table/index.ts create mode 100644 src/taxonomy/tree-table/messages.ts create mode 100644 src/taxonomy/tree-table/types.ts diff --git a/package-lock.json b/package-lock.json index 1774875140..74e0aa0378 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "2.11.2", "@tanstack/react-query": "5.90.21", + "@tanstack/react-table": "^8.21.3", "@tinymce/tinymce-react": "^6.0.0", "classnames": "2.5.1", "codemirror": "^6.0.0", @@ -177,6 +178,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -2394,6 +2396,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -2416,6 +2419,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^14 || ^16 || >=18" } @@ -2492,6 +2496,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2669,6 +2674,7 @@ "resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.5.5.tgz", "integrity": "sha512-imExY37cxE7qzKYg3gaqcdfhc0rzpV1DEFmy6PPCJg4m+cycQNiXtAKl3nITkcQkzhV0JYh3qttEgq6d4a1QXw==", "license": "AGPL-3.0", + "peer": true, "dependencies": { "@cospired/i18n-iso-languages": "4.2.0", "@formatjs/intl-pluralrules": "4.3.3", @@ -3440,6 +3446,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" }, @@ -5409,6 +5416,7 @@ "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.19.1.tgz", "integrity": "sha512-c/cWnvZsGS7xyq0tJpssmv2oyfYG6Fuawy6EzWy8CYiQ4oD67EVuSwBInCfSJoNZhvvkUE+4B/YhDIRGUVDz5w==", "license": "Apache-2.0", + "peer": true, "workspaces": [ "example", "component-generator", @@ -6367,6 +6375,7 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -6390,6 +6399,7 @@ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", @@ -6415,7 +6425,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@reduxjs/toolkit/node_modules/redux-thunk": { "version": "3.1.0", @@ -6663,6 +6674,7 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -6787,6 +6799,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -6955,8 +7000,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -7352,6 +7396,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -7363,6 +7408,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -7501,6 +7547,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -7547,6 +7594,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -8226,6 +8274,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8315,6 +8364,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -8810,6 +8860,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -8993,6 +9044,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -9369,6 +9421,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9727,7 +9780,8 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/clean-css": { "version": "5.3.3", @@ -11078,8 +11132,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-converter": { "version": "0.2.0", @@ -11271,6 +11324,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/email-validator/-/email-validator-2.0.4.tgz", "integrity": "sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==", + "peer": true, "engines": { "node": ">4.0" } @@ -11615,6 +11669,7 @@ "integrity": "sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", @@ -11671,6 +11726,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-airbnb/-/eslint-config-airbnb-19.0.4.tgz", "integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==", "license": "MIT", + "peer": true, "dependencies": { "eslint-config-airbnb-base": "^15.0.0", "object.assign": "^4.1.2", @@ -11711,6 +11767,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-17.1.0.tgz", "integrity": "sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==", "license": "MIT", + "peer": true, "dependencies": { "eslint-config-airbnb-base": "^15.0.0" }, @@ -12186,7 +12243,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "license": "MIT", - "peer": true, "dependencies": { "ms": "^2.1.1" } @@ -12196,7 +12252,6 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -12209,6 +12264,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.7.1.tgz", "integrity": "sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.7", "aria-query": "^5.1.3", @@ -12245,6 +12301,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.flatmap": "^1.3.1", @@ -12275,6 +12332,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.1.tgz", "integrity": "sha512-Ck77j8hF7l9N4S/rzSLOWEKpn994YH6iwUK8fr9mXIaQvGpQYmOnQLbiue1u5kI5T1y+gdgqosnEAO9NCz0DBg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -13326,6 +13384,7 @@ } ], "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "deepmerge": "^2.1.1", @@ -15491,6 +15550,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -16899,7 +16959,8 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-es": { "version": "4.17.23", @@ -17489,6 +17550,7 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", + "peer": true, "engines": { "node": "*" } @@ -18118,6 +18180,7 @@ "integrity": "sha512-W3gmZSOzNFGs9EwU8i3xlDpC0aqynQNtoDnaftdAZ3FE8cR7W625pPRbSmtsUOtTC0MPixx1i08R6uRVLfPp7g==", "dev": true, "license": "MIT", + "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" }, @@ -18731,6 +18794,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -19450,6 +19514,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -19535,7 +19600,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -19551,7 +19615,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -19564,8 +19627,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/process": { "version": "0.11.10", @@ -19600,6 +19662,7 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -19873,6 +19936,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -20051,6 +20115,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -20093,6 +20158,7 @@ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz", "integrity": "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.5" }, @@ -20353,6 +20419,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -20384,6 +20451,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.16.0.tgz", "integrity": "sha512-FPvF2XxTSikpJxcr+bHut2H4gJ17+18Uy20D5/F+SKzFap62R3cM5wH6b8WN3LyGSYeQilLEcJcR1fjBSI2S1A==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -20473,6 +20541,7 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", + "peer": true, "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" @@ -20840,6 +20909,7 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -21317,6 +21387,7 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz", "integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==", "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -21448,6 +21519,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -22455,6 +22527,7 @@ "integrity": "sha512-+xU0IA1StzqAqFs/QtXkK+XJa7wpS4X5H+JQccRKsRCElgeLGocFU1U/UMvMUylKFw6vwGV+Y/a2wb2pm5rFFQ==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@bundled-es-modules/deepmerge": "^4.3.1", "@bundled-es-modules/glob": "^10.4.2", @@ -22549,6 +22622,7 @@ "integrity": "sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^2.3.1", "@csstools/css-tokenizer": "^2.2.0", @@ -23175,6 +23249,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -23186,7 +23261,8 @@ "version": "5.10.9", "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.9.tgz", "integrity": "sha512-5bkrors87X9LhYX2xq8GgPHrIgJYHl87YNs+kBcjQ5I3CiUgzo/vFcGvT3MZQ9QHsEeYMhYO6a5CLGGffR8hMg==", - "license": "LGPL-2.1" + "license": "LGPL-2.1", + "peer": true }, "node_modules/tmp": { "version": "0.2.5", @@ -23328,6 +23404,7 @@ "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.4.tgz", "integrity": "sha512-YiHwDhSvCiItoAgsKtoLFCuakDzDsJ1DLDnSouTaTmdOcOwIkSzbLXduaQ6M5DRVhuZC/NYaaZ/mtHbWMv/S6Q==", "license": "MIT", + "peer": true, "dependencies": { "bs-logger": "0.x", "fast-json-stable-stringify": "2.x", @@ -23492,7 +23569,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsutils": { "version": "3.21.0", @@ -23541,6 +23619,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -23640,6 +23719,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23782,6 +23862,7 @@ "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -24063,6 +24144,7 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", + "peer": true, "bin": { "uuid": "dist/esm/bin/uuid" } @@ -24188,6 +24270,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -24292,6 +24375,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -24378,6 +24462,7 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -24914,6 +24999,7 @@ "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz", "integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/lodash": "^4.14.175", diff --git a/package.json b/package.json index f838811307..2cc5b3fda2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/taxonomy/data/api.test.ts b/src/taxonomy/data/api.test.ts index ed6ef8cdce..1a0cbf2df3 100644 --- a/src/taxonomy/data/api.test.ts +++ b/src/taxonomy/data/api.test.ts @@ -7,6 +7,7 @@ import { getTaxonomyListData, getTaxonomy, deleteTaxonomy, + getApiErrorMessage, } from './api'; describe('taxonomy api calls', () => { @@ -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'); + }); + }); }); diff --git a/src/taxonomy/data/api.ts b/src/taxonomy/data/api.ts index 60ad85b5c7..063971ee08 100644 --- a/src/taxonomy/data/api.ts +++ b/src/taxonomy/data/api.ts @@ -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; @@ -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. */ @@ -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>; /** @@ -109,3 +125,46 @@ export async function getTaxonomy(taxonomyId: number): Promise { 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'); +}; diff --git a/src/taxonomy/data/apiHooks.test.jsx b/src/taxonomy/data/apiHooks.test.jsx index 78b7349556..1f012ee946 100644 --- a/src/taxonomy/data/apiHooks.test.jsx +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -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'; @@ -11,6 +12,7 @@ import MockAdapter from 'axios-mock-adapter'; import { apiUrls } from './api'; import { + useCreateTag, useImportPlan, useImportTags, useImportNewTaxonomy, @@ -28,7 +30,9 @@ const queryClient = new QueryClient({ const wrapper = ({ children }) => ( - {children} + + {children} + ); @@ -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)); + }); }); diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index f8856c86ba..753503cf3f 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -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 @@ -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)); } }, onSuccess: (data) => { @@ -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! @@ -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, }); }; @@ -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) }); + }, + }); +}; diff --git a/src/taxonomy/data/constants.ts b/src/taxonomy/data/constants.ts new file mode 100644 index 0000000000..dc205768a2 --- /dev/null +++ b/src/taxonomy/data/constants.ts @@ -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; diff --git a/src/taxonomy/data/types.ts b/src/taxonomy/data/types.ts index dbc7186031..d8ca63d1e7 100644 --- a/src/taxonomy/data/types.ts +++ b/src/taxonomy/data/types.ts @@ -32,6 +32,8 @@ export interface TaxonomyListData { export interface QueryOptions { pageIndex: number; pageSize: number; + enabled?: boolean; + disablePagination?: boolean; } export interface TagData { @@ -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; diff --git a/src/taxonomy/messages.ts b/src/taxonomy/messages.ts index c904346dd3..0fca895539 100644 --- a/src/taxonomy/messages.ts +++ b/src/taxonomy/messages.ts @@ -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; diff --git a/src/taxonomy/tag-list/OptionalExpandLink.test.tsx b/src/taxonomy/tag-list/OptionalExpandLink.test.tsx new file mode 100644 index 0000000000..72091a28cc --- /dev/null +++ b/src/taxonomy/tag-list/OptionalExpandLink.test.tsx @@ -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 }) => ( + {children} +); + +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(, { 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(, { 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(, { wrapper }); + const button = screen.getByRole('button', { name: 'Hide Subtags' }); + + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/src/taxonomy/tag-list/OptionalExpandLink.tsx b/src/taxonomy/tag-list/OptionalExpandLink.tsx new file mode 100644 index 0000000000..edfa6580f2 --- /dev/null +++ b/src/taxonomy/tag-list/OptionalExpandLink.tsx @@ -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; + 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 ( + + ); + } + + const isExpanded = !!row?.getIsExpanded(); + const buttonLabel = isExpanded + ? intl.formatMessage(messages.hideSubtagsButtonLabel) + : intl.formatMessage(messages.showSubtagsButtonLabel); + + return ( + + ); +}; + +export default OptionalExpandLink; diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx deleted file mode 100644 index 16cc963878..0000000000 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ /dev/null @@ -1,121 +0,0 @@ -// @ts-check -import React, { useState } from 'react'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { DataTable } from '@openedx/paragon'; -import { isEqual } from 'lodash'; -import Proptypes from 'prop-types'; - -import { LoadingSpinner } from '../../generic/Loading'; -import messages from './messages'; -import { useTagListData, useSubTags } from '../data/apiHooks'; - -const SubTagsExpanded = ({ taxonomyId, parentTagValue }) => { - const subTagsData = useSubTags(taxonomyId, parentTagValue); - - if (subTagsData.isPending) { - return ; - } - if (subTagsData.isError) { - return ; - } - - return ( -
      - {subTagsData.data.results.map(tagData => ( -
    • - {tagData.value} {tagData.descendantCount > 0 ? `(${tagData.descendantCount})` : null} -
    • - ))} -
    - ); -}; - -SubTagsExpanded.propTypes = { - taxonomyId: Proptypes.number.isRequired, - parentTagValue: Proptypes.string.isRequired, -}; - -/** - * An "Expand" toggle to show/hide subtags, but one which is hidden if the given tag row has no subtags. - */ -const OptionalExpandLink = ({ row }) => ( - row.original.childCount > 0 ?
    : null -); -OptionalExpandLink.propTypes = DataTable.ExpandRow.propTypes; - -/** - * Custom DataTable cell to join tag value with child count - */ -const TagValue = ({ row }) => ( - <> - {row.original.value} - {` (${row.original.descendantCount})`} - -); -TagValue.propTypes = { - row: Proptypes.shape({ - original: Proptypes.shape({ - value: Proptypes.string.isRequired, - childCount: Proptypes.number.isRequired, - descendantCount: Proptypes.number.isRequired, - }).isRequired, - }).isRequired, -}; - -const TagListTable = ({ taxonomyId }) => { - const intl = useIntl(); - const [options, setOptions] = useState({ - pageIndex: 0, - pageSize: 100, - }); - const { isLoading, data: tagList } = useTagListData(taxonomyId, options); - - const fetchData = (args) => { - if (!isEqual(args, options)) { - setOptions({ ...args }); - } - }; - - return ( -
    - ( - - )} - columns={[ - { - Header: intl.formatMessage(messages.tagListColumnValueHeader), - Cell: TagValue, - }, - { - id: 'expander', - Header: DataTable.ExpandAll, - Cell: OptionalExpandLink, - }, - ]} - > - - - {tagList?.numPages !== undefined && tagList?.numPages > 1 - && } - -
    - ); -}; - -TagListTable.propTypes = { - taxonomyId: Proptypes.number.isRequired, -}; - -export default TagListTable; diff --git a/src/taxonomy/tag-list/TagListTable.scss b/src/taxonomy/tag-list/TagListTable.scss index ad5c23467b..c1ddef1079 100644 --- a/src/taxonomy/tag-list/TagListTable.scss +++ b/src/taxonomy/tag-list/TagListTable.scss @@ -1,12 +1,5 @@ .tag-list-table { - table tr:first-child > th:nth-child(2) > span { - // Used to move "Expand all" button to the right. - // Find the first of the second of the first of the . - // - // The approach of the expand buttons cannot be applied here since the - // table headers are rendered differently and at the component level - // there is no control of this style. - display: flex; - justify-content: flex-end; + tr:nth-child(even) { + background-color: var(--pgn-color-light-200); } } diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index ac37792e18..a7616a2ab7 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -1,32 +1,51 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { initializeMockApp } from '@edx/frontend-platform'; import { AppProvider } from '@edx/frontend-platform/react'; import { - render, waitFor, screen, within, + render, waitFor, waitForElementToBeRemoved, screen, within, + fireEvent, act, cleanup, } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; import initializeStore from '../../store'; +import * as apiHooksModule from '../data/apiHooks'; +import * as hooksModule from './hooks'; +import * as treeTableModule from '../tree-table'; import TagListTable from './TagListTable'; let store; let axiosMock; const queryClient = new QueryClient(); +const adminUser = { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], +}; +const nonAdminUser = { + ...adminUser, + administrator: false, +}; -const RootWrapper = () => ( +const RootWrapper = ({ maxDepth = 3 }) => ( - + ); -const tagDefaults = { depth: 0, external_id: null, parent_value: null }; +RootWrapper.propTypes = { + maxDepth: PropTypes.number, +}; + +const tagDefaults = { depth: 0, external_id: '', parent_value: null }; const mockTagsResponse = { next: null, previous: null, @@ -59,6 +78,24 @@ const mockTagsResponse = { _id: 1003, sub_tags_url: '/request/to/load/subtags/3', }, + { + ...tagDefaults, + depth: 1, + value: 'the child tag', + child_count: 0, + _id: 1111, + sub_tags_url: null, + parent_value: 'root tag 1', + }, + { + ...tagDefaults, + depth: 2, + value: 'the grandchild tag', + child_count: 0, + _id: 1111, + sub_tags_url: null, + parent_value: 'the child tag', + }, ], }; const mockTagsPaginationResponse = { @@ -70,7 +107,7 @@ const mockTagsPaginationResponse = { start: 0, results: [], }; -const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?page=1&page_size=100'; +const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000'; const subTagsResponse = { next: null, previous: null, @@ -90,62 +127,108 @@ const subTagsResponse = { ], }; const subTagsUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000&parent_tag=root+tag+1'; +const createTagUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/'; + +const renderTagListTable = (maxDepth = 3) => render(); + +const flushReactUpdates = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; + +const waitForRootTag = async () => { + const tag = await screen.findByText('root tag 1'); + expect(tag).toBeInTheDocument(); + return tag; +}; + +const getDraftRows = () => screen.getAllByRole('row').filter(row => row.querySelector('input')); + +const expectNoDraftRows = () => { + expect(getDraftRows().length).toBe(0); +}; + +const openTopLevelDraftRow = async () => { + const addButton = await screen.findByLabelText('Create Tag'); + await act(async () => { + fireEvent.click(addButton); + }); + const creatingRow = await screen.findByTestId('creating-top-row'); + const input = creatingRow.querySelector('input'); + expect(input).toBeInTheDocument(); + return { creatingRow, input }; +}; + +const openActionsMenuForTag = (tagName, actionButtonName = /actions/i) => { + const row = screen.getByText(tagName).closest('tr'); + const actionsButton = within(row).getByRole('button', { name: actionButtonName }); + act(() => { + fireEvent.click(actionsButton); + }); + return row; +}; + +const openSubtagDraftRow = async ({ + tagName, + actionButtonName = /actions/i, + addSubtagIndex = 0, +}) => { + openActionsMenuForTag(tagName, actionButtonName); + fireEvent.click(screen.getAllByText('Add Subtag')[addSubtagIndex]); + const rows = await screen.findAllByRole('row'); + const draftRow = rows.find(row => row.querySelector('input')); + const input = draftRow.querySelector('input'); + expect(input).toBeInTheDocument(); + return { rows, draftRow, input }; +}; describe('', () => { beforeAll(async () => { initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, + authenticatedUser: adminUser, }); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); beforeEach(async () => { store = initializeStore(); axiosMock.reset(); + axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); + axiosMock.onGet(subTagsUrl).reply(200, subTagsResponse); + renderTagListTable(); + await waitForRootTag(); + await flushReactUpdates(); }); - it('shows the spinner before the query is complete', async () => { - // Simulate an actual slow response from the API: - let resolveResponse; - const promise = new Promise(resolve => { resolveResponse = resolve; }); - axiosMock.onGet(rootTagsListUrl).reply(() => promise); - render(); - const spinner = screen.getByRole('status'); - expect(spinner.textContent).toEqual('loading'); - resolveResponse([200, {}]); - const noFoundComponent = await screen.findByText('No results found'); - expect(noFoundComponent).toBeInTheDocument(); + it('has a valid tr -> td structure when the table is expanded to show subtags', async () => { + const expandButton = screen.getAllByText('Expand All')[0]; + fireEvent.click(expandButton); + const childTag = await screen.findByText('the child tag'); + expect(childTag).toBeInTheDocument(); + const allCells = screen.getAllByRole('cell'); + allCells.forEach(cell => { + const nestedTr = cell.querySelector('tr'); + expect(nestedTr).toBeNull(); + }); }); it('should render page correctly', async () => { - axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); - render(); - const tag = await screen.findByText('root tag 1'); - expect(tag).toBeInTheDocument(); - const rows = screen.getAllByRole('row'); expect(rows.length).toBe(3 + 1); // 3 items plus header expect(within(rows[0]).getAllByRole('columnheader')[0].textContent).toEqual('Tag name'); - expect(within(rows[1]).getAllByRole('cell')[0].textContent).toEqual('root tag 1 (14)'); + expect(within(rows[1]).getAllByRole('cell')[0].textContent).toEqual('root tag 1'); }); it('should render page correctly with subtags', async () => { - axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); - axiosMock.onGet(subTagsUrl).reply(200, subTagsResponse); - render(); - const expandButton = screen.getAllByLabelText('Expand row')[0]; - expandButton.click(); + const expandButton = await screen.findByLabelText('Show Subtags'); + fireEvent.click(expandButton); const childTag = await screen.findByText('the child tag'); expect(childTag).toBeInTheDocument(); }); - it('should not render pagination footer', async () => { + it('should not render pagination footer if too few results', async () => { axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); - render(); + renderTagListTable(); await waitFor(() => { expect(screen.queryByRole('navigation', { name: /table pagination/i, @@ -153,13 +236,789 @@ describe('', () => { }); }); - it('should render pagination footer', async () => { + // temporarily skipped because pagination is not implemented yet + it.skip('should render pagination footer', async () => { axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsPaginationResponse); - render(); + renderTagListTable(); const tableFooter = await screen.findAllByRole('navigation', { name: /table pagination/i, }); expect(tableFooter[0]).toBeInTheDocument(); - expect(tableFooter[1]).toBeInTheDocument(); + }); + + // temporarily skipped because pagination is not implemented yet + it.skip('should render correct number of items in pagination footer', async () => { + axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsPaginationResponse); + renderTagListTable(); + const paginationButtons = await screen.findByText('Page 1 of 2'); + expect(paginationButtons).toBeInTheDocument(); + }); + + describe('Create a new top-level tag', () => { + describe('with editable user and loaded taxonomy', () => { + it('should add draft row when top-level "Add tag" button is clicked', async () => { + const { creatingRow } = await openTopLevelDraftRow(); + + expect(within(creatingRow).getByText('Cancel')).toBeInTheDocument(); + expect(within(creatingRow).getByText('Save')).toBeInTheDocument(); + }); + + it('should create a new tag when the draft row is saved', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'a new tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ + tag: 'a new tag', + })); + }); + }); + + it('should not create a new tag when the draft row is cancelled', async () => { + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + const cancelButton = within(creatingRow).getByText('Cancel'); + fireEvent.click(cancelButton); + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(0); + expect(screen.queryByText('a new tag')).not.toBeInTheDocument(); + expectNoDraftRows(); + }); + }); + + it('should not create a new tag when the escape button is pressed', async () => { + const { input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }); + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(0); + expect(screen.queryByText('a new tag')).not.toBeInTheDocument(); + expectNoDraftRows(); + }); + }); + it('should show a loading spinner when saving a new tag', async () => { + axiosMock.onPost(createTagUrl).reply(() => new Promise(resolve => { + setTimeout(() => { + resolve([201, { + ...tagDefaults, + value: 'a new tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }]); + }, 100); + })); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + const spinner = await screen.findByRole('status'); + expect(spinner.textContent).toEqual('Saving...'); + }); + + it('should show a newly created top-level tag without triggering a page refresh', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'a new tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + let newTag; + await waitFor(() => { + newTag = screen.getByText('a new tag'); + expect(newTag).toBeInTheDocument(); + }); + // expect the new tag to be the first row after the header, that is, the top of the list + const rows = screen.getAllByRole('row'); + expect(rows[1]).toContainElement(newTag); + expectNoDraftRows(); + + // expect only one get request to have been made, that is, the table should not have been refreshed + expect(axiosMock.history.get.length).toBe(1); + }); + + it('should show a toast message when a new tag is successfully saved', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'a new tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'a new tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + const toast = await screen.findByText('Tag "a new tag" created successfully'); + expect(toast).toBeInTheDocument(); + }); + + it('should add a temporary row to the top of the table', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'xyz tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'xyz tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + // no input row should be in the document + await waitFor(() => { + expectNoDraftRows(); + }); + const temporaryRow = await screen.findByText('xyz tag'); + expect(temporaryRow).toBeInTheDocument(); + }); + + // temporarily skipped because pagination is not implemented yet + it.skip('should refresh the table and remove the temporary row when a pagination button is clicked', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'xyz tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }); + const { creatingRow, input } = await openTopLevelDraftRow(); + + fireEvent.change(input, { target: { value: 'xyz tag' } }); + const saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + const temporaryRow = await screen.findByText('xyz tag'); + // temporaryRow should be at the top of the table, that is, the first row after the header + const rows = screen.getAllByRole('row'); + expect(rows[1]).toContainElement(temporaryRow); + + // Simulate clicking a pagination button + const paginationButton = await screen.findByRole('button', { name: 'Go to page 2' }); + fireEvent.click(paginationButton); + + await waitFor(() => { + // A get request should have refreshed the table data + expect(axiosMock.history.get.length).toBeGreaterThan(1); + const xyzTagRow = screen.queryByText('xyz tag'); + expect(xyzTagRow).toBeInTheDocument(); + // expect the row to not be the first row after the header + expect(rows[1]).not.toContainElement(xyzTagRow); + }); + }); + + // a bit flaky when ran together with other tests - any way to improve this? + it('should allow adding multiple tags consecutively without a page refresh', async () => { + axiosMock.onPost(createTagUrl).reply(config => { + const requestData = JSON.parse(config.data); + return [201, { + ...tagDefaults, + value: requestData.tag, + child_count: 0, + descendant_count: 0, + _id: Math.floor(Math.random() * 10000), + }]; + }); + let addButton = await screen.findByLabelText('Create Tag'); + fireEvent.click(addButton); + let creatingRow = await screen.findByTestId('creating-top-row'); + let input = creatingRow.querySelector('input'); + expect(input).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: 'Tag A' } }); + let saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + const tagA = await screen.findByText('Tag A'); + expect(tagA).toBeInTheDocument(); + + addButton = await screen.findByLabelText('Create Tag'); + fireEvent.click(addButton); + creatingRow = await screen.findByTestId('creating-top-row'); + input = creatingRow.querySelector('input'); + expect(input).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: 'Tag B' } }); + saveButton = within(creatingRow).getByText('Save'); + fireEvent.click(saveButton); + const tagB = await screen.findByText('Tag B'); + expect(tagB).toBeInTheDocument(); + + // expect Tag B to be above Tag A in the list + const rows = screen.getAllByRole('row'); + const tagBRowIndex = rows.findIndex(row => within(row).queryByText('Tag B')); + const tagARowIndex = rows.findIndex(row => within(row).queryByText('Tag A')); + expect(tagBRowIndex).toBeLessThan(tagARowIndex); + + // no additional get requests should have been made, that is, the table should not have been refreshed + expect(axiosMock.history.get.length).toBe(1); + }); + + it('should disable the Save button when the input is empty', async () => { + const addButton = await screen.findByLabelText('Create Tag'); + fireEvent.click(addButton); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + expect(input).toBeInTheDocument(); + const saveButton = within(draftRow[1]).getByText('Save'); + expect(saveButton).toBeDisabled(); + fireEvent.change(input, { target: { value: 'a new tag' } }); + expect(saveButton).not.toBeDisabled(); + }); + + it('should disable the Save button when the input only contains whitespace', async () => { + const addButton = await screen.findByLabelText('Create Tag'); + fireEvent.click(addButton); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + expect(input).toBeInTheDocument(); + const saveButton = within(draftRow[1]).getByText('Save'); + expect(saveButton).toBeDisabled(); + fireEvent.change(input, { target: { value: ' ' } }); + expect(saveButton).toBeDisabled(); + fireEvent.change(input, { target: { value: ' a ' } }); + expect(saveButton).not.toBeDisabled(); + }); + + it('should trim leading and trailing whitespace from the tag name before save', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'Tag A', + child_count: 0, + descendant_count: 0, + _id: 4567, + }); + + fireEvent.click(await screen.findByLabelText('Create Tag')); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + const saveButton = within(draftRow[1]).getByText('Save'); + + fireEvent.change(input, { target: { value: ' Tag A ' } }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ tag: 'Tag A' })); + }); + }); + + it('should disable save and show an inline validation error for invalid characters', async () => { + fireEvent.click(await screen.findByLabelText('Create Tag')); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + const saveButton = within(draftRow[1]).getByText('Save'); + + await act(async () => { + fireEvent.change(input, { target: { value: 'invalid;tag' } }); + }); + + expect(saveButton).toBeDisabled(); + expect(screen.getByText(/invalid character/i)).toBeInTheDocument(); + }); + + it('should show an inline duplicate-name error when the entered root tag already exists', async () => { + axiosMock.onPost(createTagUrl).reply(400, ['Tag with this name already exists']); + + fireEvent.click(await screen.findByLabelText('Create Tag')); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + const saveButton = within(draftRow[1]).getByText('Save'); + + fireEvent.change(input, { target: { value: 'root tag 1' } }); + fireEvent.click(saveButton); + + expect(await screen.findByText('Tag with this name already exists')).toBeInTheDocument(); + }); + + it('should keep the inline row and show a failure toast when save request fails', async () => { + axiosMock.onPost(createTagUrl).reply(500, { + error: 'Internal server error', + }); + + fireEvent.click(await screen.findByLabelText('Create Tag')); + const draftRow = await screen.findAllByRole('row'); + const input = draftRow[1].querySelector('input'); + const saveButton = within(draftRow[1]).getByText('Save'); + + fireEvent.change(input, { target: { value: 'will fail' } }); + fireEvent.click(saveButton); + + await waitFor(() => { + const rows = screen.getAllByRole('row'); + const draftRows = rows.filter(row => row.querySelector('input')); + expect(draftRows.length).toBe(1); + }); + + // Banner error message should be shown at the top of the table + expect(await screen.findByText('Error saving changes')).toBeInTheDocument(); + + // Toast message to indicate that the save failed + expect(await screen.findByText('Error creating tag: Internal server error')).toBeInTheDocument(); + // expect the input to retain the value that was entered before + expect(draftRow[1].querySelector('input').value).toEqual('will fail'); + // expect the new tag to not be in the document outside the input field + expect(screen.queryByText('will fail')).not.toBeInTheDocument(); + }); + + it('should disable Add Tag button when the draft row is displayed', async () => { + fireEvent.click(await screen.findByLabelText('Create Tag')); + const addButton = await screen.findByLabelText('Create Tag'); + expect(addButton).toBeDisabled(); + }); + }); + + it('should hide Add Tag for users without taxonomy edit permissions', async () => { + initializeMockApp({ authenticatedUser: nonAdminUser }); + axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); + + expect(screen.queryByText('Add Tag')).not.toBeInTheDocument(); + }); + }); + + describe('Create a new subtag', () => { + describe('with editable user and loaded taxonomy', () => { + it('should show an Add sub-tag option in the parent tag actions', async () => { + expect(screen.queryAllByText('Add Subtag').length).toBe(0); + // user clicks on row actions for root tag 1 + openActionsMenuForTag('root tag 1'); + expect(screen.getByText('Add Subtag')).toBeInTheDocument(); + }); + + it('should render an inline add-subtag row with input, placeholder, and action buttons', async () => { + const { rows } = await openSubtagDraftRow({ tagName: 'root tag 1' }); + const draftRows = rows.filter(tableRow => tableRow.querySelector('input')); + expect(draftRows[0].querySelector('input')).toBeInTheDocument(); + // expect the draft row to be directly beneath the parent tag row + const parentRowIndex = rows.findIndex(tableRow => within(tableRow).queryByText('root tag 1')); + const draftRowIndex = rows.findIndex(tableRow => tableRow.querySelector('input')); + expect(draftRowIndex).toBe(parentRowIndex + 1); + expect(draftRows[0].querySelector('input')).toBeInTheDocument(); + expect(within(draftRows[0]).getByText('Cancel')).toBeInTheDocument(); + expect(within(draftRows[0]).getByText('Save')).toBeInTheDocument(); + }); + + it('should remove add-subtag row and avoid create request when cancelled', async () => { + const { draftRow, input } = await openSubtagDraftRow({ tagName: 'root tag 1' }); + fireEvent.change(input, { target: { value: 'new subtag' } }); + fireEvent.click(within(draftRow).getByText('Cancel')); + + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(0); + expectNoDraftRows(); + }); + }); + + it('should remove add-subtag row and avoid create request on escape key', async () => { + const { input } = await openSubtagDraftRow({ tagName: 'root tag 1' }); + + fireEvent.change(input, { target: { value: 'new subtag' } }); + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }); + + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(0); + expectNoDraftRows(); + }); + }); + + it('should disable Save and show required-name inline error for empty sub-tag input', async () => { + openActionsMenuForTag('root tag 1'); + fireEvent.click(screen.getByText('Add Subtag')); + const rows = await screen.findAllByRole('row'); + const draftRow = rows.find(tableRow => tableRow.querySelector('input')); + const saveButton = within(draftRow).getByText('Save'); + const input = draftRow.querySelector('input'); + act(() => { + fireEvent.change(input, { target: { value: ' ' } }); + }); + + expect(saveButton).toBeDisabled(); + expect(within(draftRow).getByText(/Name is required/i)).toBeInTheDocument(); + }); + + it('should keep Save disabled for whitespace-only sub-tag input', async () => { + openActionsMenuForTag('root tag 1'); + fireEvent.click(screen.getByText('Add Subtag')); + const rows = await screen.findAllByRole('row'); + const draftRow = rows.find(tableRow => tableRow.querySelector('input')); + const input = draftRow.querySelector('input'); + const saveButton = within(draftRow).getByText('Save'); + + fireEvent.change(input, { target: { value: ' ' } }); + expect(saveButton).toBeDisabled(); + }); + + it('should disable Save and show invalid-character error for sub-tag input', async () => { + openActionsMenuForTag('root tag 1'); + fireEvent.click(screen.getByText('Add Subtag')); + const rows = await screen.findAllByRole('row'); + const draftRow = rows.find(row => row.querySelector('input')); + const input = draftRow.querySelector('input'); + const saveButton = within(draftRow).getByText('Save'); + + fireEvent.change(input, { target: { value: 'invalid;name' } }); + expect(saveButton).toBeDisabled(); + expect(within(draftRow).getByText(/invalid character/i)).toBeInTheDocument(); + }); + + it('should keep inline row and show failure feedback when sub-tag save fails', async () => { + axiosMock.onPost(createTagUrl).reply(500, { + error: 'Internal server error', + }); + + openActionsMenuForTag('root tag 1'); + fireEvent.click(screen.getByText('Add Subtag')); + const rows = await screen.findAllByRole('row'); + const draftRow = rows.find(row => row.querySelector('input')); + const input = draftRow.querySelector('input'); + fireEvent.change(input, { target: { value: 'subtag fail' } }); + fireEvent.click(within(draftRow).getByText('Save')); + + await waitFor(() => { + expect(getDraftRows().length).toBe(1); + }); + expect(await screen.findByText(/Error creating tag:/i)).toBeInTheDocument(); + }); + }); + + it('should hide or disable Add sub-tag actions when user lacks edit permissions', async () => { + initializeMockApp({ authenticatedUser: nonAdminUser }); + const addSubtagActions = screen.queryAllByText('Add Subtag'); + if (addSubtagActions.length === 0) { + expect(addSubtagActions.length).toBe(0); + } else { + addSubtagActions.forEach(action => { + expect(action).toBeDisabled(); + }); + } + }); + }); + + describe('At smaller max depth', () => { + beforeEach(async () => { + const maxDepth = 2; + // clear all previously rendered react + cleanup(); + axiosMock.onGet(rootTagsListUrl).reply(200, { + ...mockTagsResponse, + max_depth: maxDepth, + }); + // re-render with a smaller max depth to allow nested sub-tags + renderTagListTable(maxDepth); + await waitForRootTag(); + await flushReactUpdates(); + }); + it('should only allow adding sub-tags up to the taxonomy max depth', async () => { + fireEvent.click(screen.getAllByText('Expand All')[0]); + + // open actions menu for depth 0 root tag + openActionsMenuForTag('root tag 1'); + expect(screen.getByText('Add Subtag')).toBeInTheDocument(); + + await screen.findByText('the child tag'); + await screen.findByText('the grandchild tag'); + + // depth 1 is not innermost when maxDepth=2, so adding another sub-tag is allowed + const childTagRow = screen.getByText('the child tag').closest('tr'); + expect(within(childTagRow).getByRole('button', { name: /actions/i })).toBeInTheDocument(); + + // depth 2 is innermost when maxDepth=2, so no add-subtag action should be shown + const grandchildTagRow = screen.getByText('the grandchild tag').closest('tr'); + expect(within(grandchildTagRow).queryByRole('button', { name: /actions/i })).not.toBeInTheDocument(); + }); + }); +}); + +// These async creation flows are intentionally isolated because they pass individually +// but can be flaky when interleaved with the larger suite's async/query timing. +describe(' isolated async subtag tests', () => { + beforeAll(async () => { + initializeMockApp({ + authenticatedUser: adminUser, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: adminUser, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + queryClient.clear(); + }); + + it('shows the spinner before the query is complete', async () => { + // Simulate an actual slow response from the API: + let resolveResponse; + const promise = new Promise(resolve => { resolveResponse = resolve; }); + axiosMock.onGet(rootTagsListUrl).reply(() => promise); + renderTagListTable(); + const spinner = await screen.findByRole('status'); + expect(spinner.textContent).toEqual('Loading...'); + resolveResponse([200, { results: [] }]); + await waitForElementToBeRemoved(() => screen.queryByRole('status')); + const noFoundComponent = await screen.findByText('No results found'); + expect(noFoundComponent).toBeInTheDocument(); + }); + + describe('with loaded root tags', () => { + beforeEach(async () => { + axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); + cleanup(); + renderTagListTable(); + await waitForRootTag(); + await flushReactUpdates(); + }); + + it('should create and render a new sub-tag under the selected parent', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'child-new', + child_count: 0, + descendant_count: 0, + _id: 2222, + parent_value: 'root tag 1', + }); + + const { draftRow, input } = await openSubtagDraftRow({ tagName: 'root tag 1' }); + + fireEvent.change(input, { target: { value: 'child-new' } }); + fireEvent.click(within(draftRow).getByText('Save')); + + await waitFor(() => { + expect(screen.getByText('child-new')).toBeInTheDocument(); + expectNoDraftRows(); + }); + }); + + it('should show a newly created sub-tag without triggering a page refresh', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'child appears immediately', + child_count: 0, + descendant_count: 0, + _id: 3333, + parent_value: 'root tag 1', + }); + + const { draftRow, input } = await openSubtagDraftRow({ tagName: 'root tag 1' }); + fireEvent.change(input, { target: { value: 'child appears immediately' } }); + expect(screen.queryByText('child appears immediately')).toBeNull(); + fireEvent.click(within(draftRow).getByText('Save')); + + await waitFor(() => { + expect(screen.queryByText('child appears immediately')).toBeInTheDocument(); + }); + expect(axiosMock.history.get.length).toBe(1); + }); + + it('should allow adding a nested sub-tag under a sub-tag', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'nested child', + child_count: 0, + descendant_count: 0, + _id: 4444, + parent_value: 'the child tag', + }); + + const expandButton = screen.getAllByLabelText('Show Subtags')[0]; + fireEvent.click(expandButton); + + await screen.findByText('the child tag'); + const { input } = await openSubtagDraftRow({ + tagName: 'the child tag', + actionButtonName: /more actions for tag "the child tag"/i, + }); + fireEvent.change(input, { target: { value: 'nested child' } }); + fireEvent.click(within(input.closest('tr')).getByText('Save')); + + expect(await screen.findByText('nested child')).toBeInTheDocument(); + }); + + it('should show a newly created nested sub-tag without triggering a page refresh', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'nested child appears immediately', + child_count: 0, + descendant_count: 0, + _id: 5555, + parent_value: 'the child tag', + }); + + const expandButton = screen.getAllByLabelText('Show Subtags')[0]; + fireEvent.click(expandButton); + await screen.findByText('the child tag'); + + const { draftRow, input } = await openSubtagDraftRow({ tagName: 'the child tag' }); + fireEvent.change(input, { target: { value: 'nested child appears immediately' } }); + + const saveButton = within(draftRow).getByText('Save'); + + fireEvent.click(saveButton); + + expect(await screen.findByText('nested child appears immediately')).toBeInTheDocument(); + expect(axiosMock.history.get.length).toBe(1); + }); + + it('should allow adding a great-grandchild sub-tag under a grandchild tag', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'great grandchild', + child_count: 0, + descendant_count: 0, + _id: 6666, + parent_value: 'the grandchild tag', + }); + + fireEvent.click(screen.getAllByText('Expand All')[0]); + await screen.findByText('the grandchild tag'); + + const { input } = await openSubtagDraftRow({ + tagName: 'the grandchild tag', + actionButtonName: /more actions for tag "the grandchild tag"/i, + }); + fireEvent.change(input, { target: { value: 'great grandchild' } }); + fireEvent.click(within(input.closest('tr')).getByText('Save')); + + expect(await screen.findByText('great grandchild')).toBeInTheDocument(); + }); + + it('should show a newly created great-grandchild sub-tag without triggering a page refresh', async () => { + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'great grandchild appears immediately', + child_count: 0, + descendant_count: 0, + _id: 7777, + parent_value: 'the grandchild tag', + }); + + fireEvent.click(screen.getAllByText('Expand All')[0]); + await screen.findByText('the grandchild tag'); + + const { draftRow, input } = await openSubtagDraftRow({ + tagName: 'the grandchild tag', + actionButtonName: /more actions for tag "the grandchild tag"/i, + }); + fireEvent.change(input, { target: { value: 'great grandchild appears immediately' } }); + + const saveButton = within(draftRow).getByText('Save'); + + fireEvent.click(saveButton); + + expect(await screen.findByText('great grandchild appears immediately')).toBeInTheDocument(); + expect(axiosMock.history.get.length).toBe(1); + }); + + it('should allow adding a sub-tag at depth 2 when maxDepth is 3', async () => { + fireEvent.click(screen.getAllByText('Expand All')[0]); + + await screen.findByText('the grandchild tag'); + const grandchildRow = screen.getByText('the grandchild tag').closest('tr'); + const grandchildActionsButton = within(grandchildRow).getByRole('button', { + name: /more actions for tag "the grandchild tag"/i, + }); + + fireEvent.click(grandchildActionsButton); + expect(screen.getByText('Add Subtag')).toBeInTheDocument(); + }); + }); +}); + +describe(' pagination transition behavior', () => { + let tableViewProps; + const mockEnterViewMode = jest.fn(); + + const mockTableMode = (tableMode) => { + jest.spyOn(hooksModule, 'useTableModes').mockReturnValue({ + tableMode, + enterDraftMode: jest.fn(), + exitDraftWithoutSave: jest.fn(), + enterPreviewMode: jest.fn(), + enterViewMode: mockEnterViewMode, + }); + }; + + beforeEach(() => { + tableViewProps = null; + mockEnterViewMode.mockReset(); + store = initializeStore(); + queryClient.clear(); + + jest.spyOn(apiHooksModule, 'useTagListData').mockReturnValue({ + isLoading: false, + data: { + results: [], + numPages: 1, + }, + }); + jest.spyOn(apiHooksModule, 'useCreateTag').mockReturnValue({ + isPending: false, + mutateAsync: jest.fn(), + }); + jest.spyOn(hooksModule, 'useEditActions').mockReturnValue({ + handleCreateTag: jest.fn(), + handleUpdateTag: jest.fn(), + validate: jest.fn(() => true), + }); + jest.spyOn(treeTableModule, 'TableView').mockImplementation((props) => { + tableViewProps = props; + return
    ; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('transitions from preview mode back to view mode on pagination changes', async () => { + mockTableMode('preview'); + + renderTagListTable(); + + expect(await screen.findByTestId('mock-table-view')).toBeInTheDocument(); + expect(tableViewProps).toBeTruthy(); + + act(() => { + tableViewProps.handlePaginationChange({ pageIndex: 1, pageSize: 100 }); + }); + + expect(mockEnterViewMode).toHaveBeenCalled(); + }); + + it('does not transition to view mode on pagination changes when already in view mode', async () => { + mockTableMode('view'); + + renderTagListTable(); + + expect(await screen.findByTestId('mock-table-view')).toBeInTheDocument(); + expect(tableViewProps).toBeTruthy(); + + act(() => { + tableViewProps.handlePaginationChange({ pageIndex: 1, pageSize: 100 }); + }); + + expect(mockEnterViewMode).not.toHaveBeenCalled(); }); }); diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx new file mode 100644 index 0000000000..01795b8917 --- /dev/null +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -0,0 +1,174 @@ +import React, { + useState, + useMemo, + useEffect, +} from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import type { PaginationState } from '@tanstack/react-table'; +import { useTagListData, useCreateTag } from '../data/apiHooks'; +import { TagTree } from './tagTree'; +import { TableView } from '../tree-table'; +import type { + RowId, + TreeColumnDef, + TreeRowData, +} from '../tree-table/types'; +import { + TABLE_MODES, +} from './constants'; +import { getColumns } from './tagColumns'; +import { useTableModes, useEditActions } from './hooks'; + +interface TagListTableProps { + taxonomyId: number; + maxDepth: number; +} + +// TODO: Fix and enable pagination on backend and frontend.For now, disable pagination by showing all tags on one page. +const DISABLE_PAGINATION = true; + +const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { + // The table has a VIEW, DRAFT, and a PREVIEW mode. It starts in VIEW mode. + // It switches to DRAFT mode when a user edits or creates a tag. + // It switches to PREVIEW mode after saving changes, and only switches to VIEW when + // the user refreshes the page, orders a column, or navigates to a different page. + // During DRAFT and PREVIEW mode the table makes POST requests and receives + // success or failure responses. + // However, the table does not refresh to show the updated data from the backend. + // This allows us to show the newly created or updated tag in the same place without reordering. + // + // TODO: Simpler approaches have been suggested. Two options are to just use simple React state: + // `isCurrentlyEditingTag` and `lastCreatedTag`, or to use optimistic updates. + // For reference, see https://github.com/openedx/frontend-app-authoring/pull/2872#discussion_r2880965005. + const intl = useIntl(); + + const [creatingParentId, setCreatingParentId] = useState(null); + const [editingRowId, setEditingRowId] = useState(null); + + // TODO: change to use the global ToastContext (waiting for UX refinement on that). + const [toast, setToast] = useState({ show: false, message: '', variant: 'success' }); + const [tagTree, setTagTree] = useState(null); + const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); + const [activeActionMenuRowId, setActiveActionMenuRowId] = useState(null); + const [draftError, setDraftError] = useState(''); + const treeData = (tagTree?.getAllAsDeepCopy() || []) as unknown as TreeRowData[]; + const hasOpenDraft = isCreatingTopTag || creatingParentId !== null || editingRowId !== null; + + // TABLE MODES + const { + tableMode, enterDraftMode, exitDraftWithoutSave, enterPreviewMode, enterViewMode, + } = useTableModes(); + + // PAGINATION + // TODO: Fix and enable pagination. For now, disable pagination. + const [{ pageIndex, pageSize }, setPagination] = useState({ + pageIndex: 0, + pageSize: 50, + }); + const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); + const handlePaginationChange = (updater: React.SetStateAction) => { + if (tableMode === TABLE_MODES.PREVIEW) { + enterViewMode(); + } + setPagination(updater); + }; + + // API HOOKS + const { isLoading, data: tagList } = useTagListData(taxonomyId, { + ...pagination, + disablePagination: DISABLE_PAGINATION, + enabled: tableMode === TABLE_MODES.VIEW, + }); + const createTagMutation = useCreateTag(taxonomyId); + const pageCount = tagList?.numPages ?? -1; + + // TODO: to make this more readable, introduce a React context for the TagListTable instead of passing props. + + // Custom Edit Actions Hook - handles table mode transitions, API calls, + // and updating the table without a full data reload when creating or editing tags. + const { handleCreateTag, handleUpdateTag, validate } = useEditActions({ + setTagTree, + setDraftError, + createTagMutation, + enterPreviewMode, + setToast, + setIsCreatingTopTag, + setCreatingParentId, + exitDraftWithoutSave, + setEditingRowId, + }); + + const columns = useMemo( + () => getColumns({ + setIsCreatingTopTag, + setCreatingParentId, + handleUpdateTag, + setEditingRowId, + onStartDraft: enterDraftMode, + setActiveActionMenuRowId, + hasOpenDraft, + draftError, + setDraftError, + isSavingDraft: createTagMutation.isPending, + maxDepth, + creatingParentId, + }), + [ + isCreatingTopTag, + editingRowId, + tableMode, + activeActionMenuRowId, + hasOpenDraft, + creatingParentId, + draftError, + createTagMutation.isPending, + maxDepth, + setIsCreatingTopTag, + setCreatingParentId, + handleUpdateTag, + setEditingRowId, + enterDraftMode, + setActiveActionMenuRowId, + setDraftError, + ], + ); + + // RELOAD DATA IN VIEW MODE + useEffect(() => { + // Get row data in VIEW mode. Otherwise keep current data to avoid disrupting + // users while they edit or create a tag. + if (tableMode === TABLE_MODES.VIEW && tagList?.results) { + const tree = new TagTree(tagList?.results); + if (tree) { + setTagTree(tree); + } + } + }, [tagList?.results, tableMode]); + + return ( + + ); +}; + +export default TagListTable; diff --git a/src/taxonomy/tag-list/constants.ts b/src/taxonomy/tag-list/constants.ts new file mode 100644 index 0000000000..33b0dc058d --- /dev/null +++ b/src/taxonomy/tag-list/constants.ts @@ -0,0 +1,32 @@ +/** Tag list table modes - see explanation in `` component (`src/taxonomy/tag-list/TagListTable.tsx`) */ +const TABLE_MODES = { + VIEW: 'view', + DRAFT: 'draft', + PREVIEW: 'preview', +}; + +/** Allowed transitions for table mode. + * An invalid transition is mainly an illegal switch from DRAFT mode to VIEW mode, + * which would refresh data and suddenly reorder the table and disrupt the user's workflow. + * Refreshing data is only allowed in VIEW mode. + */ +const TRANSITION_TABLE = { + [TABLE_MODES.VIEW]: [TABLE_MODES.VIEW, TABLE_MODES.DRAFT], + [TABLE_MODES.DRAFT]: [TABLE_MODES.DRAFT, TABLE_MODES.PREVIEW], + [TABLE_MODES.PREVIEW]: [TABLE_MODES.PREVIEW, TABLE_MODES.DRAFT, TABLE_MODES.VIEW], +}; + +/** Table mode action types for the React's `useReducer` hook */ +const TABLE_MODE_ACTIONS = { + TRANSITION: 'transition', +}; + +// forbidden characters: '\t', '>', ';' +const TAG_NAME_PATTERN = /^[^\t>;]*$/; + +export { + TABLE_MODES, + TRANSITION_TABLE, + TABLE_MODE_ACTIONS, + TAG_NAME_PATTERN, +}; diff --git a/src/taxonomy/tag-list/errors.ts b/src/taxonomy/tag-list/errors.ts new file mode 100644 index 0000000000..389621388c --- /dev/null +++ b/src/taxonomy/tag-list/errors.ts @@ -0,0 +1,16 @@ +/** Custom error classes for the Tag List feature. */ +/* eslint-disable max-classes-per-file */ + +export class TagTreeError extends Error { + constructor(message: string) { + super(message); + this.name = 'TagTreeError'; + } +} + +export class TagListTableError extends Error { + constructor(message: string) { + super(message); + this.name = 'TagListTableError'; + } +} diff --git a/src/taxonomy/tag-list/hooks.test.tsx b/src/taxonomy/tag-list/hooks.test.tsx new file mode 100644 index 0000000000..7d799afceb --- /dev/null +++ b/src/taxonomy/tag-list/hooks.test.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { act, renderHook } from '@testing-library/react'; + +import { TagTree } from './tagTree'; +import { useEditActions, useTableModes } from './hooks'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('useTableModes', () => { + it('supports valid transitions from view to draft to preview', () => { + const { result } = renderHook(() => useTableModes()); + + expect(result.current.tableMode).toEqual('view'); + + act(() => { + result.current.enterDraftMode(); + }); + expect(result.current.tableMode).toEqual('draft'); + + act(() => { + result.current.enterPreviewMode(); + }); + expect(result.current.tableMode).toEqual('preview'); + }); + + it('throws when transition is invalid for the current mode', () => { + const { result } = renderHook(() => useTableModes()); + + act(() => { + result.current.enterDraftMode(); + }); + + expect(() => { + act(() => { + result.current.enterViewMode(); + }); + }).toThrow('Invalid table mode transition from draft to view'); + }); +}); + +describe('useEditActions', () => { + const buildActions = (overrides = {}) => { + const createTagMutation = { mutateAsync: jest.fn() }; + const setTagTree = jest.fn(); + const setDraftError = jest.fn(); + const enterPreviewMode = jest.fn(); + const setToast = jest.fn(); + const setIsCreatingTopTag = jest.fn(); + const setCreatingParentId = jest.fn(); + const exitDraftWithoutSave = jest.fn(); + const setEditingRowId = jest.fn(); + + const params = { + setTagTree, + setDraftError, + createTagMutation: createTagMutation as any, + enterPreviewMode, + setToast, + setIsCreatingTopTag, + setCreatingParentId, + exitDraftWithoutSave, + setEditingRowId, + ...(overrides as any), + }; + + const { result } = renderHook(() => useEditActions(params), { wrapper }); + + return { + actions: result.current, + createTagMutation, + setTagTree, + setDraftError, + enterPreviewMode, + setToast, + setIsCreatingTopTag, + setCreatingParentId, + exitDraftWithoutSave, + setEditingRowId, + }; + }; + + it('throws inline validation error in hard mode for invalid characters', () => { + const { actions } = buildActions(); + expect(() => actions.validate('invalid;tag', 'hard')).toThrow('Invalid character in tag name'); + }); + + it('sets an inline validation error and returns false in soft mode', () => { + const { actions, setDraftError } = buildActions(); + + const isValid = actions.validate(' ', 'soft'); + + expect(isValid).toBe(false); + expect(setDraftError).toHaveBeenCalledWith('Name is required'); + }); + + it('adds a new root node when table data is initially empty', () => { + let updatedTree: any = null; + const setTagTree = jest.fn((updater: (current: TagTree | null) => TagTree) => { + updatedTree = updater(null); + }); + + const { actions } = buildActions({ setTagTree }); + act(() => { + actions.updateTableWithoutDataReload('brand new root'); + }); + + expect(updatedTree.getTagAsDeepCopy('brand new root')).not.toBeNull(); + }); + + it('does not transition to preview when update value is unchanged after trimming', async () => { + const { + actions, + enterPreviewMode, + setToast, + setEditingRowId, + } = buildActions(); + + await act(async () => { + await actions.handleUpdateTag(' same value ', 'same value'); + }); + + expect(enterPreviewMode).not.toHaveBeenCalled(); + expect(setToast).not.toHaveBeenCalled(); + expect(setEditingRowId).toHaveBeenCalledWith(null); + }); + + it('shows success toast and enters preview when update value changes', async () => { + const { + actions, + enterPreviewMode, + setToast, + setEditingRowId, + } = buildActions(); + + await act(async () => { + await actions.handleUpdateTag('updated', 'original'); + }); + + expect(enterPreviewMode).toHaveBeenCalled(); + expect(setToast).toHaveBeenCalledWith({ + show: true, + message: 'Tag "updated" updated successfully', + }); + expect(setEditingRowId).toHaveBeenCalledWith(null); + }); + + it('keeps draft open and shows failure toast when createTag request fails', async () => { + const { + actions, + createTagMutation, + setDraftError, + setToast, + } = buildActions(); + createTagMutation.mutateAsync.mockRejectedValue(new Error('server failed')); + + await act(async () => { + await actions.handleCreateTag('new tag'); + }); + + expect(setDraftError).toHaveBeenCalledWith('server failed'); + expect(setToast).toHaveBeenCalledWith({ + show: true, + message: 'Error creating tag: server failed', + }); + }); +}); diff --git a/src/taxonomy/tag-list/hooks.ts b/src/taxonomy/tag-list/hooks.ts new file mode 100644 index 0000000000..a350e3eabe --- /dev/null +++ b/src/taxonomy/tag-list/hooks.ts @@ -0,0 +1,200 @@ +import { useReducer } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { useCreateTag } from '../data/apiHooks'; +import { TagTree } from './tagTree'; +import { TagListTableError } from './errors'; +import type { RowId } from '../tree-table/types'; +import { + TABLE_MODES, + TRANSITION_TABLE, + TABLE_MODE_ACTIONS, + TAG_NAME_PATTERN, +} from './constants'; + +import messages from './messages'; + +/** Interface for table mode actions for React's `useReducer` hook. + * + * `type`: Action type. + * `targetMode`: The table mode to transition to. Must be one of the allowed transitions defined in `TRANSITION_TABLE`. + * An invalid transition (e.g. from DRAFT to VIEW) will throw an error to prevent disruptive data refreshes. + * + * For examples, see: https://react.dev/learn/extracting-state-logic-into-a-reducer#writing-reducers-well +*/ +export interface TableModeAction { + type: string; + targetMode: string; +} + +interface UseTableModesReturn { + tableMode: string; + enterDraftMode: () => void; + exitDraftWithoutSave: () => void; + enterPreviewMode: () => void; + enterViewMode: () => void; +} + +interface UseEditActionsParams { + setTagTree: React.Dispatch>; + setDraftError: React.Dispatch>; + createTagMutation: ReturnType; + enterPreviewMode: () => void; + setToast: React.Dispatch>; + setIsCreatingTopTag: React.Dispatch>; + setCreatingParentId: React.Dispatch>; + exitDraftWithoutSave: () => void; + setEditingRowId: React.Dispatch>; +} + +const getInlineValidationMessage = (value: string, intl: ReturnType): string => { + const trimmed = value.trim(); + if (!trimmed) { + return intl.formatMessage(messages.nameRequired); + } + if (!TAG_NAME_PATTERN.test(trimmed)) { + return intl.formatMessage(messages.invalidCharacterInTagName); + } + return ''; +}; + +/** Table mode reducer for React's `useReducer` hook. + * This will throw an error if an invalid table mode transition is attempted, + * as defined in the `TRANSITION_TABLE` constant. + * + * @param currentMode - The current table mode. + * @param action - The action to perform on the table mode. + * @returns The new table mode. + */ +const tableModeReducer = (currentMode: string, action: TableModeAction): string => { + if (action?.type !== TABLE_MODE_ACTIONS.TRANSITION) { + throw new TagListTableError(`Unknown table mode action: ${action?.type}`); + } + + const { targetMode } = action; + if (TRANSITION_TABLE[currentMode].includes(targetMode)) { + return targetMode; + } + + throw new TagListTableError(`Invalid table mode transition from ${currentMode} to ${targetMode}`); +}; + +/** Simple custom hook providing table modes. + * The main purpose of this hook is to manage allowed transitions between table modes + * to prevent disruptive data refreshes. + * This allows a component to check the current mode and switch to a different mode without risking invalid transitions. + * Transitions are defined separately in the `TRANSITION_TABLE` constant, + * which makes it easy to understand and update allowed transitions in one place. + */ +const useTableModes = (): UseTableModesReturn => { + const [tableMode, dispatchTableMode] = useReducer(tableModeReducer, TABLE_MODES.VIEW); + + const transitionTableMode = (targetMode: string) => { + dispatchTableMode({ type: TABLE_MODE_ACTIONS.TRANSITION, targetMode }); + }; + + const enterDraftMode = () => transitionTableMode(TABLE_MODES.DRAFT); + const exitDraftWithoutSave = () => transitionTableMode(TABLE_MODES.PREVIEW); + const enterPreviewMode = () => transitionTableMode(TABLE_MODES.PREVIEW); + const enterViewMode = () => transitionTableMode(TABLE_MODES.VIEW); + + return { + tableMode, enterDraftMode, exitDraftWithoutSave, enterPreviewMode, enterViewMode, + }; +}; + +const useEditActions = ({ + setTagTree, + setDraftError, + createTagMutation, + enterPreviewMode, + setToast, + setIsCreatingTopTag, + setCreatingParentId, + setEditingRowId, +}: UseEditActionsParams) => { + const intl = useIntl(); + const updateTableWithoutDataReload = (value: string, parentTagValue: string | null = null) => { + setTagTree((currentTagTree) => { + const nextTree = currentTagTree || new TagTree([]); + const parentTag = parentTagValue ? nextTree.getTagAsDeepCopy(parentTagValue) : null; + + nextTree.addNode({ + id: Date.now(), + value, + parentValue: parentTagValue, + depth: parentTag ? parentTag.depth + 1 : 0, + childCount: 0, + descendantCount: 0, + subTagsUrl: null, + externalId: '', + }, parentTagValue); + + return nextTree; + }); + }; + + /** Validates a tag value and sets a draft error message if invalid. + * In 'hard' mode, it will throw an error instead of setting a draft error message; + * in 'soft' mode, it will set a draft error message and return false. + */ + const validate = (value: string, mode: 'soft' | 'hard' = 'hard'): boolean => { + const validationError = getInlineValidationMessage(value, intl); + if (validationError) { + if (mode === 'hard') { + throw new Error(validationError); + } + setDraftError(validationError); + return false; + } + + setDraftError(''); + return true; + }; + + const handleCreateTag = async (value: string, parentTagValue?: string) => { + const trimmed = value.trim(); + + if (!validate(trimmed, 'soft')) { + return; + } + + try { + setDraftError(''); + await createTagMutation.mutateAsync({ value: trimmed, parentTagValue }); + updateTableWithoutDataReload(trimmed, parentTagValue || null); + enterPreviewMode(); + setToast({ + show: true, + message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), + }); + setIsCreatingTopTag(false); + setCreatingParentId(null); + } catch (error) { + const message = intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage: (error as Error)?.message }); + setDraftError((error as Error)?.message || intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage: '' })); + setToast({ show: true, message }); + } + }; + + const handleUpdateTag = async (value: string, originalValue: string) => { + const trimmed = value.trim(); + if (trimmed && trimmed !== originalValue) { + enterPreviewMode(); + setToast({ + show: true, + message: intl.formatMessage(messages.tagUpdateSuccessMessage, { name: trimmed }), + }); + } + setEditingRowId(null); + }; + + return { + updateTableWithoutDataReload, + handleCreateTag, + handleUpdateTag, + validate, + }; +}; + +export { useTableModes, useEditActions }; diff --git a/src/taxonomy/tag-list/messages.ts b/src/taxonomy/tag-list/messages.ts index 77c0efa11a..2e9b7ecadb 100644 --- a/src/taxonomy/tag-list/messages.ts +++ b/src/taxonomy/tag-list/messages.ts @@ -1,10 +1,6 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ - noResultsFoundMessage: { - id: 'course-authoring.tag-list.no-results-found.message', - defaultMessage: 'No results found', - }, tagListColumnValueHeader: { id: 'course-authoring.tag-list.column.value.header', defaultMessage: 'Tag name', @@ -13,6 +9,50 @@ const messages = defineMessages({ id: 'course-authoring.tag-list.error', defaultMessage: 'Error: unable to load child tags', }, + tagCreationSuccessMessage: { + id: 'course-authoring.tag-list.creation-success', + defaultMessage: 'Tag "{name}" created successfully', + }, + tagCreationErrorMessage: { + id: 'course-authoring.tag-list.creation-error', + defaultMessage: 'Error creating tag: {errorMessage}', + }, + tagUpdateSuccessMessage: { + id: 'course-authoring.tag-list.update-success', + defaultMessage: 'Tag "{name}" updated successfully', + }, + addSubtag: { + id: 'course-authoring.tag-list.add-subtag', + defaultMessage: 'Add Subtag', + }, + nameRequired: { + id: 'course-authoring.tag-list.validation.name-required', + defaultMessage: 'Name is required', + }, + invalidCharacterInTagName: { + id: 'course-authoring.tag-list.validation.invalid-character', + defaultMessage: 'Invalid character in tag name', + }, + createNewTagTooltip: { + id: 'course-authoring.tag-list.create-new-tag.tooltip', + defaultMessage: 'Create a new tag', + }, + createTagButtonLabel: { + id: 'course-authoring.tag-list.create-tag.button-label', + defaultMessage: 'Create Tag', + }, + moreActionsForTag: { + id: 'course-authoring.tag-list.more-actions-for-tag', + defaultMessage: 'More actions for tag "{tagName}"', + }, + showSubtagsButtonLabel: { + id: 'course-authoring.tag-list.show-subtags.button-label', + defaultMessage: 'Show Subtags', + }, + hideSubtagsButtonLabel: { + id: 'course-authoring.tag-list.hide-subtags.button-label', + defaultMessage: 'Hide Subtags', + }, }); export default messages; diff --git a/src/taxonomy/tag-list/mockData.ts b/src/taxonomy/tag-list/mockData.ts new file mode 100644 index 0000000000..77d88cb60a --- /dev/null +++ b/src/taxonomy/tag-list/mockData.ts @@ -0,0 +1,1390 @@ +import { TagTreeNode } from './tagTree'; +import { TagData } from '../data/types'; + +export const rawData: TagData[] = [ + { + value: 'ab', + externalId: 'some-external-id', + childCount: 2, + descendantCount: 4, + depth: 0, + parentValue: null, + id: 31, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ab&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'aaa', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'ab', + id: 49, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=aaa&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'aa', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'aaa', + id: 52, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'ab2', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'ab', + id: 50, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ab2&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'S3', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'ab2', + id: 51, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Brass2', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 36, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Celli', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 34, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Celli&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'ViolaDaGamba', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Celli', + id: 42, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ViolaDaGamba&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Soprano', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'ViolaDaGamba', + id: 46, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Contrabass', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 35, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Electrodrum', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 38, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Electronic instruments', + externalId: 'ELECTRIC', + childCount: 2, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 3, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Electronic+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Synthesizer', + externalId: 'SYNTH', + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'Electronic instruments', + id: 25, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Theramin', + externalId: 'THERAMIN', + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'Electronic instruments', + id: 9, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Fiddle', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 54, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'grand piano', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 48, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Horns', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 55, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Horns&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'English Horn', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Horns', + id: 56, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=English+Horn&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Small English Horn', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'English Horn', + id: 57, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Keyboard', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 37, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Kid drum', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 33, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Mezzosopranocello', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 41, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Oriental', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 53, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Percussion instruments', + externalId: 'PERCUSS', + childCount: 4, + descendantCount: 11, + depth: 0, + parentValue: null, + id: 2, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Percussion+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Chordophone', + externalId: 'CHORD', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Percussion instruments', + id: 10, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Chordophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Piano', + externalId: 'PIANO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Chordophone', + id: 29, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Drum', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Percussion instruments', + id: 45, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Drum&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'bass drum', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Drum', + id: 47, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Idiophone', + externalId: 'BELLS', + childCount: 2, + descendantCount: 2, + depth: 1, + parentValue: 'Percussion instruments', + id: 5, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Idiophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Celesta', + externalId: 'CELESTA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Idiophone', + id: 26, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Hi-hat', + externalId: 'HI-HAT', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Idiophone', + id: 27, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Membranophone', + externalId: 'DRUMS', + childCount: 2, + descendantCount: 3, + depth: 1, + parentValue: 'Percussion instruments', + id: 6, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Membranophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Cajón', + externalId: 'CAJÓN', + childCount: 1, + descendantCount: 1, + depth: 2, + parentValue: 'Membranophone', + id: 7, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Caj%C3%B3n&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Tabla', + externalId: 'TABLA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Membranophone', + id: 28, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Recorder', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 39, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'String instruments', + externalId: 'STRINGS', + childCount: 3, + descendantCount: 9, + depth: 0, + parentValue: null, + id: 4, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=String+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Bowed strings', + externalId: 'BOW', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'String instruments', + id: 18, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Bowed+strings&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Cello', + externalId: 'CELLO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 20, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Viola', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 44, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Violin', + externalId: 'VIOLIN', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 19, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Other strings', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'String instruments', + id: 43, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Plucked strings', + externalId: 'PLUCK', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'String instruments', + id: 14, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Plucked+strings&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Banjo', + externalId: 'BANJO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 17, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Harp', + externalId: 'HARP', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 16, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Mandolin', + externalId: 'MANDOLIN', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 15, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Subbass', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 40, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Trumpets', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 30, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Wind instruments', + externalId: 'WINDS', + childCount: 2, + descendantCount: 7, + depth: 0, + parentValue: null, + id: 1, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Wind+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Brass', + externalId: 'BRASS', + childCount: 2, + descendantCount: 2, + depth: 1, + parentValue: 'Wind instruments', + id: 11, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Brass&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Trumpet', + externalId: 'TRUMPET', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Brass', + id: 23, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Tuba', + externalId: 'TUBA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Brass', + id: 24, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Woodwinds', + externalId: 'WOODS', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'Wind instruments', + id: 12, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Woodwinds&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Clarinet', + externalId: 'CLARINET', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 21, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Flute', + externalId: 'FLUTE', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 13, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Oboe', + externalId: 'OBOE', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 22, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Xyllophones', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 32, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, +]; + +export const treeRowData: TagTreeNode[] = [ + { + value: 'ab', + externalId: 'some-external-id', + childCount: 2, + descendantCount: 4, + depth: 0, + parentValue: null, + id: 31, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ab&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'aaa', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'ab', + id: 49, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=aaa&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'aa', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'aaa', + id: 52, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'ab2', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'ab', + id: 50, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ab2&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'S3', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'ab2', + id: 51, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Brass2', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 36, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Celli', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 34, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Celli&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'ViolaDaGamba', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Celli', + id: 42, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=ViolaDaGamba&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Soprano', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'ViolaDaGamba', + id: 46, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Contrabass', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 35, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Electrodrum', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 38, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Electronic instruments', + externalId: 'ELECTRIC', + childCount: 2, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 3, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Electronic+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Synthesizer', + externalId: 'SYNTH', + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'Electronic instruments', + id: 25, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Theramin', + externalId: 'THERAMIN', + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'Electronic instruments', + id: 9, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Fiddle', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 54, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'grand piano', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 48, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Horns', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 2, + depth: 0, + parentValue: null, + id: 55, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Horns&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'English Horn', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Horns', + id: 56, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=English+Horn&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Small English Horn', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'English Horn', + id: 57, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Keyboard', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 37, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Kid drum', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 33, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Mezzosopranocello', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 41, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Oriental', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 53, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Percussion instruments', + externalId: 'PERCUSS', + childCount: 4, + descendantCount: 11, + depth: 0, + parentValue: null, + id: 2, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Percussion+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Chordophone', + externalId: 'CHORD', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Percussion instruments', + id: 10, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Chordophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Piano', + externalId: 'PIANO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Chordophone', + id: 29, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Drum', + externalId: 'some-external-id', + childCount: 1, + descendantCount: 1, + depth: 1, + parentValue: 'Percussion instruments', + id: 45, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Drum&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'bass drum', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Drum', + id: 47, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Idiophone', + externalId: 'BELLS', + childCount: 2, + descendantCount: 2, + depth: 1, + parentValue: 'Percussion instruments', + id: 5, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Idiophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Celesta', + externalId: 'CELESTA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Idiophone', + id: 26, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Hi-hat', + externalId: 'HI-HAT', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Idiophone', + id: 27, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Membranophone', + externalId: 'DRUMS', + childCount: 2, + descendantCount: 3, + depth: 1, + parentValue: 'Percussion instruments', + id: 6, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Membranophone&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Cajón', + externalId: 'CAJÓN', + childCount: 1, + descendantCount: 1, + depth: 2, + parentValue: 'Membranophone', + id: 7, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Caj%C3%B3n&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Tabla', + externalId: 'TABLA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Membranophone', + id: 28, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Recorder', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 39, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'String instruments', + externalId: 'STRINGS', + childCount: 3, + descendantCount: 9, + depth: 0, + parentValue: null, + id: 4, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=String+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Bowed strings', + externalId: 'BOW', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'String instruments', + id: 18, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Bowed+strings&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Cello', + externalId: 'CELLO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 20, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Viola', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 44, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Violin', + externalId: 'VIOLIN', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Bowed strings', + id: 19, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Other strings', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 1, + parentValue: 'String instruments', + id: 43, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Plucked strings', + externalId: 'PLUCK', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'String instruments', + id: 14, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Plucked+strings&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Banjo', + externalId: 'BANJO', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 17, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Harp', + externalId: 'HARP', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 16, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Mandolin', + externalId: 'MANDOLIN', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Plucked strings', + id: 15, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Subbass', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 40, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Trumpets', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 30, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Wind instruments', + externalId: 'WINDS', + childCount: 2, + descendantCount: 7, + depth: 0, + parentValue: null, + id: 1, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Wind+instruments&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Brass', + externalId: 'BRASS', + childCount: 2, + descendantCount: 2, + depth: 1, + parentValue: 'Wind instruments', + id: 11, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Brass&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Trumpet', + externalId: 'TRUMPET', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Brass', + id: 23, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Tuba', + externalId: 'TUBA', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Brass', + id: 24, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + { + value: 'Woodwinds', + externalId: 'WOODS', + childCount: 3, + descendantCount: 3, + depth: 1, + parentValue: 'Wind instruments', + id: 12, + subTagsUrl: 'http://studio.local.openedx.io:8001/api/content_tagging/v1/taxonomies/1/tags/?parent_tag=Woodwinds&full_depth_threshold=1000', + canChangeTag: true, + canDeleteTag: true, + subRows: [ + { + value: 'Clarinet', + externalId: 'CLARINET', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 21, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Flute', + externalId: 'FLUTE', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 13, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + { + value: 'Oboe', + externalId: 'OBOE', + childCount: 0, + descendantCount: 0, + depth: 2, + parentValue: 'Woodwinds', + id: 22, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, + ], + }, + ], + }, + { + value: 'Xyllophones', + externalId: 'some-external-id', + childCount: 0, + descendantCount: 0, + depth: 0, + parentValue: null, + id: 32, + subTagsUrl: null, + canChangeTag: true, + canDeleteTag: true, + }, +]; diff --git a/src/taxonomy/tag-list/tagColumns.tsx b/src/taxonomy/tag-list/tagColumns.tsx new file mode 100644 index 0000000000..c70b415a69 --- /dev/null +++ b/src/taxonomy/tag-list/tagColumns.tsx @@ -0,0 +1,197 @@ +import { + Button, + Icon, + IconButton, + IconButtonWithTooltip, + Dropdown, +} from '@openedx/paragon'; +import { + AddCircle, + MoreVert, +} from '@openedx/paragon/icons'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import type { Row } from '@tanstack/react-table'; + +import messages from './messages'; +import type { + RowId, + TreeColumnDef, + TreeRowData, +} from '../tree-table/types'; +import OptionalExpandLink from './OptionalExpandLink'; + +interface TagListRowData extends TreeRowData { + depth: number; + childCount: number; + descendantCount: number; + isNew?: boolean; + isEditing?: boolean; +} + +const asTagListRowData = (row: Row): TagListRowData => ( + row.original as unknown as TagListRowData +); + +interface GetColumnsArgs { + setIsCreatingTopTag: (isCreating: boolean) => void; + setCreatingParentId: (id: RowId | null) => void; + handleUpdateTag: (value: string, originalValue: string) => void; + setEditingRowId: (id: RowId | null) => void; + onStartDraft: () => void; + setActiveActionMenuRowId: (id: RowId | null) => void; + hasOpenDraft: boolean; + draftError: string; + setDraftError: (error: string) => void; + isSavingDraft: boolean; + maxDepth: number; + creatingParentId: RowId | null; +} + +interface ActionsHeaderProps { + onStartDraft: () => void; + setDraftError: (error: string) => void; + setIsCreatingTopTag: (isCreating: boolean) => void; + setEditingRowId: (id: RowId | null) => void; + setActiveActionMenuRowId: (id: RowId | null) => void; + hasOpenDraft: boolean; + draftInProgressHintId: string; +} + +const ActionsHeader = ({ + onStartDraft, + setDraftError, + setIsCreatingTopTag, + setEditingRowId, + setActiveActionMenuRowId, + hasOpenDraft, + draftInProgressHintId, +}: ActionsHeaderProps) => { + const intl = useIntl(); + return ( +
    + {intl.formatMessage(messages.createNewTagTooltip)}
    } + src={AddCircle} + alt={intl.formatMessage(messages.createTagButtonLabel)} + size="inline" + onClick={() => { + onStartDraft(); + setDraftError(''); + setIsCreatingTopTag(true); + setEditingRowId(null); + setActiveActionMenuRowId(null); + }} + disabled={hasOpenDraft} + aria-describedby={hasOpenDraft ? draftInProgressHintId : undefined} + /> +
    + ); +}; + +interface ActionsMenuProps { + rowData: TagListRowData; + startSubtagDraft: () => void; + disableAddSubtag: boolean; +} + +const ActionsMenu = ({ rowData, startSubtagDraft, disableAddSubtag }: ActionsMenuProps) => { + const intl = useIntl(); + + return ( + + + + + {intl.formatMessage(messages.addSubtag)} + + + + ); +} + +function getColumns({ + setIsCreatingTopTag, + setCreatingParentId, + setEditingRowId, + onStartDraft, + setActiveActionMenuRowId, + hasOpenDraft, + setDraftError, + maxDepth, + creatingParentId, +}: GetColumnsArgs): TreeColumnDef[] { + const canAddSubtag = (row: Row) => row.depth < maxDepth; + const draftInProgressHintId = 'tag-list-draft-in-progress-hint'; + + return [ + { + id: 'valueColumn', + header: () => , + cell: ({ row }) => { + const { + value, + } = asTagListRowData(row); + + return ( + + + {value} + + ); + }, + }, + { + id: 'actions', + header: () => ( + + ), + cell: ({ row }) => { + const rowData = asTagListRowData(row); + + if (rowData.isNew || rowData.isEditing || !canAddSubtag(row)) { + return
    ; + } + + const disableAddSubtag = hasOpenDraft && creatingParentId !== rowData.id; + const startSubtagDraft = () => { + onStartDraft(); + setDraftError(''); + setCreatingParentId(rowData.id); + setEditingRowId(null); + setIsCreatingTopTag(false); + setActiveActionMenuRowId(null); + row.toggleExpanded(true); + }; + + return ( +
    + +
    + ); + }, + }, + ]; +} + +export { getColumns }; diff --git a/src/taxonomy/tag-list/tagTree.test.ts b/src/taxonomy/tag-list/tagTree.test.ts new file mode 100644 index 0000000000..068005adb5 --- /dev/null +++ b/src/taxonomy/tag-list/tagTree.test.ts @@ -0,0 +1,330 @@ +import { rawData, treeRowData } from './mockData'; +import { TagTree } from './tagTree'; +import { TagTreeError } from './errors'; +import { TagData } from '../data/types'; + +// For testing purposes, we define a new child node that can be added to the tree in various test cases. +const newChildNode: TagData = { + value: 'newChild', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 8, + parentValue: 'ab', + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 1, +}; + +describe('TagTree', () => { + it('builds a tree structure from flat tag data', () => { + const tree = new TagTree(rawData); + expect(tree.getAllAsDeepCopy()).toEqual(treeRowData); + }); + + it('handles empty data', () => { + const tree = new TagTree([]); + expect(tree.getAllAsDeepCopy()).toEqual([]); + }); + + it('gets all rows as deep copy', () => { + const tree = new TagTree(rawData); + const nodes = tree.getAllAsDeepCopy(); + expect(nodes).toEqual(treeRowData); + }); + + it('gets a node by value', () => { + const tree = new TagTree(rawData); + const node = tree.getTagAsDeepCopy('ab'); + expect(node).not.toBeNull(); + expect(node?.value).toBe('ab'); + }); + + it('gets a deep copy when getting a node so that direct mutations do not affect the original tree', () => { + const tree = new TagTree(rawData); + const node = tree.getTagAsDeepCopy('ab'); + expect(node?.externalId).toBe('some-external-id'); + + if (node) { + node.externalId = 'modified'; + } + const originalNode = tree.getTagAsDeepCopy('ab'); + expect(originalNode?.externalId).toBe('some-external-id'); + }); + + it('returns null for non-existent node', () => { + const tree = new TagTree(rawData); + const node = tree.getTagAsDeepCopy('nonExistent'); + expect(node).toBeNull(); + }); + + it('creates a new top-level row', () => { + const tree = new TagTree(rawData); + const newRow: TagData = { + value: 'newTopLevel', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 7, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }; + tree.addNode(newRow, null); + expect(tree.getAllAsDeepCopy()).toContainEqual(newRow); + }); + + it('creates a new child row', () => { + const tree = new TagTree(rawData); + tree.addNode(newChildNode, 'ab'); + const parentNode = tree.getTagAsDeepCopy('ab'); + expect(parentNode?.subRows).toContainEqual(newChildNode); + }); + + it('edits a node value', () => { + const tree = new TagTree(rawData); + tree.addNode(newChildNode, 'ab'); + tree.editTagValue('ab', 'editedAb'); + expect(tree.getTagAsDeepCopy('editedAb')).not.toBeNull(); + expect(tree.getTagAsDeepCopy('ab')).toBeNull(); + expect(tree.getTagAsDeepCopy('editedAb')?.value).toBe('editedAb'); + expect(tree.getTagAsDeepCopy('editedAb')?.subRows).toContainEqual(newChildNode); + }); + + it('deletes a top-level node and its children', () => { + const tree = new TagTree(rawData); + tree.addNode(newChildNode, 'ab'); + tree.removeNode('ab'); + expect(tree.getTagAsDeepCopy('ab')).toBeNull(); + expect(tree.getTagAsDeepCopy('newChild')).toBeNull(); + }); + + it('deletes a child node', () => { + const tree = new TagTree(rawData); + tree.addNode(newChildNode, 'ab'); + tree.removeNode('newChild', 'ab'); + const parentNode = tree.getTagAsDeepCopy('ab'); + expect(parentNode?.subRows).not.toContainEqual(newChildNode); + }); + + it('returns null and leaves tree unchanged when removing a non-existent node', () => { + const tree = new TagTree(rawData); + const before = tree.getTagAsDeepCopy('ab'); + + const removed = tree.removeNode('does-not-exist'); + + expect(removed).toBeNull(); + expect(tree.getTagAsDeepCopy('ab')).toEqual(before); + }); + + it('returns null and leaves tree unchanged when editing a non-existent node', () => { + const tree = new TagTree(rawData); + const before = tree.getTagAsDeepCopy('ab'); + + const edited = tree.editTagValue('does-not-exist', 'new-value'); + + expect(edited).toBeNull(); + expect(tree.getTagAsDeepCopy('ab')).toEqual(before); + }); + + it('does not add a node when parentValue is provided but parent does not exist', () => { + const tree = new TagTree(rawData); + const rowCountBefore = tree.getAllAsDeepCopy().length; + + tree.addNode(newChildNode, 'missing-parent'); + + expect(tree.getAllAsDeepCopy()).toHaveLength(rowCountBefore); + expect(tree.getTagAsDeepCopy('newChild')).toBeNull(); + }); + + it('treats orphaned nodes as roots during tree construction', () => { + const orphanData = [ + { + value: 'orphan', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 900, + parentValue: 'missing-parent', + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 1, + }, + ]; + + const tree = new TagTree(orphanData); + + expect(tree.getAllAsDeepCopy()).toHaveLength(1); + expect(tree.getAllAsDeepCopy()[0].value).toBe('orphan'); + }); + + it('rejects duplicate tag values during tree construction', () => { + const duplicateValueData = [ + { + value: 'dup', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 1001, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }, + { + value: 'dup', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 1002, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }, + ]; + + expect(() => new TagTree(duplicateValueData)).toThrow(TagTreeError); + }); + + it('rejects cycles in parent/child relationships during tree construction', () => { + const cyclicData = [ + { + value: 'a', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 1101, + parentValue: 'b', + subTagsUrl: null, + childCount: 1, + descendantCount: 1, + depth: 0, + }, + { + value: 'b', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 1102, + parentValue: 'a', + subTagsUrl: null, + childCount: 1, + descendantCount: 1, + depth: 1, + }, + ]; + + expect(() => new TagTree(cyclicData)).toThrow(TagTreeError); + }); + + it('throws TagTreeError when editing a tag value to one that already exists', () => { + const tree = new TagTree(rawData); + + expect(() => tree.editTagValue('ab', 'Brass2')).toThrow(TagTreeError); + }); + + it('throws TagTreeError when adding a node with a value that already exists', () => { + const tree = new TagTree(rawData); + const newNode = { + value: 'ab', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 999, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }; + + expect(() => tree.addNode(newNode)).toThrow(TagTreeError); + }); + + it('adds new top-level rows to the beginning of the tree', () => { + const tree = new TagTree(rawData); + const newNode = { + value: 'new row', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 1000, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }; + + tree.addNode(newNode, null); // Add as the first child of the root + + expect(tree.getAllAsDeepCopy()[0]).toEqual(newNode); + const nextNewNode = { + value: 'another new row', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 1001, + parentValue: null, + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 0, + }; + tree.addNode(nextNewNode, null); // Add another top-level node + expect(tree.getAllAsDeepCopy()[0]).toEqual(nextNewNode); + expect(tree.getAllAsDeepCopy()[1]).toEqual(newNode); + }); + + it('adds new child rows to the beginning of the parent node children', () => { + const tree = new TagTree(rawData); + const newChild = { + value: 'new child', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 1002, + parentValue: 'ab', + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 1, + }; + + tree.addNode(newChild, 'ab'); // Add as the first child of 'ab' + + let parentNode = tree.getTagAsDeepCopy('ab'); + expect(parentNode?.subRows?.[0]).toEqual(newChild); + + const nextNewChild = { + value: 'another new child', + externalId: 'some-external-id', + canChangeTag: true, + canDeleteTag: true, + id: 1003, + parentValue: 'ab', + subTagsUrl: null, + childCount: 0, + descendantCount: 0, + depth: 1, + }; + tree.addNode(nextNewChild, 'ab'); // Add another child to 'ab' + parentNode = tree.getTagAsDeepCopy('ab'); + expect(parentNode?.subRows?.[0]).toEqual(nextNewChild); + expect(parentNode?.subRows?.[1]).toEqual(newChild); + }); + + it('returns a flattened list of all nodes including subRows', () => { + const tree = new TagTree(rawData); + const flattened = tree.getAllFlattenedAsCopy(); + const expectedValues = rawData.map(item => item.value); + expect(flattened.map(node => node.value)).toEqual(expectedValues); + }); +}); diff --git a/src/taxonomy/tag-list/tagTree.ts b/src/taxonomy/tag-list/tagTree.ts new file mode 100644 index 0000000000..881e3dc623 --- /dev/null +++ b/src/taxonomy/tag-list/tagTree.ts @@ -0,0 +1,238 @@ +import { TagTreeError } from './errors'; +import type { TagData } from '../data/types'; + +export interface TagTreeNode extends TagData { + subRows?: TagTreeNode[]; +} + +/** + * TagTree + * A robust utility class for managing a tree of table rows based on a flat list of TagData. + * + * The tree is designed to be used as row data for tanstack/react-table. + * The focus is on reliability, and it has not been performance-optimized yet. + */ +export class TagTree { + private data: TagData[]; + + private rows: TagTreeNode[]; + + constructor(data: TagData[]) { + this.data = data; + this.rows = []; + this.buildTree(); + } + + /** Returns a flattened copy of all nodes in the tree. + * For example, this array is not nested even though it contains a parent and a child tag: + * [ + * { + * value: 'parent tag name', + * externalId: null, + * childCount: 2, + * descendantCount: 4, + * depth: 0, + * parentValue: null, + * id: 1, + * subTagsUrl: 'http://example.com', + * canChangeTag: true, + * canDeleteTag: true, + * }, + * { + * value: 'child tag name', + * externalId: null, + * childCount: 0, + * descendantCount: 0, + * depth: 1, + * parentValue: 'parent tag name', + * id: 2, + * subTagsUrl: 'http://example.com', + * canChangeTag: true, + * canDeleteTag: true, + * }, + * // ... more tags + * ] + */ + getAllFlattenedAsCopy(): TagData[] { + const flatten = (nodes: TagTreeNode[], accumulator: TagData[] = []): TagData[] => { + for (const node of nodes) { + const { subRows, ...tagData } = node; + accumulator.push({ ...tagData }); // Create a shallow copy of the tag data without subRows + if (node.subRows) { + flatten(node.subRows, accumulator); + } + } + return accumulator; + }; + return flatten(this.rows); + } + + getAllAsDeepCopy(): TagTreeNode[] { + return JSON.parse(JSON.stringify(this.rows)); + } + + /** For extra robustness, we verify that there are no duplicate values + * in the data. (The backend also guarantees this.) + */ + private validateNoDuplicateValues(items: TagData[]) { + const seenValues = new Set(); + for (const item of items) { + const lowerCaseValue = item.value.toLowerCase(); + if (seenValues.has(lowerCaseValue)) { + throw new TagTreeError(`Duplicate tag value found: ${lowerCaseValue}`); + } + seenValues.add(lowerCaseValue); + } + } + + /** For extra robustness, we verify that there are no cycles in the data. (The backend also guarantees this.) */ + private validateNoCycles(items: TagData[]) { + const parentByValue: { [key: string]: string | null } = {}; + for (const item of items) { + parentByValue[item.value.toLowerCase()] = item.parentValue ? item.parentValue.toLowerCase() : null; + } + + const visitStatus: { [key: string]: number } = {}; + + const detectCycle = (value: string): boolean => { + const status = visitStatus[value] || 0; + if (status === 1) { + return true; + } + if (status === 2) { + return false; + } + + visitStatus[value] = 1; + const parentValue = parentByValue[value]; + if (parentValue !== null && Object.prototype.hasOwnProperty.call(parentByValue, parentValue)) { + if (detectCycle(parentValue)) { + return true; + } + } + visitStatus[value] = 2; + return false; + }; + + for (const item of items) { + if (detectCycle(item.value.toLowerCase())) { + throw new TagTreeError('Cycle detected in tag hierarchy.'); + } + } + } + + buildTree() { + if (!this.data) { + this.rows = []; + return; + } + + this.validateNoDuplicateValues(this.data); + this.validateNoCycles(this.data); + + const treeChildren: TagTreeNode[] = []; + const lookup: { [key: string]: TagTreeNode } = {}; + + // Step 1: Create a lookup map of all items using 'value' as the key. + // We use the spread operator (...) to create a shallow copy so we + // don't mutate the original data array. + for (const item of this.data) { + lookup[item.value] = { ...item }; + } + + // Step 2: Iterate through the data again to link children to their parents. + for (const item of this.data) { + // Get the reference to the newly copied object in our lookup map + const currentNode = lookup[item.value]; + const parentValue = currentNode?.parentValue; + + if (parentValue !== null && lookup[parentValue]) { + // If the node has a parent, initialize the subRows array (if needed) and push it + const parentNode = lookup[parentValue]; + if (!parentNode.subRows) { + parentNode.subRows = []; + } + parentNode.subRows.push(currentNode); + } else { + // If there is no parentValue (or it equals null), it is a root node + treeChildren.push(currentNode); + } + } + + this.rows = treeChildren; + } + + private findNodeByValueRecursive(nodes: TagTreeNode[], value: string): TagTreeNode | null { + for (const node of nodes) { + if (node.value === value) { + return node; + } + if (node.subRows) { + const found = this.findNodeByValueRecursive(node.subRows, value); + if (found) { + return found; + } + } + } + return null; + } + + private getNode(value: string): TagTreeNode | null { + return this.findNodeByValueRecursive(this.rows, value); + } + + // We don't want to expose editing the tree nodes directly, so that tree integrity is maintained. + getTagAsDeepCopy(value: string): TagTreeNode | null { + const node = this.getNode(value); + if (node) { + return JSON.parse(JSON.stringify(node)); + } + return null; + } + + // For now, only editing a tag's "value" property is supported. + editTagValue(oldValue: string, newValue: string) { + const node = this.getNode(oldValue); + if (node) { + if (oldValue !== newValue && this.getNode(newValue)) { + throw new TagTreeError(`Cannot change tag value to existing value: ${newValue}`); + } + node.value = newValue; + } + return node; + } + + addNode(newNode: TagTreeNode, parentValue: string | null = null) { + if (this.getNode(newNode.value)) { + throw new TagTreeError(`Cannot add duplicate tag value: ${newNode.value}`); + } + + if (parentValue) { + const parentNode = this.getNode(parentValue); + if (parentNode) { + if (!parentNode.subRows) { + parentNode.subRows = []; + } + parentNode.subRows.unshift(newNode); + } + } else { + this.rows.unshift(newNode); + } + } + + removeNode(value: string, parentValue: string | null = null): TagTreeNode | null { + const node = this.getNode(value); + if (!node) { + return null; + } + if (parentValue) { + const parentNode = this.getNode(parentValue); + if (parentNode && parentNode.subRows) { + parentNode.subRows = parentNode.subRows.filter(subNode => subNode.value !== value); + } + } else { + this.rows = this.rows.filter(rootNode => rootNode.value !== value); + } + return node; + } +} diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx index bb9fd89c46..bcf14020f4 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx @@ -19,6 +19,7 @@ import { TaxonomyMenu } from '../taxonomy-menu'; import TaxonomyDetailSideCard from './TaxonomyDetailSideCard'; import { useTaxonomyDetails } from '../data/apiHooks'; import SystemDefinedBadge from '../system-defined-badge'; +import { TAXONOMY_MAX_DEPTH } from './constants'; const TaxonomyDetailPage = () => { const intl = useIntl(); @@ -88,7 +89,7 @@ const TaxonomyDetailPage = () => { xl={[{ span: 9 }, { span: 3 }]} > - + diff --git a/src/taxonomy/taxonomy-detail/constants.ts b/src/taxonomy/taxonomy-detail/constants.ts new file mode 100644 index 0000000000..442573c7c3 --- /dev/null +++ b/src/taxonomy/taxonomy-detail/constants.ts @@ -0,0 +1,11 @@ +/** + * The maximum allowable depth for any tag in the taxonomy (0-indexed). + * * **Constraint**: A value of 3 allows levels 0, 1, 2, and 3. Creation of new subtags + * is disabled for any tag already at this depth to prevent exceeding the limit. + * * **Data Handling**: This is a UI safety gate, not a filter. If the backend returns + * tags exceeding this depth, they will still be rendered, but further nesting will be blocked. + * * **Sync Required**: This must match `TAXONOMY_MAX_DEPTH` in the openedx-core backend. + */ +const TAXONOMY_MAX_DEPTH = 3; + +export { TAXONOMY_MAX_DEPTH }; diff --git a/src/taxonomy/tree-table/CreateRow.test.tsx b/src/taxonomy/tree-table/CreateRow.test.tsx new file mode 100644 index 0000000000..dedfb12490 --- /dev/null +++ b/src/taxonomy/tree-table/CreateRow.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { CreateRow } from './CreateRow'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const baseProps = () => ({ + draftError: '', + setDraftError: jest.fn(), + handleCreateRow: jest.fn(), + setIsCreatingTopRow: jest.fn(), + exitDraftWithoutSave: jest.fn(), + createRowMutation: { isPending: false }, + columns: [{ id: 'value' }], + validate: jest.fn((value: string) => value.trim().length > 0), +}); + +describe('CreateRow', () => { + it('saves on Enter when value is valid', () => { + const props = baseProps(); + render( +
    + + + +
    , + { wrapper }, + ); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: ' new tag ' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(props.handleCreateRow).toHaveBeenCalledWith('new tag'); + }); + + it('does not save on Enter when mutation is pending', () => { + const props = baseProps(); + props.createRowMutation = { isPending: true }; + + render( + + + + +
    , + { wrapper }, + ); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'pending tag' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(props.handleCreateRow).not.toHaveBeenCalled(); + }); + + it('cancels on Escape and resets draft state', () => { + const props = baseProps(); + + render( + + + + +
    , + { wrapper }, + ); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'will cancel' } }); + fireEvent.keyDown(input, { key: 'Escape' }); + + expect(props.setDraftError).toHaveBeenCalledWith(''); + expect(props.setIsCreatingTopRow).toHaveBeenCalledWith(false); + expect(props.exitDraftWithoutSave).toHaveBeenCalled(); + }); +}); diff --git a/src/taxonomy/tree-table/CreateRow.tsx b/src/taxonomy/tree-table/CreateRow.tsx new file mode 100644 index 0000000000..57ea7dcfd2 --- /dev/null +++ b/src/taxonomy/tree-table/CreateRow.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { Button, Spinner } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { EditableCell } from './EditableCell'; +import type { CreateRowMutationState, TreeColumnDef } from './types'; +import messages from './messages'; + +interface CreateRowProps { + draftError: string; + setDraftError: (error: string) => void; + handleCreateRow: (value: string) => void; + setIsCreatingTopRow: (isCreating: boolean) => void; + exitDraftWithoutSave: () => void; + createRowMutation: CreateRowMutationState; + columns: TreeColumnDef[]; + indent?: number; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; +} + +const CreateRow: React.FC = ({ + draftError, + setDraftError, + handleCreateRow, + setIsCreatingTopRow, + exitDraftWithoutSave, + createRowMutation, + columns, + indent = 0, + validate, +}) => { + const [newRowValue, setNewRowValue] = useState(''); + const intl = useIntl(); + const [saveDisabled, setSaveDisabled] = useState(true); + + const handleValueChange = (e: React.ChangeEvent) => { + const { value } = e.target; + setNewRowValue(value); + const isValid = validate(value, 'soft'); + setSaveDisabled(!isValid || createRowMutation.isPending || false); + }; + + const handleCancel = () => { + setDraftError(''); + setNewRowValue(''); + setIsCreatingTopRow(false); + exitDraftWithoutSave(); + }; + + const handleSave = () => { + handleCreateRow(newRowValue.trim()); + }; + + const handleValueCellKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && newRowValue.trim() && !createRowMutation.isPending && !draftError) { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancel(); + } + }; + + return ( + + + +
    + +
    + + + + + + + + + + {createRowMutation.isPending && ( + + )} + + + + ); +}; + +export { CreateRow }; diff --git a/src/taxonomy/tree-table/EditableCell.test.tsx b/src/taxonomy/tree-table/EditableCell.test.tsx new file mode 100644 index 0000000000..d09fa09e05 --- /dev/null +++ b/src/taxonomy/tree-table/EditableCell.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { + createEvent, + fireEvent, + render, + screen, +} from '@testing-library/react'; + +import { EditableCell } from './EditableCell'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('EditableCell', () => { + it('renders inline validation message when provided by validator', () => { + render( + 'Invalid character in tag name'} + />, + { wrapper }, + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Invalid character in tag name'); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-describedby'); + }); + + it('prioritizes explicit errorMessage over validator message', () => { + render( + 'Inline message'} + />, + { wrapper }, + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Server error'); + }); + + it('propagates onChange updates from input', () => { + const onChange = jest.fn(); + render(, { wrapper }); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'next' } }); + expect(onChange).toHaveBeenCalled(); + expect(screen.getByRole('textbox')).toHaveValue('next'); + }); + + it('prevents input clicks from bubbling to parent rows', () => { + render(, { wrapper }); + const input = screen.getByRole('textbox'); + const clickEvent = createEvent.click(input); + const stopPropagationSpy = jest.spyOn(clickEvent, 'stopPropagation'); + + fireEvent(input, clickEvent); + expect(stopPropagationSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/taxonomy/tree-table/EditableCell.tsx b/src/taxonomy/tree-table/EditableCell.tsx new file mode 100644 index 0000000000..09a660c159 --- /dev/null +++ b/src/taxonomy/tree-table/EditableCell.tsx @@ -0,0 +1,96 @@ +import React, { + useState, + useEffect, + useId, + useRef, +} from 'react'; + +import { Form } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import OptionalExpandLink from '../tag-list/OptionalExpandLink'; + +/** + * Props for the EditableCell component. + */ +interface EditableCellProps { + /** The initial value to display in the cell */ + initialValue?: string; + /** Callback function triggered on keyboard events */ + onKeyDown?: (event: React.KeyboardEvent) => void; + /** Callback function triggered when the input value changes */ + onChange?: (event: React.ChangeEvent) => void; + /** Error message to display if validation fails */ + errorMessage?: string; + /** Indicates whether the cell value is currently being saved to the server */ + isSaving?: boolean; + /** If true, the input field will automatically receive focus when the cell enters edit mode */ + autoFocus?: boolean; + /** Function that returns a validation message to be displayed based on the current input value. */ + getInlineValidationMessage?: (value: string) => string; +} + +const EditableCell = ({ + initialValue = '', + onKeyDown, + onChange = () => {}, + errorMessage = '', + isSaving = false, + getInlineValidationMessage = () => '', + autoFocus = false, +}: EditableCellProps) => { + const [value, setValue] = useState(initialValue); + const [validationMessage, setValidationMessage] = useState(''); + const inputId = useId(); + const inputRef = useRef(null); + const intl = useIntl(); + + useEffect(() => { + if (autoFocus && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [inputRef.current]); // autoFocus explicitly not a dependency, to avoid unexpected focus change. + + useEffect(() => { + setValue(initialValue); + setValidationMessage(getInlineValidationMessage(initialValue)); + }, []); // initialValue explicitly not a dependency, to avoid overwriting user input. + + const effectiveErrorMessage = errorMessage || validationMessage; + const errorMessageId = `${inputId}-error`; + + return ( + + + + + { + setValue(e.target.value); + setValidationMessage(getInlineValidationMessage(e.target.value)); + onChange(e); + }} + size="sm" + onKeyDown={onKeyDown} + onClick={(e) => e.stopPropagation()} + floatingLabel={intl.formatMessage(messages.editTagInputLabel)} + disabled={isSaving} + autoComplete="off" + isInvalid={!!effectiveErrorMessage} + aria-describedby={effectiveErrorMessage ? errorMessageId : undefined} + /> + {effectiveErrorMessage && ( + + )} + + + + ); +}; + +export { EditableCell }; diff --git a/src/taxonomy/tree-table/NestedRows.test.tsx b/src/taxonomy/tree-table/NestedRows.test.tsx new file mode 100644 index 0000000000..1eda32fe41 --- /dev/null +++ b/src/taxonomy/tree-table/NestedRows.test.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import NestedRows from './NestedRows'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const makeCell = (id: string, content: string) => ({ + id, + column: { columnDef: { cell: () => content } }, + getContext: () => ({}), +}); + +const makeRow = ({ + id, + value, + expanded = true, + subRows = [], +}: { + id: number; + value: string; + expanded?: boolean; + subRows?: any[]; +}) => ({ + id: String(id), + original: { id, value }, + subRows, + getIsExpanded: () => expanded, + getVisibleCells: () => [makeCell(`${id}-cell`, value)], +}); + +describe('NestedRows', () => { + it('renders nothing when parent row is collapsed', () => { + const parent = makeRow({ id: 1, value: 'parent', expanded: false }); + const { container } = render( + + + true} + /> + +
    , + { wrapper }, + ); + + expect(container.querySelector('tr')).toBeNull(); + }); + + it('resets creating parent and runs cancel callback for nested create row', () => { + const nestedChild = makeRow({ id: 2, value: 'child', expanded: true }); + const parent = makeRow({ + id: 1, + value: 'parent', + expanded: true, + subRows: [nestedChild], + }); + const setCreatingParentId = jest.fn(); + const onCancelCreation = jest.fn(); + + render( + + + true} + /> + +
    , + { wrapper }, + ); + + fireEvent.click(screen.getByText('Cancel')); + + expect(setCreatingParentId).toHaveBeenCalledWith(null); + expect(onCancelCreation).toHaveBeenCalled(); + }); +}); diff --git a/src/taxonomy/tree-table/NestedRows.tsx b/src/taxonomy/tree-table/NestedRows.tsx new file mode 100644 index 0000000000..8facb825b7 --- /dev/null +++ b/src/taxonomy/tree-table/NestedRows.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { flexRender } from '@tanstack/react-table'; + +import type { + RowId, + TreeRow, + CreateRowMutationState, +} from './types'; +import { CreateRow } from './CreateRow'; + +interface NestedRowsProps { + /** The parent row object from TanStack React Table */ + parentRow: TreeRow; + /** The value identifier of the parent row */ + parentRowValue: string; + /** Whether a new child row is currently being created for this parent */ + isCreating?: boolean; + /** Callback when a new child row is saved (receives value and parentRowValue) */ + onSaveNewChildRow?: (value: string, parentRowValue: string) => void; + /** Callback when child row creation is cancelled */ + onCancelCreation?: () => void; + /** Array of child row objects to render */ + childRowsData?: TreeRow[]; + /** Current nesting depth level (used for indentation calculation) */ + depth?: number; + /** Error message to display in draft creation form */ + draftError?: string; + /** Setter function for draft error state */ + setDraftError?: (error: string) => void; + /** ID of the row currently in creation mode */ + creatingParentId?: RowId | null; + /** Setter function for which row is in creation mode */ + setCreatingParentId?: (value: RowId | null) => void; + /** Callback to set whether top-level row creation is active */ + setIsCreatingTopRow: (isCreating: boolean) => void; + /** State object for the row creation mutation (isPending, isError, error) */ + createRowMutation: CreateRowMutationState; + /** Validation function for new row values (receives value and optional 'soft' or 'hard' mode; + * in 'hard' mode an exception is thrown on validation failure) */ + validate: (value: string, mode?: 'soft' | 'hard') => boolean; +} + +/** + * NestedRows + * + * Recursively renders nested child rows within a tree table structure. This component handles: + * - Display of child rows when a parent row is expanded + * - Indentation based on nesting depth + * - Creation of new child rows with validation + * - Management of draft state during row creation + * - Recursive rendering of grandchild rows and deeper levels + * + * The component uses the TanStack React Table library to render table cells and manages + * the creation flow by displaying a CreateRow form when a parent is in creation mode. + */ +const NestedRows = ({ + parentRow, + parentRowValue, + isCreating = false, + onSaveNewChildRow = () => {}, + onCancelCreation = () => {}, + childRowsData = [], + depth = 1, + draftError = '', + setDraftError = () => {}, + creatingParentId = null, + setCreatingParentId = () => {}, + setIsCreatingTopRow, + createRowMutation, + validate, +}: NestedRowsProps) => { + if (!parentRow.getIsExpanded()) { + return null; + } + const indent = Math.max(depth, 1); + + return ( + <> + {isCreating && ( + onSaveNewChildRow(value, parentRowValue)} + setIsCreatingTopRow={setIsCreatingTopRow} + exitDraftWithoutSave={onCancelCreation} + createRowMutation={createRowMutation} + columns={[]} + indent={indent} + validate={validate} + /> + )} + {childRowsData?.map(row => { + const rowData = row.original || row; + return ( + + + {row.getVisibleCells() + .map((cell, index) => { + const content = flexRender(cell.column.columnDef.cell, cell.getContext()); + const isFirstColumn = index === 0; + + return ( + + {isFirstColumn ? ( +
    {content}
    + ) : ( + content + )} + + ); + })} + + { + setCreatingParentId(null); + onCancelCreation(); + } + } + creatingParentId={creatingParentId} + setCreatingParentId={setCreatingParentId} + depth={depth + 1} + draftError={draftError} + setDraftError={setDraftError} + setIsCreatingTopRow={setIsCreatingTopRow} + createRowMutation={createRowMutation} + validate={validate} + /> +
    + ); + })} + + ); +}; + +export default NestedRows; diff --git a/src/taxonomy/tree-table/TableBody.tsx b/src/taxonomy/tree-table/TableBody.tsx new file mode 100644 index 0000000000..77a9b7af21 --- /dev/null +++ b/src/taxonomy/tree-table/TableBody.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { flexRender } from '@tanstack/react-table'; + +import { LoadingSpinner } from '@src/generic/Loading'; +import NestedRows from './NestedRows'; + +import messages from './messages'; + +import type { + CreateRowMutationState, + RowId, + TreeColumnDef, + TreeTable, +} from './types'; +import { CreateRow } from './CreateRow'; + +interface TableBodyProps { + columns: TreeColumnDef[]; + isCreatingTopRow: boolean; + draftError: string; + setIsCreatingTopRow: (isCreating: boolean) => void; + exitDraftWithoutSave: () => void; + handleCreateRow: (value: string, parentRowValue?: string) => void; + creatingParentId: RowId | null; + setCreatingParentId: (id: RowId | null) => void; + setDraftError: (error: string) => void; + createRowMutation: CreateRowMutationState; + table: TreeTable; + isLoading: boolean; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; +} + +const TableBody = ({ + columns, + isCreatingTopRow, + draftError, + handleCreateRow, + setIsCreatingTopRow, + exitDraftWithoutSave, + creatingParentId, + setCreatingParentId, + setDraftError, + createRowMutation, + table, + isLoading, + validate, +}: TableBodyProps) => { + const intl = useIntl(); + + if (isLoading) { + return ( + + + + + + + + ); + } + + return ( + + {table.getRowModel().rows.length === 0 && ( + + + {intl.formatMessage(messages.noResultsFoundMessage)} + + + )} + + {isCreatingTopRow && ( + + )} + + {table.getRowModel().rows.filter(row => row.depth === 0).map(row => ( + + + {row.getVisibleCells() + .map((cell, index) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + { + setDraftError(''); + setCreatingParentId(null); + exitDraftWithoutSave(); + }} + creatingParentId={creatingParentId} + setCreatingParentId={setCreatingParentId} + depth={1} + draftError={draftError} + createRowMutation={createRowMutation} + setDraftError={setDraftError} + setIsCreatingTopRow={setIsCreatingTopRow} + validate={validate} + /> + + ))} + + ); +}; + +export default TableBody; diff --git a/src/taxonomy/tree-table/TableView.scss b/src/taxonomy/tree-table/TableView.scss new file mode 100644 index 0000000000..19647fadf2 --- /dev/null +++ b/src/taxonomy/tree-table/TableView.scss @@ -0,0 +1,21 @@ +.tree-table-layout-fixed { + table-layout: fixed; +} + +.tree-table-create-row-actions-cell { + overflow-wrap: anywhere; +} + +.tree-table-overflow-anywhere { + overflow-wrap: anywhere; +} + +.tree-table-indent { + padding-inline-start: var(--pgn-spacing-spacer-base); +} + +@for $depth from 2 through 10 { + .tree-table-indent-#{$depth} { + padding-inline-start: calc(var(--pgn-spacing-spacer-base) * #{$depth}); + } +} diff --git a/src/taxonomy/tree-table/TableView.test.tsx b/src/taxonomy/tree-table/TableView.test.tsx new file mode 100644 index 0000000000..47a4720b7f --- /dev/null +++ b/src/taxonomy/tree-table/TableView.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { TableView } from './TableView'; + +jest.mock('./TableBody', () => { + const MockTableBody = () => ( + + + mock body + + + ); + return MockTableBody; +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const baseProps = () => ({ + treeData: [{ id: 1, value: 'root' }], + columns: [{ accessorKey: 'value', header: 'Tag name', cell: (info: any) => info.getValue() }], + pageCount: 3, + pagination: { pageIndex: 0, pageSize: 10 }, + handlePaginationChange: jest.fn(), + isLoading: false, + isCreatingTopRow: false, + draftError: '', + createRowMutation: { isPending: false, isError: false }, + toast: { show: false, message: '', variant: 'success' }, + setToast: jest.fn(), + setIsCreatingTopRow: jest.fn(), + exitDraftWithoutSave: jest.fn(), + handleCreateRow: jest.fn(), + creatingParentId: null, + setCreatingParentId: jest.fn(), + setDraftError: jest.fn(), + validate: jest.fn(() => true), +}); + +describe('TableView', () => { + it('shows and dismisses save error banner', () => { + const props = baseProps(); + props.createRowMutation = { isPending: false, isError: true }; + + render(, { wrapper }); + + expect(screen.getByText('Error saving changes')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); + expect(screen.queryByText('Error saving changes')).not.toBeInTheDocument(); + }); + + it('keeps pagination hidden by default even when multiple pages are reported', () => { + const props = baseProps(); + render(, { wrapper }); + + expect(screen.queryByRole('navigation', { name: /table pagination/i })).not.toBeInTheDocument(); + }); + + it('renders pagination and updates page selection when explicitly enabled', () => { + const props = baseProps(); + render(, { wrapper }); + + expect(screen.getByText('Page 1 of 3')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /^page 2$/i })); + expect(props.handlePaginationChange).toHaveBeenCalled(); + }); + + it('hides pagination when there is only one page', () => { + const props = baseProps(); + props.pageCount = 1; + render(, { wrapper }); + + expect(screen.queryByRole('navigation', { name: /table pagination/i })).not.toBeInTheDocument(); + }); + + it('closes toast by setting show to false', () => { + const props = baseProps(); + props.toast = { show: true, message: 'created', variant: 'success' }; + + render(, { wrapper }); + + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(props.setToast).toHaveBeenCalled(); + const updater = props.setToast.mock.calls[0][0]; + expect(updater({ show: true, message: 'created', variant: 'success' })).toEqual({ + show: false, + message: 'created', + variant: 'success', + }); + }); +}); diff --git a/src/taxonomy/tree-table/TableView.tsx b/src/taxonomy/tree-table/TableView.tsx new file mode 100644 index 0000000000..19f347b06d --- /dev/null +++ b/src/taxonomy/tree-table/TableView.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { + Button, + Toast, + Card, + ActionRow, + Pagination, + Alert, + Icon, +} from '@openedx/paragon'; + +import { + useReactTable, + getCoreRowModel, + getExpandedRowModel, + flexRender, + type OnChangeFn, + type PaginationState, +} from '@tanstack/react-table'; + +import { ArrowDropUpDown, Info } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import TableBody from './TableBody'; +import './TableView.scss'; +import type { + CreateRowMutationState, + RowId, + ToastState, + TreeColumnDef, + TreeRowData, +} from './types'; +import messages from './messages'; + +interface TableViewProps { + treeData: TreeRowData[]; + columns: TreeColumnDef[]; + pageCount: number; + enablePagination?: boolean; + pagination: PaginationState; + handlePaginationChange: OnChangeFn; + isLoading: boolean; + isCreatingTopRow: boolean; + draftError: string; + createRowMutation: CreateRowMutationState; + toast: ToastState; + setToast: React.Dispatch>; + setIsCreatingTopRow: (isCreating: boolean) => void; + exitDraftWithoutSave: () => void; + handleCreateRow: (value: string, parentRowValue?: string) => void; + creatingParentId: RowId | null; + setCreatingParentId: (id: RowId | null) => void; + setDraftError: (error: string) => void; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; +} + +const TableView = ({ + treeData, + columns, + pageCount, + enablePagination = false, + pagination, + handlePaginationChange, + isLoading, + isCreatingTopRow, + draftError, + createRowMutation, + handleCreateRow, + toast, + setToast, + setIsCreatingTopRow, + exitDraftWithoutSave, + creatingParentId, + setCreatingParentId, + setDraftError, + validate, +}: TableViewProps) => { + const intl = useIntl(); + + const table = useReactTable({ + data: treeData, + columns, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + manualPagination: true, + pageCount: pageCount ?? -1, + state: { + pagination, + }, + onPaginationChange: handlePaginationChange, + getSubRows: (row) => row?.subRows || undefined, + }); + + const currentPageIndex = table.getState().pagination.pageIndex + 1; + + const { isError } = createRowMutation; + const [showError, setShowError] = React.useState(true); + + return ( + <> + {isError && showError && ( + setShowError(false)}> + + {intl.formatMessage(messages.errorSavingTitle)} + + {intl.formatMessage(messages.errorSavingMessage, { errorMessage: draftError || intl.formatMessage(messages.errorSavingMessage, { errorMessage: '' }) })} + + )} + + +
    + {/* TODO: Implement search functionality */} + + + +
    + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map((header, index) => ( + + ))} + + ))} + + +
    + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
    +
    + + {enablePagination && pageCount > 1 && ( +
    + + {intl.formatMessage(messages.tablePaginationPageStatus, { + currentPage: currentPageIndex, + pageCount, + })} + + { + table.setPageIndex(page - 1); + }} + /> +
    + )} + { + setToast((prevToast) => ({ ...prevToast, show: false })); + }} + delay={15000} + > + {toast.message} + +
    + + ); +}; + +export { TableView }; diff --git a/src/taxonomy/tree-table/index.ts b/src/taxonomy/tree-table/index.ts new file mode 100644 index 0000000000..33e31066d9 --- /dev/null +++ b/src/taxonomy/tree-table/index.ts @@ -0,0 +1,2 @@ +export { TableView } from './TableView'; +export { EditableCell } from './EditableCell'; diff --git a/src/taxonomy/tree-table/messages.ts b/src/taxonomy/tree-table/messages.ts new file mode 100644 index 0000000000..e3bea741e1 --- /dev/null +++ b/src/taxonomy/tree-table/messages.ts @@ -0,0 +1,54 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + errorSavingTitle: { + id: 'course-authoring.tree-table.error-saving.title', + defaultMessage: 'Error saving changes', + }, + errorSavingMessage: { + id: 'course-authoring.tree-table.error-saving.message', + defaultMessage: '{errorMessage}. Please try again.', + }, + expandAll: { + id: 'course-authoring.tree-table.expand-all', + defaultMessage: 'Expand All', + }, + collapseAll: { + id: 'course-authoring.tree-table.collapse-all', + defaultMessage: 'Collapse All', + }, + noResultsFoundMessage: { + id: 'course-authoring.tree-table.no-results-found.message', + defaultMessage: 'No results found', + }, + searchPlaceholder: { + id: 'course-authoring.tree-table.search.placeholder', + defaultMessage: 'Search...', + }, + editTagInputLabel: { + id: 'course-authoring.tree-table.edit-tag-input.label', + defaultMessage: 'Type tag name', + }, + cancelButtonLabel: { + id: 'course-authoring.tree-table.cancel.button-label', + defaultMessage: 'Cancel', + }, + saveButtonLabel: { + id: 'course-authoring.tree-table.save.button-label', + defaultMessage: 'Save', + }, + savingSpinnerScreenReaderText: { + id: 'course-authoring.tree-table.saving-spinner.screen-reader-text', + defaultMessage: 'Saving...', + }, + tablePaginationLabel: { + id: 'course-authoring.tree-table.pagination.label', + defaultMessage: 'table pagination', + }, + tablePaginationPageStatus: { + id: 'course-authoring.tree-table.pagination.page-status', + defaultMessage: 'Page {currentPage} of {pageCount}', + }, +}); + +export default messages; diff --git a/src/taxonomy/tree-table/types.ts b/src/taxonomy/tree-table/types.ts new file mode 100644 index 0000000000..8dabb408e0 --- /dev/null +++ b/src/taxonomy/tree-table/types.ts @@ -0,0 +1,31 @@ +import type { + ColumnDef, + Row, + Table, +} from '@tanstack/react-table'; + +export type RowId = string | number; + +export interface TreeRowData { + id: RowId; + value: string; + subRows?: TreeRowData[]; + depth?: number; + [key: string]: unknown; +} + +export type TreeRow = Row; +export type TreeTable = Table; +export type TreeColumnDef = ColumnDef; + +export interface CreateRowMutationState { + isPending?: boolean; + isError?: boolean; + error?: unknown; +} + +export interface ToastState { + show: boolean; + message: string; + variant: string; +} From 7e0c15e346dfa6cff47ef3a6d2ebd391539a40e8 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 27 Feb 2026 11:33:58 -0500 Subject: [PATCH 20/62] test: fix tests that are implemented --- src/taxonomy/tag-list/TagListTable.jsx | 21 ++++++++------- src/taxonomy/tag-list/TagListTable.test.jsx | 30 +++++++++++++++++++-- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx index 61fcb3334f..b221b4ec4b 100644 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -367,24 +367,27 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { const handleCreateTopTag = async (value, setToast) => { console.log('Creating top-level tag with value:', value); - if (value.trim()) { - await createTagMutation.mutateAsync({ value }); - setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: value }), variant: 'success' }); + const trimmed = value.trim(); + if (trimmed) { + await createTagMutation.mutateAsync({ value: trimmed }); + setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), variant: 'success' }); } setIsCreatingTopTag(false); }; const handleCreateSubTag = async (value, parentTagValue) => { - if (value.trim()) { - await createTagMutation.mutateAsync({ value, parentTagValue }); - setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: value }), variant: 'success' }); + const trimmed = value.trim(); + if (trimmed) { + await createTagMutation.mutateAsync({ value: trimmed, parentTagValue }); + setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), variant: 'success' }); } setCreatingParentId(null); }; const handleUpdateTag = async (id, value, originalValue) => { - if (value.trim() && value !== originalValue) { - console.log('Update backend here', id, value); + const trimmed = value.trim(); + if (trimmed && trimmed !== originalValue) { + console.log('Update backend here', id, trimmed); } setEditingRowId(null); }; @@ -455,7 +458,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { )} {isCreatingTopTag && ( - + handleCreateTopTag(value, setToast)} diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index a7616a2ab7..131c8b7b00 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -262,6 +262,15 @@ describe('', () => { expect(within(creatingRow).getByText('Cancel')).toBeInTheDocument(); expect(within(creatingRow).getByText('Save')).toBeInTheDocument(); }); + const addButton = await screen.findByLabelText('Create Tag'); + addButton.click(); + const creatingRow = await screen.findByTestId('creating-top-tag-row'); + // expect input placeholder text to say "Type tag name" + expect(creatingRow.querySelector('input').placeholder).toEqual('Type tag name'); + // expect the row to include "Cancel" and "Save" buttons + expect(within(creatingRow).getByText('Cancel')).toBeInTheDocument(); + expect(within(creatingRow).getByText('Save')).toBeInTheDocument(); + }); it('should create a new tag when the draft row is saved', async () => { axiosMock.onPost(createTagUrl).reply(201, { @@ -272,6 +281,23 @@ describe('', () => { _id: 1234, }); const { creatingRow, input } = await openTopLevelDraftRow(); + it('should create a new tag when the draft row is saved', async () => { + axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); + axiosMock.onPost(createTagUrl).reply(201, { + ...tagDefaults, + value: 'a new tag', + child_count: 0, + descendant_count: 0, + _id: 1234, + }); + render(); + const tag = await screen.findByText('root tag 1'); + expect(tag).toBeInTheDocument(); + const addButton = await screen.findByLabelText('Create Tag'); + addButton.click(); + const creatingRow = await screen.findByTestId('creating-top-tag-row'); + const input = creatingRow.querySelector('input'); + expect(input).toBeInTheDocument(); fireEvent.change(input, { target: { value: 'a new tag' } }); const saveButton = within(creatingRow).getByText('Save'); @@ -544,8 +570,8 @@ describe('', () => { const input = draftRow[1].querySelector('input'); const saveButton = within(draftRow[1]).getByText('Save'); - fireEvent.change(input, { target: { value: 'root tag 1' } }); - fireEvent.click(saveButton); + fireEvent.change(input, { target: { value: 'root tag 1' } }); + fireEvent.click(saveButton); expect(await screen.findByText('Tag with this name already exists')).toBeInTheDocument(); }); From bfa7f724d66a83027eb56a286b12da15886dfda8 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 27 Feb 2026 12:25:47 -0500 Subject: [PATCH 21/62] feat: add reducer for table modes --- src/taxonomy/tag-list/TagListTable.jsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx index b221b4ec4b..d7df3efc99 100644 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -1,5 +1,5 @@ // @ts-check -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useReducer } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Button, Toast, Card, ActionRow, Icon, IconButton, IconButtonWithTooltip } from '@openedx/paragon'; import { Add, AddCircle } from '@openedx/paragon/icons'; @@ -32,10 +32,21 @@ const TRANSITION_TABLE = { [TABLE_MODES.WRITE]: [TABLE_MODES.DRAFT, TABLE_MODES.VIEW], } -const switchMode = (currentMode, targetMode) => { +const TABLE_MODE_ACTIONS = { + TRANSITION: 'transition', +}; + +/** @type {import('react').Reducer} */ +const tableModeReducer = (currentMode, action) => { + if (action?.type !== TABLE_MODE_ACTIONS.TRANSITION) { + throw new Error(`Unknown table mode action: ${action?.type}`); + } + + const { targetMode } = action; if (TRANSITION_TABLE[currentMode].includes(targetMode)) { return targetMode; } + throw new Error(`Invalid table mode transition from ${currentMode} to ${targetMode}`); }; @@ -341,7 +352,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { const [editingRowId, setEditingRowId] = useState(null); const [toast, setToast] = useState({ show: false, message: '', variant: 'success' }); - const [tableMode, setTableMode] = useState(TABLE_MODES.VIEW); + const [tableMode] = useReducer(tableModeReducer, TABLE_MODES.VIEW); const [tagTree, setTagTree] = useState(null); const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); From b2106952652e4bac3005ea454deb8b15197c9277 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 27 Feb 2026 13:08:02 -0500 Subject: [PATCH 22/62] feat: enable preview mode --- src/taxonomy/tag-list/TagListTable.jsx | 105 +++++++++++++++++++++---- 1 file changed, 89 insertions(+), 16 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx index d7df3efc99..0be576e38b 100644 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -1,5 +1,5 @@ // @ts-check -import React, { useState, useMemo, useEffect, useReducer } from 'react'; +import React, { useState, useMemo, useEffect, useReducer, useRef } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Button, Toast, Card, ActionRow, Icon, IconButton, IconButtonWithTooltip } from '@openedx/paragon'; import { Add, AddCircle } from '@openedx/paragon/icons'; @@ -209,7 +209,16 @@ const OptionalExpandLink = ({ row }) => { }; OptionalExpandLink.propTypes = { row: Proptypes.object.isRequired }; -function getColumns({ intl, handleCreateTopTag, setIsCreatingTopTag, setCreatingParentId, handleUpdateTag, setEditingRowId, setToast }) { +function getColumns({ + intl, + handleCreateTopTag, + setIsCreatingTopTag, + setCreatingParentId, + handleUpdateTag, + setEditingRowId, + setToast, + onStartDraft, +}) { return [ { header: intl.formatMessage(messages.tagListColumnValueHeader), @@ -256,6 +265,7 @@ function getColumns({ intl, handleCreateTopTag, setIsCreatingTopTag, setCreating alt="Create Tag" size="inline" onClick={() => { + onStartDraft(); setIsCreatingTopTag(true); setEditingRowId(null); }} @@ -271,6 +281,7 @@ function getColumns({ intl, handleCreateTopTag, setIsCreatingTopTag, setCreating { + onStartDraft(); setCreatingParentId(row.original.id); setEditingRowId(null); row.toggleExpanded(true); @@ -352,19 +363,59 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { const [editingRowId, setEditingRowId] = useState(null); const [toast, setToast] = useState({ show: false, message: '', variant: 'success' }); - const [tableMode] = useReducer(tableModeReducer, TABLE_MODES.VIEW); - const [tagTree, setTagTree] = useState(null); + const [tableMode, dispatchTableMode] = useReducer(tableModeReducer, TABLE_MODES.VIEW); + const [tagTree, setTagTree] = useState(/** @type {TagTree | null} */(null)); const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); + const modeBeforeDraftRef = useRef(TABLE_MODES.VIEW); + + const transitionTableMode = (targetMode) => { + if (targetMode === tableMode) { + return; + } + dispatchTableMode({ type: TABLE_MODE_ACTIONS.TRANSITION, targetMode }); + }; + + const enterDraftMode = () => { + modeBeforeDraftRef.current = tableMode; + transitionTableMode(TABLE_MODES.DRAFT); + }; - const { isLoading, data: tagList } = useTagListData(taxonomyId, pagination); + const exitDraftWithoutSave = () => { + const previousMode = modeBeforeDraftRef.current; + const targetMode = previousMode === TABLE_MODES.WRITE ? TABLE_MODES.WRITE : TABLE_MODES.VIEW; + transitionTableMode(targetMode); + }; + + const applyLocalTagPreview = (value, parentTagValue = null) => { + setTagTree((currentTagTree) => { + const nextTree = currentTagTree || new TagTree([]); + const parentTag = parentTagValue ? nextTree.getTagAsDeepCopy(parentTagValue) : null; + + nextTree.addNode({ + id: Date.now(), + value, + parentValue: parentTagValue, + depth: parentTag ? parentTag.depth + 1 : 0, + childCount: 0, + descendantCount: 0, + subTagsUrl: null, + externalId: null, + }, parentTagValue); + + return nextTree; + }); + }; + + const { isLoading, data: tagList } = useTagListData(taxonomyId, { + ...pagination, + enabled: tableMode === TABLE_MODES.VIEW, + }); const createTagMutation = useCreateTag(taxonomyId); - useMemo(() => { + useEffect(() => { // get row data if table is in VIEW mode, otherwise keep current data to avoid disrupting user while they are editing or creating a tag if (tableMode === TABLE_MODES.VIEW && tagList?.results) { - console.log('tagList results: ', tagList?.results); const tree = new TagTree(tagList?.results); - console.log('tree rows: ', tree.getAllAsDeepCopy()); if (tree) { setTagTree(tree); } @@ -377,10 +428,11 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { const showAddSubTagButton = remainingDepth > 0; const handleCreateTopTag = async (value, setToast) => { - console.log('Creating top-level tag with value:', value); const trimmed = value.trim(); if (trimmed) { await createTagMutation.mutateAsync({ value: trimmed }); + applyLocalTagPreview(trimmed); + transitionTableMode(TABLE_MODES.WRITE); setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), variant: 'success' }); } setIsCreatingTopTag(false); @@ -390,6 +442,8 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { const trimmed = value.trim(); if (trimmed) { await createTagMutation.mutateAsync({ value: trimmed, parentTagValue }); + applyLocalTagPreview(trimmed, parentTagValue); + transitionTableMode(TABLE_MODES.WRITE); setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), variant: 'success' }); } setCreatingParentId(null); @@ -404,11 +458,24 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { }; const columns = useMemo(() => getColumns({ - intl, handleCreateTopTag, setIsCreatingTopTag, setCreatingParentId,handleUpdateTag, setEditingRowId, setToast }), - [intl, isCreatingTopTag, editingRowId] + intl, + handleCreateTopTag, + setIsCreatingTopTag, + setCreatingParentId, + handleUpdateTag, + setEditingRowId, + setToast, + onStartDraft: enterDraftMode, + }), + [intl, isCreatingTopTag, editingRowId, tableMode] ); - console.log('rowData for table: ', tagTree?.getAllAsDeepCopy()); + const handlePaginationChange = (updater) => { + if (tableMode === TABLE_MODES.WRITE) { + transitionTableMode(TABLE_MODES.VIEW); + } + setPagination(updater); + }; // Initialize TanStack Table const table = useReactTable({ @@ -422,7 +489,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { state: { pagination, }, - onPaginationChange: setPagination, + onPaginationChange: handlePaginationChange, getSubRows: (row) => row.subRows || null, }); @@ -473,7 +540,10 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { handleCreateTopTag(value, setToast)} - onCancel={() => setIsCreatingTopTag(false)} /> + onCancel={() => { + setIsCreatingTopTag(false); + exitDraftWithoutSave(); + }} /> )} @@ -502,7 +572,10 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { parentTagId={row.original.id} isCreating={creatingParentId === row.original.id} onSaveNewSubTag={handleCreateSubTag} - onCancelCreation={() => setCreatingParentId(null)} + onCancelCreation={() => { + setCreatingParentId(null); + exitDraftWithoutSave(); + }} createTagMutation={createTagMutation} creatingParentId={creatingParentId} editingRowId={editingRowId} @@ -542,7 +615,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { )} { setToast({ show: false })} } + onClose={() => { setToast((prevToast) => ({ ...prevToast, show: false }))} } delay={15000} className="bg-success-100 border-success" > From 14a036ac99c1e7495738c11879dd30f4f87414a3 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 27 Feb 2026 13:13:01 -0500 Subject: [PATCH 23/62] fix: mode transitions --- src/taxonomy/tag-list/TagListTable.jsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx index 0be576e38b..7371801908 100644 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -1,5 +1,5 @@ // @ts-check -import React, { useState, useMemo, useEffect, useReducer, useRef } from 'react'; +import React, { useState, useMemo, useEffect, useReducer } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Button, Toast, Card, ActionRow, Icon, IconButton, IconButtonWithTooltip } from '@openedx/paragon'; import { Add, AddCircle } from '@openedx/paragon/icons'; @@ -366,7 +366,6 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { const [tableMode, dispatchTableMode] = useReducer(tableModeReducer, TABLE_MODES.VIEW); const [tagTree, setTagTree] = useState(/** @type {TagTree | null} */(null)); const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); - const modeBeforeDraftRef = useRef(TABLE_MODES.VIEW); const transitionTableMode = (targetMode) => { if (targetMode === tableMode) { @@ -376,14 +375,11 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { }; const enterDraftMode = () => { - modeBeforeDraftRef.current = tableMode; transitionTableMode(TABLE_MODES.DRAFT); }; const exitDraftWithoutSave = () => { - const previousMode = modeBeforeDraftRef.current; - const targetMode = previousMode === TABLE_MODES.WRITE ? TABLE_MODES.WRITE : TABLE_MODES.VIEW; - transitionTableMode(targetMode); + transitionTableMode(TABLE_MODES.WRITE); }; const applyLocalTagPreview = (value, parentTagValue = null) => { @@ -490,7 +486,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { pagination, }, onPaginationChange: handlePaginationChange, - getSubRows: (row) => row.subRows || null, + getSubRows: (row) => row.subRows || undefined, }); return ( From ec0a69052528ba1d53a73ef5a1c62cf71d01b87f Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 27 Feb 2026 18:47:43 -0500 Subject: [PATCH 24/62] feat: add row options menu --- src/taxonomy/tag-list/TagListTable.jsx | 285 ++++++++++++++++++++----- 1 file changed, 231 insertions(+), 54 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx index 7371801908..a3c1b6e834 100644 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -1,8 +1,18 @@ // @ts-check import React, { useState, useMemo, useEffect, useReducer } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { Button, Toast, Card, ActionRow, Icon, IconButton, IconButtonWithTooltip } from '@openedx/paragon'; -import { Add, AddCircle } from '@openedx/paragon/icons'; +import { + Button, + Toast, + Card, + ActionRow, + Icon, + IconButton, + IconButtonWithTooltip, + Spinner, + Pagination, +} from '@openedx/paragon'; +import { AddCircle, MoreVert } from '@openedx/paragon/icons'; import { isEqual, set } from 'lodash'; import Proptypes from 'prop-types'; @@ -36,6 +46,19 @@ const TABLE_MODE_ACTIONS = { TRANSITION: 'transition', }; +const TAG_NAME_PATTERN = /^[\w\- ]+$/; + +const getInlineValidationMessage = (value) => { + const trimmed = value.trim(); + if (!trimmed) { + return 'Name is required'; + } + if (!TAG_NAME_PATTERN.test(trimmed)) { + return 'Invalid character in tag name'; + } + return ''; +}; + /** @type {import('react').Reducer} */ const tableModeReducer = (currentMode, action) => { if (action?.type !== TABLE_MODE_ACTIONS.TRANSITION) { @@ -53,12 +76,33 @@ const tableModeReducer = (currentMode, action) => { /** * 1. Reusable Editable Cell */ -const EditableCell = ({ initialValue, onSave, onCancel }) => { +const EditableCell = ({ + initialValue, + onSave, + onCancel, + errorMessage, + isSaving, +}) => { const [value, setValue] = useState(initialValue); + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + const validationMessage = getInlineValidationMessage(value); + const effectiveErrorMessage = errorMessage || validationMessage; + const isSaveDisabled = Boolean(validationMessage) || isSaving; + + const handleSave = () => { + if (!isSaveDisabled) { + onSave(value); + } + }; + const handleKeyDown = (e) => { if (e.key === 'Enter') { - e.target.blur(); // Trigger onBlur to save + e.preventDefault(); + handleSave(); } else if (e.key === 'Escape') { onCancel(); } @@ -77,17 +121,29 @@ const EditableCell = ({ initialValue, onSave, onCancel }) => { onClick={(e) => e.stopPropagation()} placeholder='Type tag name' /> + {effectiveErrorMessage && ( +
    {effectiveErrorMessage}
    + )}
    - - + {isSaving && ( + + )}
    ); }; @@ -96,10 +152,14 @@ EditableCell.propTypes = { initialValue: Proptypes.string, onSave: Proptypes.func.isRequired, onCancel: Proptypes.func.isRequired, + errorMessage: Proptypes.string, + isSaving: Proptypes.bool, }; EditableCell.defaultProps = { initialValue: '', + errorMessage: '', + isSaving: false, }; /** @@ -118,9 +178,13 @@ const SubTagsExpanded = ({ setCreatingParentId, setEditingRowId, maxDepth, + draftError, + isSavingDraft, + onStartDraft, + setIsCreatingTopTag, + setDraftError, }) => { const columnCount = subTagsData?.[0]?.getVisibleCells?.().length || visibleColumnCount || 1; - const showAddSubTagButton = maxDepth > 0; return ( <> @@ -128,8 +192,13 @@ const SubTagsExpanded = ({ onSaveNewSubTag(val, parentTagValue)} - onCancel={onCancelCreation} + onCancel={() => { + setDraftError(''); + onCancelCreation(); + }} /> @@ -140,7 +209,6 @@ const SubTagsExpanded = ({ {row.getVisibleCells() - .filter(cell => showAddSubTagButton || cell.column.id !== 'add') .map(cell => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -164,7 +232,12 @@ const SubTagsExpanded = ({ editingRowId={editingRowId} setCreatingParentId={setCreatingParentId} setEditingRowId={setEditingRowId} - maxDepth={maxDepth - 1} + maxDepth={maxDepth} + draftError={draftError} + isSavingDraft={isSavingDraft} + onStartDraft={onStartDraft} + setIsCreatingTopTag={setIsCreatingTopTag} + setDraftError={setDraftError} /> @@ -189,6 +262,11 @@ SubTagsExpanded.propTypes = { setCreatingParentId: Proptypes.func, setEditingRowId: Proptypes.func, maxDepth: Proptypes.number, + draftError: Proptypes.string, + isSavingDraft: Proptypes.bool, + onStartDraft: Proptypes.func, + setIsCreatingTopTag: Proptypes.func, + setDraftError: Proptypes.func, }; /** @@ -218,7 +296,17 @@ function getColumns({ setEditingRowId, setToast, onStartDraft, + activeActionMenuRowId, + setActiveActionMenuRowId, + hasOpenDraft, + draftError, + setDraftError, + isSavingDraft, + maxDepth, + creatingParentId, }) { + const canAddSubtag = (row) => row.original.depth < maxDepth; + return [ { header: intl.formatMessage(messages.tagListColumnValueHeader), @@ -228,8 +316,13 @@ function getColumns({ if (isNew) { return ( handleCreateTopTag(value, setToast)} - onCancel={() => setIsCreatingTopTag(false)} /> + onCancel={() => { + setDraftError(''); + setIsCreatingTopTag(false); + }} /> ); } @@ -237,8 +330,12 @@ function getColumns({ return ( handleUpdateTag(id, newVal, value)} - onCancel={() => setEditingRowId(null)} /> + onCancel={() => { + setDraftError(''); + setEditingRowId(null); + }} /> ); } @@ -266,29 +363,52 @@ function getColumns({ size="inline" onClick={() => { onStartDraft(); + setDraftError(''); setIsCreatingTopTag(true); setEditingRowId(null); + setActiveActionMenuRowId(null); }} + disabled={hasOpenDraft} /> ), cell: ({ row }) => { - if (row.original.isNew) { + if (row.original.isNew || !canAddSubtag(row)) { return
    ; } + const isMenuOpen = activeActionMenuRowId === row.original.id; + const disableAddSubtag = hasOpenDraft && creatingParentId !== row.original.id; + const startSubtagDraft = () => { + onStartDraft(); + setDraftError(''); + setCreatingParentId(row.original.id); + setEditingRowId(null); + setIsCreatingTopTag(false); + setActiveActionMenuRowId(null); + row.toggleExpanded(true); + }; + return ( -
    - + { - onStartDraft(); - setCreatingParentId(row.original.id); - setEditingRowId(null); - row.toggleExpanded(true); - } } - > - Add Subtag - + setActiveActionMenuRowId(isMenuOpen ? null : row.original.id); + }} + disabled={disableAddSubtag} + /> + {isMenuOpen && ( + + )}
    ); } @@ -366,6 +486,8 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { const [tableMode, dispatchTableMode] = useReducer(tableModeReducer, TABLE_MODES.VIEW); const [tagTree, setTagTree] = useState(/** @type {TagTree | null} */(null)); const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); + const [activeActionMenuRowId, setActiveActionMenuRowId] = useState(null); + const [draftError, setDraftError] = useState(''); const transitionTableMode = (targetMode) => { if (targetMode === tableMode) { @@ -418,31 +540,56 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { } }, [tagList?.results, editingRowId, pagination, tableMode]); - - - const remainingDepth = maxDepth - 1 - const showAddSubTagButton = remainingDepth > 0; - const handleCreateTopTag = async (value, setToast) => { const trimmed = value.trim(); - if (trimmed) { + const validationError = getInlineValidationMessage(trimmed); + if (validationError) { + setDraftError(validationError); + return; + } + + try { + setDraftError(''); await createTagMutation.mutateAsync({ value: trimmed }); applyLocalTagPreview(trimmed); transitionTableMode(TABLE_MODES.WRITE); - setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), variant: 'success' }); + setToast({ + show: true, + message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), + variant: 'success', + }); + setIsCreatingTopTag(false); + } catch (error) { + transitionTableMode(TABLE_MODES.WRITE); + setDraftError(/** @type {any} */(error)?.message || intl.formatMessage(messages.tagCreationErrorMessage)); + setToast({ show: true, message: 'Toast: Tag not saved', variant: 'danger' }); } - setIsCreatingTopTag(false); }; const handleCreateSubTag = async (value, parentTagValue) => { const trimmed = value.trim(); - if (trimmed) { + const validationError = getInlineValidationMessage(trimmed); + if (validationError) { + setDraftError(validationError); + return; + } + + try { + setDraftError(''); await createTagMutation.mutateAsync({ value: trimmed, parentTagValue }); applyLocalTagPreview(trimmed, parentTagValue); transitionTableMode(TABLE_MODES.WRITE); - setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), variant: 'success' }); + setToast({ + show: true, + message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), + variant: 'success', + }); + setCreatingParentId(null); + } catch (error) { + transitionTableMode(TABLE_MODES.WRITE); + setDraftError(/** @type {any} */(error)?.message || intl.formatMessage(messages.tagCreationErrorMessage)); + setToast({ show: true, message: 'Toast: Tag not saved', variant: 'danger' }); } - setCreatingParentId(null); }; const handleUpdateTag = async (id, value, originalValue) => { @@ -453,6 +600,8 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { setEditingRowId(null); }; + const hasOpenDraft = isCreatingTopTag || creatingParentId !== null || editingRowId !== null; + const columns = useMemo(() => getColumns({ intl, handleCreateTopTag, @@ -462,8 +611,27 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { setEditingRowId, setToast, onStartDraft: enterDraftMode, + activeActionMenuRowId, + setActiveActionMenuRowId, + hasOpenDraft, + draftError, + setDraftError, + isSavingDraft: createTagMutation.isPending, + maxDepth, + creatingParentId, }), - [intl, isCreatingTopTag, editingRowId, tableMode] + [ + intl, + isCreatingTopTag, + editingRowId, + tableMode, + activeActionMenuRowId, + hasOpenDraft, + creatingParentId, + draftError, + createTagMutation.isPending, + maxDepth, + ] ); const handlePaginationChange = (updater) => { @@ -535,8 +703,11 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { handleCreateTopTag(value, setToast)} onCancel={() => { + setDraftError(''); setIsCreatingTopTag(false); exitDraftWithoutSave(); }} /> @@ -548,7 +719,6 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { {/* Main Row */} {row.getVisibleCells() - .filter(cell => showAddSubTagButton || cell.column.id !== 'add') .map(cell => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -569,6 +739,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { isCreating={creatingParentId === row.original.id} onSaveNewSubTag={handleCreateSubTag} onCancelCreation={() => { + setDraftError(''); setCreatingParentId(null); exitDraftWithoutSave(); }} @@ -577,7 +748,12 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { editingRowId={editingRowId} setCreatingParentId={setCreatingParentId} setEditingRowId={setEditingRowId} - maxDepth={remainingDepth - 1} + maxDepth={maxDepth} + draftError={draftError} + isSavingDraft={createTagMutation.isPending} + onStartDraft={enterDraftMode} + setIsCreatingTopTag={setIsCreatingTopTag} + setDraftError={setDraftError} /> @@ -590,30 +766,31 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { )} {/* Basic Pagination Controls */} - {(tagList?.numPages || 0) > 1 && ( -
    - + {((tagList?.numPages || 0)) > 1 && ( +
    - Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} + Page {table.getState().pagination.pageIndex + 1} of {((tableMode === TABLE_MODES.WRITE) + ? Math.max(tagList?.numPages || 1, 2) + : (tagList?.numPages || 0))} - + { + table.setPageIndex(page - 1); + }} + />
    )} { setToast((prevToast) => ({ ...prevToast, show: false }))} } delay={15000} - className="bg-success-100 border-success" + className={toast.variant === 'danger' ? 'bg-danger-100 border-danger' : 'bg-success-100 border-success'} > {toast.message} From 815d3d9012f89f58e01665340dfef52c28b4dce7 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 2 Mar 2026 14:11:31 -0500 Subject: [PATCH 25/62] refactor: change table mode name to preview --- src/taxonomy/tag-list/TagListTable.jsx | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx index a3c1b6e834..a1eaf8e8ee 100644 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -33,13 +33,13 @@ import { TagTree } from './tagTree'; const TABLE_MODES = { VIEW: 'view', DRAFT: 'draft', - WRITE: 'write', + PREVIEW: 'preview', } const TRANSITION_TABLE = { [TABLE_MODES.VIEW]: [TABLE_MODES.DRAFT], - [TABLE_MODES.DRAFT]: [TABLE_MODES.WRITE], - [TABLE_MODES.WRITE]: [TABLE_MODES.DRAFT, TABLE_MODES.VIEW], + [TABLE_MODES.DRAFT]: [TABLE_MODES.PREVIEW], + [TABLE_MODES.PREVIEW]: [TABLE_MODES.DRAFT, TABLE_MODES.VIEW], } const TABLE_MODE_ACTIONS = { @@ -463,10 +463,10 @@ function getColumns({ // } const TagListTable = ({ taxonomyId, maxDepth }) => { - // The table has a VIEW and a WRITE mode. It starts in VIEW mode. - // It switches to WRITE mode when a user edits or creates a tag. It remains in WRITE mode even after saving changes, + // The table has a VIEW, DRAFT, and a PREVIEW mode. It starts in VIEW mode. + // It switches to DRAFT mode when a user edits or creates a tag. It switches to PREVIEW mode after saving changes, // and only switches to VIEW when the user refreshes the page, orders a column, or navigates to a different page of the table. - // During WRITE mode, the table makes POST requests to the backend and receives success or failure responses. + // During DRAFT and PREVIEW mode the table makes POST requests to the backend and receives success or failure responses. // However, the table does not refresh to show the updated data from the backend. // This allows us to show the newly created or updated tag in the same place without reordering. const intl = useIntl(); @@ -501,7 +501,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { }; const exitDraftWithoutSave = () => { - transitionTableMode(TABLE_MODES.WRITE); + transitionTableMode(TABLE_MODES.PREVIEW); }; const applyLocalTagPreview = (value, parentTagValue = null) => { @@ -552,7 +552,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { setDraftError(''); await createTagMutation.mutateAsync({ value: trimmed }); applyLocalTagPreview(trimmed); - transitionTableMode(TABLE_MODES.WRITE); + transitionTableMode(TABLE_MODES.PREVIEW); setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), @@ -560,7 +560,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { }); setIsCreatingTopTag(false); } catch (error) { - transitionTableMode(TABLE_MODES.WRITE); + transitionTableMode(TABLE_MODES.PREVIEW); setDraftError(/** @type {any} */(error)?.message || intl.formatMessage(messages.tagCreationErrorMessage)); setToast({ show: true, message: 'Toast: Tag not saved', variant: 'danger' }); } @@ -578,7 +578,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { setDraftError(''); await createTagMutation.mutateAsync({ value: trimmed, parentTagValue }); applyLocalTagPreview(trimmed, parentTagValue); - transitionTableMode(TABLE_MODES.WRITE); + transitionTableMode(TABLE_MODES.PREVIEW); setToast({ show: true, message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), @@ -586,7 +586,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { }); setCreatingParentId(null); } catch (error) { - transitionTableMode(TABLE_MODES.WRITE); + transitionTableMode(TABLE_MODES.PREVIEW); setDraftError(/** @type {any} */(error)?.message || intl.formatMessage(messages.tagCreationErrorMessage)); setToast({ show: true, message: 'Toast: Tag not saved', variant: 'danger' }); } @@ -635,7 +635,7 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { ); const handlePaginationChange = (updater) => { - if (tableMode === TABLE_MODES.WRITE) { + if (tableMode === TABLE_MODES.PREVIEW) { transitionTableMode(TABLE_MODES.VIEW); } setPagination(updater); @@ -769,14 +769,14 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { {((tagList?.numPages || 0)) > 1 && (
    - Page {table.getState().pagination.pageIndex + 1} of {((tableMode === TABLE_MODES.WRITE) + Page {table.getState().pagination.pageIndex + 1} of {((tableMode === TABLE_MODES.PREVIEW) ? Math.max(tagList?.numPages || 1, 2) : (tagList?.numPages || 0))} Date: Mon, 2 Mar 2026 14:11:47 -0500 Subject: [PATCH 26/62] refactor: extract subcomponents --- src/taxonomy/tag-list/EditableCell.jsx | 94 ++++++++ src/taxonomy/tag-list/SubTagsExpanded.jsx | 117 ++++++++++ src/taxonomy/tag-list/TagListTableDisplay.jsx | 203 ++++++++++++++++++ src/taxonomy/tag-list/constants.js | 9 + 4 files changed, 423 insertions(+) create mode 100644 src/taxonomy/tag-list/EditableCell.jsx create mode 100644 src/taxonomy/tag-list/SubTagsExpanded.jsx create mode 100644 src/taxonomy/tag-list/TagListTableDisplay.jsx create mode 100644 src/taxonomy/tag-list/constants.js diff --git a/src/taxonomy/tag-list/EditableCell.jsx b/src/taxonomy/tag-list/EditableCell.jsx new file mode 100644 index 0000000000..173603907b --- /dev/null +++ b/src/taxonomy/tag-list/EditableCell.jsx @@ -0,0 +1,94 @@ +import React, { useState, useEffect } from 'react'; +import Proptypes from 'prop-types'; + +import { Button, Spinner } from '@openedx/paragon'; + +const EditableCell = ({ + initialValue, + onSave, + onCancel, + errorMessage, + isSaving, + getInlineValidationMessage = () => '', +}) => { + const [value, setValue] = useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + const validationMessage = getInlineValidationMessage(value); + const effectiveErrorMessage = errorMessage || validationMessage; + const isSaveDisabled = Boolean(validationMessage) || isSaving; + + const handleSave = () => { + if (!isSaveDisabled) { + onSave(value); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + onCancel(); + } + }; + + return ( + + + setValue(e.target.value)} + onKeyDown={handleKeyDown} + onClick={(e) => e.stopPropagation()} + placeholder="Type tag name" + /> + {effectiveErrorMessage && ( +
    {effectiveErrorMessage}
    + )} +
    + + + + + + + {isSaving && ( + + )} +
    + ); +}; + +EditableCell.propTypes = { + initialValue: Proptypes.string, + onSave: Proptypes.func.isRequired, + onCancel: Proptypes.func.isRequired, + errorMessage: Proptypes.string, + isSaving: Proptypes.bool, + getInlineValidationMessage: Proptypes.func, +}; + +EditableCell.defaultProps = { + initialValue: '', + errorMessage: '', + isSaving: false, + getInlineValidationMessage: () => '', +}; + +export default EditableCell; diff --git a/src/taxonomy/tag-list/SubTagsExpanded.jsx b/src/taxonomy/tag-list/SubTagsExpanded.jsx new file mode 100644 index 0000000000..561340e870 --- /dev/null +++ b/src/taxonomy/tag-list/SubTagsExpanded.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import Proptypes from 'prop-types'; +import { flexRender } from '@tanstack/react-table'; + +import EditableCell from './EditableCell'; + +const SubTagsExpanded = ({ + parentTagValue, + isCreating, + onSaveNewSubTag, + onCancelCreation, + subTagsData, + visibleColumnCount, + createTagMutation, + creatingParentId, + editingRowId, + setCreatingParentId, + setEditingRowId, + maxDepth, + draftError, + isSavingDraft, + onStartDraft, + setIsCreatingTopTag, + setDraftError, +}) => { + const columnCount = subTagsData?.[0]?.getVisibleCells?.().length || visibleColumnCount || 1; + + return ( + <> + {isCreating && ( + + + onSaveNewSubTag(val, parentTagValue)} + onCancel={() => { + setDraftError(''); + onCancelCreation(); + }} + getInlineValidationMessage={(value) => { + if (!value.trim()) { + return 'Tag name cannot be empty.'; + } + return ''; + }} + /> + + + )} + {subTagsData?.map(row => { + const tagData = row.original || row; // Handle both raw and table row data + return ( + + + {row.getVisibleCells() + .map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + + + {/* colSpan stretches the sub-row across the whole table */} + + setCreatingParentId(null)} + createTagMutation={createTagMutation} + creatingParentId={creatingParentId} + editingRowId={editingRowId} + setCreatingParentId={setCreatingParentId} + setEditingRowId={setEditingRowId} + maxDepth={maxDepth} + draftError={draftError} + isSavingDraft={isSavingDraft} + onStartDraft={onStartDraft} + setIsCreatingTopTag={setIsCreatingTopTag} + setDraftError={setDraftError} + /> + + + + ); + })} + + ); +}; + +SubTagsExpanded.propTypes = { + subTagsData: Proptypes.array.isRequired, + visibleColumnCount: Proptypes.number, + parentTagValue: Proptypes.string.isRequired, + parentTagId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]).isRequired, + isCreating: Proptypes.bool, + onSaveNewSubTag: Proptypes.func, + onCancelCreation: Proptypes.func, + createTagMutation: Proptypes.object, + creatingParentId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]), + editingRowId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]), + setCreatingParentId: Proptypes.func, + setEditingRowId: Proptypes.func, + maxDepth: Proptypes.number, + draftError: Proptypes.string, + isSavingDraft: Proptypes.bool, + onStartDraft: Proptypes.func, + setIsCreatingTopTag: Proptypes.func, + setDraftError: Proptypes.func, +}; + +export default SubTagsExpanded; diff --git a/src/taxonomy/tag-list/TagListTableDisplay.jsx b/src/taxonomy/tag-list/TagListTableDisplay.jsx new file mode 100644 index 0000000000..68bef8627e --- /dev/null +++ b/src/taxonomy/tag-list/TagListTableDisplay.jsx @@ -0,0 +1,203 @@ +// @ts-check +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + Toast, + Card, + ActionRow, + Pagination, +} from '@openedx/paragon'; + +import { + useReactTable, + getCoreRowModel, + getExpandedRowModel, + flexRender, +} from '@tanstack/react-table'; + +import { LoadingSpinner } from '../../generic/Loading'; +import messages from './messages'; +import EditableCell from './EditableCell'; +import SubTagsExpanded from './SubTagsExpanded'; + +const TagListTableDisplay = ({ + maxDepth, + tagTree, + columns, + tagList, + pagination, + handlePaginationChange, + isLoading, + isCreatingTopTag, + draftError, + createTagMutation, + handleCreateTopTag, + toast, + setToast, + setIsCreatingTopTag, + exitDraftWithoutSave, + handleCreateSubTag, + creatingParentId, + setCreatingParentId, + editingRowId, + setEditingRowId, + setDraftError, + enterDraftMode, +}) => { + // Initialize TanStack Table + const table = useReactTable({ + data: tagTree?.getAllAsDeepCopy() || [], + columns, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + // Manual pagination config + manualPagination: true, + pageCount: tagList?.numPages ?? -1, + state: { + pagination, + }, + onPaginationChange: handlePaginationChange, + getSubRows: (row) => row?.subRows || undefined, + }); + + const intl = useIntl(); + + return ( + + + + + } /> + + {isLoading ? ( + + ) : ( + + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + + {table.getRowModel().rows.length === 0 && ( + + + + )} + + {isCreatingTopTag && ( + + + + )} + {table.getRowModel().rows.filter(row => row.depth === 0).map(row => ( + + {/* Main Row */} + + {row.getVisibleCells() + .map(cell => ( + + ))} + + + {/* Subcomponent Rendering */} + {row.getIsExpanded() && ( + + {/* colSpan stretches the sub-row across the whole table */} + + + )} + + ))} + +
    + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} +
    + {intl.formatMessage(messages.noResultsFoundMessage)} +
    + handleCreateTopTag(value, setToast)} + onCancel={() => { + setDraftError(''); + setIsCreatingTopTag(false); + exitDraftWithoutSave(); + }} /> +
    + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
    + { + setDraftError(''); + setCreatingParentId(null); + exitDraftWithoutSave(); + }} + createTagMutation={createTagMutation} + creatingParentId={creatingParentId} + editingRowId={editingRowId} + setCreatingParentId={setCreatingParentId} + setEditingRowId={setEditingRowId} + maxDepth={maxDepth} + draftError={draftError} + isSavingDraft={createTagMutation.isPending} + onStartDraft={enterDraftMode} + setIsCreatingTopTag={setIsCreatingTopTag} + setDraftError={setDraftError} + /> +
    +
    + )} + + {/* Basic Pagination Controls */} + {((tagList?.numPages || 0)) > 1 && ( +
    + + Page {table.getState().pagination.pageIndex + 1} of {(tagList?.numPages || 0)} + + { + table.setPageIndex(page - 1); + }} + /> +
    + )} + { setToast((prevToast) => ({ ...prevToast, show: false }))} } + delay={15000} + className={toast.variant === 'danger' ? 'bg-danger-100 border-danger' : 'bg-success-100 border-success'} + > + {toast.message} + +
    + ); +}; + +export default TagListTableDisplay; diff --git a/src/taxonomy/tag-list/constants.js b/src/taxonomy/tag-list/constants.js new file mode 100644 index 0000000000..254cb06549 --- /dev/null +++ b/src/taxonomy/tag-list/constants.js @@ -0,0 +1,9 @@ +const TABLE_MODES = { + VIEW: 'view', + DRAFT: 'draft', + PREVIEW: 'preview', +}; + +export { + TABLE_MODES, +}; From c5b6916b50a5c7831ae9defba7c837fb6e08d635 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 2 Mar 2026 15:42:05 -0500 Subject: [PATCH 27/62] refactor: extract table display component --- ...splay.jsx => EditableTreeTableDisplay.jsx} | 18 +- src/taxonomy/tag-list/TagListTable.jsx | 167 ++++-------------- 2 files changed, 39 insertions(+), 146 deletions(-) rename src/taxonomy/tag-list/{TagListTableDisplay.jsx => EditableTreeTableDisplay.jsx} (95%) diff --git a/src/taxonomy/tag-list/TagListTableDisplay.jsx b/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx similarity index 95% rename from src/taxonomy/tag-list/TagListTableDisplay.jsx rename to src/taxonomy/tag-list/EditableTreeTableDisplay.jsx index 68bef8627e..80c503c174 100644 --- a/src/taxonomy/tag-list/TagListTableDisplay.jsx +++ b/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx @@ -21,11 +21,11 @@ import messages from './messages'; import EditableCell from './EditableCell'; import SubTagsExpanded from './SubTagsExpanded'; -const TagListTableDisplay = ({ +const EditableTreeTableDisplay = ({ maxDepth, - tagTree, + treeData, columns, - tagList, + pageCount, pagination, handlePaginationChange, isLoading, @@ -47,13 +47,13 @@ const TagListTableDisplay = ({ }) => { // Initialize TanStack Table const table = useReactTable({ - data: tagTree?.getAllAsDeepCopy() || [], + data: treeData, columns, getCoreRowModel: getCoreRowModel(), getExpandedRowModel: getExpandedRowModel(), // Manual pagination config manualPagination: true, - pageCount: tagList?.numPages ?? -1, + pageCount: pageCount ?? -1, state: { pagination, }, @@ -172,15 +172,15 @@ const TagListTableDisplay = ({ )} {/* Basic Pagination Controls */} - {((tagList?.numPages || 0)) > 1 && ( + {(pageCount) > 1 && (
    - Page {table.getState().pagination.pageIndex + 1} of {(tagList?.numPages || 0)} + Page {table.getState().pagination.pageIndex + 1} of {pageCount} { table.setPageIndex(page - 1); @@ -200,4 +200,4 @@ const TagListTableDisplay = ({ ); }; -export default TagListTableDisplay; +export default EditableTreeTableDisplay; diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx index a1eaf8e8ee..240a7e5598 100644 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -27,6 +27,7 @@ import { LoadingSpinner } from '../../generic/Loading'; import messages from './messages'; import { useTagListData, useSubTags, useCreateTag } from '../data/apiHooks'; import { TagTree } from './tagTree'; +import EditableTreeTableDisplay from './EditableTreeTableDisplay'; // State machine for table modes @@ -657,144 +658,36 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { getSubRows: (row) => row.subRows || undefined, }); - return ( - - - - - } /> - - {isLoading ? ( - - ) : ( - - - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - ))} - - ))} - - - - {table.getRowModel().rows.length === 0 && ( - - - - )} - - {isCreatingTopTag && ( - - - - )} - {table.getRowModel().rows.filter(row => row.depth === 0).map(row => ( - - {/* Main Row */} - - {row.getVisibleCells() - .map(cell => ( - - ))} - - - {/* Subcomponent Rendering */} - {row.getIsExpanded() && ( - - {/* colSpan stretches the sub-row across the whole table */} - - - )} - - ))} - -
    - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} -
    - {intl.formatMessage(messages.noResultsFoundMessage)} -
    - handleCreateTopTag(value, setToast)} - onCancel={() => { - setDraftError(''); - setIsCreatingTopTag(false); - exitDraftWithoutSave(); - }} /> -
    - {flexRender(cell.column.columnDef.cell, cell.getContext())} -
    - { - setDraftError(''); - setCreatingParentId(null); - exitDraftWithoutSave(); - }} - createTagMutation={createTagMutation} - creatingParentId={creatingParentId} - editingRowId={editingRowId} - setCreatingParentId={setCreatingParentId} - setEditingRowId={setEditingRowId} - maxDepth={maxDepth} - draftError={draftError} - isSavingDraft={createTagMutation.isPending} - onStartDraft={enterDraftMode} - setIsCreatingTopTag={setIsCreatingTopTag} - setDraftError={setDraftError} - /> -
    -
    - )} + const pageCount = tagList?.numPages ?? -1; + const treeData = tagTree?.getAllAsDeepCopy() || []; - {/* Basic Pagination Controls */} - {((tagList?.numPages || 0)) > 1 && ( -
    - - Page {table.getState().pagination.pageIndex + 1} of {((tableMode === TABLE_MODES.PREVIEW) - ? Math.max(tagList?.numPages || 1, 2) - : (tagList?.numPages || 0))} - - { - table.setPageIndex(page - 1); - }} - /> -
    - )} - { setToast((prevToast) => ({ ...prevToast, show: false }))} } - delay={15000} - className={toast.variant === 'danger' ? 'bg-danger-100 border-danger' : 'bg-success-100 border-success'} - > - {toast.message} - -
    + return ( + ); }; From f685672bdbc0cc7461e6bbd3824dca6572d61469 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 2 Mar 2026 15:49:47 -0500 Subject: [PATCH 28/62] refactor: make table components reusable --- .../tag-list/EditableTreeTableDisplay.jsx | 32 ++++++------- src/taxonomy/tag-list/SubTagsExpanded.jsx | 46 +++++++++---------- src/taxonomy/tag-list/TagListTable.jsx | 10 ++-- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx b/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx index 80c503c174..6d1c795169 100644 --- a/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx +++ b/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx @@ -29,15 +29,15 @@ const EditableTreeTableDisplay = ({ pagination, handlePaginationChange, isLoading, - isCreatingTopTag, + isCreatingTopRow, draftError, - createTagMutation, - handleCreateTopTag, + createRowMutation, + handleCreateTopRow, toast, setToast, - setIsCreatingTopTag, + setIsCreatingTopRow, exitDraftWithoutSave, - handleCreateSubTag, + handleCreateChildRow, creatingParentId, setCreatingParentId, editingRowId, @@ -105,16 +105,16 @@ const EditableTreeTableDisplay = ({ )} - {isCreatingTopTag && ( + {isCreatingTopRow && ( handleCreateTopTag(value, setToast)} + isSaving={createRowMutation.isPending} + onSave={(value) => handleCreateTopRow(value, setToast)} onCancel={() => { setDraftError(''); - setIsCreatingTopTag(false); + setIsCreatingTopRow(false); exitDraftWithoutSave(); }} /> @@ -138,27 +138,27 @@ const EditableTreeTableDisplay = ({ {/* colSpan stretches the sub-row across the whole table */} { setDraftError(''); setCreatingParentId(null); exitDraftWithoutSave(); }} - createTagMutation={createTagMutation} + createRowMutation={createRowMutation} creatingParentId={creatingParentId} editingRowId={editingRowId} setCreatingParentId={setCreatingParentId} setEditingRowId={setEditingRowId} maxDepth={maxDepth} draftError={draftError} - isSavingDraft={createTagMutation.isPending} + isSavingDraft={createRowMutation.isPending} onStartDraft={enterDraftMode} - setIsCreatingTopTag={setIsCreatingTopTag} + setIsCreatingTopRow={setIsCreatingTopRow} setDraftError={setDraftError} /> diff --git a/src/taxonomy/tag-list/SubTagsExpanded.jsx b/src/taxonomy/tag-list/SubTagsExpanded.jsx index 561340e870..eaf250f7cc 100644 --- a/src/taxonomy/tag-list/SubTagsExpanded.jsx +++ b/src/taxonomy/tag-list/SubTagsExpanded.jsx @@ -5,13 +5,13 @@ import { flexRender } from '@tanstack/react-table'; import EditableCell from './EditableCell'; const SubTagsExpanded = ({ - parentTagValue, + parentRowValue, isCreating, - onSaveNewSubTag, + onSaveNewChildRow, onCancelCreation, - subTagsData, + childRowsData, visibleColumnCount, - createTagMutation, + createRowMutation, creatingParentId, editingRowId, setCreatingParentId, @@ -20,10 +20,10 @@ const SubTagsExpanded = ({ draftError, isSavingDraft, onStartDraft, - setIsCreatingTopTag, + setIsCreatingTopRow, setDraftError, }) => { - const columnCount = subTagsData?.[0]?.getVisibleCells?.().length || visibleColumnCount || 1; + const columnCount = childRowsData?.[0]?.getVisibleCells?.().length || visibleColumnCount || 1; return ( <> @@ -33,14 +33,14 @@ const SubTagsExpanded = ({ onSaveNewSubTag(val, parentTagValue)} + onSave={(val) => onSaveNewChildRow(val, parentRowValue)} onCancel={() => { setDraftError(''); onCancelCreation(); }} getInlineValidationMessage={(value) => { if (!value.trim()) { - return 'Tag name cannot be empty.'; + return 'Name cannot be empty.'; } return ''; }} @@ -48,10 +48,10 @@ const SubTagsExpanded = ({ )} - {subTagsData?.map(row => { - const tagData = row.original || row; // Handle both raw and table row data + {childRowsData?.map(row => { + const rowData = row.original || row; // Handle both raw and table row data return ( - + {row.getVisibleCells() .map(cell => ( @@ -65,14 +65,14 @@ const SubTagsExpanded = ({ {/* colSpan stretches the sub-row across the whole table */} setCreatingParentId(null)} - createTagMutation={createTagMutation} + createRowMutation={createRowMutation} creatingParentId={creatingParentId} editingRowId={editingRowId} setCreatingParentId={setCreatingParentId} @@ -81,7 +81,7 @@ const SubTagsExpanded = ({ draftError={draftError} isSavingDraft={isSavingDraft} onStartDraft={onStartDraft} - setIsCreatingTopTag={setIsCreatingTopTag} + setIsCreatingTopRow={setIsCreatingTopRow} setDraftError={setDraftError} /> @@ -94,14 +94,14 @@ const SubTagsExpanded = ({ }; SubTagsExpanded.propTypes = { - subTagsData: Proptypes.array.isRequired, + childRowsData: Proptypes.array.isRequired, visibleColumnCount: Proptypes.number, - parentTagValue: Proptypes.string.isRequired, - parentTagId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]).isRequired, + parentRowValue: Proptypes.string.isRequired, + parentRowId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]).isRequired, isCreating: Proptypes.bool, - onSaveNewSubTag: Proptypes.func, + onSaveNewChildRow: Proptypes.func, onCancelCreation: Proptypes.func, - createTagMutation: Proptypes.object, + createRowMutation: Proptypes.object, creatingParentId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]), editingRowId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]), setCreatingParentId: Proptypes.func, @@ -110,7 +110,7 @@ SubTagsExpanded.propTypes = { draftError: Proptypes.string, isSavingDraft: Proptypes.bool, onStartDraft: Proptypes.func, - setIsCreatingTopTag: Proptypes.func, + setIsCreatingTopRow: Proptypes.func, setDraftError: Proptypes.func, }; diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx index 240a7e5598..77e17ba020 100644 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -671,15 +671,15 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { pagination, handlePaginationChange, isLoading, - isCreatingTopTag, + isCreatingTopRow: isCreatingTopTag, draftError, - createTagMutation, - handleCreateTopTag, + createRowMutation: createTagMutation, + handleCreateTopRow: handleCreateTopTag, toast, setToast, - setIsCreatingTopTag, + setIsCreatingTopRow: setIsCreatingTopTag, exitDraftWithoutSave, - handleCreateSubTag, + handleCreateChildRow: handleCreateSubTag, creatingParentId, setCreatingParentId, editingRowId, From e54d36977fd76b1354564248b5aedeec7ca41c0a Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 2 Mar 2026 16:54:26 -0500 Subject: [PATCH 29/62] refactor: simplify and extract components --- .../tag-list/EditableTreeTableDisplay.jsx | 53 ++++----- ...ubTagsExpanded.jsx => SubRowsExpanded.jsx} | 71 ++++-------- src/taxonomy/tag-list/TagListTable.jsx | 107 ------------------ src/taxonomy/tag-list/TreeTableBody.jsx | 93 +++++++++++++++ src/taxonomy/tag-list/tagTreeError.ts | 6 + 5 files changed, 143 insertions(+), 187 deletions(-) rename src/taxonomy/tag-list/{SubTagsExpanded.jsx => SubRowsExpanded.jsx} (51%) create mode 100644 src/taxonomy/tag-list/TreeTableBody.jsx create mode 100644 src/taxonomy/tag-list/tagTreeError.ts diff --git a/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx b/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx index 6d1c795169..86fa7431cf 100644 --- a/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx +++ b/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx @@ -19,10 +19,9 @@ import { import { LoadingSpinner } from '../../generic/Loading'; import messages from './messages'; import EditableCell from './EditableCell'; -import SubTagsExpanded from './SubTagsExpanded'; +import SubRowsExpanded from './SubRowsExpanded'; const EditableTreeTableDisplay = ({ - maxDepth, treeData, columns, pageCount, @@ -40,10 +39,7 @@ const EditableTreeTableDisplay = ({ handleCreateChildRow, creatingParentId, setCreatingParentId, - editingRowId, - setEditingRowId, setDraftError, - enterDraftMode, }) => { // Initialize TanStack Table const table = useReactTable({ @@ -134,35 +130,24 @@ const EditableTreeTableDisplay = ({ {/* Subcomponent Rendering */} {row.getIsExpanded() && ( - - {/* colSpan stretches the sub-row across the whole table */} - - { - setDraftError(''); - setCreatingParentId(null); - exitDraftWithoutSave(); - }} - createRowMutation={createRowMutation} - creatingParentId={creatingParentId} - editingRowId={editingRowId} - setCreatingParentId={setCreatingParentId} - setEditingRowId={setEditingRowId} - maxDepth={maxDepth} - draftError={draftError} - isSavingDraft={createRowMutation.isPending} - onStartDraft={enterDraftMode} - setIsCreatingTopRow={setIsCreatingTopRow} - setDraftError={setDraftError} - /> - - + { + setDraftError(''); + setCreatingParentId(null); + exitDraftWithoutSave(); + }} + creatingParentId={creatingParentId} + setCreatingParentId={setCreatingParentId} + depth={1} + draftError={draftError} + isSavingDraft={createRowMutation.isPending} + setDraftError={setDraftError} + /> )} ))} diff --git a/src/taxonomy/tag-list/SubTagsExpanded.jsx b/src/taxonomy/tag-list/SubRowsExpanded.jsx similarity index 51% rename from src/taxonomy/tag-list/SubTagsExpanded.jsx rename to src/taxonomy/tag-list/SubRowsExpanded.jsx index eaf250f7cc..031f71c88a 100644 --- a/src/taxonomy/tag-list/SubTagsExpanded.jsx +++ b/src/taxonomy/tag-list/SubRowsExpanded.jsx @@ -4,32 +4,28 @@ import { flexRender } from '@tanstack/react-table'; import EditableCell from './EditableCell'; -const SubTagsExpanded = ({ +const SubRowsExpanded = ({ parentRowValue, isCreating, onSaveNewChildRow, onCancelCreation, childRowsData, visibleColumnCount, - createRowMutation, - creatingParentId, - editingRowId, - setCreatingParentId, - setEditingRowId, - maxDepth, + depth, draftError, isSavingDraft, - onStartDraft, - setIsCreatingTopRow, setDraftError, + creatingParentId, + setCreatingParentId, }) => { const columnCount = childRowsData?.[0]?.getVisibleCells?.().length || visibleColumnCount || 1; + const paddingLeft = depth + 4; // Additional left padding for sub-rows return ( <> {isCreating && ( - + {row.getVisibleCells() .map(cell => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} - - - {/* colSpan stretches the sub-row across the whole table */} - - setCreatingParentId(null)} - createRowMutation={createRowMutation} - creatingParentId={creatingParentId} - editingRowId={editingRowId} - setCreatingParentId={setCreatingParentId} - setEditingRowId={setEditingRowId} - maxDepth={maxDepth} - draftError={draftError} - isSavingDraft={isSavingDraft} - onStartDraft={onStartDraft} - setIsCreatingTopRow={setIsCreatingTopRow} - setDraftError={setDraftError} - /> - - + setCreatingParentId(null)} + creatingParentId={creatingParentId} + setCreatingParentId={setCreatingParentId} + depth={depth + 1} + draftError={draftError} + isSavingDraft={isSavingDraft} + setDraftError={setDraftError} + /> ); })} @@ -93,25 +78,19 @@ const SubTagsExpanded = ({ ); }; -SubTagsExpanded.propTypes = { +SubRowsExpanded.propTypes = { childRowsData: Proptypes.array.isRequired, visibleColumnCount: Proptypes.number, parentRowValue: Proptypes.string.isRequired, - parentRowId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]).isRequired, isCreating: Proptypes.bool, onSaveNewChildRow: Proptypes.func, onCancelCreation: Proptypes.func, - createRowMutation: Proptypes.object, creatingParentId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]), - editingRowId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]), setCreatingParentId: Proptypes.func, - setEditingRowId: Proptypes.func, - maxDepth: Proptypes.number, + depth: Proptypes.number, draftError: Proptypes.string, isSavingDraft: Proptypes.bool, - onStartDraft: Proptypes.func, - setIsCreatingTopRow: Proptypes.func, setDraftError: Proptypes.func, }; -export default SubTagsExpanded; +export default SubRowsExpanded; diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx index 77e17ba020..7865a98ab8 100644 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -163,113 +163,6 @@ EditableCell.defaultProps = { isSaving: false, }; -/** - * SubTagsExpanded Component - */ -const SubTagsExpanded = ({ - parentTagValue, - isCreating, - onSaveNewSubTag, - onCancelCreation, - subTagsData, - visibleColumnCount, - createTagMutation, - creatingParentId, - editingRowId, - setCreatingParentId, - setEditingRowId, - maxDepth, - draftError, - isSavingDraft, - onStartDraft, - setIsCreatingTopTag, - setDraftError, -}) => { - const columnCount = subTagsData?.[0]?.getVisibleCells?.().length || visibleColumnCount || 1; - - return ( - <> - {isCreating && ( - - - onSaveNewSubTag(val, parentTagValue)} - onCancel={() => { - setDraftError(''); - onCancelCreation(); - }} - /> - - - )} - {subTagsData?.map(row => { - const tagData = row.original || row; // Handle both raw and table row data - return ( - - - {row.getVisibleCells() - .map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - - - {/* colSpan stretches the sub-row across the whole table */} - - setCreatingParentId(null)} - createTagMutation={createTagMutation} - creatingParentId={creatingParentId} - editingRowId={editingRowId} - setCreatingParentId={setCreatingParentId} - setEditingRowId={setEditingRowId} - maxDepth={maxDepth} - draftError={draftError} - isSavingDraft={isSavingDraft} - onStartDraft={onStartDraft} - setIsCreatingTopTag={setIsCreatingTopTag} - setDraftError={setDraftError} - /> - - - - ); - })} - - ); -}; - -SubTagsExpanded.propTypes = { - subTagsData: Proptypes.array.isRequired, - visibleColumnCount: Proptypes.number, - parentTagValue: Proptypes.string.isRequired, - parentTagId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]).isRequired, - isCreating: Proptypes.bool, - onSaveNewSubTag: Proptypes.func, - onCancelCreation: Proptypes.func, - createTagMutation: Proptypes.object, - creatingParentId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]), - editingRowId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]), - setCreatingParentId: Proptypes.func, - setEditingRowId: Proptypes.func, - maxDepth: Proptypes.number, - draftError: Proptypes.string, - isSavingDraft: Proptypes.bool, - onStartDraft: Proptypes.func, - setIsCreatingTopTag: Proptypes.func, - setDraftError: Proptypes.func, -}; - /** * Expand toggle for rows with children (Updated for v8 API) */ diff --git a/src/taxonomy/tag-list/TreeTableBody.jsx b/src/taxonomy/tag-list/TreeTableBody.jsx new file mode 100644 index 0000000000..00aa0a6769 --- /dev/null +++ b/src/taxonomy/tag-list/TreeTableBody.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import Proptypes from 'prop-types'; +import { flexRender } from '@tanstack/react-table'; + +import SubRowsExpanded from './SubRowsExpanded'; +import messages from './messages'; +import EditableCell from './EditableCell'; + +const TreeTableBody = ({ + treeData, + columns, + isCreatingTopRow, + draftError, + handleCreateTopRow, + setIsCreatingTopRow, + exitDraftWithoutSave, + handleCreateChildRow, + creatingParentId, + setCreatingParentId, + setDraftError, + createRowMutation, + table, +}) => { + const intl = useIntl(); + + return ( + + {table.getRowModel().rows.length === 0 && ( + + + {intl.formatMessage(messages.noResultsFoundMessage)} + + + )} + + {isCreatingTopRow && ( + + + handleCreateTopRow(value, setToast)} + onCancel={() => { + setDraftError(''); + setIsCreatingTopRow(false); + exitDraftWithoutSave(); + }} + /> + + + )} + + {table.getRowModel().rows.filter(row => row.depth === 0).map(row => ( + + {/* Main Row */} + + {row.getVisibleCells() + .map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + + {/* Subcomponent Rendering */} + {row.getIsExpanded() && ( + { + setDraftError(''); + setCreatingParentId(null); + exitDraftWithoutSave(); + }} + creatingParentId={creatingParentId} + setCreatingParentId={setCreatingParentId} + depth={1} + draftError={draftError} + isSavingDraft={createRowMutation.isPending} + setDraftError={setDraftError} + /> + )} + + ))} + + ); +}; + +export default TreeTableBody; diff --git a/src/taxonomy/tag-list/tagTreeError.ts b/src/taxonomy/tag-list/tagTreeError.ts new file mode 100644 index 0000000000..5e1615f257 --- /dev/null +++ b/src/taxonomy/tag-list/tagTreeError.ts @@ -0,0 +1,6 @@ +export default class TagTreeError extends Error { + constructor(message: string) { + super(message); + this.name = 'TagTreeError'; + } +} From 942d374286faf636f33b85823c23ded578f08b33 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 2 Mar 2026 17:16:45 -0500 Subject: [PATCH 30/62] refactor: extract reusable tree table components --- .../tag-list/EditableTreeTableDisplay.jsx | 188 ------------------ src/taxonomy/tag-list/TagListTable.jsx | 20 +- .../{tag-list => tree-table}/EditableCell.jsx | 0 .../SubRowsExpanded.jsx | 0 .../TableBody.jsx} | 13 +- src/taxonomy/tree-table/TableView.jsx | 140 +++++++++++++ 6 files changed, 150 insertions(+), 211 deletions(-) delete mode 100644 src/taxonomy/tag-list/EditableTreeTableDisplay.jsx rename src/taxonomy/{tag-list => tree-table}/EditableCell.jsx (100%) rename src/taxonomy/{tag-list => tree-table}/SubRowsExpanded.jsx (100%) rename src/taxonomy/{tag-list/TreeTableBody.jsx => tree-table/TableBody.jsx} (92%) create mode 100644 src/taxonomy/tree-table/TableView.jsx diff --git a/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx b/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx deleted file mode 100644 index 86fa7431cf..0000000000 --- a/src/taxonomy/tag-list/EditableTreeTableDisplay.jsx +++ /dev/null @@ -1,188 +0,0 @@ -// @ts-check -import React from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { - Button, - Toast, - Card, - ActionRow, - Pagination, -} from '@openedx/paragon'; - -import { - useReactTable, - getCoreRowModel, - getExpandedRowModel, - flexRender, -} from '@tanstack/react-table'; - -import { LoadingSpinner } from '../../generic/Loading'; -import messages from './messages'; -import EditableCell from './EditableCell'; -import SubRowsExpanded from './SubRowsExpanded'; - -const EditableTreeTableDisplay = ({ - treeData, - columns, - pageCount, - pagination, - handlePaginationChange, - isLoading, - isCreatingTopRow, - draftError, - createRowMutation, - handleCreateTopRow, - toast, - setToast, - setIsCreatingTopRow, - exitDraftWithoutSave, - handleCreateChildRow, - creatingParentId, - setCreatingParentId, - setDraftError, -}) => { - // Initialize TanStack Table - const table = useReactTable({ - data: treeData, - columns, - getCoreRowModel: getCoreRowModel(), - getExpandedRowModel: getExpandedRowModel(), - // Manual pagination config - manualPagination: true, - pageCount: pageCount ?? -1, - state: { - pagination, - }, - onPaginationChange: handlePaginationChange, - getSubRows: (row) => row?.subRows || undefined, - }); - - const intl = useIntl(); - - return ( - - - - - } /> - - {isLoading ? ( - - ) : ( - - - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - ))} - - ))} - - - - {table.getRowModel().rows.length === 0 && ( - - - - )} - - {isCreatingTopRow && ( - - - - )} - {table.getRowModel().rows.filter(row => row.depth === 0).map(row => ( - - {/* Main Row */} - - {row.getVisibleCells() - .map(cell => ( - - ))} - - - {/* Subcomponent Rendering */} - {row.getIsExpanded() && ( - { - setDraftError(''); - setCreatingParentId(null); - exitDraftWithoutSave(); - }} - creatingParentId={creatingParentId} - setCreatingParentId={setCreatingParentId} - depth={1} - draftError={draftError} - isSavingDraft={createRowMutation.isPending} - setDraftError={setDraftError} - /> - )} - - ))} - -
    - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} -
    - {intl.formatMessage(messages.noResultsFoundMessage)} -
    - handleCreateTopRow(value, setToast)} - onCancel={() => { - setDraftError(''); - setIsCreatingTopRow(false); - exitDraftWithoutSave(); - }} /> -
    - {flexRender(cell.column.columnDef.cell, cell.getContext())} -
    -
    - )} - - {/* Basic Pagination Controls */} - {(pageCount) > 1 && ( -
    - - Page {table.getState().pagination.pageIndex + 1} of {pageCount} - - { - table.setPageIndex(page - 1); - }} - /> -
    - )} - { setToast((prevToast) => ({ ...prevToast, show: false }))} } - delay={15000} - className={toast.variant === 'danger' ? 'bg-danger-100 border-danger' : 'bg-success-100 border-success'} - > - {toast.message} - -
    - ); -}; - -export default EditableTreeTableDisplay; diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx index 7865a98ab8..074615a83c 100644 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ b/src/taxonomy/tag-list/TagListTable.jsx @@ -27,7 +27,7 @@ import { LoadingSpinner } from '../../generic/Loading'; import messages from './messages'; import { useTagListData, useSubTags, useCreateTag } from '../data/apiHooks'; import { TagTree } from './tagTree'; -import EditableTreeTableDisplay from './EditableTreeTableDisplay'; +import { TreeTableView } from '../tree-table'; // State machine for table modes @@ -535,27 +535,11 @@ const TagListTable = ({ taxonomyId, maxDepth }) => { setPagination(updater); }; - // Initialize TanStack Table - const table = useReactTable({ - data: tagTree?.getAllAsDeepCopy() || [], - columns, - getCoreRowModel: getCoreRowModel(), - getExpandedRowModel: getExpandedRowModel(), - // Manual pagination config - manualPagination: true, - pageCount: tagList?.numPages ?? -1, - state: { - pagination, - }, - onPaginationChange: handlePaginationChange, - getSubRows: (row) => row.subRows || undefined, - }); - const pageCount = tagList?.numPages ?? -1; const treeData = tagTree?.getAllAsDeepCopy() || []; return ( - { const intl = useIntl(); @@ -35,7 +38,7 @@ const TreeTableBody = ({ )} {isCreatingTopRow && ( - + { + // Initialize TanStack Table + const table = useReactTable({ + data: treeData, + columns, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + // Manual pagination config + manualPagination: true, + pageCount: pageCount ?? -1, + state: { + pagination, + }, + onPaginationChange: handlePaginationChange, + getSubRows: (row) => row?.subRows || undefined, + }); + + return ( + + + + + )} + /> + + {isLoading ? ( + + ) : ( + + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + +
    + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
    +
    + )} + + {/* Basic Pagination Controls */} + {(pageCount) > 1 && ( +
    + + Page {table.getState().pagination.pageIndex + 1} of {pageCount} + + { + table.setPageIndex(page - 1); + }} + /> +
    + )} + { setToast((prevToast) => ({ ...prevToast, show: false }))} } + delay={15000} + className={toast.variant === 'danger' ? 'bg-danger-100 border-danger' : 'bg-success-100 border-success'} + > + {toast.message} + +
    + ); +}; + +export default TableView; From 6ffa46b0ebff11d20a00beefa66c152ba0095066 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Mon, 2 Mar 2026 17:25:48 -0500 Subject: [PATCH 31/62] refactor: convert to typescript --- src/taxonomy/tree-table/EditableCell.jsx | 94 ------------ ...ubRowsExpanded.jsx => SubRowsExpanded.tsx} | 70 ++++----- src/taxonomy/tree-table/TableBody.jsx | 96 ------------ src/taxonomy/tree-table/TableView.jsx | 140 ------------------ 4 files changed, 36 insertions(+), 364 deletions(-) delete mode 100644 src/taxonomy/tree-table/EditableCell.jsx rename src/taxonomy/tree-table/{SubRowsExpanded.jsx => SubRowsExpanded.tsx} (64%) delete mode 100644 src/taxonomy/tree-table/TableBody.jsx delete mode 100644 src/taxonomy/tree-table/TableView.jsx diff --git a/src/taxonomy/tree-table/EditableCell.jsx b/src/taxonomy/tree-table/EditableCell.jsx deleted file mode 100644 index 173603907b..0000000000 --- a/src/taxonomy/tree-table/EditableCell.jsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import Proptypes from 'prop-types'; - -import { Button, Spinner } from '@openedx/paragon'; - -const EditableCell = ({ - initialValue, - onSave, - onCancel, - errorMessage, - isSaving, - getInlineValidationMessage = () => '', -}) => { - const [value, setValue] = useState(initialValue); - - useEffect(() => { - setValue(initialValue); - }, [initialValue]); - - const validationMessage = getInlineValidationMessage(value); - const effectiveErrorMessage = errorMessage || validationMessage; - const isSaveDisabled = Boolean(validationMessage) || isSaving; - - const handleSave = () => { - if (!isSaveDisabled) { - onSave(value); - } - }; - - const handleKeyDown = (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSave(); - } else if (e.key === 'Escape') { - onCancel(); - } - }; - - return ( - - - setValue(e.target.value)} - onKeyDown={handleKeyDown} - onClick={(e) => e.stopPropagation()} - placeholder="Type tag name" - /> - {effectiveErrorMessage && ( -
    {effectiveErrorMessage}
    - )} -
    - - - - - - - {isSaving && ( - - )} -
    - ); -}; - -EditableCell.propTypes = { - initialValue: Proptypes.string, - onSave: Proptypes.func.isRequired, - onCancel: Proptypes.func.isRequired, - errorMessage: Proptypes.string, - isSaving: Proptypes.bool, - getInlineValidationMessage: Proptypes.func, -}; - -EditableCell.defaultProps = { - initialValue: '', - errorMessage: '', - isSaving: false, - getInlineValidationMessage: () => '', -}; - -export default EditableCell; diff --git a/src/taxonomy/tree-table/SubRowsExpanded.jsx b/src/taxonomy/tree-table/SubRowsExpanded.tsx similarity index 64% rename from src/taxonomy/tree-table/SubRowsExpanded.jsx rename to src/taxonomy/tree-table/SubRowsExpanded.tsx index 031f71c88a..f58e956eea 100644 --- a/src/taxonomy/tree-table/SubRowsExpanded.jsx +++ b/src/taxonomy/tree-table/SubRowsExpanded.tsx @@ -1,25 +1,43 @@ import React from 'react'; -import Proptypes from 'prop-types'; import { flexRender } from '@tanstack/react-table'; import EditableCell from './EditableCell'; +import type { + RowId, + TreeRow, +} from './types'; + +interface SubRowsExpandedProps { + parentRowValue: string; + isCreating?: boolean; + onSaveNewChildRow?: (value: string, parentRowValue: string) => void; + onCancelCreation?: () => void; + childRowsData?: TreeRow[]; + visibleColumnCount?: number; + depth?: number; + draftError?: string; + isSavingDraft?: boolean; + setDraftError?: (error: string) => void; + creatingParentId?: RowId | null; + setCreatingParentId?: (value: RowId | null) => void; +} const SubRowsExpanded = ({ parentRowValue, - isCreating, - onSaveNewChildRow, - onCancelCreation, - childRowsData, + isCreating = false, + onSaveNewChildRow = () => {}, + onCancelCreation = () => {}, + childRowsData = [], visibleColumnCount, - depth, - draftError, - isSavingDraft, - setDraftError, - creatingParentId, - setCreatingParentId, -}) => { + depth = 1, + draftError = '', + isSavingDraft = false, + setDraftError = () => {}, + creatingParentId = null, + setCreatingParentId = () => {}, +}: SubRowsExpandedProps) => { const columnCount = childRowsData?.[0]?.getVisibleCells?.().length || visibleColumnCount || 1; - const paddingLeft = depth + 4; // Additional left padding for sub-rows + const paddingLeft = depth + 4; return ( <> @@ -45,9 +63,9 @@ const SubRowsExpanded = ({ )} {childRowsData?.map(row => { - const rowData = row.original || row; // Handle both raw and table row data + const rowData = row.original || row; return ( - + {row.getVisibleCells() .map(cell => ( @@ -57,10 +75,9 @@ const SubRowsExpanded = ({ ))} setCreatingParentId(null)} @@ -78,19 +95,4 @@ const SubRowsExpanded = ({ ); }; -SubRowsExpanded.propTypes = { - childRowsData: Proptypes.array.isRequired, - visibleColumnCount: Proptypes.number, - parentRowValue: Proptypes.string.isRequired, - isCreating: Proptypes.bool, - onSaveNewChildRow: Proptypes.func, - onCancelCreation: Proptypes.func, - creatingParentId: Proptypes.oneOfType([Proptypes.string, Proptypes.number]), - setCreatingParentId: Proptypes.func, - depth: Proptypes.number, - draftError: Proptypes.string, - isSavingDraft: Proptypes.bool, - setDraftError: Proptypes.func, -}; - -export default SubRowsExpanded; +export default SubRowsExpanded; \ No newline at end of file diff --git a/src/taxonomy/tree-table/TableBody.jsx b/src/taxonomy/tree-table/TableBody.jsx deleted file mode 100644 index 40fa31d069..0000000000 --- a/src/taxonomy/tree-table/TableBody.jsx +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { flexRender } from '@tanstack/react-table'; - -import SubRowsExpanded from './SubRowsExpanded'; - -// TODO: refactor to remove dependency -import messages from '../tag-list/messages'; - -import EditableCell from './EditableCell'; - -const TableBody = ({ - treeData, - columns, - isCreatingTopRow, - draftError, - handleCreateTopRow, - setIsCreatingTopRow, - exitDraftWithoutSave, - handleCreateChildRow, - creatingParentId, - setCreatingParentId, - setDraftError, - createRowMutation, - table, - setToast, -}) => { - const intl = useIntl(); - - return ( - - {table.getRowModel().rows.length === 0 && ( - - - {intl.formatMessage(messages.noResultsFoundMessage)} - - - )} - - {isCreatingTopRow && ( - - - handleCreateTopRow(value, setToast)} - onCancel={() => { - setDraftError(''); - setIsCreatingTopRow(false); - exitDraftWithoutSave(); - }} - /> - - - )} - - {table.getRowModel().rows.filter(row => row.depth === 0).map(row => ( - - {/* Main Row */} - - {row.getVisibleCells() - .map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - - {/* Subcomponent Rendering */} - {row.getIsExpanded() && ( - { - setDraftError(''); - setCreatingParentId(null); - exitDraftWithoutSave(); - }} - creatingParentId={creatingParentId} - setCreatingParentId={setCreatingParentId} - depth={1} - draftError={draftError} - isSavingDraft={createRowMutation.isPending} - setDraftError={setDraftError} - /> - )} - - ))} - - ); -}; - -export default TableBody; diff --git a/src/taxonomy/tree-table/TableView.jsx b/src/taxonomy/tree-table/TableView.jsx deleted file mode 100644 index af57106f60..0000000000 --- a/src/taxonomy/tree-table/TableView.jsx +++ /dev/null @@ -1,140 +0,0 @@ -// @ts-check -import React from 'react'; -import { - Button, - Toast, - Card, - ActionRow, - Pagination, -} from '@openedx/paragon'; - -import { - useReactTable, - getCoreRowModel, - getExpandedRowModel, - flexRender, -} from '@tanstack/react-table'; - -import { LoadingSpinner } from '../../generic/Loading'; -import TableBody from './TableBody'; - -const TableView = ({ - treeData, - columns, - pageCount, - pagination, - handlePaginationChange, - isLoading, - isCreatingTopRow, - draftError, - createRowMutation, - handleCreateTopRow, - toast, - setToast, - setIsCreatingTopRow, - exitDraftWithoutSave, - handleCreateChildRow, - creatingParentId, - setCreatingParentId, - setDraftError, -}) => { - // Initialize TanStack Table - const table = useReactTable({ - data: treeData, - columns, - getCoreRowModel: getCoreRowModel(), - getExpandedRowModel: getExpandedRowModel(), - // Manual pagination config - manualPagination: true, - pageCount: pageCount ?? -1, - state: { - pagination, - }, - onPaginationChange: handlePaginationChange, - getSubRows: (row) => row?.subRows || undefined, - }); - - return ( - - - - - )} - /> - - {isLoading ? ( - - ) : ( - - - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - ))} - - ))} - - - -
    - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
    -
    - )} - - {/* Basic Pagination Controls */} - {(pageCount) > 1 && ( -
    - - Page {table.getState().pagination.pageIndex + 1} of {pageCount} - - { - table.setPageIndex(page - 1); - }} - /> -
    - )} - { setToast((prevToast) => ({ ...prevToast, show: false }))} } - delay={15000} - className={toast.variant === 'danger' ? 'bg-danger-100 border-danger' : 'bg-success-100 border-success'} - > - {toast.message} - -
    - ); -}; - -export default TableView; From be8dfd1510f982ca89d152eb0d4980fbcef0ef3f Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Tue, 3 Mar 2026 13:47:14 -0500 Subject: [PATCH 32/62] refactor: make tree table more readable --- src/taxonomy/tag-list/TagListTable.jsx | 575 -------------------- src/taxonomy/tag-list/columns.tsx | 222 ++++++++ src/taxonomy/tag-list/constants.js | 9 - src/taxonomy/tag-list/messages.ts | 4 + src/taxonomy/tree-table/SubRowsExpanded.tsx | 98 ---- 5 files changed, 226 insertions(+), 682 deletions(-) delete mode 100644 src/taxonomy/tag-list/TagListTable.jsx create mode 100644 src/taxonomy/tag-list/columns.tsx delete mode 100644 src/taxonomy/tag-list/constants.js delete mode 100644 src/taxonomy/tree-table/SubRowsExpanded.tsx diff --git a/src/taxonomy/tag-list/TagListTable.jsx b/src/taxonomy/tag-list/TagListTable.jsx deleted file mode 100644 index 074615a83c..0000000000 --- a/src/taxonomy/tag-list/TagListTable.jsx +++ /dev/null @@ -1,575 +0,0 @@ -// @ts-check -import React, { useState, useMemo, useEffect, useReducer } from 'react'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { - Button, - Toast, - Card, - ActionRow, - Icon, - IconButton, - IconButtonWithTooltip, - Spinner, - Pagination, -} from '@openedx/paragon'; -import { AddCircle, MoreVert } from '@openedx/paragon/icons'; -import { isEqual, set } from 'lodash'; -import Proptypes from 'prop-types'; - -import { - useReactTable, - getCoreRowModel, - getExpandedRowModel, - flexRender, -} from '@tanstack/react-table'; - -import { LoadingSpinner } from '../../generic/Loading'; -import messages from './messages'; -import { useTagListData, useSubTags, useCreateTag } from '../data/apiHooks'; -import { TagTree } from './tagTree'; -import { TreeTableView } from '../tree-table'; - -// State machine for table modes - -const TABLE_MODES = { - VIEW: 'view', - DRAFT: 'draft', - PREVIEW: 'preview', -} - -const TRANSITION_TABLE = { - [TABLE_MODES.VIEW]: [TABLE_MODES.DRAFT], - [TABLE_MODES.DRAFT]: [TABLE_MODES.PREVIEW], - [TABLE_MODES.PREVIEW]: [TABLE_MODES.DRAFT, TABLE_MODES.VIEW], -} - -const TABLE_MODE_ACTIONS = { - TRANSITION: 'transition', -}; - -const TAG_NAME_PATTERN = /^[\w\- ]+$/; - -const getInlineValidationMessage = (value) => { - const trimmed = value.trim(); - if (!trimmed) { - return 'Name is required'; - } - if (!TAG_NAME_PATTERN.test(trimmed)) { - return 'Invalid character in tag name'; - } - return ''; -}; - -/** @type {import('react').Reducer} */ -const tableModeReducer = (currentMode, action) => { - if (action?.type !== TABLE_MODE_ACTIONS.TRANSITION) { - throw new Error(`Unknown table mode action: ${action?.type}`); - } - - const { targetMode } = action; - if (TRANSITION_TABLE[currentMode].includes(targetMode)) { - return targetMode; - } - - throw new Error(`Invalid table mode transition from ${currentMode} to ${targetMode}`); -}; - -/** - * 1. Reusable Editable Cell - */ -const EditableCell = ({ - initialValue, - onSave, - onCancel, - errorMessage, - isSaving, -}) => { - const [value, setValue] = useState(initialValue); - - useEffect(() => { - setValue(initialValue); - }, [initialValue]); - - const validationMessage = getInlineValidationMessage(value); - const effectiveErrorMessage = errorMessage || validationMessage; - const isSaveDisabled = Boolean(validationMessage) || isSaving; - - const handleSave = () => { - if (!isSaveDisabled) { - onSave(value); - } - }; - - const handleKeyDown = (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSave(); - } else if (e.key === 'Escape') { - onCancel(); - } - }; - - return ( - - - setValue(e.target.value)} - onKeyDown={handleKeyDown} - onClick={(e) => e.stopPropagation()} - placeholder='Type tag name' - /> - {effectiveErrorMessage && ( -
    {effectiveErrorMessage}
    - )} -
    - - - - - - - {isSaving && ( - - )} -
    - ); -}; - -EditableCell.propTypes = { - initialValue: Proptypes.string, - onSave: Proptypes.func.isRequired, - onCancel: Proptypes.func.isRequired, - errorMessage: Proptypes.string, - isSaving: Proptypes.bool, -}; - -EditableCell.defaultProps = { - initialValue: '', - errorMessage: '', - isSaving: false, -}; - -/** - * Expand toggle for rows with children (Updated for v8 API) - */ -const OptionalExpandLink = ({ row }) => { - return ( - row.depth === 0 && row.original.childCount > 0 ? ( - - Expand row - - ) : null - ) -}; -OptionalExpandLink.propTypes = { row: Proptypes.object.isRequired }; - -function getColumns({ - intl, - handleCreateTopTag, - setIsCreatingTopTag, - setCreatingParentId, - handleUpdateTag, - setEditingRowId, - setToast, - onStartDraft, - activeActionMenuRowId, - setActiveActionMenuRowId, - hasOpenDraft, - draftError, - setDraftError, - isSavingDraft, - maxDepth, - creatingParentId, -}) { - const canAddSubtag = (row) => row.original.depth < maxDepth; - - return [ - { - header: intl.formatMessage(messages.tagListColumnValueHeader), - cell: ({ row }) => { - const { isNew, isEditing, value, descendantCount, id } = row.original; - - if (isNew) { - return ( - handleCreateTopTag(value, setToast)} - onCancel={() => { - setDraftError(''); - setIsCreatingTopTag(false); - }} /> - ); - } - - if (isEditing) { - return ( - handleUpdateTag(id, newVal, value)} - onCancel={() => { - setDraftError(''); - setEditingRowId(null); - }} /> - ); - } - - return ( - <> - {value} - {` (${descendantCount})`} - - ); - }, - }, - { - id: 'expander', - header: () => <>, - cell: OptionalExpandLink, - }, - { - id: 'add', - header: () => ( - Create a new tag
    } - src={AddCircle} - alt="Create Tag" - size="inline" - onClick={() => { - onStartDraft(); - setDraftError(''); - setIsCreatingTopTag(true); - setEditingRowId(null); - setActiveActionMenuRowId(null); - }} - disabled={hasOpenDraft} - /> - ), - cell: ({ row }) => { - if (row.original.isNew || !canAddSubtag(row)) { - return
    ; - } - - const isMenuOpen = activeActionMenuRowId === row.original.id; - const disableAddSubtag = hasOpenDraft && creatingParentId !== row.original.id; - const startSubtagDraft = () => { - onStartDraft(); - setDraftError(''); - setCreatingParentId(row.original.id); - setEditingRowId(null); - setIsCreatingTopTag(false); - setActiveActionMenuRowId(null); - row.toggleExpanded(true); - }; - - return ( -
    - { - setActiveActionMenuRowId(isMenuOpen ? null : row.original.id); - }} - disabled={disableAddSubtag} - /> - {isMenuOpen && ( - - )} -
    - ); - } - }, - // { - // id: 'edit', - // cell: ({ row }) => { - // if (row.original.isNew) { - // return
    ; - // } - - // return ( - //
    - // { - // setEditingRowId(row.original.id); - // setCreatingParentId(null); - // } } - // > - // Edit - // - //
    - // ); - // } - // }, - ]; -} - -// function addEditRow(data, editingRowId) { -// if (!data) return [] -// const augmentedData = data.map(item => ({ -// ...item, -// isEditing: item.id === editingRowId, -// })); -// const tree = new TagTree(augmentedData); - -// return tree.getAllAsDeepCopy(); -// } - -// function getDisplayData(data, editingRowId, creatingParentId, tableMode) { -// if (tableMode === TABLE_MODES.DRAFT && creatingParentId === 'top') { -// data.unshift({ -// id: 'draft-top-row', -// isNew: true, -// value: '', -// descendantCount: 0, -// childCount: 0, -// }); -// } -// return data; -// } - -const TagListTable = ({ taxonomyId, maxDepth }) => { - // The table has a VIEW, DRAFT, and a PREVIEW mode. It starts in VIEW mode. - // It switches to DRAFT mode when a user edits or creates a tag. It switches to PREVIEW mode after saving changes, - // and only switches to VIEW when the user refreshes the page, orders a column, or navigates to a different page of the table. - // During DRAFT and PREVIEW mode the table makes POST requests to the backend and receives success or failure responses. - // However, the table does not refresh to show the updated data from the backend. - // This allows us to show the newly created or updated tag in the same place without reordering. - const intl = useIntl(); - - // Standardizing pagination state for TanStack v8 - const [{ pageIndex, pageSize }, setPagination] = useState({ - pageIndex: 0, - pageSize: 100, - }); - - const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); - - const [creatingParentId, setCreatingParentId] = useState(null); - const [editingRowId, setEditingRowId] = useState(null); - const [toast, setToast] = useState({ show: false, message: '', variant: 'success' }); - - const [tableMode, dispatchTableMode] = useReducer(tableModeReducer, TABLE_MODES.VIEW); - const [tagTree, setTagTree] = useState(/** @type {TagTree | null} */(null)); - const [isCreatingTopTag, setIsCreatingTopTag] = useState(false); - const [activeActionMenuRowId, setActiveActionMenuRowId] = useState(null); - const [draftError, setDraftError] = useState(''); - - const transitionTableMode = (targetMode) => { - if (targetMode === tableMode) { - return; - } - dispatchTableMode({ type: TABLE_MODE_ACTIONS.TRANSITION, targetMode }); - }; - - const enterDraftMode = () => { - transitionTableMode(TABLE_MODES.DRAFT); - }; - - const exitDraftWithoutSave = () => { - transitionTableMode(TABLE_MODES.PREVIEW); - }; - - const applyLocalTagPreview = (value, parentTagValue = null) => { - setTagTree((currentTagTree) => { - const nextTree = currentTagTree || new TagTree([]); - const parentTag = parentTagValue ? nextTree.getTagAsDeepCopy(parentTagValue) : null; - - nextTree.addNode({ - id: Date.now(), - value, - parentValue: parentTagValue, - depth: parentTag ? parentTag.depth + 1 : 0, - childCount: 0, - descendantCount: 0, - subTagsUrl: null, - externalId: null, - }, parentTagValue); - - return nextTree; - }); - }; - - const { isLoading, data: tagList } = useTagListData(taxonomyId, { - ...pagination, - enabled: tableMode === TABLE_MODES.VIEW, - }); - const createTagMutation = useCreateTag(taxonomyId); - - useEffect(() => { - // get row data if table is in VIEW mode, otherwise keep current data to avoid disrupting user while they are editing or creating a tag - if (tableMode === TABLE_MODES.VIEW && tagList?.results) { - const tree = new TagTree(tagList?.results); - if (tree) { - setTagTree(tree); - } - } - }, [tagList?.results, editingRowId, pagination, tableMode]); - - const handleCreateTopTag = async (value, setToast) => { - const trimmed = value.trim(); - const validationError = getInlineValidationMessage(trimmed); - if (validationError) { - setDraftError(validationError); - return; - } - - try { - setDraftError(''); - await createTagMutation.mutateAsync({ value: trimmed }); - applyLocalTagPreview(trimmed); - transitionTableMode(TABLE_MODES.PREVIEW); - setToast({ - show: true, - message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), - variant: 'success', - }); - setIsCreatingTopTag(false); - } catch (error) { - transitionTableMode(TABLE_MODES.PREVIEW); - setDraftError(/** @type {any} */(error)?.message || intl.formatMessage(messages.tagCreationErrorMessage)); - setToast({ show: true, message: 'Toast: Tag not saved', variant: 'danger' }); - } - }; - - const handleCreateSubTag = async (value, parentTagValue) => { - const trimmed = value.trim(); - const validationError = getInlineValidationMessage(trimmed); - if (validationError) { - setDraftError(validationError); - return; - } - - try { - setDraftError(''); - await createTagMutation.mutateAsync({ value: trimmed, parentTagValue }); - applyLocalTagPreview(trimmed, parentTagValue); - transitionTableMode(TABLE_MODES.PREVIEW); - setToast({ - show: true, - message: intl.formatMessage(messages.tagCreationSuccessMessage, { name: trimmed }), - variant: 'success', - }); - setCreatingParentId(null); - } catch (error) { - transitionTableMode(TABLE_MODES.PREVIEW); - setDraftError(/** @type {any} */(error)?.message || intl.formatMessage(messages.tagCreationErrorMessage)); - setToast({ show: true, message: 'Toast: Tag not saved', variant: 'danger' }); - } - }; - - const handleUpdateTag = async (id, value, originalValue) => { - const trimmed = value.trim(); - if (trimmed && trimmed !== originalValue) { - console.log('Update backend here', id, trimmed); - } - setEditingRowId(null); - }; - - const hasOpenDraft = isCreatingTopTag || creatingParentId !== null || editingRowId !== null; - - const columns = useMemo(() => getColumns({ - intl, - handleCreateTopTag, - setIsCreatingTopTag, - setCreatingParentId, - handleUpdateTag, - setEditingRowId, - setToast, - onStartDraft: enterDraftMode, - activeActionMenuRowId, - setActiveActionMenuRowId, - hasOpenDraft, - draftError, - setDraftError, - isSavingDraft: createTagMutation.isPending, - maxDepth, - creatingParentId, - }), - [ - intl, - isCreatingTopTag, - editingRowId, - tableMode, - activeActionMenuRowId, - hasOpenDraft, - creatingParentId, - draftError, - createTagMutation.isPending, - maxDepth, - ] - ); - - const handlePaginationChange = (updater) => { - if (tableMode === TABLE_MODES.PREVIEW) { - transitionTableMode(TABLE_MODES.VIEW); - } - setPagination(updater); - }; - - const pageCount = tagList?.numPages ?? -1; - const treeData = tagTree?.getAllAsDeepCopy() || []; - - return ( - - ); -}; - -TagListTable.propTypes = { - taxonomyId: Proptypes.number.isRequired, -}; - -export default TagListTable; diff --git a/src/taxonomy/tag-list/columns.tsx b/src/taxonomy/tag-list/columns.tsx new file mode 100644 index 0000000000..5fee31b217 --- /dev/null +++ b/src/taxonomy/tag-list/columns.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { + Button, + Icon, + IconButton, + IconButtonWithTooltip, +} from '@openedx/paragon'; +import { AddCircle, MoreVert } from '@openedx/paragon/icons'; +import type { Row } from '@tanstack/react-table'; +import type { IntlShape } from 'react-intl'; + +import messages from './messages'; +import type { + RowId, + TreeColumnDef, + TreeRowData, +} from '../tree-table/types'; +import { EditableCell } from '../tree-table'; + +interface TagListRowData extends TreeRowData { + depth: number; + childCount: number; + descendantCount: number; + isNew?: boolean; + isEditing?: boolean; +} + +const asTagListRowData = (row: Row): TagListRowData => ( + row.original as unknown as TagListRowData +); + +interface GetColumnsArgs { + intl: IntlShape; + handleCreateTopTag: (value: string) => void; + setIsCreatingTopTag: (isCreating: boolean) => void; + setCreatingParentId: (id: RowId | null) => void; + handleUpdateTag: (value: string, originalValue: string) => void; + setEditingRowId: (id: RowId | null) => void; + onStartDraft: () => void; + activeActionMenuRowId: RowId | null; + setActiveActionMenuRowId: (id: RowId | null) => void; + hasOpenDraft: boolean; + draftError: string; + setDraftError: (error: string) => void; + isSavingDraft: boolean; + maxDepth: number; + creatingParentId: RowId | null; +} + +const OptionalExpandLink = ({ row }: { row: Row }) => ( + row.depth === 0 && asTagListRowData(row).childCount > 0 ? ( + + ) : null +); + +function getColumns({ + intl, + handleCreateTopTag, + setIsCreatingTopTag, + setCreatingParentId, + handleUpdateTag, + setEditingRowId, + onStartDraft, + activeActionMenuRowId, + setActiveActionMenuRowId, + hasOpenDraft, + draftError, + setDraftError, + isSavingDraft, + maxDepth, + creatingParentId, +}: GetColumnsArgs): TreeColumnDef[] { + const canAddSubtag = (row: Row) => asTagListRowData(row).depth < maxDepth; + + return [ + { + header: intl.formatMessage(messages.tagListColumnValueHeader), + cell: ({ row }: { row: Row }) => { + const { + isNew, + isEditing, + value, + descendantCount, + } = asTagListRowData(row); + + if (isNew) { + return ( + handleCreateTopTag(newValue)} + onCancel={() => { + setDraftError(''); + setIsCreatingTopTag(false); + }} + /> + ); + } + + if (isEditing) { + return ( + handleUpdateTag(newVal, value)} + onCancel={() => { + setDraftError(''); + setEditingRowId(null); + }} + /> + ); + } + + return ( + <> + {value} + {` (${descendantCount})`} + + ); + }, + }, + { + id: 'expander', + header: () => null, + cell: OptionalExpandLink, + }, + { + id: 'add', + header: () => ( + Create a new tag
    } + src={AddCircle} + alt="Create Tag" + size="inline" + onClick={() => { + onStartDraft(); + setDraftError(''); + setIsCreatingTopTag(true); + setEditingRowId(null); + setActiveActionMenuRowId(null); + }} + disabled={hasOpenDraft} + /> + ), + cell: ({ row }: { row: Row }) => { + const rowData = asTagListRowData(row); + + if (rowData.isNew || !canAddSubtag(row)) { + return
    ; + } + + const isMenuOpen = activeActionMenuRowId === rowData.id; + const disableAddSubtag = hasOpenDraft && creatingParentId !== rowData.id; + const startSubtagDraft = () => { + onStartDraft(); + setDraftError(''); + setCreatingParentId(rowData.id); + setEditingRowId(null); + setIsCreatingTopTag(false); + setActiveActionMenuRowId(null); + row.toggleExpanded(true); + }; + + return ( +
    + { + setActiveActionMenuRowId(isMenuOpen ? null : rowData.id); + }} + disabled={disableAddSubtag} + /> + {isMenuOpen && ( + + )} +
    + ); + }, + }, + // { + // id: 'edit', + // cell: ({ row }) => { + // if (row.original.isNew) { + // return
    ; + // } + + // return ( + //
    + // { + // setEditingRowId(row.original.id); + // setCreatingParentId(null); + // } } + // > + // Edit + // + //
    + // ); + // } + // }, + ]; +} + +export { getColumns }; diff --git a/src/taxonomy/tag-list/constants.js b/src/taxonomy/tag-list/constants.js deleted file mode 100644 index 254cb06549..0000000000 --- a/src/taxonomy/tag-list/constants.js +++ /dev/null @@ -1,9 +0,0 @@ -const TABLE_MODES = { - VIEW: 'view', - DRAFT: 'draft', - PREVIEW: 'preview', -}; - -export { - TABLE_MODES, -}; diff --git a/src/taxonomy/tag-list/messages.ts b/src/taxonomy/tag-list/messages.ts index 2e9b7ecadb..40db643e21 100644 --- a/src/taxonomy/tag-list/messages.ts +++ b/src/taxonomy/tag-list/messages.ts @@ -53,6 +53,10 @@ const messages = defineMessages({ id: 'course-authoring.tag-list.hide-subtags.button-label', defaultMessage: 'Hide Subtags', }, + tagUpdateSuccessMessage: { + id: 'course-authoring.tag-list.update-success', + defaultMessage: 'Tag \"{name}\" updated successfully', + }, }); export default messages; diff --git a/src/taxonomy/tree-table/SubRowsExpanded.tsx b/src/taxonomy/tree-table/SubRowsExpanded.tsx deleted file mode 100644 index f58e956eea..0000000000 --- a/src/taxonomy/tree-table/SubRowsExpanded.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import { flexRender } from '@tanstack/react-table'; - -import EditableCell from './EditableCell'; -import type { - RowId, - TreeRow, -} from './types'; - -interface SubRowsExpandedProps { - parentRowValue: string; - isCreating?: boolean; - onSaveNewChildRow?: (value: string, parentRowValue: string) => void; - onCancelCreation?: () => void; - childRowsData?: TreeRow[]; - visibleColumnCount?: number; - depth?: number; - draftError?: string; - isSavingDraft?: boolean; - setDraftError?: (error: string) => void; - creatingParentId?: RowId | null; - setCreatingParentId?: (value: RowId | null) => void; -} - -const SubRowsExpanded = ({ - parentRowValue, - isCreating = false, - onSaveNewChildRow = () => {}, - onCancelCreation = () => {}, - childRowsData = [], - visibleColumnCount, - depth = 1, - draftError = '', - isSavingDraft = false, - setDraftError = () => {}, - creatingParentId = null, - setCreatingParentId = () => {}, -}: SubRowsExpandedProps) => { - const columnCount = childRowsData?.[0]?.getVisibleCells?.().length || visibleColumnCount || 1; - const paddingLeft = depth + 4; - - return ( - <> - {isCreating && ( - - - onSaveNewChildRow(val, parentRowValue)} - onCancel={() => { - setDraftError(''); - onCancelCreation(); - }} - getInlineValidationMessage={(value) => { - if (!value.trim()) { - return 'Name cannot be empty.'; - } - return ''; - }} - /> - - - )} - {childRowsData?.map(row => { - const rowData = row.original || row; - return ( - - - {row.getVisibleCells() - .map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - setCreatingParentId(null)} - creatingParentId={creatingParentId} - setCreatingParentId={setCreatingParentId} - depth={depth + 1} - draftError={draftError} - isSavingDraft={isSavingDraft} - setDraftError={setDraftError} - /> - - ); - })} - - ); -}; - -export default SubRowsExpanded; \ No newline at end of file From 25a6d2c3026a45db31b53f17a9af3ab9ee2dde9a Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 4 Mar 2026 12:37:49 -0500 Subject: [PATCH 33/62] fix: delete duplicate file --- src/taxonomy/tag-list/columns.tsx | 222 ------------------------------ 1 file changed, 222 deletions(-) delete mode 100644 src/taxonomy/tag-list/columns.tsx diff --git a/src/taxonomy/tag-list/columns.tsx b/src/taxonomy/tag-list/columns.tsx deleted file mode 100644 index 5fee31b217..0000000000 --- a/src/taxonomy/tag-list/columns.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React from 'react'; -import { - Button, - Icon, - IconButton, - IconButtonWithTooltip, -} from '@openedx/paragon'; -import { AddCircle, MoreVert } from '@openedx/paragon/icons'; -import type { Row } from '@tanstack/react-table'; -import type { IntlShape } from 'react-intl'; - -import messages from './messages'; -import type { - RowId, - TreeColumnDef, - TreeRowData, -} from '../tree-table/types'; -import { EditableCell } from '../tree-table'; - -interface TagListRowData extends TreeRowData { - depth: number; - childCount: number; - descendantCount: number; - isNew?: boolean; - isEditing?: boolean; -} - -const asTagListRowData = (row: Row): TagListRowData => ( - row.original as unknown as TagListRowData -); - -interface GetColumnsArgs { - intl: IntlShape; - handleCreateTopTag: (value: string) => void; - setIsCreatingTopTag: (isCreating: boolean) => void; - setCreatingParentId: (id: RowId | null) => void; - handleUpdateTag: (value: string, originalValue: string) => void; - setEditingRowId: (id: RowId | null) => void; - onStartDraft: () => void; - activeActionMenuRowId: RowId | null; - setActiveActionMenuRowId: (id: RowId | null) => void; - hasOpenDraft: boolean; - draftError: string; - setDraftError: (error: string) => void; - isSavingDraft: boolean; - maxDepth: number; - creatingParentId: RowId | null; -} - -const OptionalExpandLink = ({ row }: { row: Row }) => ( - row.depth === 0 && asTagListRowData(row).childCount > 0 ? ( - - ) : null -); - -function getColumns({ - intl, - handleCreateTopTag, - setIsCreatingTopTag, - setCreatingParentId, - handleUpdateTag, - setEditingRowId, - onStartDraft, - activeActionMenuRowId, - setActiveActionMenuRowId, - hasOpenDraft, - draftError, - setDraftError, - isSavingDraft, - maxDepth, - creatingParentId, -}: GetColumnsArgs): TreeColumnDef[] { - const canAddSubtag = (row: Row) => asTagListRowData(row).depth < maxDepth; - - return [ - { - header: intl.formatMessage(messages.tagListColumnValueHeader), - cell: ({ row }: { row: Row }) => { - const { - isNew, - isEditing, - value, - descendantCount, - } = asTagListRowData(row); - - if (isNew) { - return ( - handleCreateTopTag(newValue)} - onCancel={() => { - setDraftError(''); - setIsCreatingTopTag(false); - }} - /> - ); - } - - if (isEditing) { - return ( - handleUpdateTag(newVal, value)} - onCancel={() => { - setDraftError(''); - setEditingRowId(null); - }} - /> - ); - } - - return ( - <> - {value} - {` (${descendantCount})`} - - ); - }, - }, - { - id: 'expander', - header: () => null, - cell: OptionalExpandLink, - }, - { - id: 'add', - header: () => ( - Create a new tag
    } - src={AddCircle} - alt="Create Tag" - size="inline" - onClick={() => { - onStartDraft(); - setDraftError(''); - setIsCreatingTopTag(true); - setEditingRowId(null); - setActiveActionMenuRowId(null); - }} - disabled={hasOpenDraft} - /> - ), - cell: ({ row }: { row: Row }) => { - const rowData = asTagListRowData(row); - - if (rowData.isNew || !canAddSubtag(row)) { - return
    ; - } - - const isMenuOpen = activeActionMenuRowId === rowData.id; - const disableAddSubtag = hasOpenDraft && creatingParentId !== rowData.id; - const startSubtagDraft = () => { - onStartDraft(); - setDraftError(''); - setCreatingParentId(rowData.id); - setEditingRowId(null); - setIsCreatingTopTag(false); - setActiveActionMenuRowId(null); - row.toggleExpanded(true); - }; - - return ( -
    - { - setActiveActionMenuRowId(isMenuOpen ? null : rowData.id); - }} - disabled={disableAddSubtag} - /> - {isMenuOpen && ( - - )} -
    - ); - }, - }, - // { - // id: 'edit', - // cell: ({ row }) => { - // if (row.original.isNew) { - // return
    ; - // } - - // return ( - //
    - // { - // setEditingRowId(row.original.id); - // setCreatingParentId(null); - // } } - // > - // Edit - // - //
    - // ); - // } - // }, - ]; -} - -export { getColumns }; From 29b2af5a0c3cda9af1f2c18c214e64017c37fc38 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Wed, 4 Mar 2026 18:13:58 -0500 Subject: [PATCH 34/62] fix: expand rows style --- src/taxonomy/tag-list/messages.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/taxonomy/tag-list/messages.ts b/src/taxonomy/tag-list/messages.ts index 40db643e21..fa1a0940f0 100644 --- a/src/taxonomy/tag-list/messages.ts +++ b/src/taxonomy/tag-list/messages.ts @@ -57,6 +57,10 @@ const messages = defineMessages({ id: 'course-authoring.tag-list.update-success', defaultMessage: 'Tag \"{name}\" updated successfully', }, + addSubtag: { + id: 'course-authoring.tag-list.add-subtag', + defaultMessage: 'Add Subtag', + }, }); export default messages; From cc70e72fd9a051c4f3f0d858f3cc5fe75f849164 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:58:49 -0700 Subject: [PATCH 35/62] chore(deps): update dependency @openedx/paragon to v23.19.2 (#2961) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 108 ++++++++++++---------------------------------- 1 file changed, 28 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index 74e0aa0378..21a2315521 100644 --- a/package-lock.json +++ b/package-lock.json @@ -178,7 +178,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -2396,7 +2395,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -2419,7 +2417,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^14 || ^16 || >=18" } @@ -2496,7 +2493,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2674,7 +2670,6 @@ "resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.5.5.tgz", "integrity": "sha512-imExY37cxE7qzKYg3gaqcdfhc0rzpV1DEFmy6PPCJg4m+cycQNiXtAKl3nITkcQkzhV0JYh3qttEgq6d4a1QXw==", "license": "AGPL-3.0", - "peer": true, "dependencies": { "@cospired/i18n-iso-languages": "4.2.0", "@formatjs/intl-pluralrules": "4.3.3", @@ -3446,7 +3441,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" }, @@ -5412,11 +5406,10 @@ } }, "node_modules/@openedx/paragon": { - "version": "23.19.1", - "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.19.1.tgz", - "integrity": "sha512-c/cWnvZsGS7xyq0tJpssmv2oyfYG6Fuawy6EzWy8CYiQ4oD67EVuSwBInCfSJoNZhvvkUE+4B/YhDIRGUVDz5w==", + "version": "23.19.2", + "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.19.2.tgz", + "integrity": "sha512-4umD73Ujknvo4Bt1dr5X9QvwR1vlSkdoG/s6SaKmBlUa1eP1CicpIBqZiMC5/z3BUPcorxWedXQKp1Rdlv+73Q==", "license": "Apache-2.0", - "peer": true, "workspaces": [ "example", "component-generator", @@ -5480,9 +5473,9 @@ } }, "node_modules/@openedx/paragon/node_modules/axios": { - "version": "0.30.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.30.2.tgz", - "integrity": "sha512-0pE4RQ4UQi1jKY6p7u6i1Tkzqmu+d+/tHS7Q7rKunWLB9WyilBTpHHpXzPNMDj5hTbK0B0PTLSz07yqMBiF6xg==", + "version": "0.30.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.30.3.tgz", + "integrity": "sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.4", @@ -5491,9 +5484,9 @@ } }, "node_modules/@openedx/paragon/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -5512,7 +5505,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -5529,9 +5522,9 @@ } }, "node_modules/@openedx/paragon/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -6375,7 +6368,6 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -6399,7 +6391,6 @@ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", @@ -6425,8 +6416,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@reduxjs/toolkit/node_modules/redux-thunk": { "version": "3.1.0", @@ -6674,7 +6664,6 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -7000,7 +6989,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -7396,7 +7386,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -7408,7 +7397,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -7547,7 +7535,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -7594,7 +7581,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -8274,7 +8260,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8364,7 +8349,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -8860,7 +8844,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -9044,7 +9027,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -9421,7 +9403,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9780,8 +9761,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/clean-css": { "version": "5.3.3", @@ -11132,7 +11112,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-converter": { "version": "0.2.0", @@ -11324,7 +11305,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/email-validator/-/email-validator-2.0.4.tgz", "integrity": "sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==", - "peer": true, "engines": { "node": ">4.0" } @@ -11669,7 +11649,6 @@ "integrity": "sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", @@ -11726,7 +11705,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-airbnb/-/eslint-config-airbnb-19.0.4.tgz", "integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==", "license": "MIT", - "peer": true, "dependencies": { "eslint-config-airbnb-base": "^15.0.0", "object.assign": "^4.1.2", @@ -11767,7 +11745,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-17.1.0.tgz", "integrity": "sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==", "license": "MIT", - "peer": true, "dependencies": { "eslint-config-airbnb-base": "^15.0.0" }, @@ -12243,6 +12220,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "license": "MIT", + "peer": true, "dependencies": { "ms": "^2.1.1" } @@ -12252,6 +12230,7 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -12264,7 +12243,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.7.1.tgz", "integrity": "sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.20.7", "aria-query": "^5.1.3", @@ -12301,7 +12279,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", "license": "MIT", - "peer": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.flatmap": "^1.3.1", @@ -12332,7 +12309,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.1.tgz", "integrity": "sha512-Ck77j8hF7l9N4S/rzSLOWEKpn994YH6iwUK8fr9mXIaQvGpQYmOnQLbiue1u5kI5T1y+gdgqosnEAO9NCz0DBg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -13384,7 +13360,6 @@ } ], "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "deepmerge": "^2.1.1", @@ -15550,7 +15525,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -16959,8 +16933,7 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.23", @@ -17550,7 +17523,6 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", - "peer": true, "engines": { "node": "*" } @@ -18180,7 +18152,6 @@ "integrity": "sha512-W3gmZSOzNFGs9EwU8i3xlDpC0aqynQNtoDnaftdAZ3FE8cR7W625pPRbSmtsUOtTC0MPixx1i08R6uRVLfPp7g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" }, @@ -18794,7 +18765,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -19514,7 +19484,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -19600,6 +19569,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -19615,6 +19585,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -19627,7 +19598,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/process": { "version": "0.11.10", @@ -19662,7 +19634,6 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -19936,7 +19907,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -20115,7 +20085,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -20158,7 +20127,6 @@ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz", "integrity": "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.5" }, @@ -20419,7 +20387,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -20451,7 +20418,6 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.16.0.tgz", "integrity": "sha512-FPvF2XxTSikpJxcr+bHut2H4gJ17+18Uy20D5/F+SKzFap62R3cM5wH6b8WN3LyGSYeQilLEcJcR1fjBSI2S1A==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -20541,7 +20507,6 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", - "peer": true, "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" @@ -20909,7 +20874,6 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -21387,7 +21351,6 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz", "integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==", "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -21519,7 +21482,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -22527,7 +22489,6 @@ "integrity": "sha512-+xU0IA1StzqAqFs/QtXkK+XJa7wpS4X5H+JQccRKsRCElgeLGocFU1U/UMvMUylKFw6vwGV+Y/a2wb2pm5rFFQ==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@bundled-es-modules/deepmerge": "^4.3.1", "@bundled-es-modules/glob": "^10.4.2", @@ -22622,7 +22583,6 @@ "integrity": "sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^2.3.1", "@csstools/css-tokenizer": "^2.2.0", @@ -23249,7 +23209,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -23261,8 +23220,7 @@ "version": "5.10.9", "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.9.tgz", "integrity": "sha512-5bkrors87X9LhYX2xq8GgPHrIgJYHl87YNs+kBcjQ5I3CiUgzo/vFcGvT3MZQ9QHsEeYMhYO6a5CLGGffR8hMg==", - "license": "LGPL-2.1", - "peer": true + "license": "LGPL-2.1" }, "node_modules/tmp": { "version": "0.2.5", @@ -23404,7 +23362,6 @@ "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.4.tgz", "integrity": "sha512-YiHwDhSvCiItoAgsKtoLFCuakDzDsJ1DLDnSouTaTmdOcOwIkSzbLXduaQ6M5DRVhuZC/NYaaZ/mtHbWMv/S6Q==", "license": "MIT", - "peer": true, "dependencies": { "bs-logger": "0.x", "fast-json-stable-stringify": "2.x", @@ -23569,8 +23526,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsutils": { "version": "3.21.0", @@ -23619,7 +23575,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -23719,7 +23674,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23862,7 +23816,6 @@ "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -24144,7 +24097,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "peer": true, "bin": { "uuid": "dist/esm/bin/uuid" } @@ -24270,7 +24222,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -24375,7 +24326,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -24462,7 +24412,6 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -24999,7 +24948,6 @@ "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz", "integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/lodash": "^4.14.175", From 160c5fa640d60a3ccd22787b058cc708f1507c5a Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Thu, 5 Mar 2026 17:09:21 -0500 Subject: [PATCH 36/62] feat: attempt to make editable row --- src/taxonomy/tree-table/reactTableMeta.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/taxonomy/tree-table/reactTableMeta.d.ts diff --git a/src/taxonomy/tree-table/reactTableMeta.d.ts b/src/taxonomy/tree-table/reactTableMeta.d.ts new file mode 100644 index 0000000000..c178ef305f --- /dev/null +++ b/src/taxonomy/tree-table/reactTableMeta.d.ts @@ -0,0 +1,8 @@ +import type { RowData } from '@tanstack/react-table'; + +declare module '@tanstack/react-table' { + interface TableMeta { + updateData: (rowId?: string | number, columnId?: string, value: unknown) => void; + saveRow: (rowId: string | number, parentRowValue?: string) => void; + } +} From 11910d151494b39ec74223f82e4e458a9b62e01b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:00:21 -0700 Subject: [PATCH 37/62] chore(deps): update dependency @openedx/frontend-build to v14.6.3 (#2960) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 40 +++++++++++++++------------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 21a2315521..85e6a5b985 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5202,9 +5202,9 @@ "link": true }, "node_modules/@openedx/frontend-build": { - "version": "14.6.2", - "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.6.2.tgz", - "integrity": "sha512-Iu4/GPq90Xr/MSWnonn2qX8VDhI89HN7KOYBZ0/sxmAQgvXXNc7OYNC7kumvzbYzKueJQTyZoUYS7UjKB/n1WA==", + "version": "14.6.3", + "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.6.3.tgz", + "integrity": "sha512-6TVe8WWRuakErz/5wwN+CbaE2MItp8pKiJc2rB+3J0azRIjbWiEK40Vk0SKJVkdnZBlp0VlSSSQGZnlwbFF60g==", "license": "AGPL-3.0", "dependencies": { "@babel/cli": "7.24.8", @@ -5225,7 +5225,7 @@ "@types/jest": "29.5.12", "@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/parser": "^5.58.0", - "autoprefixer": "10.4.20", + "autoprefixer": "10.4.27", "babel-jest": "29.7.0", "babel-loader": "9.2.1", "babel-plugin-formatjs": "^10.4.0", @@ -8779,9 +8779,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", "funding": [ { "type": "opencollective", @@ -8798,11 +8798,10 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -13393,15 +13392,15 @@ } }, "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "patreon", + "type": "github", "url": "https://github.com/sponsors/rawify" } }, @@ -17768,15 +17767,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", From 3e3b3ce1ebfad326ab32ee16aa25f53c0bea7064 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 6 Mar 2026 12:57:14 -0500 Subject: [PATCH 38/62] feat: prettify expand all --- src/taxonomy/tree-table/messages.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/taxonomy/tree-table/messages.ts b/src/taxonomy/tree-table/messages.ts index e3bea741e1..75ae07b41c 100644 --- a/src/taxonomy/tree-table/messages.ts +++ b/src/taxonomy/tree-table/messages.ts @@ -49,6 +49,14 @@ const messages = defineMessages({ id: 'course-authoring.tree-table.pagination.page-status', defaultMessage: 'Page {currentPage} of {pageCount}', }, + expandAll: { + id: 'course-authoring.tree-table.expand-all', + defaultMessage: 'Expand All', + }, + collapseAll: { + id: 'course-authoring.tree-table.collapse-all', + defaultMessage: 'Collapse All', + }, }); export default messages; From 67671d931fb0609ba12ed48e3abf610d53b7725f Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 6 Mar 2026 15:23:28 -0500 Subject: [PATCH 39/62] fix: transitions and styles --- src/taxonomy/tree-table/messages.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/taxonomy/tree-table/messages.ts b/src/taxonomy/tree-table/messages.ts index 75ae07b41c..41048c5309 100644 --- a/src/taxonomy/tree-table/messages.ts +++ b/src/taxonomy/tree-table/messages.ts @@ -57,6 +57,14 @@ const messages = defineMessages({ id: 'course-authoring.tree-table.collapse-all', defaultMessage: 'Collapse All', }, + noResultsFoundMessage: { + id: 'course-authoring.tree-table.no-results-found.message', + defaultMessage: 'No results found', + }, + searchPlaceholder: { + id: 'course-authoring.tree-table.search.placeholder', + defaultMessage: 'Search...', + }, }); export default messages; From bb801cf246d15d03df41bf563a0a6bd28ae13eae Mon Sep 17 00:00:00 2001 From: tbain Date: Wed, 11 Mar 2026 12:30:05 -0700 Subject: [PATCH 40/62] feat: #253 Initial commit for usage count display in taxonomy tags --- src/taxonomy/tag-list/TagListTable.test.jsx | 5 +++++ src/taxonomy/tag-list/messages.ts | 4 ++++ src/taxonomy/tag-list/tagColumns.tsx | 13 +++++++++++++ 3 files changed, 22 insertions(+) diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index 131c8b7b00..3b2e5ebb7c 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -61,6 +61,7 @@ const mockTagsResponse = { descendant_count: 14, _id: 1001, sub_tags_url: '/request/to/load/subtags/1', + usage_count: 1, }, { ...tagDefaults, @@ -69,6 +70,7 @@ const mockTagsResponse = { descendant_count: 10, _id: 1002, sub_tags_url: '/request/to/load/subtags/2', + usage_count: 0, }, { ...tagDefaults, @@ -77,6 +79,7 @@ const mockTagsResponse = { descendant_count: 5, _id: 1003, sub_tags_url: '/request/to/load/subtags/3', + usage_count: 3, }, { ...tagDefaults, @@ -86,6 +89,7 @@ const mockTagsResponse = { _id: 1111, sub_tags_url: null, parent_value: 'root tag 1', + usage_count: 1, }, { ...tagDefaults, @@ -95,6 +99,7 @@ const mockTagsResponse = { _id: 1111, sub_tags_url: null, parent_value: 'the child tag', + usage_count: 1, }, ], }; diff --git a/src/taxonomy/tag-list/messages.ts b/src/taxonomy/tag-list/messages.ts index fa1a0940f0..30ab780e85 100644 --- a/src/taxonomy/tag-list/messages.ts +++ b/src/taxonomy/tag-list/messages.ts @@ -5,6 +5,10 @@ const messages = defineMessages({ id: 'course-authoring.tag-list.column.value.header', defaultMessage: 'Tag name', }, + tagListColumnCountHeader: { + id: 'course-authoring.tag-list.column.count.header', + defaultMessage: 'Usage Count', + }, tagListError: { id: 'course-authoring.tag-list.error', defaultMessage: 'Error: unable to load child tags', diff --git a/src/taxonomy/tag-list/tagColumns.tsx b/src/taxonomy/tag-list/tagColumns.tsx index c70b415a69..f8aabcfbaa 100644 --- a/src/taxonomy/tag-list/tagColumns.tsx +++ b/src/taxonomy/tag-list/tagColumns.tsx @@ -1,4 +1,5 @@ import { + Bubble, Button, Icon, IconButton, @@ -24,6 +25,7 @@ interface TagListRowData extends TreeRowData { depth: number; childCount: number; descendantCount: number; + usageCount: number; isNew?: boolean; isEditing?: boolean; } @@ -47,6 +49,12 @@ interface GetColumnsArgs { creatingParentId: RowId | null; } +const UsageCountDisplay = ({ row }: { row: Row }) => { + const count = asTagListRowData(row).usageCount ?? 0 + return (count > 0 && + {count} + ) +}; interface ActionsHeaderProps { onStartDraft: () => void; setDraftError: (error: string) => void; @@ -153,6 +161,11 @@ function getColumns({ ); }, }, + { + id: 'count', + header: intl.formatMessage(messages.tagListColumnCountHeader), + cell: UsageCountDisplay, + }, { id: 'actions', header: () => ( From 9cbc074ec6dc01eb6b29c47a491536966da81f67 Mon Sep 17 00:00:00 2001 From: tbain Date: Wed, 11 Mar 2026 15:23:44 -0700 Subject: [PATCH 41/62] feat: #253 follow on commit to address GH Copilot suggestions --- src/taxonomy/tag-list/tagColumns.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/taxonomy/tag-list/tagColumns.tsx b/src/taxonomy/tag-list/tagColumns.tsx index f8aabcfbaa..e067b72898 100644 --- a/src/taxonomy/tag-list/tagColumns.tsx +++ b/src/taxonomy/tag-list/tagColumns.tsx @@ -25,7 +25,7 @@ interface TagListRowData extends TreeRowData { depth: number; childCount: number; descendantCount: number; - usageCount: number; + usageCount?: number; isNew?: boolean; isEditing?: boolean; } @@ -50,10 +50,10 @@ interface GetColumnsArgs { } const UsageCountDisplay = ({ row }: { row: Row }) => { - const count = asTagListRowData(row).usageCount ?? 0 - return (count > 0 && - {count} - ) + const count = asTagListRowData(row).usageCount ?? 0 + return (count > 0 && + {count} + ) }; interface ActionsHeaderProps { onStartDraft: () => void; From 1b7eee050046b2498b5412169ea472160a092922 Mon Sep 17 00:00:00 2001 From: tbain Date: Mon, 16 Mar 2026 15:43:17 -0700 Subject: [PATCH 42/62] feat: #253 Add FE Unit tests, add invalidation logic on page load to force data refresh --- src/taxonomy/tag-list/TagListTable.test.jsx | 39 +++++++++++++++++++-- src/taxonomy/tag-list/TagListTable.tsx | 16 ++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index 3b2e5ebb7c..4a05beca9e 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -99,7 +99,7 @@ const mockTagsResponse = { _id: 1111, sub_tags_url: null, parent_value: 'the child tag', - usage_count: 1, + usage_count: null, }, ], }; @@ -112,7 +112,7 @@ const mockTagsPaginationResponse = { start: 0, results: [], }; -const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000'; +const rootTagsListUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/?full_depth_threshold=10000&include_counts=true'; const subTagsResponse = { next: null, previous: null, @@ -222,8 +222,15 @@ describe('', () => { expect(rows.length).toBe(3 + 1); // 3 items plus header expect(within(rows[0]).getAllByRole('columnheader')[0].textContent).toEqual('Tag name'); expect(within(rows[1]).getAllByRole('cell')[0].textContent).toEqual('root tag 1'); + expect(within(rows[0]).getAllByRole('columnheader')[1].textContent).toEqual('Usage Count'); }); + it('should render usage count correctly for root tag', async () => { + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(3 + 1); // 3 items plus header + expect(within(rows[1]).getAllByRole('cell')[1].textContent).toEqual('1'); + }) + it('should render page correctly with subtags', async () => { const expandButton = await screen.findByLabelText('Show Subtags'); fireEvent.click(expandButton); @@ -231,6 +238,34 @@ describe('', () => { expect(childTag).toBeInTheDocument(); }); + it('should render usage count correctly for sub tag', async () => { + //Expand all tags and await for child tag to render + const expandButton = screen.getAllByText('Expand All')[0]; + fireEvent.click(expandButton); + const childTag = await screen.findByText('the child tag'); + + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(5 + 1); // 5 items plus header + expect(within(rows[2]).getAllByRole('cell')[1].textContent).toEqual('1'); + }) + + it('should render usage count as empty/no content when usage count is "0"', async () => { + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(3 + 1); // 3 items plus header + expect(within(rows[2]).getAllByRole('cell')[1].textContent).toEqual(''); + }) + + it('should render usage count as empty/no when usage count is "null"', async () => { + //Expand all tags and await for child tag to render + const expandButton = screen.getAllByText('Expand All')[0]; + fireEvent.click(expandButton); + const childTag = await screen.findByText('the child tag'); + + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(5 + 1); // 5 items plus header + expect(within(rows[4]).getAllByRole('cell')[1].textContent).toEqual(''); + }) + it('should not render pagination footer if too few results', async () => { axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); renderTagListTable(); diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index 01795b8917..ebfff2ecfc 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -4,8 +4,9 @@ import React, { useEffect, } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { useQueryClient } from '@tanstack/react-query'; import type { PaginationState } from '@tanstack/react-table'; -import { useTagListData, useCreateTag } from '../data/apiHooks'; +import { useTagListData, useCreateTag, taxonomyQueryKeys } from '../data/apiHooks'; import { TagTree } from './tagTree'; import { TableView } from '../tree-table'; import type { @@ -42,6 +43,7 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { // For reference, see https://github.com/openedx/frontend-app-authoring/pull/2872#discussion_r2880965005. const intl = useIntl(); + const queryClient = useQueryClient(); const [creatingParentId, setCreatingParentId] = useState(null); const [editingRowId, setEditingRowId] = useState(null); @@ -145,6 +147,18 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { } }, [tagList?.results, tableMode]); + //RELOAD DATA ON PAGE LOAD + // Addresses issue of stale data in taxonomies/tags query on + // page load without manual page refresh + useEffect(() => { + queryClient.invalidateQueries({ + queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), + }); + queryClient.invalidateQueries({ + queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) + }); + }, []); + return ( Date: Fri, 27 Mar 2026 17:15:15 +0000 Subject: [PATCH 43/62] chore(deps): update codemirror to v6.6.0 (#2944) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 85e6a5b985..b0190a282e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2328,21 +2328,21 @@ } }, "node_modules/@codemirror/state": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", - "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "node_modules/@codemirror/view": { - "version": "6.39.16", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz", - "integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz", + "integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==", "license": "MIT", "dependencies": { - "@codemirror/state": "^6.5.0", + "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" From 37eca33a898ffe5eaebd018e45c5f3fb1d579724 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Tue, 17 Mar 2026 16:57:17 -0400 Subject: [PATCH 44/62] refactor: extract constants --- src/taxonomy/data/apiHooks.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index 753503cf3f..9689e4bfd7 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -17,6 +17,7 @@ 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'; +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 From 9de8c51990c74a7fd7c4e8e5c364f6d4747e6ffa Mon Sep 17 00:00:00 2001 From: tbain Date: Wed, 18 Mar 2026 12:24:20 -0700 Subject: [PATCH 45/62] feat: #253 Removing superfluous file, fixing paged query not using include_counts param --- src/taxonomy/data/api.ts | 1 + src/taxonomy/tree-table/reactTableMeta.d.ts | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 src/taxonomy/tree-table/reactTableMeta.d.ts diff --git a/src/taxonomy/data/api.ts b/src/taxonomy/data/api.ts index 063971ee08..671cfc6887 100644 --- a/src/taxonomy/data/api.ts +++ b/src/taxonomy/data/api.ts @@ -73,6 +73,7 @@ export const apiUrls = { page: (pageIndex ?? 0) + 1, page_size: pageSize ?? 10, full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0, + include_counts: 'true', }); }, /** diff --git a/src/taxonomy/tree-table/reactTableMeta.d.ts b/src/taxonomy/tree-table/reactTableMeta.d.ts deleted file mode 100644 index c178ef305f..0000000000 --- a/src/taxonomy/tree-table/reactTableMeta.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { RowData } from '@tanstack/react-table'; - -declare module '@tanstack/react-table' { - interface TableMeta { - updateData: (rowId?: string | number, columnId?: string, value: unknown) => void; - saveRow: (rowId: string | number, parentRowValue?: string) => void; - } -} From a8e4544500136bfd389c2788263bd8a6c449e025 Mon Sep 17 00:00:00 2001 From: tbain Date: Tue, 24 Mar 2026 13:45:04 -0700 Subject: [PATCH 46/62] feat: #253 Fixing lint issues --- src/taxonomy/tag-list/TagListTable.test.jsx | 14 ++++++++------ src/taxonomy/tag-list/TagListTable.tsx | 4 ++-- src/taxonomy/tag-list/tagColumns.tsx | 13 +++++++++---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index 4a05beca9e..3ed83fd173 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -229,7 +229,7 @@ describe('', () => { const rows = screen.getAllByRole('row'); expect(rows.length).toBe(3 + 1); // 3 items plus header expect(within(rows[1]).getAllByRole('cell')[1].textContent).toEqual('1'); - }) + }); it('should render page correctly with subtags', async () => { const expandButton = await screen.findByLabelText('Show Subtags'); @@ -239,32 +239,34 @@ describe('', () => { }); it('should render usage count correctly for sub tag', async () => { - //Expand all tags and await for child tag to render + // Expand all tags and await for child tag to render const expandButton = screen.getAllByText('Expand All')[0]; fireEvent.click(expandButton); const childTag = await screen.findByText('the child tag'); + expect(childTag).toBeInTheDocument(); const rows = screen.getAllByRole('row'); expect(rows.length).toBe(5 + 1); // 5 items plus header expect(within(rows[2]).getAllByRole('cell')[1].textContent).toEqual('1'); - }) + }); it('should render usage count as empty/no content when usage count is "0"', async () => { const rows = screen.getAllByRole('row'); expect(rows.length).toBe(3 + 1); // 3 items plus header expect(within(rows[2]).getAllByRole('cell')[1].textContent).toEqual(''); - }) + }); it('should render usage count as empty/no when usage count is "null"', async () => { - //Expand all tags and await for child tag to render + // Expand all tags and await for child tag to render const expandButton = screen.getAllByText('Expand All')[0]; fireEvent.click(expandButton); const childTag = await screen.findByText('the child tag'); + expect(childTag).toBeInTheDocument(); const rows = screen.getAllByRole('row'); expect(rows.length).toBe(5 + 1); // 5 items plus header expect(within(rows[4]).getAllByRole('cell')[1].textContent).toEqual(''); - }) + }); it('should not render pagination footer if too few results', async () => { axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index ebfff2ecfc..3f23185c19 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -147,7 +147,7 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { } }, [tagList?.results, tableMode]); - //RELOAD DATA ON PAGE LOAD + // RELOAD DATA ON PAGE LOAD // Addresses issue of stale data in taxonomies/tags query on // page load without manual page refresh useEffect(() => { @@ -155,7 +155,7 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), }); queryClient.invalidateQueries({ - queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) + queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId), }); }, []); diff --git a/src/taxonomy/tag-list/tagColumns.tsx b/src/taxonomy/tag-list/tagColumns.tsx index e067b72898..410bd56c50 100644 --- a/src/taxonomy/tag-list/tagColumns.tsx +++ b/src/taxonomy/tag-list/tagColumns.tsx @@ -50,11 +50,16 @@ interface GetColumnsArgs { } const UsageCountDisplay = ({ row }: { row: Row }) => { - const count = asTagListRowData(row).usageCount ?? 0 - return (count > 0 && - {count} - ) + const count = asTagListRowData(row).usageCount ?? 0 + return ( + count > 0 && ( + + {count} + + ) + ); }; + interface ActionsHeaderProps { onStartDraft: () => void; setDraftError: (error: string) => void; From dbb02f31ad034de4a0c7a31165edb4f49eb1a9ff Mon Sep 17 00:00:00 2001 From: tbain Date: Mon, 16 Mar 2026 15:43:17 -0700 Subject: [PATCH 47/62] feat: #253 Adding FE Unit tests, addition to add invalidation logic on page load to force data refresh --- src/taxonomy/tag-list/TagListTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index 3f23185c19..ebfff2ecfc 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -147,7 +147,7 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { } }, [tagList?.results, tableMode]); - // RELOAD DATA ON PAGE LOAD + //RELOAD DATA ON PAGE LOAD // Addresses issue of stale data in taxonomies/tags query on // page load without manual page refresh useEffect(() => { @@ -155,7 +155,7 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), }); queryClient.invalidateQueries({ - queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId), + queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) }); }, []); From 0d25d495be0f5d30781155eff091fc69c77fe2b0 Mon Sep 17 00:00:00 2001 From: tbain Date: Tue, 24 Mar 2026 13:45:04 -0700 Subject: [PATCH 48/62] feat: #253 Fixing lint issues --- src/taxonomy/tag-list/TagListTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index ebfff2ecfc..3f23185c19 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -147,7 +147,7 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { } }, [tagList?.results, tableMode]); - //RELOAD DATA ON PAGE LOAD + // RELOAD DATA ON PAGE LOAD // Addresses issue of stale data in taxonomies/tags query on // page load without manual page refresh useEffect(() => { @@ -155,7 +155,7 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), }); queryClient.invalidateQueries({ - queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) + queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId), }); }, []); From aedde44291836f97d366d1eaa3da0d0a23c13b1c Mon Sep 17 00:00:00 2001 From: tbain Date: Wed, 25 Mar 2026 15:11:18 -0700 Subject: [PATCH 49/62] feat: #253 Refactoring refresh logic to fix erroneous refresh, issues causing UT failiures --- src/taxonomy/data/apiHooks.ts | 8 +++- src/taxonomy/tag-list/TagListTable.test.jsx | 53 +++++++++++++++++++++ src/taxonomy/tag-list/TagListTable.tsx | 16 +------ 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index 9689e4bfd7..4970d6f699 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -98,6 +98,7 @@ export const useDeleteTaxonomy = () => { export const useTaxonomyDetails = (taxonomyId: number) => useQuery({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId), queryFn: () => api.getTaxonomy(taxonomyId), + refetchOnMount: 'always', }); /** @@ -195,6 +196,7 @@ export const useTagListData = (taxonomyId: number, options: QueryOptions) => { return camelCaseObject(data) as TagListData; }, enabled, + refetchOnMount: 'always', }); }; @@ -229,9 +231,13 @@ export const useCreateTag = (taxonomyId: number) => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), + refetchType: 'none', }); // In the metadata, 'tagsCount' (and possibly other fields) will have changed: - queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) }); + queryClient.invalidateQueries({ + queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId), + refetchType: 'none', + }); }, }); }; diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index 3ed83fd173..8772db8d4b 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -835,6 +835,59 @@ describe(' isolated async subtag tests', () => { queryClient.clear(); }); + describe(' revisit data freshness', () => { + let revisitQueryClient; + + const renderWithRevisitClient = () => render( + + + + + + + , + ); + + beforeEach(() => { + initializeMockApp({ + authenticatedUser: adminUser, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + revisitQueryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 60_000, + retry: false, + }, + }, + }); + axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); + }); + + afterEach(() => { + cleanup(); + revisitQueryClient.clear(); + axiosMock.restore(); + }); + + it('should refetch once when revisiting the page with fresh cached data', async () => { + const firstRender = renderWithRevisitClient(); + await screen.findByText('root tag 1'); + await waitFor(() => { + expect(axiosMock.history.get.length).toBe(1); + }); + + firstRender.unmount(); + renderWithRevisitClient(); + + await screen.findByText('root tag 1'); + await waitFor(() => { + expect(axiosMock.history.get.length).toBe(2); + }); + }); + }); + it('shows the spinner before the query is complete', async () => { // Simulate an actual slow response from the API: let resolveResponse; diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index 3f23185c19..01795b8917 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -4,9 +4,8 @@ import React, { useEffect, } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useQueryClient } from '@tanstack/react-query'; import type { PaginationState } from '@tanstack/react-table'; -import { useTagListData, useCreateTag, taxonomyQueryKeys } from '../data/apiHooks'; +import { useTagListData, useCreateTag } from '../data/apiHooks'; import { TagTree } from './tagTree'; import { TableView } from '../tree-table'; import type { @@ -43,7 +42,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { // For reference, see https://github.com/openedx/frontend-app-authoring/pull/2872#discussion_r2880965005. const intl = useIntl(); - const queryClient = useQueryClient(); const [creatingParentId, setCreatingParentId] = useState(null); const [editingRowId, setEditingRowId] = useState(null); @@ -147,18 +145,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { } }, [tagList?.results, tableMode]); - // RELOAD DATA ON PAGE LOAD - // Addresses issue of stale data in taxonomies/tags query on - // page load without manual page refresh - useEffect(() => { - queryClient.invalidateQueries({ - queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), - }); - queryClient.invalidateQueries({ - queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId), - }); - }, []); - return ( Date: Fri, 27 Mar 2026 17:28:48 +0000 Subject: [PATCH 50/62] chore(deps): update dependency @tanstack/react-query to v5.95.2 (#2954) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index b0190a282e..7320432d91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "@openedx/paragon": "^23.5.0", "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "2.11.2", - "@tanstack/react-query": "5.90.21", + "@tanstack/react-query": "5.95.2", "@tanstack/react-table": "^8.21.3", "@tinymce/tinymce-react": "^6.0.0", "classnames": "2.5.1", @@ -6763,9 +6763,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.20", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", - "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz", + "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==", "license": "MIT", "funding": { "type": "github", @@ -6773,12 +6773,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.21", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", - "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", + "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.20" + "@tanstack/query-core": "5.95.2" }, "funding": { "type": "github", diff --git a/package.json b/package.json index 2cc5b3fda2..5f187984fa 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@redux-devtools/extension": "^3.3.0", "@reduxjs/toolkit": "2.11.2", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-query": "5.90.21", + "@tanstack/react-query": "5.95.2", "@tinymce/tinymce-react": "^6.0.0", "classnames": "2.5.1", "codemirror": "^6.0.0", From 799a8c8ed7158f89b93f03288e422a178d965fef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:06:06 -0700 Subject: [PATCH 51/62] chore(deps): bump minimatch from 3.1.2 to 3.1.5 (#2962) Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5. - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5) --- updated-dependencies: - dependency-name: minimatch dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7320432d91..8bcf64a5f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2054,12 +2054,12 @@ } }, "node_modules/@bundled-es-modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -17463,9 +17463,9 @@ "license": "ISC" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -19765,9 +19765,9 @@ } }, "node_modules/purgecss/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" From f0d46118b3ab39ff36bc5b550a2a527fb207d393 Mon Sep 17 00:00:00 2001 From: tbain Date: Fri, 27 Mar 2026 13:14:37 -0700 Subject: [PATCH 52/62] feat: #253 Updating branch with latest from upstream --- .../library-info/LibraryInfo.test.tsx | 2 +- src/taxonomy/data/api.ts | 2 +- src/taxonomy/data/apiHooks.ts | 3 +- src/taxonomy/tag-list/TagListTable.test.jsx | 30 ++----------------- src/taxonomy/tag-list/TagListTable.tsx | 2 -- src/taxonomy/tag-list/messages.ts | 8 ----- src/taxonomy/tag-list/tagColumns.tsx | 7 +++-- src/taxonomy/tree-table/messages.ts | 16 ---------- 8 files changed, 9 insertions(+), 61 deletions(-) diff --git a/src/library-authoring/library-info/LibraryInfo.test.tsx b/src/library-authoring/library-info/LibraryInfo.test.tsx index 1ef5ed6f12..4b620677db 100644 --- a/src/library-authoring/library-info/LibraryInfo.test.tsx +++ b/src/library-authoring/library-info/LibraryInfo.test.tsx @@ -291,7 +291,7 @@ describe('', () => { expect(screen.getByText('Settings')).toBeInTheDocument(); }); - it('renders PublicReadToggle when user can manage team', async () => { + it('renders PublicReadToggle when user can manage team', async () => { render(); const allowSwitch = await screen.findByRole('switch', { name: /allow public read/i }); expect(allowSwitch).toBeInTheDocument(); diff --git a/src/taxonomy/data/api.ts b/src/taxonomy/data/api.ts index 671cfc6887..eb50241106 100644 --- a/src/taxonomy/data/api.ts +++ b/src/taxonomy/data/api.ts @@ -67,7 +67,7 @@ export const apiUrls = { 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/`, { full_depth_threshold: fullDepth ? MAX_TAXONOMY_ITEMS : 0, include_counts: 'true' }); } return makeUrl(`${taxonomyId}/tags/`, { page: (pageIndex ?? 0) + 1, diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index 4970d6f699..3207f5665a 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -13,11 +13,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { camelCaseObject } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useIntl } from '@edx/frontend-platform/i18n'; 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'; -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 diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index 8772db8d4b..825e2a746b 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -304,15 +304,6 @@ describe('', () => { expect(within(creatingRow).getByText('Cancel')).toBeInTheDocument(); expect(within(creatingRow).getByText('Save')).toBeInTheDocument(); }); - const addButton = await screen.findByLabelText('Create Tag'); - addButton.click(); - const creatingRow = await screen.findByTestId('creating-top-tag-row'); - // expect input placeholder text to say "Type tag name" - expect(creatingRow.querySelector('input').placeholder).toEqual('Type tag name'); - // expect the row to include "Cancel" and "Save" buttons - expect(within(creatingRow).getByText('Cancel')).toBeInTheDocument(); - expect(within(creatingRow).getByText('Save')).toBeInTheDocument(); - }); it('should create a new tag when the draft row is saved', async () => { axiosMock.onPost(createTagUrl).reply(201, { @@ -323,23 +314,6 @@ describe('', () => { _id: 1234, }); const { creatingRow, input } = await openTopLevelDraftRow(); - it('should create a new tag when the draft row is saved', async () => { - axiosMock.onGet(rootTagsListUrl).reply(200, mockTagsResponse); - axiosMock.onPost(createTagUrl).reply(201, { - ...tagDefaults, - value: 'a new tag', - child_count: 0, - descendant_count: 0, - _id: 1234, - }); - render(); - const tag = await screen.findByText('root tag 1'); - expect(tag).toBeInTheDocument(); - const addButton = await screen.findByLabelText('Create Tag'); - addButton.click(); - const creatingRow = await screen.findByTestId('creating-top-tag-row'); - const input = creatingRow.querySelector('input'); - expect(input).toBeInTheDocument(); fireEvent.change(input, { target: { value: 'a new tag' } }); const saveButton = within(creatingRow).getByText('Save'); @@ -612,8 +586,8 @@ describe('', () => { const input = draftRow[1].querySelector('input'); const saveButton = within(draftRow[1]).getByText('Save'); - fireEvent.change(input, { target: { value: 'root tag 1' } }); - fireEvent.click(saveButton); + fireEvent.change(input, { target: { value: 'root tag 1' } }); + fireEvent.click(saveButton); expect(await screen.findByText('Tag with this name already exists')).toBeInTheDocument(); }); diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index 01795b8917..01bfdbfe11 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -3,7 +3,6 @@ import React, { useMemo, useEffect, } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; import type { PaginationState } from '@tanstack/react-table'; import { useTagListData, useCreateTag } from '../data/apiHooks'; import { TagTree } from './tagTree'; @@ -40,7 +39,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { // TODO: Simpler approaches have been suggested. Two options are to just use simple React state: // `isCurrentlyEditingTag` and `lastCreatedTag`, or to use optimistic updates. // For reference, see https://github.com/openedx/frontend-app-authoring/pull/2872#discussion_r2880965005. - const intl = useIntl(); const [creatingParentId, setCreatingParentId] = useState(null); const [editingRowId, setEditingRowId] = useState(null); diff --git a/src/taxonomy/tag-list/messages.ts b/src/taxonomy/tag-list/messages.ts index 30ab780e85..b85c8a4696 100644 --- a/src/taxonomy/tag-list/messages.ts +++ b/src/taxonomy/tag-list/messages.ts @@ -57,14 +57,6 @@ const messages = defineMessages({ id: 'course-authoring.tag-list.hide-subtags.button-label', defaultMessage: 'Hide Subtags', }, - tagUpdateSuccessMessage: { - id: 'course-authoring.tag-list.update-success', - defaultMessage: 'Tag \"{name}\" updated successfully', - }, - addSubtag: { - id: 'course-authoring.tag-list.add-subtag', - defaultMessage: 'Add Subtag', - }, }); export default messages; diff --git a/src/taxonomy/tag-list/tagColumns.tsx b/src/taxonomy/tag-list/tagColumns.tsx index 410bd56c50..2fad2e25c6 100644 --- a/src/taxonomy/tag-list/tagColumns.tsx +++ b/src/taxonomy/tag-list/tagColumns.tsx @@ -50,10 +50,10 @@ interface GetColumnsArgs { } const UsageCountDisplay = ({ row }: { row: Row }) => { - const count = asTagListRowData(row).usageCount ?? 0 + const count = asTagListRowData(row).usageCount ?? 0; return ( count > 0 && ( - + {count} ) @@ -133,7 +133,7 @@ const ActionsMenu = ({ rowData, startSubtagDraft, disableAddSubtag }: ActionsMen ); -} +}; function getColumns({ setIsCreatingTopTag, @@ -148,6 +148,7 @@ function getColumns({ }: GetColumnsArgs): TreeColumnDef[] { const canAddSubtag = (row: Row) => row.depth < maxDepth; const draftInProgressHintId = 'tag-list-draft-in-progress-hint'; + const intl = useIntl(); return [ { diff --git a/src/taxonomy/tree-table/messages.ts b/src/taxonomy/tree-table/messages.ts index 41048c5309..e3bea741e1 100644 --- a/src/taxonomy/tree-table/messages.ts +++ b/src/taxonomy/tree-table/messages.ts @@ -49,22 +49,6 @@ const messages = defineMessages({ id: 'course-authoring.tree-table.pagination.page-status', defaultMessage: 'Page {currentPage} of {pageCount}', }, - expandAll: { - id: 'course-authoring.tree-table.expand-all', - defaultMessage: 'Expand All', - }, - collapseAll: { - id: 'course-authoring.tree-table.collapse-all', - defaultMessage: 'Collapse All', - }, - noResultsFoundMessage: { - id: 'course-authoring.tree-table.no-results-found.message', - defaultMessage: 'No results found', - }, - searchPlaceholder: { - id: 'course-authoring.tree-table.search.placeholder', - defaultMessage: 'Search...', - }, }); export default messages; From 9dabba672c3bbe782b038ed3bd146b423f37d035 Mon Sep 17 00:00:00 2001 From: tbain Date: Fri, 27 Mar 2026 14:14:00 -0700 Subject: [PATCH 53/62] feat: #253 re-adding missing updates after merge from upstream --- src/taxonomy/data/apiHooks.ts | 8 ++++++-- src/taxonomy/tag-list/TagListTable.tsx | 2 -- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index 46b6e969de..3207f5665a 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -13,10 +13,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { camelCaseObject } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useIntl } from '@edx/frontend-platform/i18n'; 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 @@ -230,9 +230,13 @@ export const useCreateTag = (taxonomyId: number) => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyTagList(taxonomyId), + refetchType: 'none', }); // In the metadata, 'tagsCount' (and possibly other fields) will have changed: - queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId) }); + queryClient.invalidateQueries({ + queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId), + refetchType: 'none', + }); }, }); }; diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index 01795b8917..01bfdbfe11 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -3,7 +3,6 @@ import React, { useMemo, useEffect, } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; import type { PaginationState } from '@tanstack/react-table'; import { useTagListData, useCreateTag } from '../data/apiHooks'; import { TagTree } from './tagTree'; @@ -40,7 +39,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => { // TODO: Simpler approaches have been suggested. Two options are to just use simple React state: // `isCurrentlyEditingTag` and `lastCreatedTag`, or to use optimistic updates. // For reference, see https://github.com/openedx/frontend-app-authoring/pull/2872#discussion_r2880965005. - const intl = useIntl(); const [creatingParentId, setCreatingParentId] = useState(null); const [editingRowId, setEditingRowId] = useState(null); From c34f9283292ddb92fa62980cfd2959810ec560c4 Mon Sep 17 00:00:00 2001 From: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:28:03 -0400 Subject: [PATCH 54/62] docs: update course_unit_sidebar slot ID to v2 in plugin-slots README (#2905) Co-authored-by: Claude Sonnet 4.6 --- src/plugin-slots/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugin-slots/README.md b/src/plugin-slots/README.md index 6d6603dafd..9651d829eb 100644 --- a/src/plugin-slots/README.md +++ b/src/plugin-slots/README.md @@ -8,7 +8,7 @@ ## Course Unit page * [`org.openedx.frontend.authoring.course_unit_header_actions.v1`](./CourseUnitHeaderActionsSlot/) -* [`org.openedx.frontend.authoring.course_unit_sidebar.v1`](./CourseAuthoringUnitSidebarSlot/) +* [`org.openedx.frontend.authoring.course_unit_sidebar.v2`](./CourseAuthoringUnitSidebarSlot/) ## Other Slots * [`org.openedx.frontend.authoring.additional_course_content_plugin.v1`](./AdditionalCourseContentPluginSlot/) From 807d78548376d4ad82ab2e730080a3c926f7fb58 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:17:50 -0700 Subject: [PATCH 55/62] chore(deps): update dependency oxlint to v1.57.0 (#2966) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 182 ++++++++++++++++++++++++++-------------------- 1 file changed, 103 insertions(+), 79 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8bcf64a5f1..96f873b473 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5659,9 +5659,9 @@ ] }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.56.0.tgz", - "integrity": "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.57.0.tgz", + "integrity": "sha512-C7EiyfAJG4B70496eV543nKiq5cH0o/xIh/ufbjQz3SIvHhlDDsyn+mRFh+aW8KskTyUpyH2LGWL8p2oN6bl1A==", "cpu": [ "arm" ], @@ -5676,9 +5676,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.56.0.tgz", - "integrity": "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.57.0.tgz", + "integrity": "sha512-9i80AresjZ/FZf5xK8tKFbhQnijD4s1eOZw6/FHUwD59HEZbVLRc2C88ADYJfLZrF5XofWDiRX/Ja9KefCLy7w==", "cpu": [ "arm64" ], @@ -5693,9 +5693,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.56.0.tgz", - "integrity": "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.57.0.tgz", + "integrity": "sha512-0eUfhRz5L2yKa9I8k3qpyl37XK3oBS5BvrgdVIx599WZK63P8sMbg+0s4IuxmIiZuBK68Ek+Z+gcKgeYf0otsg==", "cpu": [ "arm64" ], @@ -5710,9 +5710,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.56.0.tgz", - "integrity": "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.57.0.tgz", + "integrity": "sha512-UvrSuzBaYOue+QMAcuDITe0k/Vhj6KZGjfnI6x+NkxBTke/VoM7ZisaxgNY0LWuBkTnd1OmeQfEQdQ48fRjkQg==", "cpu": [ "x64" ], @@ -5727,9 +5727,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.56.0.tgz", - "integrity": "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.57.0.tgz", + "integrity": "sha512-wtQq0dCoiw4bUwlsNVDJJ3pxJA218fOezpgtLKrbQqUtQJcM9yP8z+I9fu14aHg0uyAxIY+99toL6uBa2r7nxA==", "cpu": [ "x64" ], @@ -5744,9 +5744,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.56.0.tgz", - "integrity": "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.57.0.tgz", + "integrity": "sha512-qxFWl2BBBFcT4djKa+OtMdnLgoHEJXpqjyGwz8OhW35ImoCwR5qtAGqApNYce5260FQqoAHW8S8eZTjiX67Tsg==", "cpu": [ "arm" ], @@ -5761,9 +5761,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.56.0.tgz", - "integrity": "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.57.0.tgz", + "integrity": "sha512-SQoIsBU7J0bDW15/f0/RvxHfY3Y0+eB/caKBQtNFbuerTiA6JCYx9P1MrrFTwY2dTm/lMgTSgskvCEYk2AtG/Q==", "cpu": [ "arm" ], @@ -5778,13 +5778,16 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.56.0.tgz", - "integrity": "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.57.0.tgz", + "integrity": "sha512-jqxYd1W6WMeozsCmqe9Rzbu3SRrGTyGDAipRlRggetyYbUksJqJKvUNTQtZR/KFoJPb+grnSm5SHhdWrywv3RQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5795,13 +5798,16 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.56.0.tgz", - "integrity": "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.57.0.tgz", + "integrity": "sha512-i66WyEPVEvq9bxRUCJ/MP5EBfnTDN3nhwEdFZFTO5MmLLvzngfWEG3NSdXQzTT3vk5B9i6C2XSIYBh+aG6uqyg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5812,13 +5818,16 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.56.0.tgz", - "integrity": "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.57.0.tgz", + "integrity": "sha512-oMZDCwz4NobclZU3pH+V1/upVlJZiZvne4jQP+zhJwt+lmio4XXr4qG47CehvrW1Lx2YZiIHuxM2D4YpkG3KVA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5829,13 +5838,16 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.56.0.tgz", - "integrity": "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.57.0.tgz", + "integrity": "sha512-uoBnjJ3MMEBbfnWC1jSFr7/nSCkcQYa72NYoNtLl1imshDnWSolYCjzb8LVCwYCCfLJXD+0gBLD7fyC14c0+0g==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5846,13 +5858,16 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.56.0.tgz", - "integrity": "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.57.0.tgz", + "integrity": "sha512-BdrwD7haPZ8a9KrZhKJRSj6jwCor+Z8tHFZ3PT89Y3Jq5v3LfMfEePeAmD0LOTWpiTmzSzdmyw9ijneapiVHKQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5863,13 +5878,16 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.56.0.tgz", - "integrity": "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.57.0.tgz", + "integrity": "sha512-BNs+7ZNsRstVg2tpNxAXfMX/Iv5oZh204dVyb8Z37+/gCh+yZqNTlg6YwCLIMPSk5wLWIGOaQjT0GUOahKYImw==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5880,13 +5898,16 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.56.0.tgz", - "integrity": "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.57.0.tgz", + "integrity": "sha512-AghS18w+XcENcAX0+BQGLiqjpqpaxKJa4cWWP0OWNLacs27vHBxu7TYkv9LUSGe5w8lOJHeMxcYfZNOAPqw2bg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5897,13 +5918,16 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.56.0.tgz", - "integrity": "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.57.0.tgz", + "integrity": "sha512-E/FV3GB8phu/Rpkhz5T96hAiJlGzn91qX5yj5gU754P5cmVGXY1Jw/VSjDSlZBCY3VHjsVLdzgdkJaomEmcNOg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5914,9 +5938,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.56.0.tgz", - "integrity": "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.57.0.tgz", + "integrity": "sha512-xvZ2yZt0nUVfU14iuGv3V25jpr9pov5N0Wr28RXnHFxHCRxNDMtYPHV61gGLhN9IlXM96gI4pyYpLSJC5ClLCQ==", "cpu": [ "arm64" ], @@ -5931,9 +5955,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.56.0.tgz", - "integrity": "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.57.0.tgz", + "integrity": "sha512-Z4D8Pd0AyHBKeazhdIXeUUy5sIS3Mo0veOlzlDECg6PhRRKgEsBJCCV1n+keUZtQ04OP+i7+itS3kOykUyNhDg==", "cpu": [ "arm64" ], @@ -5948,9 +5972,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.56.0.tgz", - "integrity": "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.57.0.tgz", + "integrity": "sha512-StOZ9nFMVKvevicbQfql6Pouu9pgbeQnu60Fvhz2S6yfMaii+wnueLnqQ5I1JPgNF0Syew4voBlAaHD13wH6tw==", "cpu": [ "ia32" ], @@ -5965,9 +5989,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.56.0.tgz", - "integrity": "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.57.0.tgz", + "integrity": "sha512-6PuxhYgth8TuW0+ABPOIkGdBYw+qYGxgIdXPHSVpiCDm+hqTTWCmC739St1Xni0DJBt8HnSHTG67i1y6gr8qrA==", "cpu": [ "x64" ], @@ -18092,9 +18116,9 @@ } }, "node_modules/oxlint": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.56.0.tgz", - "integrity": "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.57.0.tgz", + "integrity": "sha512-DGFsuBX5MFZX9yiDdtKjTrYPq45CZ8Fft6qCltJITYZxfwYjVdGf/6wycGYTACloauwIPxUnYhBVeZbHvleGhw==", "dev": true, "license": "MIT", "bin": { @@ -18107,25 +18131,25 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.56.0", - "@oxlint/binding-android-arm64": "1.56.0", - "@oxlint/binding-darwin-arm64": "1.56.0", - "@oxlint/binding-darwin-x64": "1.56.0", - "@oxlint/binding-freebsd-x64": "1.56.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", - "@oxlint/binding-linux-arm-musleabihf": "1.56.0", - "@oxlint/binding-linux-arm64-gnu": "1.56.0", - "@oxlint/binding-linux-arm64-musl": "1.56.0", - "@oxlint/binding-linux-ppc64-gnu": "1.56.0", - "@oxlint/binding-linux-riscv64-gnu": "1.56.0", - "@oxlint/binding-linux-riscv64-musl": "1.56.0", - "@oxlint/binding-linux-s390x-gnu": "1.56.0", - "@oxlint/binding-linux-x64-gnu": "1.56.0", - "@oxlint/binding-linux-x64-musl": "1.56.0", - "@oxlint/binding-openharmony-arm64": "1.56.0", - "@oxlint/binding-win32-arm64-msvc": "1.56.0", - "@oxlint/binding-win32-ia32-msvc": "1.56.0", - "@oxlint/binding-win32-x64-msvc": "1.56.0" + "@oxlint/binding-android-arm-eabi": "1.57.0", + "@oxlint/binding-android-arm64": "1.57.0", + "@oxlint/binding-darwin-arm64": "1.57.0", + "@oxlint/binding-darwin-x64": "1.57.0", + "@oxlint/binding-freebsd-x64": "1.57.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.57.0", + "@oxlint/binding-linux-arm-musleabihf": "1.57.0", + "@oxlint/binding-linux-arm64-gnu": "1.57.0", + "@oxlint/binding-linux-arm64-musl": "1.57.0", + "@oxlint/binding-linux-ppc64-gnu": "1.57.0", + "@oxlint/binding-linux-riscv64-gnu": "1.57.0", + "@oxlint/binding-linux-riscv64-musl": "1.57.0", + "@oxlint/binding-linux-s390x-gnu": "1.57.0", + "@oxlint/binding-linux-x64-gnu": "1.57.0", + "@oxlint/binding-linux-x64-musl": "1.57.0", + "@oxlint/binding-openharmony-arm64": "1.57.0", + "@oxlint/binding-win32-arm64-msvc": "1.57.0", + "@oxlint/binding-win32-ia32-msvc": "1.57.0", + "@oxlint/binding-win32-x64-msvc": "1.57.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" From ba533a299f22453c827daf502d9efeaf3dfd2a56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:28:20 -0700 Subject: [PATCH 56/62] chore(deps): bump codecov/codecov-action from 5 to 6 (#2967) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5...v6) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/validate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index eff6bfea75..be55bfaacb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -33,7 +33,7 @@ jobs: path: coverage merge-multiple: true - name: Upload coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From 87f829035285502b1c4c71411706c7b8804a08a2 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Mon, 30 Mar 2026 21:32:18 +0500 Subject: [PATCH 57/62] fix: validate authz perms with studio instead of LMS (#2947) * fix: validate authz perms with studio instead of LMS * refactor: remove utils.ts and unused util --- src/authz/data/api.ts | 6 ++++-- src/authz/data/utils.ts | 4 ---- 2 files changed, 4 insertions(+), 6 deletions(-) delete mode 100644 src/authz/data/utils.ts diff --git a/src/authz/data/api.ts b/src/authz/data/api.ts index 9801f8be88..6aca5e5307 100644 --- a/src/authz/data/api.ts +++ b/src/authz/data/api.ts @@ -5,7 +5,9 @@ import { PermissionValidationRequestItem, PermissionValidationResponseItem, } from '@src/authz/types'; -import { getApiUrl } from './utils'; +import { getConfig } from '@edx/frontend-platform'; + +export const getAuthzApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}/api/authz/${path || ''}`; export const validateUserPermissions = async ( query: PermissionValidationQuery, @@ -14,7 +16,7 @@ export const validateUserPermissions = async ( const request: PermissionValidationRequestItem[] = Object.values(query); const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post( - getApiUrl('/api/authz/v1/permissions/validate/me'), + getAuthzApiUrl('v1/permissions/validate/me'), request, ); diff --git a/src/authz/data/utils.ts b/src/authz/data/utils.ts deleted file mode 100644 index 8676ba1abd..0000000000 --- a/src/authz/data/utils.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { getConfig } from '@edx/frontend-platform'; - -export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`; -export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`; From b7955ce71299de08eaff95a918b8e82879068b4f Mon Sep 17 00:00:00 2001 From: edX requirements bot <49161187+edx-requirements-bot@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:42:42 -0400 Subject: [PATCH 58/62] chore: update browserslist DB (#2964) Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com> --- package-lock.json | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 96f873b473..8164581eb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9246,9 +9246,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", - "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -9629,9 +9629,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001780", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", - "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", "funding": [ { "type": "opencollective", diff --git a/package.json b/package.json index 5f187984fa..9af1f85eb4 100644 --- a/package.json +++ b/package.json @@ -66,8 +66,8 @@ "@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.95.2", + "@tanstack/react-table": "^8.21.3", "@tinymce/tinymce-react": "^6.0.0", "classnames": "2.5.1", "codemirror": "^6.0.0", From 448fcadc40e6d9138d2197608164d5e30b6b21f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 30 Mar 2026 20:17:41 -0300 Subject: [PATCH 59/62] feat: add deprecation warning to create legacy library form [FC-0123] (#2963) -------- Co-authored-by: Kyle McCormick --- .../CreateLegacyLibrary.tsx | 18 +++++++++++++++++- .../create-legacy-library/messages.ts | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/library-authoring/create-legacy-library/CreateLegacyLibrary.tsx b/src/library-authoring/create-legacy-library/CreateLegacyLibrary.tsx index 4a60d7e35c..260cab0cee 100644 --- a/src/library-authoring/create-legacy-library/CreateLegacyLibrary.tsx +++ b/src/library-authoring/create-legacy-library/CreateLegacyLibrary.tsx @@ -2,14 +2,16 @@ import { StudioFooterSlot } from '@edx/frontend-component-footer'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { + Alert, Container, Form, Button, StatefulButton, ActionRow, } from '@openedx/paragon'; +import { Warning } from '@openedx/paragon/icons'; import { Formik } from 'formik'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, Link } from 'react-router-dom'; import * as Yup from 'yup'; import classNames from 'classnames'; @@ -100,6 +102,20 @@ export const CreateLegacyLibrary = ({ title={intl.formatMessage(legacyMessages.createLibrary)} /> )} + + {intl.formatMessage(legacyMessages.warningTitle)} + {intl.formatMessage(legacyMessages.warningBody, { + libraryLink: ( + + {intl.formatMessage(legacyMessages.warningLibraryFeature)} + + ), + })} + Date: Tue, 31 Mar 2026 13:34:27 -0300 Subject: [PATCH 60/62] feat: add course info settings sidebar [FC-0123] (#2955) Adds the Settings tab to the course info sidebar, which shows links to some course settings pages. --- .../info-sidebar/CourseInfoSidebar.tsx | 144 ++++++++++++++++-- .../info-sidebar/InfoSidebar.test.tsx | 33 +++- src/course-unit/CourseUnit.test.tsx | 12 +- src/data/api.ts | 43 ++++++ src/data/apiHooks.ts | 11 ++ ...elpSidebarLink.jsx => HelpSidebarLink.tsx} | 29 ++-- src/generic/help-sidebar/index.ts | 2 + src/grading-settings/GradingSettings.jsx | 25 +-- src/grading-settings/GradingSettings.test.jsx | 3 +- src/grading-settings/data/api.js | 12 -- src/grading-settings/data/apiHooks.ts | 9 +- .../ScheduleAndDetails.test.jsx | 3 +- src/schedule-and-details/data/api.js | 13 -- src/schedule-and-details/data/thunks.js | 7 +- 14 files changed, 260 insertions(+), 86 deletions(-) rename src/generic/help-sidebar/{HelpSidebarLink.jsx => HelpSidebarLink.tsx} (64%) diff --git a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx index e02afd49c3..a7344952ea 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx @@ -1,34 +1,36 @@ +import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useToggle } from '@openedx/paragon'; +import { + Tab, + Tabs, + useToggle, +} from '@openedx/paragon'; import { SchoolOutline, Tag } from '@openedx/paragon/icons'; +import { useUserPermissions } from '@src/authz/data/apiHooks'; +import { COURSE_PERMISSIONS } from '@src/authz/constants'; import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; +import { useCourseSettings, useWaffleFlags } from '@src/data/apiHooks'; import { ComponentCountSnippet } from '@src/generic/block-type-utils'; +import { HelpSidebarLink, otherLinkURLParams, messages as helpSidebarMessages } from '@src/generic/help-sidebar'; +import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; import { useGetBlockTypes } from '@src/search-manager'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; - import { useCourseDetails } from '@src/course-outline/data/apiHooks'; import messages from '../messages'; -export const CourseInfoSidebar = () => { +const DetailsTab = () => { const intl = useIntl(); - const { courseId } = useCourseAuthoringContext(); - const { data: courseDetails } = useCourseDetails(courseId); + const { courseId } = useCourseAuthoringContext(); const { data: componentData } = useGetBlockTypes( [`context_key = "${courseId}"`], ); - const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); return ( -
    - + <> { onClose={closeManageTagsDrawer} showSheet={isManageTagsDrawerOpen} /> -
    + + ); +}; + +const SettingsTab = () => { + const intl = useIntl(); + const { courseId } = useCourseAuthoringContext(); + const { data: courseSettingsData } = useCourseSettings(courseId); + + const { + grading, + courseTeam, + advancedSettings, + scheduleAndDetails, + groupConfigurations, + } = otherLinkURLParams; + const waffleFlags = useWaffleFlags(courseId); + + const proctoredExamSettingsUrl = courseSettingsData?.mfeProctoredExamSettingsUrl; + + /* + AuthZ for Course Authoring + If authz.enable_course_authoring flag is enabled, validate permissions using AuthZ API. + */ + const isAuthzEnabled = waffleFlags.enableAuthzCourseAuthoring; + const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({ + canManageAdvancedSettings: { + action: COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS, + scope: courseId, + }, + }, isAuthzEnabled); + + // If it's still loading, don't show the Advanced Settings link, otherwise, use the permission to decide + const authzCanManageAdvancedSettings = isLoadingUserPermissions + ? false + : !!userPermissions?.canManageAdvancedSettings; + + // When authz is enabled, use permission, otherwise it's always allowed (legacy behavior) + const canManageAdvancedSettings = isAuthzEnabled ? authzCanManageAdvancedSettings : true; + + return ( + + + + + + {canManageAdvancedSettings && ( + + )} + {proctoredExamSettingsUrl && ( + + )} + + ); +}; + +export const CourseInfoSidebar = () => { + const intl = useIntl(); + const { courseId } = useCourseAuthoringContext(); + const { data: courseDetails } = useCourseDetails(courseId); + + return ( + <> + + + + + + + + + + ); }; diff --git a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx index a8a1d415b8..2d9359046f 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/InfoSidebar.test.tsx @@ -1,5 +1,6 @@ import { initializeMocks, render, screen } from '@src/testUtils'; -import { SelectionState } from '@src/data/types'; +import { getCourseSettingsApiUrl } from '@src/data/api'; +import type { SelectionState } from '@src/data/types'; import { OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; import { getXBlockApiUrl } from '@src/course-outline/data/api'; import userEvent from '@testing-library/user-event'; @@ -22,10 +23,12 @@ jest.mock('@src/course-outline/data/apiHooks', () => ({ }), })); +const courseId = '5'; + const openPublishModal = jest.fn(); jest.mock('@src/CourseAuthoringContext', () => ({ useCourseAuthoringContext: () => ({ - courseId: 5, + courseId, setCurrentSelection: jest.fn(), openPublishModal, getUnitUrl: jest.fn(), @@ -50,6 +53,32 @@ describe('InfoSidebar component', () => { expect(await screen.findByText('Course name')).toBeInTheDocument(); }); + it('shows the settings link for the course', async () => { + const user = userEvent.setup(); + renderComponent(); + await user.click((await screen.findByRole('tab', { name: 'Settings' }))); + const links = await screen.findAllByRole('link'); + expect(links).toHaveLength(5); + expect(links[0]).toHaveTextContent('Schedule & details'); + expect(links[1]).toHaveTextContent('Grading'); + expect(links[2]).toHaveTextContent('Course team'); + expect(links[3]).toHaveTextContent('Group configurations'); + expect(links[4]).toHaveTextContent('Advanced settings'); + }); + + it('shows the proctored exam settings link for the course if it exists', async () => { + const user = userEvent.setup(); + const courseSettingsData = { + mfeProctoredExamSettingsUrl: 'https://example.com/proctored-exam-settings', + }; + axiosMock + .onGet(getCourseSettingsApiUrl(courseId)) + .reply(200, courseSettingsData); + renderComponent(); + await user.click(await screen.findByRole('tab', { name: 'Settings' })); + expect(await screen.findByRole('link', { name: 'Proctored exam settings' })).toBeInTheDocument(); + }); + it('renders InfoSidebar with section info', async () => { const user = userEvent.setup(); selectedContainerState = { diff --git a/src/course-unit/CourseUnit.test.tsx b/src/course-unit/CourseUnit.test.tsx index 32ec0f4c05..9c6d38afef 100644 --- a/src/course-unit/CourseUnit.test.tsx +++ b/src/course-unit/CourseUnit.test.tsx @@ -713,15 +713,13 @@ describe('', () => { await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - await waitFor(async () => { - const problemButton = screen.getByRole('button', { - name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'), - hidden: true, - }); - - await user.click(problemButton); + const problemButton = await screen.findByRole('button', { + name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'), + hidden: true, }); + await user.click(problemButton); + axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, courseSectionVerticalMock); diff --git a/src/data/api.ts b/src/data/api.ts index b3cfe0d2a4..9501998bff 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -48,6 +48,8 @@ export const bulkModulestoreMigrateUrl = () => `${getStudioBaseUrl()}/api/module */ export const getPreviewModulestoreMigrationUrl = () => `${getStudioBaseUrl()}/api/modulestore_migrator/v1/migration_preview/`; +export const getCourseSettingsApiUrl = (courseId: string) => `${getStudioBaseUrl()}/api/contentstore/v1/course_settings/${courseId}`; + export const getApiWaffleFlagsUrl = (courseId?: string): string => { const baseUrl = getStudioBaseUrl(); const apiPath = '/api/contentstore/v1/course_waffle_flags'; @@ -225,3 +227,44 @@ export async function getUserAgreement(agreementType: string) { const { data } = await client.get(getUserAgreementApi(agreementType)); return camelCaseObject(data); } + +export interface CourseSettingsData { + aboutPageEditable: boolean; + canShowCertificateAvailableDateField: boolean; + courseDisplayName: string; + courseDisplayNameWithDefault: string; + creditEligibilityEnabled: boolean; + enableExtendedCourseDetails: boolean; + enrollmentEndEditable: boolean; + isCreditCourse: boolean; + isEntranceExamsEnabled: boolean; + isPrerequisiteCoursesEnabled: boolean; + languageOptions: [string, string][]; + lmsLinkForAboutPage: string; + licensingEnabled: boolean; + marketingEnabled: boolean; + mfeProctoredExamSettingsUrl: string; + platformName: string; + possiblePreRequisiteCourses: { + courseKey: string; + displayName: string; + lmsLink: string; + number: string; + org: string; + rerunLink: string; + run: string; + url: string; + } + shortDescriptionEditable: boolean; + showMinGradeWarning: boolean; + sidebarHtmlEnabled: boolean; + upgradeDeadline: string | null; +} + +/** + * Get course settings. + */ +export async function getCourseSettings(courseId: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getCourseSettingsApiUrl(courseId)); + return camelCaseObject(data); +} diff --git a/src/data/apiHooks.ts b/src/data/apiHooks.ts index b55d98143b..1a764e377c 100644 --- a/src/data/apiHooks.ts +++ b/src/data/apiHooks.ts @@ -14,6 +14,7 @@ import { getUserAgreementRecord, getWaffleFlags, updateUserAgreementRecord, waffleFlagDefaults, + getCourseSettings, } from './api'; import { RequestStatus, RequestStatusType } from './constants'; @@ -212,3 +213,13 @@ export const useUserAgreement = (agreementType:string) => ( retry: false, }) ); + +/** + * Get the course settings + */ +export const useCourseSettings = (courseId: string) => ( + useQuery({ + queryKey: ['courseSettings', courseId], + queryFn: () => getCourseSettings(courseId), + }) +); diff --git a/src/generic/help-sidebar/HelpSidebarLink.jsx b/src/generic/help-sidebar/HelpSidebarLink.tsx similarity index 64% rename from src/generic/help-sidebar/HelpSidebarLink.jsx rename to src/generic/help-sidebar/HelpSidebarLink.tsx index 4a4ee91ac8..dd853c552f 100644 --- a/src/generic/help-sidebar/HelpSidebarLink.jsx +++ b/src/generic/help-sidebar/HelpSidebarLink.tsx @@ -1,10 +1,21 @@ +import React from 'react'; + import { Link } from 'react-router-dom'; -import PropTypes from 'prop-types'; import { Hyperlink } from '@openedx/paragon'; +interface HelpSidebarLinkProps { + as?: React.ElementType; + isNewPage?: boolean; + pathToPage: string; + title: string; +} + const HelpSidebarLink = ({ - as, pathToPage, title, isNewPage, -}) => { + as = 'li', + isNewPage = true, + pathToPage, + title, +}: HelpSidebarLinkProps) => { const TagElement = as; if (isNewPage) { return ( @@ -29,16 +40,4 @@ const HelpSidebarLink = ({ ); }; -HelpSidebarLink.propTypes = { - isNewPage: PropTypes.bool, - pathToPage: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - as: PropTypes.string, -}; - -HelpSidebarLink.defaultProps = { - as: 'li', - isNewPage: true, -}; - export default HelpSidebarLink; diff --git a/src/generic/help-sidebar/index.ts b/src/generic/help-sidebar/index.ts index 7f0fc8374a..3b5b9d2463 100644 --- a/src/generic/help-sidebar/index.ts +++ b/src/generic/help-sidebar/index.ts @@ -1,2 +1,4 @@ export { default as HelpSidebar } from './HelpSidebar'; export { default as HelpSidebarLink } from './HelpSidebarLink'; +export { otherLinkURLParams } from './constants'; +export { default as messages } from './messages'; diff --git a/src/grading-settings/GradingSettings.jsx b/src/grading-settings/GradingSettings.jsx index 56c0da6620..a359087db4 100644 --- a/src/grading-settings/GradingSettings.jsx +++ b/src/grading-settings/GradingSettings.jsx @@ -3,22 +3,23 @@ import { Button, Container, Layout, StatefulButton, } from '@openedx/paragon'; import { Add as IconAdd, CheckCircle, Warning } from '@openedx/paragon/icons'; -import { - useCourseSettings, - useGradingSettings, - useGradingSettingUpdater, -} from 'CourseAuthoring/grading-settings/data/apiHooks'; import { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; + import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { STATEFUL_BUTTON_STATES } from '../constants'; -import AlertMessage from '../generic/alert-message'; -import InternetConnectionAlert from '../generic/internet-connection-alert'; +import { STATEFUL_BUTTON_STATES } from '@src/constants'; +import { useCourseSettings } from '@src/data/apiHooks'; +import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert'; +import SectionSubHeader from '@src/generic/section-sub-header'; +import SubHeader from '@src/generic/sub-header/SubHeader'; +import AlertMessage from '@src/generic/alert-message'; +import InternetConnectionAlert from '@src/generic/internet-connection-alert'; +import getPageHeadTitle from '@src/generic/utils'; -import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; -import SectionSubHeader from '../generic/section-sub-header'; -import SubHeader from '../generic/sub-header/SubHeader'; -import getPageHeadTitle from '../generic/utils'; +import { + useGradingSettings, + useGradingSettingUpdater, +} from './data/apiHooks'; import AssignmentSection from './assignment-section'; import CreditSection from './credit-section'; import DeadlineSection from './deadline-section'; diff --git a/src/grading-settings/GradingSettings.test.jsx b/src/grading-settings/GradingSettings.test.jsx index c524845acd..76a667de4c 100644 --- a/src/grading-settings/GradingSettings.test.jsx +++ b/src/grading-settings/GradingSettings.test.jsx @@ -2,9 +2,10 @@ import { act, fireEvent, render, screen, initializeMocks, } from '@src/testUtils'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { getCourseSettingsApiUrl } from '@src/data/api'; import gradingSettings from './__mocks__/gradingSettings'; -import { getCourseSettingsApiUrl, getGradingSettingsApiUrl } from './data/api'; +import { getGradingSettingsApiUrl } from './data/api'; import * as apiHooks from './data/apiHooks'; import GradingSettings from './GradingSettings'; import messages from './messages'; diff --git a/src/grading-settings/data/api.js b/src/grading-settings/data/api.js index 8e73c720bb..d21b128f64 100644 --- a/src/grading-settings/data/api.js +++ b/src/grading-settings/data/api.js @@ -5,7 +5,6 @@ import { deepConvertingKeysToCamelCase, deepConvertingKeysToSnakeCase } from '.. const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getGradingSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_grading/${courseId}`; -export const getCourseSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_settings/${courseId}`; /** * Get's grading setting for a course. @@ -29,14 +28,3 @@ export async function sendGradingSettings(courseId, settings) { .post(getGradingSettingsApiUrl(courseId), deepConvertingKeysToSnakeCase(settings)); return camelCaseObject(data); } - -/** - * Get course settings. - * @param {string} courseId - * @returns {Promise} - */ -export async function getCourseSettings(courseId) { - const { data } = await getAuthenticatedHttpClient() - .get(getCourseSettingsApiUrl(courseId)); - return camelCaseObject(data); -} diff --git a/src/grading-settings/data/apiHooks.ts b/src/grading-settings/data/apiHooks.ts index c963575b8a..8d12f172f4 100644 --- a/src/grading-settings/data/apiHooks.ts +++ b/src/grading-settings/data/apiHooks.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { getCourseSettings, getGradingSettings, sendGradingSettings } from './api'; +import { getGradingSettings, sendGradingSettings } from './api'; export const useGradingSettings = (courseId: string) => ( useQuery({ @@ -8,13 +8,6 @@ export const useGradingSettings = (courseId: string) => ( }) ); -export const useCourseSettings = (courseId: string) => ( - useQuery({ - queryKey: ['courseSettings', courseId], - queryFn: () => getCourseSettings(courseId), - }) -); - export const useGradingSettingUpdater = (courseId: string) => { const queryClient = useQueryClient(); return useMutation({ diff --git a/src/schedule-and-details/ScheduleAndDetails.test.jsx b/src/schedule-and-details/ScheduleAndDetails.test.jsx index 77c0192e9c..f0990358aa 100644 --- a/src/schedule-and-details/ScheduleAndDetails.test.jsx +++ b/src/schedule-and-details/ScheduleAndDetails.test.jsx @@ -9,10 +9,11 @@ import { import { executeThunk } from '@src/utils'; import genericMessages from '@src/generic/help-sidebar/messages'; import { DATE_FORMAT } from '@src/constants'; +import { getCourseSettingsApiUrl } from '@src/data/api'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; import { courseDetailsMock, courseSettingsMock } from './__mocks__'; -import { getCourseDetailsApiUrl, getCourseSettingsApiUrl } from './data/api'; +import { getCourseDetailsApiUrl } from './data/api'; import { updateCourseDetailsQuery } from './data/thunks'; import creditMessages from './credit-section/messages'; import pacingMessages from './pacing-section/messages'; diff --git a/src/schedule-and-details/data/api.js b/src/schedule-and-details/data/api.js index 23f83db79b..a6202f0091 100644 --- a/src/schedule-and-details/data/api.js +++ b/src/schedule-and-details/data/api.js @@ -4,7 +4,6 @@ import { convertObjectToSnakeCase } from '../../utils'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getCourseDetailsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_details/${courseId}`; -export const getCourseSettingsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_settings/${courseId}`; export const getUploadAssetsUrl = (courseId) => `${getApiBaseUrl()}/assets/${courseId}/`; /** @@ -32,15 +31,3 @@ export async function updateCourseDetails(courseId, details) { ); return camelCaseObject(data); } - -/** - * Get course settings. - * @param {string} courseId - * @returns {Promise} - */ -export async function getCourseSettings(courseId) { - const { data } = await getAuthenticatedHttpClient().get( - `${getCourseSettingsApiUrl(courseId)}`, - ); - return camelCaseObject(data); -} diff --git a/src/schedule-and-details/data/thunks.js b/src/schedule-and-details/data/thunks.js index bc2be6dc41..fe14d29260 100644 --- a/src/schedule-and-details/data/thunks.js +++ b/src/schedule-and-details/data/thunks.js @@ -1,8 +1,11 @@ -import { RequestStatus } from '../../data/constants'; +import { + getCourseSettings, +} from '@src/data/api'; +import { RequestStatus } from '@src/data/constants'; + import { getCourseDetails, updateCourseDetails, - getCourseSettings, } from './api'; import { updateSavingStatus, From ec4cd641221e968f41b4c28fb033df8b8ce47753 Mon Sep 17 00:00:00 2001 From: Areeb Sajjad <89740222+asajjad2@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:15:06 +0500 Subject: [PATCH 61/62] fix: configure modal visibility tab background overflow (#2923) --- src/course-outline/CourseOutline.tsx | 5 +++++ src/generic/configure-modal/ConfigureModal.tsx | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 95d75627c9..0ae520a447 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -498,6 +498,11 @@ const CourseOutline = () => { isOpen={isConfigureModalOpen} onClose={handleConfigureModalClose} onConfigureSubmit={handleConfigureItemSubmit} + /** + * Only sections need overflow visible (for the Release date datepicker, fixed in #2901); + * enabling it for subsection/unit modals causes the Visibility tab background to clip. + */ + isOverflowVisible={itemCategory === COURSE_BLOCK_NAMES.chapter.id} currentItemData={currentItemData} enableProctoredExams={enableProctoredExams} enableTimedExams={enableTimedExams} diff --git a/src/generic/configure-modal/ConfigureModal.tsx b/src/generic/configure-modal/ConfigureModal.tsx index abf9b101f6..5b2ac38bba 100644 --- a/src/generic/configure-modal/ConfigureModal.tsx +++ b/src/generic/configure-modal/ConfigureModal.tsx @@ -28,6 +28,7 @@ interface Props { currentItemData?: AccessManagedXBlockDataTypes, isXBlockComponent?: boolean, isSelfPaced?: boolean, + isOverflowVisible?: boolean, } const ConfigureModal = ({ @@ -39,6 +40,7 @@ const ConfigureModal = ({ enableTimedExams = false, isXBlockComponent = false, isSelfPaced, + isOverflowVisible = false, }: Props) => { const intl = useIntl(); @@ -298,7 +300,7 @@ const ConfigureModal = ({ onClose={onClose} hasCloseButton isFullscreenOnMobile - isOverflowVisible + isOverflowVisible={isOverflowVisible} >
    From 50812e2bcb37e83316ee836ea2a6173dc6ce2587 Mon Sep 17 00:00:00 2001 From: tbain Date: Wed, 1 Apr 2026 13:38:48 -0700 Subject: [PATCH 62/62] feat: #253 Removing unneccessary file --- src/taxonomy/tag-list/tagTreeError.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 src/taxonomy/tag-list/tagTreeError.ts diff --git a/src/taxonomy/tag-list/tagTreeError.ts b/src/taxonomy/tag-list/tagTreeError.ts deleted file mode 100644 index 5e1615f257..0000000000 --- a/src/taxonomy/tag-list/tagTreeError.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default class TagTreeError extends Error { - constructor(message: string) { - super(message); - this.name = 'TagTreeError'; - } -}