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.
This commit is contained in:
Navin Karkera
2025-11-04 03:36:50 +05:30
committed by GitHub
parent 86a7e06a3c
commit 436ac3155d
16 changed files with 161 additions and 45 deletions

View File

@@ -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}`;

View File

@@ -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' });
});
});
});

View File

@@ -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 nonread-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;
};

View File

@@ -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',

View File

@@ -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<React.PropsWithChildren> = ({ children }) => {
const {
@@ -48,6 +49,7 @@ const LibraryLayoutWrapper: React.FC<React.PropsWithChildren> = ({ children }) =
<CreateCollectionModal />
<CreateContainerModal />
<ComponentEditorModal />
<LibraryTeamModal />
</SidebarProvider>
</LibraryProvider>
);

View File

@@ -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<string>('');
const [isMutationInProgress, setIsMutationInProgress] = useState<boolean>(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -144,6 +144,7 @@ export const LibraryBackupPage = () => {
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
readOnly={readOnly}
isLibrary
containerProps={{
size: undefined,

View File

@@ -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,

View File

@@ -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<SidebarInfoTab>(
defaultTab.component,
'st',
LibQueryParamKeys.SidebarTab,
(value: string) => toSidebarInfoTab(value),
(value: SidebarInfoTab) => value.toString(),
);
const [sidebarAction, setSidebarAction] = useStateWithUrlSearchParam<SidebarActions>(
SidebarActions.None,
'sa',
LibQueryParamKeys.SidebarActions,
(value: string) => Object.values(SidebarActions).find((enumValue) => value === enumValue),
(value: SidebarActions) => value.toString(),
);

View File

@@ -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 (
<Stack direction="vertical" gap={2.5}>
@@ -81,7 +76,6 @@ const LibraryInfo = () => {
</span>
</Stack>
</Stack>
{isLibraryTeamModalOpen && <LibraryTeamModal onClose={closeLibraryTeamModal} />}
</Stack>
);
};

View File

@@ -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<LibraryTeamModalProps> = ({
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 (
<StandardModal
isOpen
isOpen={isOpen}
title={intl.formatMessage(messages.modalTitle)}
onClose={onClose}
size="lg"

View File

@@ -14,6 +14,11 @@ import { ContainerType, getBlockType } from '../generic/key-utils';
export const BASE_ROUTE = '/library/:libraryId';
export enum LibQueryParamKeys {
SidebarActions = 'sa',
SidebarTab = 'st',
}
export const ROUTES = {
// LibraryAuthoringPage routes:
// * Components tab, with an optionally selected component in the sidebar.
@@ -240,7 +245,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
}
// 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.

View File

@@ -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,
}}

View File

@@ -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,

View File

@@ -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,
}}

View File

@@ -63,6 +63,7 @@ const slice = createSlice({
studioShortName?: string;
techSupportEmail?: string;
userIsActive?: boolean;
canAccessAdvancedSettings?: boolean;
},
studioHomeCoursesRequestParams: studioHomeCoursesRequestParamsDefault,
},