Compare commits

...

4 Commits

Author SHA1 Message Date
renovate[bot]
26fd7e4809 chore(deps): update dependency fast-xml-parser to v5.3.8 [security] 2026-03-16 00:33:27 +00:00
edX requirements bot
1efd559786 chore: update browserslist DB (#2943)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2026-03-16 00:30:22 +00:00
Navin Karkera
df79861685 fix: unit preview in course outline sidebar (#2940) 2026-03-13 13:42:39 -05:00
Rômulo Penido
24e1c73f6b feat: add UnitSidebarPagesContext (#2874) 2026-03-10 10:27:45 -05:00
14 changed files with 407 additions and 340 deletions

18
package-lock.json generated
View File

@@ -8978,9 +8978,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -9361,9 +9361,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001777",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
"integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
"version": "1.0.30001779",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz",
"integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==",
"funding": [
{
"type": "opencollective",
@@ -12520,9 +12520,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
"version": "5.3.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz",
"integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==",
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.8.tgz",
"integrity": "sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==",
"funding": [
{
"type": "github",

View File

@@ -19,7 +19,7 @@ export type OutlineSidebarPages = {
align?: SidebarPage;
};
export const getOutlineSidebarPages = () => ({
const getOutlineSidebarPages = () => ({
info: {
component: InfoSidebar,
icon: Info,
@@ -55,24 +55,24 @@ export const getOutlineSidebarPages = () => ({
* export function CourseOutlineSidebarWrapper(
* { component, pluginProps }: { component: React.ReactNode, pluginProps: CourseOutlineAspectsPageProps },
* ) {
* const AnalyticsPage = React.useCallback(() => <CourseOutlineAspectsPage {...pluginProps} />, [pluginProps]);
* const sidebarPages = useOutlineSidebarPagesContext();
*
* const AnalyticsPage = React.useCallback(() => <CourseOutlineAspectsPage {...pluginProps} />, [pluginProps]);
* const sidebarPages = useOutlineSidebarPagesContext();
* const overridedPages = useMemo(() => ({
* ...sidebarPages,
* analytics: {
* component: AnalyticsPage,
* icon: AutoGraph,
* title: messages.analyticsLabel,
* },
* }), [sidebarPages, AnalyticsPage]);
*
* const overridedPages = useMemo(() => ({
* ...sidebarPages,
* analytics: {
* component: AnalyticsPage,
* icon: AutoGraph,
* title: messages.analyticsLabel,
* },
* }), [sidebarPages, AnalyticsPage]);
*
* return (
* <OutlineSidebarPagesContext.Provider value={overridedPages}>
* {component}
* </OutlineSidebarPagesContext.Provider>
*}
* return (
* <OutlineSidebarPagesContext.Provider value={overridedPages}>
* {component}
* </OutlineSidebarPagesContext.Provider>
* );
* }
*/
export const OutlineSidebarPagesContext = createContext<OutlineSidebarPages | undefined>(undefined);
@@ -94,6 +94,7 @@ export const OutlineSidebarPagesProvider = ({ children }: OutlineSidebarPagesPro
export const useOutlineSidebarPagesContext = (): OutlineSidebarPages => {
const ctx = useContext(OutlineSidebarPagesContext);
// istanbul ignore if: this should never happen
if (ctx === undefined) { throw new Error('useOutlineSidebarPages must be used within an OutlineSidebarPagesProvider'); }
return ctx;
};

View File

@@ -122,10 +122,10 @@ jest.mock('@src/studio-home/hooks', () => ({
* This can be used to mimic events like deletion or other actions
* sent from Backbone or other sources via postMessage.
*
* @param {string} type - The type of the message event (e.g., 'deleteXBlock').
* @param {Object} payload - The payload data for the message event.
* @param type - The type of the message event (e.g., 'deleteXBlock').
* @param payload - The payload data for the message event.
*/
function simulatePostMessageEvent(type, payload) {
function simulatePostMessageEvent(type: string, payload?: object) {
const messageEvent = new MessageEvent('message', {
data: { type, payload },
});
@@ -331,7 +331,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', courseVerticalChildrenMock.children.length),
.replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
);
simulatePostMessageEvent(messageTypes.deleteXBlock, {
@@ -422,7 +422,7 @@ describe('<CourseUnit />', () => {
)).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
.replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
);
// after removing the xblock, the sidebar status changes to Draft (unpublished changes)
expect(await screen.findByText(
@@ -485,10 +485,7 @@ describe('<CourseUnit />', () => {
});
axiosMock
.onPost(postXBlockBaseApiUrl({
parent_locator: blockId,
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
}))
.onPost(postXBlockBaseApiUrl())
.replyOnce(200, { locator: '1234567890' });
const updatedCourseVerticalChildren = [
@@ -520,7 +517,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', courseVerticalChildrenMock.children.length),
.replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
);
simulatePostMessageEvent(messageTypes.duplicateXBlock, {
@@ -566,7 +563,7 @@ describe('<CourseUnit />', () => {
expect(xblockIframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
.replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
);
// after duplicate the xblock, the sidebar status changes to Draft (unpublished changes)
@@ -616,16 +613,14 @@ describe('<CourseUnit />', () => {
it('checks courseUnit title changing when edit query is successfully', async () => {
const user = userEvent.setup();
render(<RootWrapper />);
let editTitleButton = null;
let titleEditField = null;
const newDisplayName = `${unitDisplayName} new`;
axiosMock
.onPost(getXBlockBaseApiUrl(blockId, {
.onPost(getXBlockBaseApiUrl(blockId), {
metadata: {
display_name: newDisplayName,
},
}))
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -634,7 +629,6 @@ describe('<CourseUnit />', () => {
xblock_info: {
...courseSectionVerticalMock.xblock_info,
metadata: {
...courseSectionVerticalMock.xblock_info.metadata,
display_name: newDisplayName,
},
},
@@ -653,15 +647,14 @@ describe('<CourseUnit />', () => {
},
});
await waitFor(() => {
const unitHeaderTitle = screen.getByTestId('unit-header-title');
editTitleButton = within(unitHeaderTitle)
.getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
titleEditField = within(unitHeaderTitle)
.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
});
const unitHeaderTitle = await screen.findByTestId('unit-header-title');
const editTitleButton = within(unitHeaderTitle)
.getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
let titleEditField = within(unitHeaderTitle)
.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
expect(titleEditField).not.toBeInTheDocument();
await user.click(editTitleButton);
titleEditField = screen.getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
await user.clear(titleEditField);
@@ -680,7 +673,7 @@ describe('<CourseUnit />', () => {
const user = userEvent.setup();
const { courseKey, locator } = courseCreateXblockMock;
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
.onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
.reply(500, {});
render(<RootWrapper />);
@@ -695,7 +688,7 @@ describe('<CourseUnit />', () => {
it('handle creating Problem xblock and showing editor modal', async () => {
const user = userEvent.setup();
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'problem', category: 'problem', parentLocator: blockId }))
.onPost(postXBlockBaseApiUrl(), { type: 'problem', category: 'problem', parent_locator: blockId })
.reply(200, courseCreateXblockMock);
render(<RootWrapper />);
@@ -759,17 +752,17 @@ describe('<CourseUnit />', () => {
it('correct addition of a new course unit after click on the "Add new unit" button', async () => {
const user = userEvent.setup();
render(<RootWrapper />);
let units = null;
let units: HTMLElement[] | null = null;
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
await waitFor(async () => {
units = screen.getAllByTestId('course-unit-btn');
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info!.children;
expect(units).toHaveLength(courseUnits.length);
});
@@ -788,7 +781,7 @@ describe('<CourseUnit />', () => {
const addNewUnitBtn = screen.getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage });
units = screen.getAllByTestId('course-unit-btn');
const updatedCourseUnits = updatedCourseSectionVerticalData
.xblock_info.ancestor_info.ancestors[0].child_info.children;
.xblock_info.ancestor_info.ancestors[0].child_info!.children;
await user.click(addNewUnitBtn);
expect(units.length).toEqual(updatedCourseUnits.length);
@@ -826,18 +819,18 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
const newDisplayName = `${unitDisplayName} new`;
axiosMock
.onPost(getXBlockBaseApiUrl(blockId, {
.onPost(getXBlockBaseApiUrl(blockId), {
metadata: {
display_name: newDisplayName,
},
}))
})
.reply(200, { dummy: 'value' })
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
@@ -845,7 +838,6 @@ describe('<CourseUnit />', () => {
xblock_info: {
...courseSectionVerticalMock.xblock_info,
metadata: {
...courseSectionVerticalMock.xblock_info.metadata,
display_name: newDisplayName,
},
},
@@ -879,7 +871,7 @@ describe('<CourseUnit />', () => {
const waffleSpy = mockWaffleFlags({ useVideoGalleryFlow: true });
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
.onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
.reply(200, courseCreateXblockMock);
render(<RootWrapper />);
@@ -950,12 +942,13 @@ describe('<CourseUnit />', () => {
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument();
waffleSpy.mockRestore();
});
it('handles creating Video xblock and showing editor modal', async () => {
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
.onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
.reply(200, courseCreateXblockMock);
const user = userEvent.setup();
render(<RootWrapper />);
@@ -1160,11 +1153,12 @@ describe('<CourseUnit />', () => {
const modalNotification = screen.getByRole('dialog');
const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalMakeVisibilityActionButtonText.defaultMessage });
const cancelBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalMakeVisibilityCancelButtonText.defaultMessage });
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalMakeVisibilityTitle.defaultMessage, class: 'pgn__modal-title' });
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalMakeVisibilityTitle.defaultMessage });
expect(makeVisibilityBtn).toBeInTheDocument();
expect(cancelBtn).toBeInTheDocument();
expect(headingElement).toBeInTheDocument();
expect(headingElement).toHaveClass('pgn__modal-title');
expect(within(modalNotification)
.getByText(unitInfoMessages.modalMakeVisibilityDescription.defaultMessage)).toBeInTheDocument();
@@ -1252,8 +1246,9 @@ describe('<CourseUnit />', () => {
.getByText(unitInfoMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument();
expect(within(modalNotification)
.getByText(unitInfoMessages.modalDiscardUnitChangesCancelButtonText.defaultMessage)).toBeInTheDocument();
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalDiscardUnitChangesTitle.defaultMessage, class: 'pgn__modal-title' });
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalDiscardUnitChangesTitle.defaultMessage });
expect(headingElement).toBeInTheDocument();
expect(headingElement).toHaveClass('pgn__modal-title');
const actionBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalDiscardUnitChangesActionButtonText.defaultMessage });
expect(actionBtn).toBeInTheDocument();
@@ -1398,17 +1393,17 @@ describe('<CourseUnit />', () => {
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
let units = null;
let units: HTMLElement[] | null = null;
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
await waitFor(() => {
units = screen.getAllByTestId('course-unit-btn');
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info!.children;
expect(units).toHaveLength(courseUnits.length);
});
@@ -1425,7 +1420,7 @@ describe('<CourseUnit />', () => {
units = screen.getAllByTestId('course-unit-btn');
const updatedCourseUnits = updatedCourseSectionVerticalData
.xblock_info.ancestor_info.ancestors[0].child_info.children;
.xblock_info.ancestor_info.ancestors[0].child_info!.children;
expect(units.length).toEqual(updatedCourseUnits.length);
expect(mockedUsedNavigate).toHaveBeenCalled();
@@ -1459,7 +1454,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', courseVerticalChildrenMock.children.length),
.replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
);
simulatePostMessageEvent(messageTypes.copyXBlock, {
@@ -1495,7 +1490,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute(
'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
.replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
);
});
});
@@ -1522,12 +1517,12 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
axiosMock
.onPost(postXBlockBaseApiUrl(postXBlockBody))
.onPost(postXBlockBaseApiUrl(), postXBlockBody)
.reply(200, clipboardMockResponse);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -1575,12 +1570,12 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
axiosMock
.onPost(postXBlockBaseApiUrl(postXBlockBody))
.onPost(postXBlockBaseApiUrl(), postXBlockBody)
.reply(200, clipboardMockResponse);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -1630,12 +1625,12 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children,
...updatedAncestorsChild.child_info!.children,
courseUnitMock,
]);
axiosMock
.onPost(postXBlockBaseApiUrl(postXBlockBody))
.onPost(postXBlockBaseApiUrl(), postXBlockBody)
.reply(200, clipboardMockResponse);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -1808,7 +1803,7 @@ describe('<CourseUnit />', () => {
});
await waitFor(async () => {
const currentUnit = currentSubsection.child_info.children[0];
const currentUnit = currentSubsection.child_info!.children[0];
const currentUnitItemBtn = screen.getByRole('button', {
name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
});
@@ -1848,13 +1843,13 @@ describe('<CourseUnit />', () => {
simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator });
const dismissButton = screen.queryByRole('button', {
const dismissButton = screen.getByRole('button', {
name: /dismiss/i, hidden: true,
});
const undoButton = screen.queryByRole('button', {
const undoButton = screen.getByRole('button', {
name: messages.undoMoveButton.defaultMessage, hidden: true,
});
const newLocationButton = screen.queryByRole('button', {
const newLocationButton = screen.getByRole('button', {
name: messages.newLocationButton.defaultMessage, hidden: true,
});
@@ -1894,7 +1889,7 @@ describe('<CourseUnit />', () => {
callbackFn: requestData.callbackFn,
}), store.dispatch);
const newLocationButton = screen.queryByRole('button', {
const newLocationButton = screen.getByRole('button', {
name: messages.newLocationButton.defaultMessage, hidden: true,
});
await user.click(newLocationButton);
@@ -2248,6 +2243,7 @@ describe('<CourseUnit />', () => {
];
sidebarContent.forEach(({ query, type, name }) => {
// @ts-ignore
expect(type ? query(type, { name }) : query(name)).toBeInTheDocument();
});
@@ -2273,10 +2269,10 @@ describe('<CourseUnit />', () => {
targetChild.block_id = 'block-v1:OpenedX+L153+3T2023+type@html+block@test123original';
axiosMock
.onPost(postXBlockBaseApiUrl({
.onPost(postXBlockBaseApiUrl(), {
parent_locator: blockId,
duplicate_source_locator: targetChild.block_id,
}))
})
.replyOnce(200, { locator: '1234567890' });
axiosMock
@@ -2973,7 +2969,7 @@ describe('<CourseUnit />', () => {
// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.mockReset();
fetchMock.post(searchEndpoint, (_url, req) => {
const requestData = JSON.parse((req.body ?? ''));
const requestData = JSON.parse((req.body ?? '') as string);
const query = requestData?.queries[0]?.q ?? '';
// We have to replace the query (search keywords) in the mock results with the actual query,
// because otherwise Instantsearch will update the UI and change the query,

View File

@@ -48,6 +48,7 @@ import MoveModal from './move-modal';
import IframePreviewLibraryXBlockChanges from './preview-changes';
import CourseUnitHeaderActionsSlot from '../plugin-slots/CourseUnitHeaderActionsSlot';
import { UnitSidebarProvider } from './unit-sidebar/UnitSidebarContext';
import { UnitSidebarPagesProvider } from './unit-sidebar/UnitSidebarPagesContext';
import { UNIT_VISIBILITY_STATES } from './constants';
import { isUnitPageNewDesignEnabled } from './utils';
@@ -242,178 +243,180 @@ const CourseUnit = () => {
return (
<UnitSidebarProvider readOnly={readOnly}>
<Container fluid className="course-unit px-4">
<section className="course-unit-container mb-4 mt-5">
<TransitionReplace>
{movedXBlockParams.isSuccess ? (
<AlertMessage
key="xblock-moved-alert"
data-testid="xblock-moved-alert"
show={movedXBlockParams.isSuccess}
variant="success"
icon={CheckCircleIcon}
title={movedXBlockParams.isUndo
? intl.formatMessage(messages.alertMoveCancelTitle)
: intl.formatMessage(messages.alertMoveSuccessTitle)}
description={movedXBlockParams.isUndo
? intl.formatMessage(messages.alertMoveCancelDescription, { title: movedXBlockParams.title })
: intl.formatMessage(messages.alertMoveSuccessDescription, { title: movedXBlockParams.title })}
aria-hidden={movedXBlockParams.isSuccess}
dismissible
actions={movedXBlockParams.isUndo ? undefined : [
<Button
onClick={handleRollbackMovedXBlock}
key="xblock-moved-alert-undo-move-button"
>
{intl.formatMessage(messages.undoMoveButton)}
</Button>,
<Button
onClick={handleNavigateToTargetUnit}
key="xblock-moved-alert-new-location-button"
>
{intl.formatMessage(messages.newLocationButton)}
</Button>,
]}
onClose={handleCloseXBlockMovedAlert}
/>
) : null}
</TransitionReplace>
{courseUnit.upstreamInfo?.upstreamLink && (
<AlertMessage
title={intl.formatMessage(
messages.alertLibraryUnitReadOnlyText,
{
link: (
<Alert.Link
href={courseUnit.upstreamInfo.upstreamLink}
>
{intl.formatMessage(messages.alertLibraryUnitReadOnlyLinkText)}
</Alert.Link>
),
},
)}
variant="info"
/>
)}
<SubHeader
hideBorder
title={(
<HeaderTitle
unitTitle={unitTitle}
isTitleEditFormOpen={isTitleEditFormOpen}
handleTitleEdit={handleTitleEdit}
handleTitleEditSubmit={handleTitleEditSubmit}
handleConfigureSubmit={handleConfigureSubmit}
/>
)}
breadcrumbs={(
<Breadcrumbs
courseId={courseId}
parentUnitId={sequenceId}
/>
)}
headerActions={(
<CourseUnitHeaderActionsSlot
category={unitCategory}
headerNavigationsActions={headerNavigationsActions}
unitTitle={unitTitle}
verticalBlocks={courseVerticalChildren.children}
/>
)}
/>
<div className="unit-header-status-bar h5 mt-2 mb-4 font-weight-normal">
{isUnitPageNewDesignEnabled() && isUnitVerticalType && (
<StatusBar courseUnit={courseUnit} />
)}
</div>
{isUnitVerticalType && (
<Sequence
courseId={courseId}
sequenceId={sequenceId}
unitId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
showPasteUnit={showPasteUnit}
/>
)}
<div className="d-flex align-items-baseline">
<div className="flex-fill">
{currentlyVisibleToStudents && (
<UnitSidebarPagesProvider>
<Container fluid className="course-unit px-4">
<section className="course-unit-container mb-4 mt-5">
<TransitionReplace>
{movedXBlockParams.isSuccess ? (
<AlertMessage
className="course-unit__alert"
title={intl.formatMessage(messages.alertUnpublishedVersion)}
variant="warning"
icon={WarningIcon}
key="xblock-moved-alert"
data-testid="xblock-moved-alert"
show={movedXBlockParams.isSuccess}
variant="success"
icon={CheckCircleIcon}
title={movedXBlockParams.isUndo
? intl.formatMessage(messages.alertMoveCancelTitle)
: intl.formatMessage(messages.alertMoveSuccessTitle)}
description={movedXBlockParams.isUndo
? intl.formatMessage(messages.alertMoveCancelDescription, { title: movedXBlockParams.title })
: intl.formatMessage(messages.alertMoveSuccessDescription, { title: movedXBlockParams.title })}
aria-hidden={movedXBlockParams.isSuccess}
dismissible
actions={movedXBlockParams.isUndo ? undefined : [
<Button
onClick={handleRollbackMovedXBlock}
key="xblock-moved-alert-undo-move-button"
>
{intl.formatMessage(messages.undoMoveButton)}
</Button>,
<Button
onClick={handleNavigateToTargetUnit}
key="xblock-moved-alert-new-location-button"
>
{intl.formatMessage(messages.newLocationButton)}
</Button>,
]}
onClose={handleCloseXBlockMovedAlert}
/>
)}
{staticFileNotices && (
<PasteNotificationAlert
staticFileNotices={staticFileNotices}
courseId={courseId}
/>
)}
{blockId && (
<XBlockContainerIframe
courseId={courseId}
blockId={blockId}
isUnitVerticalType={isUnitVerticalType}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
) : null}
</TransitionReplace>
{courseUnit.upstreamInfo?.upstreamLink && (
<AlertMessage
title={intl.formatMessage(
messages.alertLibraryUnitReadOnlyText,
{
link: (
<Alert.Link
href={courseUnit.upstreamInfo.upstreamLink}
>
{intl.formatMessage(messages.alertLibraryUnitReadOnlyLinkText)}
</Alert.Link>
),
},
)}
variant="info"
/>
)}
<SubHeader
hideBorder
title={(
<HeaderTitle
unitTitle={unitTitle}
isTitleEditFormOpen={isTitleEditFormOpen}
handleTitleEdit={handleTitleEdit}
handleTitleEditSubmit={handleTitleEditSubmit}
handleConfigureSubmit={handleConfigureSubmit}
/>
)}
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData
&& /* istanbul ignore next */ (
<PasteComponent
clipboardData={sharedClipboardData}
onClick={
/* istanbul ignore next */
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
}
text={intl.formatMessage(messages.pasteButtonText)}
/>
)}
{!readOnly && blockId && (
<AddComponent
parentLocator={blockId}
isSplitTestType={isSplitTestType}
isUnitVerticalType={isUnitVerticalType}
isProblemBankType={isProblemBankType}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
addComponentTemplateData={addComponentTemplateData}
breadcrumbs={(
<Breadcrumbs
courseId={courseId}
parentUnitId={sequenceId}
/>
)}
<MoveModal
isOpenModal={isMoveModalOpen}
openModal={openMoveModal}
closeModal={closeMoveModal}
courseId={courseId}
/>
<IframePreviewLibraryXBlockChanges />
headerActions={(
<CourseUnitHeaderActionsSlot
category={unitCategory}
headerNavigationsActions={headerNavigationsActions}
unitTitle={unitTitle}
verticalBlocks={courseVerticalChildren.children}
/>
)}
/>
<div className="unit-header-status-bar h5 mt-2 mb-4 font-weight-normal">
{isUnitPageNewDesignEnabled() && isUnitVerticalType && (
<StatusBar courseUnit={courseUnit} />
)}
</div>
{!isUnitLegacyLibraryType && (
<CourseAuthoringUnitSidebarSlot
{isUnitVerticalType && (
<Sequence
courseId={courseId}
blockId={blockId}
unitTitle={unitTitle}
xBlocks={courseVerticalChildren.children}
readOnly={readOnly}
isUnitVerticalType={isUnitVerticalType}
isSplitTestType={isSplitTestType}
sequenceId={sequenceId}
unitId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
showPasteUnit={showPasteUnit}
/>
)}
</div>
</section>
</Container>
<div className="alert-toast">
<ProcessingNotification
isShow={isShowProcessingNotification}
title={processingNotificationTitle}
/>
<SavingErrorAlert
savingStatus={savingStatus}
errorMessage={errorMessage}
/>
</div>
<div className="d-flex align-items-baseline">
<div className="flex-fill">
{currentlyVisibleToStudents && (
<AlertMessage
className="course-unit__alert"
title={intl.formatMessage(messages.alertUnpublishedVersion)}
variant="warning"
icon={WarningIcon}
/>
)}
{staticFileNotices && (
<PasteNotificationAlert
staticFileNotices={staticFileNotices}
courseId={courseId}
/>
)}
{blockId && (
<XBlockContainerIframe
courseId={courseId}
blockId={blockId}
isUnitVerticalType={isUnitVerticalType}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}
/>
)}
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData
&& /* istanbul ignore next */ (
<PasteComponent
clipboardData={sharedClipboardData}
onClick={
/* istanbul ignore next */
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
}
text={intl.formatMessage(messages.pasteButtonText)}
/>
)}
{!readOnly && blockId && (
<AddComponent
parentLocator={blockId}
isSplitTestType={isSplitTestType}
isUnitVerticalType={isUnitVerticalType}
isProblemBankType={isProblemBankType}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
addComponentTemplateData={addComponentTemplateData}
/>
)}
<MoveModal
isOpenModal={isMoveModalOpen}
openModal={openMoveModal}
closeModal={closeMoveModal}
courseId={courseId}
/>
<IframePreviewLibraryXBlockChanges />
</div>
{!isUnitLegacyLibraryType && (
<CourseAuthoringUnitSidebarSlot
courseId={courseId}
blockId={blockId}
unitTitle={unitTitle}
xBlocks={courseVerticalChildren.children}
readOnly={readOnly}
isUnitVerticalType={isUnitVerticalType}
isSplitTestType={isSplitTestType}
/>
)}
</div>
</section>
</Container>
<div className="alert-toast">
<ProcessingNotification
isShow={isShowProcessingNotification}
title={processingNotificationTitle}
/>
<SavingErrorAlert
savingStatus={savingStatus}
errorMessage={errorMessage}
/>
</div>
</UnitSidebarPagesProvider>
</UnitSidebarProvider>
);
};

View File

@@ -4,7 +4,8 @@ import classNames from 'classnames';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import Loading from '../../generic/Loading';
import Loading from '@src/generic/Loading';
import { RequestStatus } from '../../data/constants';
import SequenceNavigation from './sequence-navigation/SequenceNavigation';
import messages from './messages';

View File

@@ -1,8 +1,9 @@
import { Sidebar } from '@src/generic/sidebar';
import LegacySidebar, { LegacySidebarProps } from '../legacy-sidebar';
import { UnitSidebarPageKeys, useUnitSidebarContext } from './UnitSidebarContext';
import { isUnitPageNewDesignEnabled } from '../utils';
import { useUnitSidebarPages } from './sidebarPages';
import { UnitSidebarPageKeys, useUnitSidebarContext } from './UnitSidebarContext';
import { useUnitSidebarPagesContext } from './UnitSidebarPagesContext';
export type UnitSidebarProps = {
legacySidebarProps: LegacySidebarProps,
@@ -22,7 +23,7 @@ export const UnitSidebar = ({
toggle,
} = useUnitSidebarContext();
const sidebarPages = useUnitSidebarPages();
const sidebarPages = useUnitSidebarPagesContext();
if (!isUnitPageNewDesignEnabled()) {
return (

View File

@@ -94,9 +94,11 @@ export const UnitSidebarProvider = ({
);
};
export function useUnitSidebarContext(): UnitSidebarContextData {
export function useUnitSidebarContext(raiseError?: true): UnitSidebarContextData;
export function useUnitSidebarContext(raiseError?: boolean): UnitSidebarContextData | undefined;
export function useUnitSidebarContext(raiseError: boolean = true): UnitSidebarContextData | undefined {
const ctx = useContext(UnitSidebarContext);
if (ctx === undefined) {
if (ctx === undefined && raiseError) {
/* istanbul ignore next */
throw new Error('useUnitSidebarContext() was used in a component without a <UnitSidebarProvider> ancestor.');
}

View File

@@ -0,0 +1,104 @@
import { createContext, useContext, useMemo } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { Info, Plus, Tag } from '@openedx/paragon/icons';
import type { SidebarPage } from '@src/generic/sidebar';
import { InfoSidebar } from './unit-info/InfoSidebar';
import { AddSidebar } from './AddSidebar';
import { UnitAlignSidebar } from './UnitAlignSidebar';
import { useUnitSidebarContext } from './UnitSidebarContext';
import messages from './messages';
export type UnitSidebarPages = {
info: SidebarPage;
add?: SidebarPage;
align?: SidebarPage;
};
const getUnitSidebarPages = (readOnly: boolean, hasComponentSelected: boolean) => {
const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true';
return {
info: {
component: InfoSidebar,
icon: Info,
title: messages.sidebarButtonInfo,
},
...(!readOnly && {
add: {
component: AddSidebar,
icon: Plus,
title: messages.sidebarButtonAdd,
disabled: hasComponentSelected,
tooltip: hasComponentSelected ? messages.sidebarDisabledAddTooltip : undefined,
},
}),
...(showAlignSidebar && {
align: {
component: UnitAlignSidebar,
icon: Tag,
title: messages.sidebarButtonAlign,
},
}),
};
};
/**
* Context for the Unit Sidebar Pages.
*
* This could be used in plugins to add new pages to the sidebar.
*
* @example
*
* ```tsx
* export function UnitOutlineSidebarWrapper(
* { component, pluginProps }: { component: React.ReactNode, pluginProps: UnitOutlineAspectsPageProps},
* ) {
* const sidebarPages = useUnitSidebarPagesContext();
* const AnalyticsPage = useCallback(() => <UnitOutlineAspectsPage {...pluginProps} />, [pluginProps]);
*
* const overridedPages = useMemo(() => ({
* ...sidebarPages,
* analytics: {
* component: AnalyticsPage,
* icon: AutoGraph,
* title: messages.analyticsLabel,
* },
* }), [sidebarPages, AnalyticsPage]);
*
* return (
* <UnitSidebarPagesContext.Provider value={overridedPages}>
* {component}
* </UnitSidebarPagesContext.Provider>
* );
* }
*/
export const UnitSidebarPagesContext = createContext<UnitSidebarPages | undefined>(undefined);
type UnitSidebarPagesProviderProps = {
children: React.ReactNode;
};
export const UnitSidebarPagesProvider = ({ children }: UnitSidebarPagesProviderProps) => {
const { readOnly, selectedComponentId } = useUnitSidebarContext();
const hasComponentSelected = selectedComponentId !== undefined;
const sidebarPages = useMemo(
() => getUnitSidebarPages(readOnly, hasComponentSelected),
[readOnly, hasComponentSelected],
);
return (
<UnitSidebarPagesContext.Provider value={sidebarPages}>
{children}
</UnitSidebarPagesContext.Provider>
);
};
export const useUnitSidebarPagesContext = (): UnitSidebarPages => {
const ctx = useContext(UnitSidebarPagesContext);
if (ctx === undefined) { throw new Error('useUnitSidebarPages must be used within an UnitSidebarPagesProvider'); }
return ctx;
};

View File

@@ -1,49 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { Info, Tag, Plus } from '@openedx/paragon/icons';
import { SidebarPage } from '@src/generic/sidebar';
import messages from './messages';
import { UnitAlignSidebar } from './UnitAlignSidebar';
import { AddSidebar } from './AddSidebar';
import { useUnitSidebarContext } from './UnitSidebarContext';
import { InfoSidebar } from './unit-info/InfoSidebar';
export type UnitSidebarPages = {
info: SidebarPage;
align?: SidebarPage;
add?: SidebarPage;
};
/**
* Sidebar pages for the unit sidebar
*
* This has been separated from the context to avoid a cyclical import
* if you want to use the context in the sidebar pages.
*/
export const useUnitSidebarPages = (): UnitSidebarPages => {
const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true';
const { readOnly, selectedComponentId } = useUnitSidebarContext();
const hasComponentSelected = selectedComponentId !== undefined;
return {
info: {
component: InfoSidebar,
icon: Info,
title: messages.sidebarButtonInfo,
},
...(!readOnly && {
add: {
component: AddSidebar,
icon: Plus,
title: messages.sidebarButtonAdd,
disabled: hasComponentSelected,
tooltip: hasComponentSelected ? messages.sidebarDisabledAddTooltip : undefined,
},
}),
...(showAlignSidebar && {
align: {
component: UnitAlignSidebar,
icon: Tag,
title: messages.sidebarButtonAlign,
},
}),
};
};

View File

@@ -205,7 +205,7 @@ export const UnitInfoSidebar = () => {
}, []);
return (
<div>
<>
<SidebarTitle
title={currentItemData.displayName}
icon={getItemIcon('unit')}
@@ -233,6 +233,6 @@ export const UnitInfoSidebar = () => {
</div>
</Tab>
</Tabs>
</div>
</>
);
};

View File

@@ -58,7 +58,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
const {
setCurrentPageKey,
setSelectedComponentId,
} = useUnitSidebarContext();
} = useUnitSidebarContext(!readonly) || {};
// Useful to reload iframe
const [iframeKey, setIframeKey] = useState(0);
@@ -138,7 +138,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
const onDeleteSubmit = async () => {
if (deleteXBlockId) {
await unitXBlockActions.handleDelete(deleteXBlockId);
setSelectedComponentId(undefined);
setSelectedComponentId?.(undefined);
closeDeleteModal();
}
};
@@ -192,7 +192,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
const handleOpenManageTagsModal = (id: string) => {
if (isUnitPageNewDesignEnabled()) {
setCurrentPageKey('align', id);
setCurrentPageKey?.('align', id);
sendMessageToIframe(messageTypes.selectXblock, { locator: id });
} else {
// Legacy manage tags modal
@@ -219,7 +219,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
};
const handleXBlockSelected = (id) => {
setCurrentPageKey('info', id);
setCurrentPageKey?.('info', id);
};
const messageHandlers = useMessageHandlers({

View File

@@ -29,20 +29,20 @@ describe('useWaffleFlags', () => {
axiosMock.onGet(getApiWaffleFlagsUrl()).reply(() => promise);
render(<FlagComponent />);
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading');
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
// The default should be enabled, even before we hear back from the server:
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
// Then, the server responds with a new value:
resolveResponse([200, { useNewCourseOutlinePage: false }]);
// Now, we're no longer loading and we have the new value:
await waitFor(() => {
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false');
await waitFor(async () => {
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
});
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
});
it('uses the default values if there\'s an error', async () => {
@@ -53,20 +53,20 @@ describe('useWaffleFlags', () => {
axiosMock.onGet(getApiWaffleFlagsUrl()).reply(() => promise);
render(<FlagComponent />);
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading');
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
// The default should be enabled, even before we hear back from the server:
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
// Then, the server responds with an error
resolveResponse([500, {}]);
// Now, we're no longer loading, we have an error state, and we still have the default value:
await waitFor(() => {
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false');
await waitFor(async () => {
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
});
expect(screen.getByLabelText('isError')).toHaveTextContent('error');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
expect(await screen.findByLabelText('isError')).toHaveTextContent('error');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
});
it('uses the global flag values while loading the course-specific flags', async () => {
@@ -81,9 +81,9 @@ describe('useWaffleFlags', () => {
// Check the global flag:
render(<FlagComponent />);
await waitFor(() => {
await waitFor(async () => {
// Once it loads the flags from the server, the global 'false' value will override the default 'true':
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
});
// Now check the course-specific flag:
@@ -91,16 +91,16 @@ describe('useWaffleFlags', () => {
render(<FlagComponent courseId={courseId} />);
// Now, the course-specific value is loading but in the meantime we use the global default:
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading');
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
// Now the server responds: the course-specific flag is ON:
resolveResponse([200, { useNewCourseOutlinePage: true }]);
await waitFor(() => {
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false');
await waitFor(async () => {
expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
});
expect(screen.getByLabelText('isError')).toHaveTextContent('false');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
});
});

View File

@@ -144,7 +144,6 @@ export function Sidebar<T extends SidebarPages>({
>
{Object.entries(pages).map(([key, page]) => {
const buttonData = {
key,
value: key,
src: page.icon,
alt: intl.formatMessage(page.title),
@@ -155,6 +154,7 @@ export function Sidebar<T extends SidebarPages>({
if (page.tooltip) {
return (
<IconButtonWithTooltip
key={key}
{...buttonData}
style={{ pointerEvents: 'all' }}
tooltipContent={<div>{intl.formatMessage(page.tooltip)}</div>}

View File

@@ -27,14 +27,22 @@ interface SidebarContentProps {
* </SidebarContent>
* ```
*/
export const SidebarContent = ({ children } : SidebarContentProps) => (
<Stack gap={1} className="px-3 py-1">
{Array.isArray(children) ? children.map((child, index) => (
// eslint-disable-next-line react/no-array-index-key
<React.Fragment key={index}>
{child}
{index !== children.length - 1 && <hr className="w-100" />}
</React.Fragment>
)) : children}
</Stack>
);
export const SidebarContent = ({ children } : SidebarContentProps) => {
// Flatten the array and filter out empty children to correctly render
// the hr element between each child.
const nonEmptyChildren = Array.isArray(children)
? children.flat(Infinity).filter(child => !!child)
: [children];
return (
<Stack gap={1} className="px-3 py-1">
{nonEmptyChildren.map((child, index) => (
// eslint-disable-next-line react/no-array-index-key
<React.Fragment key={index}>
{child}
{index !== nonEmptyChildren.length - 1 && <hr className="w-100" />}
</React.Fragment>
))}
</Stack>
);
};