diff --git a/src/header/Header.tsx b/src/header/Header.tsx index b9f7210fcb..56768db2b3 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 176c0905a1..28fc53c249 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 80389e3a72..d009e14834 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 b755b9dc5d..d1bb89a7b4 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 350bd5c39c..d51a25c0a8 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 d54a76563a..9cd51f0bf9 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 2b54615bd7..05a209a63a 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 fea6d9c353..a3eb59b1ad 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', @@ -129,14 +129,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 1a44937d8f..562e511e72 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 e7a9707969..87eab3a2a3 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 63055a2c21..40035ca2e3 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 e99509f013..64116a6cc2 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 f8fd6ea16e..9d447ef547 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 eeb9297ff6..8bed445b8d 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, },