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.test.ts b/src/authz/permissionHelpers.test.ts index 781becd11d..37aa6b76b4 100644 --- a/src/authz/permissionHelpers.test.ts +++ b/src/authz/permissionHelpers.test.ts @@ -12,6 +12,7 @@ import { getChecklistsPermissions, getImportExportPermissions, getViewTeamPermissions, + getTagsPermissions, } from './permissionHelpers'; import { CONTENT_LIBRARY_PERMISSIONS, COURSE_PERMISSIONS } from './constants'; @@ -258,6 +259,26 @@ describe('permissionHelpers', () => { }); }); + describe('getTagsPermissions', () => { + it('returns MANAGE_TAGS permission with the correct action and scope', () => { + const result = getTagsPermissions(courseId); + + expect(result).toEqual({ + canManageTags: { + action: COURSE_PERMISSIONS.MANAGE_TAGS, + scope: courseId, + }, + }); + }); + + it('uses the provided courseId as scope', () => { + const customCourseId = 'course-v1:TestOrg+TestCourse+2024'; + const result = getTagsPermissions(customCourseId); + + expect(result.canManageTags.scope).toBe(customCourseId); + }); + }); + describe('getViewTeamPermissions', () => { it('returns course and library view-team permissions with the correct actions', () => { const result = getViewTeamPermissions(); 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.d.ts b/src/content-tags-drawer/ContentTagsCollapsible.d.ts deleted file mode 100644 index e42a575bf5..0000000000 --- a/src/content-tags-drawer/ContentTagsCollapsible.d.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Ref } from 'react'; -import type {} from 'react-select/base'; -// This import is necessary for module augmentation. -// It allows us to extend the 'Props' interface in the 'react-select/base' module -// and add our custom property 'myCustomProp' to it. - -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: Ref; - selectAddRef: Ref; - selectInlineAddRef: Ref; - 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 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; + }; +}; /** * Custom Menu component for our Select box - * @param {import("react-select").MenuProps&{selectProps: TaxonomySelectProps}} props */ -const CustomMenu = (props) => { +const CustomMenu = (props: MenuProps) => { const { handleSelectableBoxChange, checkedTags, @@ -47,6 +93,7 @@ const CustomMenu = (props) => { selectAddRef, value, } = props.selectProps; + const stagedTags: readonly StagedTagData[] = Array.isArray(value) ? value : []; const intl = useIntl(); return ( @@ -59,7 +106,7 @@ const CustomMenu = (props) => { className="taxonomy-tags-selectable-box-set" onChange={handleSelectableBoxChange} value={checkedTags} - tabIndex="-1" + tabIndex={-1} > { className="tags-drawer-cancel-button" onClick={handleCancelStagedTags} > - {intl.formatMessage(messages.collapsibleCancelStagedTagsButtonText)} + @@ -99,7 +146,7 @@ const CustomMenu = (props) => { ); }; -const disableActionKeys = (e) => { +const disableActionKeys = (e: React.KeyboardEvent) => { const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft', 'Backspace']; if (arrowKeys.includes(e.code)) { e.preventDefault(); @@ -118,18 +165,17 @@ const CustomLoadingIndicator = () => { /** * Custom IndicatorsContainer component for our Select box - * @param {import("react-select").IndicatorsContainerProps&{selectProps: TaxonomySelectProps}} props */ -const CustomIndicatorsContainer = (props) => { +const CustomIndicatorsContainer = (props: IndicatorsContainerProps) => { const { value, handleCommitStagedTags, selectInlineAddRef, } = props.selectProps; - const intl = useIntl(); + const stagedTags: readonly StagedTagData[] = Array.isArray(value) ? value : []; return ( - {(value && value.length && ( + {(stagedTags.length > 0 && ( )) || null} {props.children} @@ -151,6 +197,17 @@ const CustomIndicatorsContainer = (props) => { ); }; +interface ContentTagsCollapsibleProps { + /** Id of the content object */ + contentId: string; + /** Taxonomy metadata & applied tags */ + taxonomyAndTagsData: DrawerTaxonomy; + /** Array of staged tags represented as objects with value/label */ + stagedContentTags: StagedTagData[]; + /** True if the collapsible is open */ + collapsibleState: boolean; +} + /** * Collapsible component that holds a Taxonomy along with Tags that belong to it. * This includes both applied tags and tags that are available to select @@ -220,27 +277,20 @@ const CustomIndicatorsContainer = (props) => { * Here is an example of what the value of the "Virology" tag would be: * * "Science%20and%20Research,Molecular%2C%20Cellular%2C%20and%20Microbiology,Virology" - * - * @param {Object} props - The component props. - * @param {string} props.contentId - Id of the content object - * @param {StagedTagData[]} props.stagedContentTags - * - Array of staged tags represented as objects with value/label - * @param {TaxonomyData & {contentTags: ContentTagData[]}} props.taxonomyAndTagsData - Taxonomy metadata & applied tags - * @param {boolean} props.collapsibleState - True if the collapsible is open */ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, stagedContentTags, collapsibleState, -}) => { +}: ContentTagsCollapsibleProps) => { const intl = useIntl(); const { id: taxonomyId, name, canTagObject } = taxonomyAndTagsData; - const selectCancelRef = React.useRef(/** @type {HTMLSelectElement | null} */ (null)); - const selectAddRef = React.useRef(/** @type {HTMLSelectElement | null} */ (null)); - const selectInlineAddRef = React.useRef(/** @type {HTMLSelectElement | null} */ (null)); - const selectInlineEditModeRef = React.useRef(/** @type {HTMLButtonElement | null} */ (null)); - const selectRef = React.useRef(/** @type {HTMLSelectElement | null} */ (null)); + const selectCancelRef = React.useRef(null); + const selectAddRef = React.useRef(null); + const selectInlineAddRef = React.useRef(null); + const selectInlineEditModeRef = React.useRef(null); + const selectRef = React.useRef>(null); const [selectMenuIsOpen, setSelectMenuIsOpen] = React.useState(false); @@ -269,15 +319,15 @@ const ContentTagsCollapsible = ({ const [searchTerm, setSearchTerm] = React.useState(''); - const handleSelectableBoxChange = React.useCallback((e) => { + const handleSelectableBoxChange = React.useCallback((e: React.ChangeEvent) => { tagChangeHandler(e.target.value, e.target.checked); }, [tagChangeHandler]); - const handleSearch = debounce((term) => { + const handleSearch = debounce((term: string) => { setSearchTerm(term.trim()); }, 500); // Perform search after 500ms - const handleSearchChange = React.useCallback((value, { action }) => { + const handleSearchChange = React.useCallback((value: string, { action }: InputActionMeta) => { if (action === 'input-blur') { if (!selectMenuIsOpen) { // Cancel/clear search if focused away from select input and menu closed @@ -299,7 +349,7 @@ const ContentTagsCollapsible = ({ // staged tags in the react-select input are removed or fully cleared. // The remaining staged tags are passed in as the parameter, so we set the state // to the passed in tags - const handleStagedTagsMenuChange = React.useCallback((stagedTags) => { + const handleStagedTagsMenuChange = React.useCallback((stagedTags: readonly StagedTagData[]) => { // Get tags that were unstaged to remove them from checkbox selector const unstagedTags = stagedContentTags.filter( t1 => !stagedTags.some(t2 => t1.value === t2.value), @@ -310,7 +360,7 @@ const ContentTagsCollapsible = ({ // only called when a change occurs in the react-select menu component we know that tags can only be // removed from there, hence the tagChangeHandler is always called with `checked=false`. unstagedTags.forEach(unstagedTag => tagChangeHandler(unstagedTag.value, false)); - setStagedTags(taxonomyId, stagedTags); + setStagedTags(taxonomyId, [...stagedTags]); }, [taxonomyId, setStagedTags, stagedContentTags, tagChangeHandler]); const handleCommitStagedTags = React.useCallback(() => { @@ -328,7 +378,7 @@ const ContentTagsCollapsible = ({ setSelectMenuIsOpen(false); }, [handleStagedTagsMenuChange, selectRef, setSearchTerm]); - const handleSelectOnKeyDown = (event) => { + const handleSelectOnKeyDown = (event: React.KeyboardEvent) => { const focusedElement = event.target; if (event.key === 'Escape') { @@ -345,7 +395,6 @@ const ContentTagsCollapsible = ({ setSelectMenuIsOpen(false); } // Navigating backwards - // @ts-ignore inputRef actually exists under the current selectRef } else if (event.shiftKey && focusedElement === selectRef.current?.inputRef) { setSelectMenuIsOpen(false); } @@ -359,10 +408,11 @@ const ContentTagsCollapsible = ({ }, [setSelectMenuIsOpen, setSearchTerm]); // Handles logic to close the select menu when clicking outside - const handleOnBlur = React.useCallback((event) => { + const handleOnBlur = React.useCallback((event: React.FocusEvent) => { // Check if a target we are focusing to is an element in our select menu, if not close it const menuClasses = ['dropdown-selector', 'inline-add-button', 'cancel-add-tags-button']; - if (!event.relatedTarget || !menuClasses.some(cls => event.relatedTarget.className?.includes(cls))) { + const { relatedTarget } = event; + if (!relatedTarget || !menuClasses.some(cls => relatedTarget.className?.includes(cls))) { setSelectMenuIsOpen(false); } }, [setSelectMenuIsOpen]); @@ -391,7 +441,7 @@ const ContentTagsCollapsible = ({ && (

- {intl.formatMessage(messages.collapsibleNoTagsAddedText)} + {canTagObject && ( )}

@@ -419,7 +469,7 @@ const ContentTagsCollapsible = ({ )}
- {isEditMode && ( + {isEditMode && canTagObject && (