Compare commits

...

4 Commits

Author SHA1 Message Date
renovate[bot]
d24bc96a00 fix(deps): update dependency tinymce to v7 [security] 2026-03-16 00:33:34 +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
15 changed files with 416 additions and 343 deletions

28
package-lock.json generated
View File

@@ -74,7 +74,7 @@
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"redux-thunk": "^2.4.1", "redux-thunk": "^2.4.1",
"reselect": "^4.1.5", "reselect": "^4.1.5",
"tinymce": "^5.10.4", "tinymce": "^7.0.0",
"universal-cookie": "^8.0.0", "universal-cookie": "^8.0.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"xmlchecker": "^0.1.0", "xmlchecker": "^0.1.0",
@@ -8978,9 +8978,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.0", "version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.cjs" "baseline-browser-mapping": "dist/cli.cjs"
@@ -9361,9 +9361,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001777", "version": "1.0.30001779",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz",
"integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -13178,6 +13178,12 @@
"tinymce": "^5.10.4" "tinymce": "^5.10.4"
} }
}, },
"node_modules/frontend-components-tinymce-advanced-plugins/node_modules/tinymce": {
"version": "5.10.9",
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.9.tgz",
"integrity": "sha512-5bkrors87X9LhYX2xq8GgPHrIgJYHl87YNs+kBcjQ5I3CiUgzo/vFcGvT3MZQ9QHsEeYMhYO6a5CLGGffR8hMg==",
"license": "LGPL-2.1"
},
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -22961,10 +22967,10 @@
} }
}, },
"node_modules/tinymce": { "node_modules/tinymce": {
"version": "5.10.9", "version": "7.9.2",
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.9.tgz", "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.9.2.tgz",
"integrity": "sha512-5bkrors87X9LhYX2xq8GgPHrIgJYHl87YNs+kBcjQ5I3CiUgzo/vFcGvT3MZQ9QHsEeYMhYO6a5CLGGffR8hMg==", "integrity": "sha512-zS2gn2CPQmZhUqLzkhwYH+WGsx/DIRY/mS18RVzsIcuQg2lN2uzaqoHSJU6DdMUpvXBtBFnLNpumC3QrDwLBzA==",
"license": "LGPL-2.1" "license": "GPL-2.0-or-later"
}, },
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.2.5", "version": "0.2.5",

View File

@@ -98,7 +98,7 @@
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"redux-thunk": "^2.4.1", "redux-thunk": "^2.4.1",
"reselect": "^4.1.5", "reselect": "^4.1.5",
"tinymce": "^5.10.4", "tinymce": "^7.0.0",
"universal-cookie": "^8.0.0", "universal-cookie": "^8.0.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"xmlchecker": "^0.1.0", "xmlchecker": "^0.1.0",

View File

