From 392066aa6fc6b3e0e020d0b944a515a1a95c78d9 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Wed, 3 Jun 2026 16:18:45 -0500 Subject: [PATCH 1/7] feat: add tag management permissions --- src/authz/constants.ts | 2 ++ src/authz/permissionHelpers.ts | 7 ++++++ .../ContentTagsCollapsible.jsx | 2 +- .../ContentTagsDrawerHelper.jsx | 10 +++++++- .../tags-sidebar-controls/TagsSidebarBody.tsx | 23 +++++++++++-------- .../tags-sidebar-controls/index.tsx | 5 ++-- .../outline-sidebar/OutlineAlignSidebar.tsx | 4 ++++ .../info-sidebar/CourseInfoSidebar.tsx | 5 ++-- src/course-unit/legacy-sidebar/index.tsx | 5 +++- src/generic/sidebar/AlignSidebar.tsx | 6 ++++- 10 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/authz/constants.ts b/src/authz/constants.ts index 5944c910d9..e3a2a0a380 100644 --- a/src/authz/constants.ts +++ b/src/authz/constants.ts @@ -50,4 +50,6 @@ export const COURSE_PERMISSIONS = { IMPORT_COURSE: 'courses.import_course', EXPORT_COURSE: 'courses.export_course', EXPORT_TAGS: 'courses.export_tags', + + MANAGE_TAGS: 'courses.manage_tags', }; diff --git a/src/authz/permissionHelpers.ts b/src/authz/permissionHelpers.ts index c8f301b5d1..2965cee22a 100644 --- a/src/authz/permissionHelpers.ts +++ b/src/authz/permissionHelpers.ts @@ -131,6 +131,13 @@ export const getImportExportPermissions = (courseId: string) => ({ }, }); +export const getTagsPermissions = (courseId: string) => ({ + canManageTags: { + action: COURSE_PERMISSIONS.MANAGE_TAGS, + scope: courseId, + }, +}); + export const getFilesPermissions = (courseId: string) => ({ canViewFiles: { action: COURSE_PERMISSIONS.VIEW_FILES, diff --git a/src/content-tags-drawer/ContentTagsCollapsible.jsx b/src/content-tags-drawer/ContentTagsCollapsible.jsx index 51f960fdbf..7714493230 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsible.jsx @@ -419,7 +419,7 @@ const ContentTagsCollapsible = ({ )}
- {isEditMode && ( + {isEditMode && canTagObject && ( Props (selectProps). -declare module 'react-select/base' { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - export interface Props> extends TaxonomySelectProps { - } -} - -export type TagTree = { - [key: string]: { - children: TagTree; - canChangeObjecttag: boolean; - canDeleteObjecttag: boolean; - explicit: boolean; - isCopied: boolean; - }; -}; - -export default ContentTagsCollapsible; diff --git a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx b/src/content-tags-drawer/ContentTagsCollapsible.test.tsx similarity index 94% rename from src/content-tags-drawer/ContentTagsCollapsible.test.jsx rename to src/content-tags-drawer/ContentTagsCollapsible.test.tsx index 1d04f9271b..42d2471ee7 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsible.test.tsx @@ -12,6 +12,40 @@ import userEvent from '@testing-library/user-event'; import ContentTagsCollapsible from './ContentTagsCollapsible'; import messages from './messages'; import { ContentTagsDrawerContext } from './common/context'; +import type { ContentTagsDrawerContextData } from './common/context'; +import type { StagedTagData, TagsInTaxonomy } from './data/types'; + +/** Only the parts of a content tag that these tests exercise. */ +interface MockContentTag { + value: string; + lineage: string[]; + canDeleteObjecttag: boolean; +} + +interface ContentTagsCollapsibleComponentProps { + contentId: string; + taxonomyAndTagsData: { + id: number; + name: string; + canTagObject: boolean; + contentTags: MockContentTag[]; + }; + stagedContentTags: StagedTagData[]; + addStagedContentTag: jest.Mock; + removeStagedContentTag: jest.Mock; + setStagedTags: jest.Mock; + removeGlobalStagedContentTag: jest.Mock; + addRemovedContentTag: jest.Mock; + deleteRemovedContentTag: jest.Mock; + globalStagedContentTags: Record; + globalStagedRemovedContentTags: Record; + setGlobalStagedContentTags: jest.Mock; + isEditMode: boolean; + toEditMode: jest.Mock; + collapsibleState: boolean; + openCollapsible: jest.Mock; + closeCollapsible: jest.Mock; +} const taxonomyMockData = { hasMorePages: false, @@ -97,7 +131,7 @@ jest.mock('./data/apiHooks', () => ({ }), })); -const data = { +const data: ContentTagsCollapsibleComponentProps = { contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab', taxonomyAndTagsData: { id: 123, @@ -156,7 +190,7 @@ const ContentTagsCollapsibleComponent = ({ collapsibleState, openCollapsible, closeCollapsible, -}) => { +}: ContentTagsCollapsibleComponentProps) => { const context = useMemo(() => ({ addStagedContentTag, removeStagedContentTag, @@ -173,11 +207,11 @@ const ContentTagsCollapsibleComponent = ({ closeCollapsible, }), []); return ( - + @@ -186,8 +220,6 @@ const ContentTagsCollapsibleComponent = ({ ); }; -ContentTagsCollapsibleComponent.propTypes = ContentTagsCollapsible.propTypes; - describe('', () => { beforeAll(() => { jest.useFakeTimers(); // To account for debounce timer @@ -201,7 +233,7 @@ describe('', () => { jest.clearAllMocks(); // Reset all mock function call counts after each test case }); - async function getComponent(updatedData) { + async function getComponent(updatedData?: ContentTagsCollapsibleComponentProps) { const componentData = !updatedData ? data : updatedData; return render( @@ -589,7 +621,7 @@ describe('', () => { // Simulate clicking outside the dropdown remove focus const outsideElement = container.querySelector('.taxonomy-tags-count-chip'); - const selectElement = container.querySelector('.react-select-add-tags__input'); + const selectElement = container.querySelector('.react-select-add-tags__input')!; fireEvent.blur(selectElement, { relatedTarget: outsideElement }); // Wait for the dropdown selector for tags to close, Tag 3 is no longer on diff --git a/src/content-tags-drawer/ContentTagsCollapsible.jsx b/src/content-tags-drawer/ContentTagsCollapsible.tsx similarity index 79% rename from src/content-tags-drawer/ContentTagsCollapsible.jsx rename to src/content-tags-drawer/ContentTagsCollapsible.tsx index 7714493230..47a72b1f53 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsible.tsx @@ -1,9 +1,16 @@ -// @ts-check -// disable prop-types since we're using TypeScript to define the prop types, -// but the linter can't detect that in a .jsx file. -/* eslint-disable react/prop-types */ import React, { useContext } from 'react'; import Select, { components } from 'react-select'; +import type { + GroupBase, + IndicatorsContainerProps, + InputActionMeta, + MenuProps, + SelectInstance, +} from 'react-select'; +// This import is necessary for the module augmentation below. +// It allows us to extend the 'Props' interface in the 'react-select/base' module +// and add our custom properties to it. +import type {} from 'react-select/base'; import { Collapsible, Button, @@ -23,17 +30,56 @@ import ContentTagsDropDownSelector from './ContentTagsDropDownSelector'; import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper'; import TagsTree from './TagsTree'; import { ContentTagsDrawerContext } from './common/context'; +import type { DrawerTaxonomy, StagedTagData } from './data/types'; -/** @typedef {import("./ContentTagsCollapsible").TaxonomySelectProps} TaxonomySelectProps */ -/** @typedef {import("../taxonomy/data/types.js").TaxonomyData} TaxonomyData */ -/** @typedef {import("./data/types.js").Tag} ContentTagData */ -/** @typedef {import("./data/types.js").StagedTagData} StagedTagData */ +export interface TagTreeEntry { + explicit: boolean; + children: Record; + isCopied: boolean; + canChangeObjecttag: boolean; + canDeleteObjecttag: boolean; +} + +export interface TaxonomySelectProps { + taxonomyId: number; + searchTerm: string; + appliedContentTagsTree: Record; + stagedContentTagsTree: Record; + checkedTags: string[]; + selectCancelRef: React.RefObject; + selectAddRef: React.RefObject; + selectInlineAddRef: React.RefObject; + handleCommitStagedTags: () => void; + handleCancelStagedTags: () => void; + handleSelectableBoxChange: React.ChangeEventHandler; +} + +// Unfortunately the only way to specify the custom props we pass into React Select +// is with this global type augmentation. +// https://react-select.com/typescript#custom-select-props +// If in the future other parts of this MFE need to use React Select for different things, +// we should change to using a 'react context' to share this data within , +// rather than using the custom