From 436ac3155d95aa4caa6baa0969ffa9be41b0c1cf Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 4 Nov 2025 03:36:50 +0530 Subject: [PATCH] feat: nav dropdowns in library authoring view (#2556) Updates navbar in library authoring page to include `Team Access` and `Import` menu options. Clicking on `Team Access` button opens Team management modal. As per this new PR: https://github.com/openedx/frontend-app-authoring/pull/2570, if admin console url is set, it should be used instead of team access modal. So updated this PR accordingly. --- src/header/Header.tsx | 19 +++++- src/header/{hooks.test.js => hooks.test.ts} | 54 +++++++++++++++-- src/header/{hooks.jsx => hooks.tsx} | 60 ++++++++++++++++--- src/header/{index.js => index.ts} | 0 src/header/{messages.js => messages.ts} | 10 ++++ src/library-authoring/LibraryLayout.tsx | 2 + .../backup-restore/LibraryBackupPage.tsx | 3 +- .../collections/LibraryCollectionPage.tsx | 2 + .../common/context/SidebarContext.tsx | 8 +-- .../library-info/LibraryInfo.tsx | 8 +-- .../library-team/LibraryTeamModal.tsx | 20 +++---- src/library-authoring/routes.ts | 7 ++- .../LibrarySectionPage.tsx | 3 +- .../LibrarySubsectionPage.tsx | 3 +- .../units/LibraryUnitPage.tsx | 6 +- src/studio-home/data/slice.ts | 1 + 16 files changed, 161 insertions(+), 45 deletions(-) rename src/header/{hooks.test.js => hooks.test.ts} (71%) rename src/header/{hooks.jsx => hooks.tsx} (70%) rename src/header/{index.js => index.ts} (100%) rename src/header/{messages.js => messages.ts} (94%) diff --git a/src/header/Header.tsx b/src/header/Header.tsx index b9f7210fc..56768db2b 100644 --- a/src/header/Header.tsx +++ b/src/header/Header.tsx @@ -6,7 +6,7 @@ import { type Container, useToggle } from '@openedx/paragon'; import { useWaffleFlags } from '../data/apiHooks'; import { SearchModal } from '../search-modal'; import { - useContentMenuItems, useLibraryToolsMenuItems, useSettingMenuItems, useToolsMenuItems, + useContentMenuItems, useLibrarySettingsMenuItems, useLibraryToolsMenuItems, useSettingMenuItems, useToolsMenuItems, } from './hooks'; import messages from './messages'; @@ -20,6 +20,7 @@ interface HeaderProps { isHiddenMainMenu?: boolean, isLibrary?: boolean, containerProps?: ContainerPropsType, + readOnly?: boolean, } const Header = ({ @@ -30,6 +31,7 @@ const Header = ({ isHiddenMainMenu = false, isLibrary = false, containerProps = {}, + readOnly = false, }: HeaderProps) => { const intl = useIntl(); const waffleFlags = useWaffleFlags(); @@ -43,7 +45,8 @@ const Header = ({ const settingMenuItems = useSettingMenuItems(contextId); const toolsMenuItems = useToolsMenuItems(contextId); const libraryToolsMenuItems = useLibraryToolsMenuItems(contextId); - const mainMenuDropdowns = !isLibrary ? [ + const libraryToolsSettingsItems = useLibrarySettingsMenuItems(contextId, readOnly); + let mainMenuDropdowns = !isLibrary ? [ { id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.content']), @@ -65,6 +68,18 @@ const Header = ({ items: libraryToolsMenuItems, }]; + // Include settings menu only if user is allowed to see them. + if (isLibrary && libraryToolsSettingsItems.length > 0) { + mainMenuDropdowns = [ + { + id: `${intl.formatMessage(messages['header.links.settings'])}-dropdown-menu`, + buttonTitle: intl.formatMessage(messages['header.links.settings']), + items: libraryToolsSettingsItems, + }, + ...mainMenuDropdowns, + ]; + } + const getOutlineLink = () => { if (isLibrary) { return `/library/${contextId}`; diff --git a/src/header/hooks.test.js b/src/header/hooks.test.ts similarity index 71% rename from src/header/hooks.test.js rename to src/header/hooks.test.ts index 176c0905a..28fc53c24 100644 --- a/src/header/hooks.test.js +++ b/src/header/hooks.test.ts @@ -2,7 +2,9 @@ import { useSelector } from 'react-redux'; import { getConfig, setConfig } from '@edx/frontend-platform'; import { renderHook } from '@testing-library/react'; import messages from './messages'; -import { useContentMenuItems, useToolsMenuItems, useSettingMenuItems } from './hooks'; +import { + useContentMenuItems, useToolsMenuItems, useSettingMenuItems, useLibrarySettingsMenuItems, useLibraryToolsMenuItems, +} from './hooks'; import { mockWaffleFlags } from '../data/apiHooks.mock'; jest.mock('@edx/frontend-platform/i18n', () => ({ @@ -28,7 +30,7 @@ jest.mock('react-redux', () => ({ describe('header utils', () => { describe('getContentMenuItems', () => { it('when video upload page enabled should include Video Uploads option', () => { - useSelector.mockReturnValue({ + jest.mocked(useSelector).mockReturnValue({ librariesV2Enabled: false, }); setConfig({ @@ -39,7 +41,7 @@ describe('header utils', () => { expect(actualItems).toHaveLength(5); }); it('when video upload page disabled should not include Video Uploads option', () => { - useSelector.mockReturnValue({ + jest.mocked(useSelector).mockReturnValue({ librariesV2Enabled: false, }); setConfig({ @@ -50,7 +52,7 @@ describe('header utils', () => { expect(actualItems).toHaveLength(4); }); it('adds course libraries link to content menu when libraries v2 is enabled', () => { - useSelector.mockReturnValue({ + jest.mocked(useSelector).mockReturnValue({ librariesV2Enabled: true, }); const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current; @@ -60,7 +62,7 @@ describe('header utils', () => { describe('getSettingsMenuitems', () => { beforeEach(() => { - useSelector.mockReturnValue({ + jest.mocked(useSelector).mockReturnValue({ canAccessAdvancedSettings: true, }); }); @@ -86,7 +88,7 @@ describe('header utils', () => { expect(actualItemsTitle).toContain('Advanced Settings'); }); it('when user has no access to advanced settings should not include advanced settings option', () => { - useSelector.mockReturnValue({ canAccessAdvancedSettings: false }); + jest.mocked(useSelector).mockReturnValue({ canAccessAdvancedSettings: false }); const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123')).result.current.map((item) => item.title); expect(actualItemsTitle).not.toContain('Advanced Settings'); }); @@ -137,4 +139,44 @@ describe('header utils', () => { expect(actualItemsTitle).not.toContain(messages['header.links.optimizer'].defaultMessage); }); }); + + describe('useLibrarySettingsMenuItems', () => { + it('should contain team access url', () => { + const items = renderHook(() => useLibrarySettingsMenuItems('library-123', false)).result.current; + expect(items).toContainEqual({ title: 'Team Access', href: 'http://localhost/?sa=manage-team' }); + }); + it('should contain admin console url if set', () => { + setConfig({ + ...getConfig(), + ADMIN_CONSOLE_URL: 'http://admin-console.com', + }); + const items = renderHook(() => useLibrarySettingsMenuItems('library-123', false)).result.current; + expect(items).toContainEqual({ + title: 'Team Access', + href: 'http://admin-console.com/authz/libraries/library-123', + }); + }); + it('should contain admin console url if set and readOnly is true', () => { + setConfig({ + ...getConfig(), + ADMIN_CONSOLE_URL: 'http://admin-console.com', + }); + const items = renderHook(() => useLibrarySettingsMenuItems('library-123', true)).result.current; + expect(items).toContainEqual({ + title: 'Team Access', + href: 'http://admin-console.com/authz/libraries/library-123', + }); + }); + }); + + describe('useLibraryToolsMenuItems', () => { + it('should contain backup and import url', () => { + const items = renderHook(() => useLibraryToolsMenuItems('course-123')).result.current; + expect(items).toContainEqual({ + href: '/library/course-123/backup', + title: 'Backup to local archive', + }); + expect(items).toContainEqual({ href: '/library/course-123/import', title: 'Import' }); + }); + }); }); diff --git a/src/header/hooks.jsx b/src/header/hooks.tsx similarity index 70% rename from src/header/hooks.jsx rename to src/header/hooks.tsx index 80389e3a7..d009e1483 100644 --- a/src/header/hooks.jsx +++ b/src/header/hooks.tsx @@ -3,13 +3,15 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useSelector } from 'react-redux'; import { Badge } from '@openedx/paragon'; -import { getPagePath } from '../utils'; -import { useWaffleFlags } from '../data/apiHooks'; -import { getStudioHomeData } from '../studio-home/data/selectors'; +import { getPagePath } from '@src/utils'; +import { useWaffleFlags } from '@src/data/apiHooks'; +import { getStudioHomeData } from '@src/studio-home/data/selectors'; +import courseOptimizerMessages from '@src/optimizer-page/messages'; +import { SidebarActions } from '@src/library-authoring/common/context/SidebarContext'; +import { LibQueryParamKeys } from '@src/library-authoring/routes'; import messages from './messages'; -import courseOptimizerMessages from '../optimizer-page/messages'; -export const useContentMenuItems = courseId => { +export const useContentMenuItems = (courseId: string) => { const intl = useIntl(); const studioBaseUrl = getConfig().STUDIO_BASE_URL; const waffleFlags = useWaffleFlags(); @@ -50,7 +52,7 @@ export const useContentMenuItems = courseId => { return items; }; -export const useSettingMenuItems = courseId => { +export const useSettingMenuItems = (courseId: string) => { const intl = useIntl(); const studioBaseUrl = getConfig().STUDIO_BASE_URL; const { canAccessAdvancedSettings } = useSelector(getStudioHomeData); @@ -89,7 +91,7 @@ export const useSettingMenuItems = courseId => { return items; }; -export const useToolsMenuItems = (courseId) => { +export const useToolsMenuItems = (courseId: string) => { const intl = useIntl(); const studioBaseUrl = getConfig().STUDIO_BASE_URL; const waffleFlags = useWaffleFlags(); @@ -127,7 +129,7 @@ export const useToolsMenuItems = (courseId) => { return items; }; -export const useLibraryToolsMenuItems = itemId => { +export const useLibraryToolsMenuItems = (itemId: string) => { const intl = useIntl(); const items = [ @@ -135,7 +137,49 @@ export const useLibraryToolsMenuItems = itemId => { href: `/library/${itemId}/backup`, title: intl.formatMessage(messages['header.links.exportLibrary']), }, + { + href: `/library/${itemId}/import`, + title: intl.formatMessage(messages['header.links.lib.import']), + }, ]; return items; }; + +export const useLibrarySettingsMenuItems = (itemId: string, readOnly: boolean) => { + const intl = useIntl(); + + const openTeamAccessModalUrl = () => { + const adminConsoleUrl = getConfig().ADMIN_CONSOLE_URL; + // always show link to admin console MFE if it is being used + const shouldShowAdminConsoleLink = !!adminConsoleUrl; + + // if the admin console MFE isn't being used, show team modal button for non–read-only users + const shouldShowTeamModalButton = !adminConsoleUrl && !readOnly; + if (shouldShowTeamModalButton) { + if (!window.location.href) { + return null; + } + const url = new URL(window.location.href); + // Set ?sa=manage-team in url which in turn opens team access modal + url.searchParams.set(LibQueryParamKeys.SidebarActions, SidebarActions.ManageTeam); + return url.toString(); + } + if (shouldShowAdminConsoleLink) { + return `${adminConsoleUrl}/authz/libraries/${itemId}`; + } + return null; + }; + + const items: { title: string; href: string }[] = []; + + const teamAccessUrl = openTeamAccessModalUrl(); + if (teamAccessUrl) { + items.push({ + title: intl.formatMessage(messages['header.menu.teamAccess']), + href: teamAccessUrl, + }); + } + + return items; +}; diff --git a/src/header/index.js b/src/header/index.ts similarity index 100% rename from src/header/index.js rename to src/header/index.ts diff --git a/src/header/messages.js b/src/header/messages.ts similarity index 94% rename from src/header/messages.js rename to src/header/messages.ts index b755b9dc5..d1bb89a7b 100644 --- a/src/header/messages.js +++ b/src/header/messages.ts @@ -96,6 +96,11 @@ const messages = defineMessages({ defaultMessage: 'Import', description: 'Link to Studio Import page', }, + 'header.links.lib.import': { + id: 'header.links.lib.import', + defaultMessage: 'Import', + description: 'Link to Course Import page in library', + }, 'header.links.exportCourse': { id: 'header.links.exportCourse', defaultMessage: 'Export Course', @@ -106,6 +111,11 @@ const messages = defineMessages({ defaultMessage: 'Backup to local archive', description: 'Link to Studio Backup Library page', }, + 'header.menu.teamAccess': { + id: 'header.links.teamAccess', + defaultMessage: 'Team Access', + description: 'Menu item to open team access popup', + }, 'header.links.optimizer': { id: 'header.links.optimizer', defaultMessage: 'Course Optimizer', diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index 350bd5c39..d51a25c0a 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -18,6 +18,7 @@ import { CreateContainerModal } from './create-container'; import { ROUTES } from './routes'; import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections'; import { LibraryUnitPage } from './units'; +import { LibraryTeamModal } from './library-team'; const LibraryLayoutWrapper: React.FC = ({ children }) => { const { @@ -48,6 +49,7 @@ const LibraryLayoutWrapper: React.FC = ({ children }) = + ); diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.tsx index d54a76563..9cd51f0bf 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.tsx @@ -24,7 +24,7 @@ import { useContentLibrary } from '@src/library-authoring/data/apiHooks'; export const LibraryBackupPage = () => { const intl = useIntl(); - const { libraryId } = useLibraryContext(); + const { libraryId, readOnly } = useLibraryContext(); const [taskId, setTaskId] = useState(''); const [isMutationInProgress, setIsMutationInProgress] = useState(false); const timeoutRef = useRef(null); @@ -144,6 +144,7 @@ export const LibraryBackupPage = () => { title={libraryData.title} org={libraryData.org} contextId={libraryId} + readOnly={readOnly} isLibrary containerProps={{ size: undefined, diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx index 2b54615bd..05a209a63 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.tsx @@ -107,6 +107,7 @@ const LibraryCollectionPage = () => { showOnlyPublished, extraFilter: contextExtraFilter, setCollectionId, + readOnly, } = useLibraryContext(); const { sidebarItemInfo } = useSidebarContext(); @@ -194,6 +195,7 @@ const LibraryCollectionPage = () => { title={libraryData.title} org={libraryData.org} contextId={libraryId} + readOnly={readOnly} isLibrary containerProps={{ size: undefined, diff --git a/src/library-authoring/common/context/SidebarContext.tsx b/src/library-authoring/common/context/SidebarContext.tsx index d1323adf6..c7695880c 100644 --- a/src/library-authoring/common/context/SidebarContext.tsx +++ b/src/library-authoring/common/context/SidebarContext.tsx @@ -7,10 +7,10 @@ import { useState, } from 'react'; import { useParams } from 'react-router-dom'; -import { useStateWithUrlSearchParam } from '../../../hooks'; +import { useStateWithUrlSearchParam } from '@src/hooks'; +import { LibQueryParamKeys, useLibraryRoutes } from '@src/library-authoring/routes'; import { useComponentPickerContext } from './ComponentPickerContext'; import { useLibraryContext } from './LibraryContext'; -import { useLibraryRoutes } from '../../routes'; export enum SidebarBodyItemId { AddContent = 'add-content', @@ -130,14 +130,14 @@ export const SidebarProvider = ({ const [sidebarTab, setSidebarTab] = useStateWithUrlSearchParam( defaultTab.component, - 'st', + LibQueryParamKeys.SidebarTab, (value: string) => toSidebarInfoTab(value), (value: SidebarInfoTab) => value.toString(), ); const [sidebarAction, setSidebarAction] = useStateWithUrlSearchParam( SidebarActions.None, - 'sa', + LibQueryParamKeys.SidebarActions, (value: string) => Object.values(SidebarActions).find((enumValue) => value === enumValue), (value: SidebarActions) => value.toString(), ); diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx index 1a44937d8..562e511e7 100644 --- a/src/library-authoring/library-info/LibraryInfo.tsx +++ b/src/library-authoring/library-info/LibraryInfo.tsx @@ -5,15 +5,13 @@ import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import LibraryPublishStatus from './LibraryPublishStatus'; -import { LibraryTeamModal } from '../library-team'; import { useLibraryContext } from '../common/context/LibraryContext'; import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; const LibraryInfo = () => { const intl = useIntl(); const { libraryId, libraryData, readOnly } = useLibraryContext(); - const { sidebarAction, setSidebarAction, resetSidebarAction } = useSidebarContext(); - const isLibraryTeamModalOpen = (sidebarAction === SidebarActions.ManageTeam); + const { setSidebarAction } = useSidebarContext(); const adminConsoleUrl = getConfig().ADMIN_CONSOLE_URL; // always show link to admin console MFE if it is being used @@ -25,9 +23,6 @@ const LibraryInfo = () => { const openLibraryTeamModal = useCallback(() => { setSidebarAction(SidebarActions.ManageTeam); }, [setSidebarAction]); - const closeLibraryTeamModal = useCallback(() => { - resetSidebarAction(); - }, [resetSidebarAction]); return ( @@ -81,7 +76,6 @@ const LibraryInfo = () => { - {isLibraryTeamModalOpen && } ); }; diff --git a/src/library-authoring/library-team/LibraryTeamModal.tsx b/src/library-authoring/library-team/LibraryTeamModal.tsx index e7a970796..87eab3a2a 100644 --- a/src/library-authoring/library-team/LibraryTeamModal.tsx +++ b/src/library-authoring/library-team/LibraryTeamModal.tsx @@ -1,24 +1,24 @@ -import React from 'react'; - import { StandardModal } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { useCallback } from 'react'; +import { SidebarActions, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext'; import LibraryTeam from './LibraryTeam'; import messages from './messages'; -interface LibraryTeamModalProps { - onClose: () => void; -} - -export const LibraryTeamModal: React.FC = ({ - onClose, -}) => { +export const LibraryTeamModal = () => { const intl = useIntl(); + const { sidebarAction, resetSidebarAction } = useSidebarContext(); + // Open the library team modal only when Manage Team sidebar action is set + const isOpen = (sidebarAction === SidebarActions.ManageTeam); + const onClose = useCallback(() => { + resetSidebarAction(); + }, [resetSidebarAction]); // Show Library Team modal in full screen return ( { } // Also remove the `sa` (sidebar action) search param if it exists. - searchParams.delete('sa'); + searchParams.delete(LibQueryParamKeys.SidebarActions); const newPath = generatePath(BASE_ROUTE + route, routeParams); // Prevent unnecessary navigation if the path is the same. diff --git a/src/library-authoring/section-subsections/LibrarySectionPage.tsx b/src/library-authoring/section-subsections/LibrarySectionPage.tsx index 63055a2c2..40035ca2e 100644 --- a/src/library-authoring/section-subsections/LibrarySectionPage.tsx +++ b/src/library-authoring/section-subsections/LibrarySectionPage.tsx @@ -20,7 +20,7 @@ import { ContainerEditableTitle, FooterActions, HeaderActions } from '../contain /** Full library section page */ export const LibrarySectionPage = () => { const intl = useIntl(); - const { libraryId, containerId } = useLibraryContext(); + const { libraryId, containerId, readOnly } = useLibraryContext(); const { sidebarItemInfo, } = useSidebarContext(); @@ -84,6 +84,7 @@ export const LibrarySectionPage = () => { org={libraryData.org} contextId={libraryData.id} isLibrary + readOnly={readOnly} containerProps={{ size: undefined, }} diff --git a/src/library-authoring/section-subsections/LibrarySubsectionPage.tsx b/src/library-authoring/section-subsections/LibrarySubsectionPage.tsx index e99509f01..64116a6cc 100644 --- a/src/library-authoring/section-subsections/LibrarySubsectionPage.tsx +++ b/src/library-authoring/section-subsections/LibrarySubsectionPage.tsx @@ -22,7 +22,7 @@ import { ContainerEditableTitle, FooterActions, HeaderActions } from '../contain /** Full library subsection page */ export const LibrarySubsectionPage = () => { const intl = useIntl(); - const { libraryId, containerId } = useLibraryContext(); + const { libraryId, containerId, readOnly } = useLibraryContext(); const { sidebarItemInfo } = useSidebarContext(); const { data: libraryData, isPending: isLibPending } = useContentLibrary(libraryId); @@ -64,6 +64,7 @@ export const LibrarySubsectionPage = () => { title={libraryData.title} org={libraryData.org} contextId={libraryData.id} + readOnly={readOnly} isLibrary containerProps={{ size: undefined, diff --git a/src/library-authoring/units/LibraryUnitPage.tsx b/src/library-authoring/units/LibraryUnitPage.tsx index f8fd6ea16..9d447ef54 100644 --- a/src/library-authoring/units/LibraryUnitPage.tsx +++ b/src/library-authoring/units/LibraryUnitPage.tsx @@ -23,10 +23,7 @@ import { ContainerEditableTitle, FooterActions, HeaderActions } from '../contain export const LibraryUnitPage = () => { const intl = useIntl(); - const { - libraryId, - containerId, - } = useLibraryContext(); + const { libraryId, containerId, readOnly } = useLibraryContext(); // istanbul ignore if: this should never happen if (!containerId) { @@ -71,6 +68,7 @@ export const LibraryUnitPage = () => { org={libraryData.org} contextId={libraryId} isLibrary + readOnly={readOnly} containerProps={{ size: undefined, }} diff --git a/src/studio-home/data/slice.ts b/src/studio-home/data/slice.ts index eeb9297ff..8bed445b8 100644 --- a/src/studio-home/data/slice.ts +++ b/src/studio-home/data/slice.ts @@ -63,6 +63,7 @@ const slice = createSlice({ studioShortName?: string; techSupportEmail?: string; userIsActive?: boolean; + canAccessAdvancedSettings?: boolean; }, studioHomeCoursesRequestParams: studioHomeCoursesRequestParamsDefault, },