@@ -19,7 +19,7 @@ export type OutlineSidebarPages = {
align?: SidebarPage; align?: SidebarPage;
}; };
export const getOutlineSidebarPages = () => ({ const getOutlineSidebarPages = () => ({
info: { info: {
component: InfoSidebar, component: InfoSidebar,
icon: Info, icon: Info,
@@ -55,24 +55,24 @@ export const getOutlineSidebarPages = () => ({
* export function CourseOutlineSidebarWrapper( * export function CourseOutlineSidebarWrapper(
* { component, pluginProps }: { component: React.ReactNode, pluginProps: CourseOutlineAspectsPageProps }, * { 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 overridedPages = useMemo(() => ({
* const sidebarPages = useOutlineSidebarPagesContext(); * ...sidebarPages,
* analytics: {
* component: AnalyticsPage,
* icon: AutoGraph,
* title: messages.analyticsLabel,
* },
* }), [sidebarPages, AnalyticsPage]);
* *
* const overridedPages = useMemo(() => ({ * return (
* ...sidebarPages, * <OutlineSidebarPagesContext.Provider value={overridedPages}>
* analytics: { * {component}
* component: AnalyticsPage, * </OutlineSidebarPagesContext.Provider>
* icon: AutoGraph, * );
* title: messages.analyticsLabel, * }
* },
* }), [sidebarPages, AnalyticsPage]);
*
* return (
* <OutlineSidebarPagesContext.Provider value={overridedPages}>
* {component}
* </OutlineSidebarPagesContext.Provider>
*}
*/ */
export const OutlineSidebarPagesContext = createContext<OutlineSidebarPages | undefined>(undefined); export const OutlineSidebarPagesContext = createContext<OutlineSidebarPages | undefined>(undefined);
@@ -94,6 +94,7 @@ export const OutlineSidebarPagesProvider = ({ children }: OutlineSidebarPagesPro
export const useOutlineSidebarPagesContext = (): OutlineSidebarPages => { export const useOutlineSidebarPagesContext = (): OutlineSidebarPages => {
const ctx = useContext(OutlineSidebarPagesContext); const ctx = useContext(OutlineSidebarPagesContext);
// istanbul ignore if: this should never happen
if (ctx === undefined) { throw new Error('useOutlineSidebarPages must be used within an OutlineSidebarPagesProvider'); } if (ctx === undefined) { throw new Error('useOutlineSidebarPages must be used within an OutlineSidebarPagesProvider'); }
return ctx; 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 * This can be used to mimic events like deletion or other actions
* sent from Backbone or other sources via postMessage. * sent from Backbone or other sources via postMessage.
* *
* @param {string} type - The type of the message event (e.g., 'deleteXBlock'). * @param type - The type of the message event (e.g., 'deleteXBlock').
* @param {Object} payload - The payload data for the message event. * @param payload - The payload data for the message event.
*/ */
function simulatePostMessageEvent(type, payload) { function simulatePostMessageEvent(type: string, payload?: object) {
const messageEvent = new MessageEvent('message', { const messageEvent = new MessageEvent('message', {
data: { type, payload }, data: { type, payload },
}); });
@@ -331,7 +331,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute( expect(iframe).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', courseVerticalChildrenMock.children.length), .replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
); );
simulatePostMessageEvent(messageTypes.deleteXBlock, { simulatePostMessageEvent(messageTypes.deleteXBlock, {
@@ -422,7 +422,7 @@ describe('<CourseUnit />', () => {
)).toHaveAttribute( )).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', updatedCourseVerticalChildren.length), .replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
); );
// after removing the xblock, the sidebar status changes to Draft (unpublished changes) // after removing the xblock, the sidebar status changes to Draft (unpublished changes)
expect(await screen.findByText( expect(await screen.findByText(
@@ -485,10 +485,7 @@ describe('<CourseUnit />', () => {
}); });
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl({ .onPost(postXBlockBaseApiUrl())
parent_locator: blockId,
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
}))
.replyOnce(200, { locator: '1234567890' }); .replyOnce(200, { locator: '1234567890' });
const updatedCourseVerticalChildren = [ const updatedCourseVerticalChildren = [
@@ -520,7 +517,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute( expect(iframe).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', courseVerticalChildrenMock.children.length), .replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
); );
simulatePostMessageEvent(messageTypes.duplicateXBlock, { simulatePostMessageEvent(messageTypes.duplicateXBlock, {
@@ -566,7 +563,7 @@ describe('<CourseUnit />', () => {
expect(xblockIframe).toHaveAttribute( expect(xblockIframe).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', updatedCourseVerticalChildren.length), .replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
); );
// after duplicate the xblock, the sidebar status changes to Draft (unpublished changes) // 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 () => { it('checks courseUnit title changing when edit query is successfully', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<RootWrapper />); render(<RootWrapper />);
let editTitleButton = null;
let titleEditField = null;
const newDisplayName = `${unitDisplayName} new`; const newDisplayName = `${unitDisplayName} new`;
axiosMock axiosMock
.onPost(getXBlockBaseApiUrl(blockId, { .onPost(getXBlockBaseApiUrl(blockId), {
metadata: { metadata: {
display_name: newDisplayName, display_name: newDisplayName,
}, },
})) })
.reply(200, { dummy: 'value' }); .reply(200, { dummy: 'value' });
axiosMock axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId)) .onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -634,7 +629,6 @@ describe('<CourseUnit />', () => {
xblock_info: { xblock_info: {
...courseSectionVerticalMock.xblock_info, ...courseSectionVerticalMock.xblock_info,
metadata: { metadata: {
...courseSectionVerticalMock.xblock_info.metadata,
display_name: newDisplayName, display_name: newDisplayName,
}, },
}, },
@@ -653,15 +647,14 @@ describe('<CourseUnit />', () => {
}, },
}); });
await waitFor(() => { const unitHeaderTitle = await screen.findByTestId('unit-header-title');
const unitHeaderTitle = screen.getByTestId('unit-header-title'); const editTitleButton = within(unitHeaderTitle)
editTitleButton = within(unitHeaderTitle) .getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
.getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); let titleEditField = within(unitHeaderTitle)
titleEditField = within(unitHeaderTitle) .queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
});
expect(titleEditField).not.toBeInTheDocument(); expect(titleEditField).not.toBeInTheDocument();
await user.click(editTitleButton); await user.click(editTitleButton);
titleEditField = screen.getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); titleEditField = screen.getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
await user.clear(titleEditField); await user.clear(titleEditField);
@@ -680,7 +673,7 @@ describe('<CourseUnit />', () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { courseKey, locator } = courseCreateXblockMock; const { courseKey, locator } = courseCreateXblockMock;
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) .onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
.reply(500, {}); .reply(500, {});
render(<RootWrapper />); render(<RootWrapper />);
@@ -695,7 +688,7 @@ describe('<CourseUnit />', () => {
it('handle creating Problem xblock and showing editor modal', async () => { it('handle creating Problem xblock and showing editor modal', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'problem', category: 'problem', parentLocator: blockId })) .onPost(postXBlockBaseApiUrl(), { type: 'problem', category: 'problem', parent_locator: blockId })
.reply(200, courseCreateXblockMock); .reply(200, courseCreateXblockMock);
render(<RootWrapper />); 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 () => { it('correct addition of a new course unit after click on the "Add new unit" button', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<RootWrapper />); render(<RootWrapper />);
let units = null; let units: HTMLElement[] | null = null;
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children, ...updatedAncestorsChild.child_info!.children,
courseUnitMock, courseUnitMock,
]); ]);
await waitFor(async () => { await waitFor(async () => {
units = screen.getAllByTestId('course-unit-btn'); 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); expect(units).toHaveLength(courseUnits.length);
}); });
@@ -788,7 +781,7 @@ describe('<CourseUnit />', () => {
const addNewUnitBtn = screen.getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage }); const addNewUnitBtn = screen.getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage });
units = screen.getAllByTestId('course-unit-btn'); units = screen.getAllByTestId('course-unit-btn');
const updatedCourseUnits = updatedCourseSectionVerticalData 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); await user.click(addNewUnitBtn);
expect(units.length).toEqual(updatedCourseUnits.length); expect(units.length).toEqual(updatedCourseUnits.length);
@@ -826,18 +819,18 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children, ...updatedAncestorsChild.child_info!.children,
courseUnitMock, courseUnitMock,
]); ]);
const newDisplayName = `${unitDisplayName} new`; const newDisplayName = `${unitDisplayName} new`;
axiosMock axiosMock
.onPost(getXBlockBaseApiUrl(blockId, { .onPost(getXBlockBaseApiUrl(blockId), {
metadata: { metadata: {
display_name: newDisplayName, display_name: newDisplayName,
}, },
})) })
.reply(200, { dummy: 'value' }) .reply(200, { dummy: 'value' })
.onGet(getCourseSectionVerticalApiUrl(blockId)) .onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, { .reply(200, {
@@ -845,7 +838,6 @@ describe('<CourseUnit />', () => {
xblock_info: { xblock_info: {
...courseSectionVerticalMock.xblock_info, ...courseSectionVerticalMock.xblock_info,
metadata: { metadata: {
...courseSectionVerticalMock.xblock_info.metadata,
display_name: newDisplayName, display_name: newDisplayName,
}, },
}, },
@@ -879,7 +871,7 @@ describe('<CourseUnit />', () => {
const waffleSpy = mockWaffleFlags({ useVideoGalleryFlow: true }); const waffleSpy = mockWaffleFlags({ useVideoGalleryFlow: true });
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) .onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
.reply(200, courseCreateXblockMock); .reply(200, courseCreateXblockMock);
render(<RootWrapper />); render(<RootWrapper />);
@@ -950,12 +942,13 @@ describe('<CourseUnit />', () => {
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from), .replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument(); )).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument();
waffleSpy.mockRestore(); waffleSpy.mockRestore();
}); });
it('handles creating Video xblock and showing editor modal', async () => { it('handles creating Video xblock and showing editor modal', async () => {
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) .onPost(postXBlockBaseApiUrl(), { type: 'video', category: 'video', parent_locator: blockId })
.reply(200, courseCreateXblockMock); .reply(200, courseCreateXblockMock);
const user = userEvent.setup(); const user = userEvent.setup();
render(<RootWrapper />); render(<RootWrapper />);
@@ -1160,11 +1153,12 @@ describe('<CourseUnit />', () => {
const modalNotification = screen.getByRole('dialog'); const modalNotification = screen.getByRole('dialog');
const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalMakeVisibilityActionButtonText.defaultMessage }); const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalMakeVisibilityActionButtonText.defaultMessage });
const cancelBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalMakeVisibilityCancelButtonText.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(makeVisibilityBtn).toBeInTheDocument();
expect(cancelBtn).toBeInTheDocument(); expect(cancelBtn).toBeInTheDocument();
expect(headingElement).toBeInTheDocument(); expect(headingElement).toBeInTheDocument();
expect(headingElement).toHaveClass('pgn__modal-title');
expect(within(modalNotification) expect(within(modalNotification)
.getByText(unitInfoMessages.modalMakeVisibilityDescription.defaultMessage)).toBeInTheDocument(); .getByText(unitInfoMessages.modalMakeVisibilityDescription.defaultMessage)).toBeInTheDocument();
@@ -1252,8 +1246,9 @@ describe('<CourseUnit />', () => {
.getByText(unitInfoMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument(); .getByText(unitInfoMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument();
expect(within(modalNotification) expect(within(modalNotification)
.getByText(unitInfoMessages.modalDiscardUnitChangesCancelButtonText.defaultMessage)).toBeInTheDocument(); .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).toBeInTheDocument();
expect(headingElement).toHaveClass('pgn__modal-title');
const actionBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalDiscardUnitChangesActionButtonText.defaultMessage }); const actionBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalDiscardUnitChangesActionButtonText.defaultMessage });
expect(actionBtn).toBeInTheDocument(); 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: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
let units = null; let units: HTMLElement[] | null = null;
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children, ...updatedAncestorsChild.child_info!.children,
courseUnitMock, courseUnitMock,
]); ]);
await waitFor(() => { await waitFor(() => {
units = screen.getAllByTestId('course-unit-btn'); 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); expect(units).toHaveLength(courseUnits.length);
}); });
@@ -1425,7 +1420,7 @@ describe('<CourseUnit />', () => {
units = screen.getAllByTestId('course-unit-btn'); units = screen.getAllByTestId('course-unit-btn');
const updatedCourseUnits = updatedCourseSectionVerticalData 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(units.length).toEqual(updatedCourseUnits.length);
expect(mockedUsedNavigate).toHaveBeenCalled(); expect(mockedUsedNavigate).toHaveBeenCalled();
@@ -1459,7 +1454,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute( expect(iframe).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', courseVerticalChildrenMock.children.length), .replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()),
); );
simulatePostMessageEvent(messageTypes.copyXBlock, { simulatePostMessageEvent(messageTypes.copyXBlock, {
@@ -1495,7 +1490,7 @@ describe('<CourseUnit />', () => {
expect(iframe).toHaveAttribute( expect(iframe).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', updatedCourseVerticalChildren.length), .replace('{xblockCount}', updatedCourseVerticalChildren.length.toString()),
); );
}); });
}); });
@@ -1522,12 +1517,12 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children, ...updatedAncestorsChild.child_info!.children,
courseUnitMock, courseUnitMock,
]); ]);
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl(postXBlockBody)) .onPost(postXBlockBaseApiUrl(), postXBlockBody)
.reply(200, clipboardMockResponse); .reply(200, clipboardMockResponse);
axiosMock axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId)) .onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -1575,12 +1570,12 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children, ...updatedAncestorsChild.child_info!.children,
courseUnitMock, courseUnitMock,
]); ]);
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl(postXBlockBody)) .onPost(postXBlockBaseApiUrl(), postXBlockBody)
.reply(200, clipboardMockResponse); .reply(200, clipboardMockResponse);
axiosMock axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId)) .onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -1630,12 +1625,12 @@ describe('<CourseUnit />', () => {
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
...updatedAncestorsChild.child_info.children, ...updatedAncestorsChild.child_info!.children,
courseUnitMock, courseUnitMock,
]); ]);
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl(postXBlockBody)) .onPost(postXBlockBaseApiUrl(), postXBlockBody)
.reply(200, clipboardMockResponse); .reply(200, clipboardMockResponse);
axiosMock axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId)) .onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -1808,7 +1803,7 @@ describe('<CourseUnit />', () => {
}); });
await waitFor(async () => { await waitFor(async () => {
const currentUnit = currentSubsection.child_info.children[0]; const currentUnit = currentSubsection.child_info!.children[0];
const currentUnitItemBtn = screen.getByRole('button', { const currentUnitItemBtn = screen.getByRole('button', {
name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
}); });
@@ -1848,13 +1843,13 @@ describe('<CourseUnit />', () => {
simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator }); simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator });
const dismissButton = screen.queryByRole('button', { const dismissButton = screen.getByRole('button', {
name: /dismiss/i, hidden: true, name: /dismiss/i, hidden: true,
}); });
const undoButton = screen.queryByRole('button', { const undoButton = screen.getByRole('button', {
name: messages.undoMoveButton.defaultMessage, hidden: true, name: messages.undoMoveButton.defaultMessage, hidden: true,
}); });
const newLocationButton = screen.queryByRole('button', { const newLocationButton = screen.getByRole('button', {
name: messages.newLocationButton.defaultMessage, hidden: true, name: messages.newLocationButton.defaultMessage, hidden: true,
}); });
@@ -1894,7 +1889,7 @@ describe('<CourseUnit />', () => {
callbackFn: requestData.callbackFn, callbackFn: requestData.callbackFn,
}), store.dispatch); }), store.dispatch);
const newLocationButton = screen.queryByRole('button', { const newLocationButton = screen.getByRole('button', {
name: messages.newLocationButton.defaultMessage, hidden: true, name: messages.newLocationButton.defaultMessage, hidden: true,
}); });
await user.click(newLocationButton); await user.click(newLocationButton);
@@ -2248,6 +2243,7 @@ describe('<CourseUnit />', () => {
]; ];
sidebarContent.forEach(({ query, type, name }) => { sidebarContent.forEach(({ query, type, name }) => {
// @ts-ignore
expect(type ? query(type, { name }) : query(name)).toBeInTheDocument(); 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'; targetChild.block_id = 'block-v1:OpenedX+L153+3T2023+type@html+block@test123original';
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl({ .onPost(postXBlockBaseApiUrl(), {
parent_locator: blockId, parent_locator: blockId,
duplicate_source_locator: targetChild.block_id, duplicate_source_locator: targetChild.block_id,
})) })
.replyOnce(200, { locator: '1234567890' }); .replyOnce(200, { locator: '1234567890' });
axiosMock axiosMock
@@ -2973,7 +2969,7 @@ describe('<CourseUnit />', () => {
// The Meilisearch client-side API uses fetch, not Axios. // The Meilisearch client-side API uses fetch, not Axios.
fetchMock.mockReset(); fetchMock.mockReset();
fetchMock.post(searchEndpoint, (_url, req) => { 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 ?? ''; const query = requestData?.queries[0]?.q ?? '';
// We have to replace the query (search keywords) in the mock results with the actual query, // 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, // 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 IframePreviewLibraryXBlockChanges from './preview-changes';
import CourseUnitHeaderActionsSlot from '../plugin-slots/CourseUnitHeaderActionsSlot'; import CourseUnitHeaderActionsSlot from '../plugin-slots/CourseUnitHeaderActionsSlot';
import { UnitSidebarProvider } from './unit-sidebar/UnitSidebarContext'; import { UnitSidebarProvider } from './unit-sidebar/UnitSidebarContext';
import { UnitSidebarPagesProvider } from './unit-sidebar/UnitSidebarPagesContext';
import { UNIT_VISIBILITY_STATES } from './constants'; import { UNIT_VISIBILITY_STATES } from './constants';
import { isUnitPageNewDesignEnabled } from './utils'; import { isUnitPageNewDesignEnabled } from './utils';
@@ -242,178 +243,180 @@ const CourseUnit = () => {
return ( return (
<UnitSidebarProvider readOnly={readOnly}> <UnitSidebarProvider readOnly={readOnly}>
<Container fluid className="course-unit px-4"> <UnitSidebarPagesProvider>
<section className="course-unit-container mb-4 mt-5"> <Container fluid className="course-unit px-4">
<TransitionReplace> <section className="course-unit-container mb-4 mt-5">
{movedXBlockParams.isSuccess ? ( <TransitionReplace>
<AlertMessage {movedXBlockParams.isSuccess ? (
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 && (
<AlertMessage <AlertMessage
className="course-unit__alert" key="xblock-moved-alert"
title={intl.formatMessage(messages.alertUnpublishedVersion)} data-testid="xblock-moved-alert"
variant="warning" show={movedXBlockParams.isSuccess}
icon={WarningIcon} 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}
{staticFileNotices && ( </TransitionReplace>
<PasteNotificationAlert {courseUnit.upstreamInfo?.upstreamLink && (
staticFileNotices={staticFileNotices} <AlertMessage
courseId={courseId} title={intl.formatMessage(
/> messages.alertLibraryUnitReadOnlyText,
)} {
{blockId && ( link: (
<XBlockContainerIframe <Alert.Link
courseId={courseId} href={courseUnit.upstreamInfo.upstreamLink}
blockId={blockId} >
isUnitVerticalType={isUnitVerticalType} {intl.formatMessage(messages.alertLibraryUnitReadOnlyLinkText)}
unitXBlockActions={unitXBlockActions} </Alert.Link>
courseVerticalChildren={courseVerticalChildren.children} ),
},
)}
variant="info"
/>
)}
<SubHeader
hideBorder
title={(
<HeaderTitle
unitTitle={unitTitle}
isTitleEditFormOpen={isTitleEditFormOpen}
handleTitleEdit={handleTitleEdit}
handleTitleEditSubmit={handleTitleEditSubmit}
handleConfigureSubmit={handleConfigureSubmit} handleConfigureSubmit={handleConfigureSubmit}
/> />
)} )}
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData breadcrumbs={(
&& /* istanbul ignore next */ ( <Breadcrumbs
<PasteComponent courseId={courseId}
clipboardData={sharedClipboardData} parentUnitId={sequenceId}
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 headerActions={(
isOpenModal={isMoveModalOpen} <CourseUnitHeaderActionsSlot
openModal={openMoveModal} category={unitCategory}
closeModal={closeMoveModal} headerNavigationsActions={headerNavigationsActions}
courseId={courseId} unitTitle={unitTitle}
/> verticalBlocks={courseVerticalChildren.children}
<IframePreviewLibraryXBlockChanges /> />
)}
/>
<div className="unit-header-status-bar h5 mt-2 mb-4 font-weight-normal">
{isUnitPageNewDesignEnabled() && isUnitVerticalType && (
<StatusBar courseUnit={courseUnit} />
)}
</div> </div>
{!isUnitLegacyLibraryType && ( {isUnitVerticalType && (
<CourseAuthoringUnitSidebarSlot <Sequence
courseId={courseId} courseId={courseId}
blockId={blockId} sequenceId={sequenceId}
unitTitle={unitTitle} unitId={blockId}
xBlocks={courseVerticalChildren.children} handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
readOnly={readOnly} showPasteUnit={showPasteUnit}
isUnitVerticalType={isUnitVerticalType}
isSplitTestType={isSplitTestType}
/> />
)} )}
</div> <div className="d-flex align-items-baseline">
</section> <div className="flex-fill">
</Container> {currentlyVisibleToStudents && (
<div className="alert-toast"> <AlertMessage
<ProcessingNotification className="course-unit__alert"
isShow={isShowProcessingNotification} title={intl.formatMessage(messages.alertUnpublishedVersion)}
title={processingNotificationTitle} variant="warning"
/> icon={WarningIcon}
<SavingErrorAlert />
savingStatus={savingStatus} )}
errorMessage={errorMessage} {staticFileNotices && (
/> <PasteNotificationAlert
</div> 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> </UnitSidebarProvider>
); );
}; };

View File

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

View File

@@ -1,8 +1,9 @@
import { Sidebar } from '@src/generic/sidebar'; import { Sidebar } from '@src/generic/sidebar';
import LegacySidebar, { LegacySidebarProps } from '../legacy-sidebar'; import LegacySidebar, { LegacySidebarProps } from '../legacy-sidebar';
import { UnitSidebarPageKeys, useUnitSidebarContext } from './UnitSidebarContext';
import { isUnitPageNewDesignEnabled } from '../utils'; import { isUnitPageNewDesignEnabled } from '../utils';
import { useUnitSidebarPages } from './sidebarPages'; import { UnitSidebarPageKeys, useUnitSidebarContext } from './UnitSidebarContext';
import { useUnitSidebarPagesContext } from './UnitSidebarPagesContext';
export type UnitSidebarProps = { export type UnitSidebarProps = {
legacySidebarProps: LegacySidebarProps, legacySidebarProps: LegacySidebarProps,
@@ -22,7 +23,7 @@ export const UnitSidebar = ({
toggle, toggle,
} = useUnitSidebarContext(); } = useUnitSidebarContext();
const sidebarPages = useUnitSidebarPages(); const sidebarPages = useUnitSidebarPagesContext();
if (!isUnitPageNewDesignEnabled()) { if (!isUnitPageNewDesignEnabled()) {
return ( 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); const ctx = useContext(UnitSidebarContext);
if (ctx === undefined) { if (ctx === undefined && raiseError) {
/* istanbul ignore next */ /* istanbul ignore next */
throw new Error('useUnitSidebarContext() was used in a component without a <UnitSidebarProvider> ancestor.'); 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 ( return (
<div> <>
<SidebarTitle <SidebarTitle
title={currentItemData.displayName} title={currentItemData.displayName}
icon={getItemIcon('unit')} icon={getItemIcon('unit')}
@@ -233,6 +233,6 @@ export const UnitInfoSidebar = () => {
</div> </div>
</Tab> </Tab>
</Tabs> </Tabs>
</div> </>
); );
}; };

View File

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

View File

@@ -29,20 +29,20 @@ describe('useWaffleFlags', () => {
axiosMock.onGet(getApiWaffleFlagsUrl()).reply(() => promise); axiosMock.onGet(getApiWaffleFlagsUrl()).reply(() => promise);
render(<FlagComponent />); render(<FlagComponent />);
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading'); expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
expect(screen.getByLabelText('isError')).toHaveTextContent('false'); expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
// The default should be enabled, even before we hear back from the server: // 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: // Then, the server responds with a new value:
resolveResponse([200, { useNewCourseOutlinePage: false }]); resolveResponse([200, { useNewCourseOutlinePage: false }]);
// Now, we're no longer loading and we have the new value: // Now, we're no longer loading and we have the new value:
await waitFor(() => { await waitFor(async () => {
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false'); expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
}); });
expect(screen.getByLabelText('isError')).toHaveTextContent('false'); expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled'); expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
}); });
it('uses the default values if there\'s an error', async () => { it('uses the default values if there\'s an error', async () => {
@@ -53,20 +53,20 @@ describe('useWaffleFlags', () => {
axiosMock.onGet(getApiWaffleFlagsUrl()).reply(() => promise); axiosMock.onGet(getApiWaffleFlagsUrl()).reply(() => promise);
render(<FlagComponent />); render(<FlagComponent />);
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading'); expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
expect(screen.getByLabelText('isError')).toHaveTextContent('false'); expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
// The default should be enabled, even before we hear back from the server: // 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 // Then, the server responds with an error
resolveResponse([500, {}]); resolveResponse([500, {}]);
// Now, we're no longer loading, we have an error state, and we still have the default value: // Now, we're no longer loading, we have an error state, and we still have the default value:
await waitFor(() => { await waitFor(async () => {
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false'); expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
}); });
expect(screen.getByLabelText('isError')).toHaveTextContent('error'); expect(await screen.findByLabelText('isError')).toHaveTextContent('error');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled'); expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled');
}); });
it('uses the global flag values while loading the course-specific flags', async () => { it('uses the global flag values while loading the course-specific flags', async () => {
@@ -81,9 +81,9 @@ describe('useWaffleFlags', () => {
// Check the global flag: // Check the global flag:
render(<FlagComponent />); render(<FlagComponent />);
await waitFor(() => { await waitFor(async () => {
// Once it loads the flags from the server, the global 'false' value will override the default 'true': // 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: // Now check the course-specific flag:
@@ -91,16 +91,16 @@ describe('useWaffleFlags', () => {
render(<FlagComponent courseId={courseId} />); render(<FlagComponent courseId={courseId} />);
// Now, the course-specific value is loading but in the meantime we use the global default: // Now, the course-specific value is loading but in the meantime we use the global default:
expect(screen.getByLabelText('isLoading')).toHaveTextContent('loading'); expect(await screen.findByLabelText('isLoading')).toHaveTextContent('loading');
expect(screen.getByLabelText('isError')).toHaveTextContent('false'); expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled'); expect(await screen.findByLabelText('useNewCourseOutlinePage')).toHaveTextContent('disabled');
// Now the server responds: the course-specific flag is ON: // Now the server responds: the course-specific flag is ON:
resolveResponse([200, { useNewCourseOutlinePage: true }]); resolveResponse([200, { useNewCourseOutlinePage: true }]);
await waitFor(() => { await waitFor(async () => {
expect(screen.getByLabelText('isLoading')).toHaveTextContent('false'); expect(await screen.findByLabelText('isLoading')).toHaveTextContent('false');
}); });
expect(screen.getByLabelText('isError')).toHaveTextContent('false'); expect(await screen.findByLabelText('isError')).toHaveTextContent('false');
expect(screen.getByLabelText('useNewCourseOutlinePage')).toHaveTextContent('enabled'); 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]) => { {Object.entries(pages).map(([key, page]) => {
const buttonData = { const buttonData = {
key,
value: key, value: key,
src: page.icon, src: page.icon,
alt: intl.formatMessage(page.title), alt: intl.formatMessage(page.title),
@@ -155,6 +154,7 @@ export function Sidebar<T extends SidebarPages>({
if (page.tooltip) { if (page.tooltip) {
return ( return (
<IconButtonWithTooltip <IconButtonWithTooltip
key={key}
{...buttonData} {...buttonData}
style={{ pointerEvents: 'all' }} style={{ pointerEvents: 'all' }}
tooltipContent={<div>{intl.formatMessage(page.tooltip)}</div>} tooltipContent={<div>{intl.formatMessage(page.tooltip)}</div>}

View File

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