feat: New Unit info sidebar [FC-0114] (#2822)

- Implements the basics for the Unit Sidebar:
    - Splits the sidebar in legacy sidebar and in the new sidebar
- Implements the Unit Info Sidebar:
    - Implements a new design for the visibility and publish status card.
    - Implements the new Visibility field.
    - Implements the settings tab for the sidebar. Implements all the new form to edit the 
      settings in the sidebar.
This commit is contained in:
Chris Chávez
2026-01-26 16:18:32 -05:00
committed by GitHub
parent ef93e95dd7
commit 5157cbcfb2
43 changed files with 1844 additions and 861 deletions

View File

@@ -39,9 +39,9 @@ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=true
ENABLE_UNIT_PAGE_NEW_DESIGN=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_UNIT_PAGE_NEW_DESIGN=true
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
HOTJAR_VERSION=6

View File

@@ -35,8 +35,8 @@ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_COURSE_IMPORT_IN_LIBRARY=true
ENABLE_COURSE_OUTLINE_NEW_DESIGN=false
ENABLE_UNIT_PAGE_NEW_DESIGN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_UNIT_PAGE_NEW_DESIGN=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true

View File

@@ -1,7 +1,8 @@
@import "./breadcrumbs/Breadcrumbs";
@import "./course-sequence/CourseSequence";
@import "./add-component/AddComponent";
@import "./sidebar/Sidebar";
@import "./legacy-sidebar/LegacySidebar";
@import "./unit-sidebar/unit-info/PublishControls";
@import "./header-title/HeaderTitle";
@import "./move-modal";
@import "./preview-changes";

View File

@@ -46,7 +46,7 @@ import { executeThunk } from '../utils';
import pasteNotificationsMessages from './clipboard/paste-notification/messages';
import headerTitleMessages from './header-title/messages';
import courseSequenceMessages from './course-sequence/messages';
import { extractCourseUnitId } from './sidebar/utils';
import { extractCourseUnitId } from './legacy-sidebar/utils';
import CourseUnit from './CourseUnit';
import tagsDrawerMessages from '../content-tags-drawer/messages';
@@ -57,7 +57,8 @@ import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants
import moveModalMessages from './move-modal/messages';
import xblockContainerIframeMessages from './xblock-container-iframe/messages';
import headerNavigationsMessages from './header-navigations/messages';
import sidebarMessages from './sidebar/messages';
import legacySidebarMessages from './legacy-sidebar/messages';
import unitInfoMessages from './unit-sidebar/unit-info/messages';
import messages from './messages';
let axiosMock;
@@ -142,31 +143,27 @@ describe('<CourseUnit />', () => {
const currentSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
await waitFor(() => {
const unitHeaderTitle = screen.getByTestId('unit-header-title');
expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: currentSectionName })).toBeInTheDocument();
expect(screen.getByRole('button', { name: currentSubSectionName })).toBeInTheDocument();
});
const unitHeaderTitle = await screen.findByTestId('unit-header-title');
expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByRole('button', { name: currentSectionName })).toBeInTheDocument();
expect(screen.getByRole('button', { name: currentSubSectionName })).toBeInTheDocument();
});
it('renders the course unit iframe with correct attributes', async () => {
render(<RootWrapper />);
await waitFor(() => {
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`);
expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
expect(iframe).toHaveAttribute('style', 'height: 0px;');
expect(iframe).toHaveAttribute('scrolling', 'no');
expect(iframe).toHaveAttribute('referrerpolicy', 'origin');
expect(iframe).toHaveAttribute('loading', 'lazy');
expect(iframe).toHaveAttribute('frameborder', '0');
});
const iframe = await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`);
expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
expect(iframe).toHaveAttribute('style', 'height: 0px;');
expect(iframe).toHaveAttribute('scrolling', 'no');
expect(iframe).toHaveAttribute('referrerpolicy', 'origin');
expect(iframe).toHaveAttribute('loading', 'lazy');
expect(iframe).toHaveAttribute('frameborder', '0');
});
it('adjusts iframe height dynamically based on courseXBlockDropdownHeight postMessage event', async () => {
@@ -184,29 +181,25 @@ describe('<CourseUnit />', () => {
it('displays an error alert when a studioAjaxError message is received', async () => {
render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.studioAjaxError, {
error: 'Some error text...',
});
const xblocksIframe = await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.studioAjaxError, {
error: 'Some error text...',
});
expect(screen.getByTestId('saving-error-alert')).toBeInTheDocument();
expect(await screen.findByTestId('saving-error-alert')).toBeInTheDocument();
});
it('renders XBlock iframe and opens legacy edit modal on editXBlock message', async () => {
render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.editXBlock, { id: blockId });
const xblocksIframe = await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.editXBlock, { id: blockId });
const legacyXBlockEditModalIframe = screen.getByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).toBeInTheDocument();
});
const legacyXBlockEditModalIframe = await screen.findByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).toBeInTheDocument();
});
it('renders the xBlocks iframe and opens the tags drawer on postMessage event', async () => {
@@ -222,31 +215,27 @@ describe('<CourseUnit />', () => {
it('closes the legacy edit modal when closeXBlockEditorModal message is received', async () => {
render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.closeXBlockEditorModal, { id: blockId });
const xblocksIframe = await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.closeXBlockEditorModal, { id: blockId });
const legacyXBlockEditModalIframe = screen.queryByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).not.toBeInTheDocument();
});
const legacyXBlockEditModalIframe = screen.queryByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).not.toBeInTheDocument();
});
it('closes legacy edit modal and updates course unit sidebar after saveEditedXBlockData message', async () => {
render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.saveEditedXBlockData);
const xblocksIframe = await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.saveEditedXBlockData);
const legacyXBlockEditModalIframe = screen.queryByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).not.toBeInTheDocument();
});
const legacyXBlockEditModalIframe = screen.queryByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
);
expect(legacyXBlockEditModalIframe).not.toBeInTheDocument();
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -259,33 +248,26 @@ describe('<CourseUnit />', () => {
},
});
await waitFor(() => {
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).getByText(sidebarMessages.releaseStatusTitle.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarBodyNote.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).queryByRole('button', {
name: sidebarMessages.actionButtonPublishTitle.defaultMessage,
}),
).toBeInTheDocument();
});
const courseUnitSidebar = await screen.findByTestId('course-unit-sidebar');
expect(
within(courseUnitSidebar).getByText(legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).queryByRole('button', {
name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage,
}),
).toBeInTheDocument();
});
it('updates course unit sidebar after receiving refreshPositions message', async () => {
render(<RootWrapper />);
await waitFor(() => {
const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.refreshPositions);
});
const xblocksIframe = await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.refreshPositions);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
@@ -298,23 +280,18 @@ describe('<CourseUnit />', () => {
},
});
await waitFor(() => {
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).getByText(sidebarMessages.releaseStatusTitle.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarBodyNote.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).queryByRole('button', {
name: sidebarMessages.actionButtonPublishTitle.defaultMessage,
}),
).toBeInTheDocument();
});
const courseUnitSidebar = await screen.findByTestId('course-unit-sidebar');
expect(
within(courseUnitSidebar).getByText(legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage),
).toBeInTheDocument();
expect(
within(courseUnitSidebar).queryByRole('button', {
name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage,
}),
).toBeInTheDocument();
});
it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => {
@@ -373,13 +350,13 @@ describe('<CourseUnit />', () => {
await waitFor(() => {
// check if the sidebar status is Published and Live
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
unitInfoMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
});
@@ -421,22 +398,24 @@ describe('<CourseUnit />', () => {
.replace('{xblockCount}', updatedCourseVerticalChildren.length),
);
// after removing the xblock, the sidebar status changes to Draft (unpublished changes)
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage,
)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage,
)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
unitInfoMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
legacySidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
@@ -508,7 +487,7 @@ describe('<CourseUnit />', () => {
});
await user.click(
await screen.findByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }),
await screen.findByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage }),
);
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
@@ -542,13 +521,13 @@ describe('<CourseUnit />', () => {
await waitFor(() => {
// check if the sidebar status is Published and Live
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
unitInfoMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
});
@@ -565,22 +544,22 @@ describe('<CourseUnit />', () => {
);
// after duplicate the xblock, the sidebar status changes to Draft (unpublished changes)
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage,
)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
unitInfoMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
legacySidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
@@ -699,7 +678,7 @@ describe('<CourseUnit />', () => {
render(<RootWrapper />);
await waitFor(async () => {
await user.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage }));
});
axiosMock
@@ -737,22 +716,22 @@ describe('<CourseUnit />', () => {
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
// after creating problem xblock, the sidebar status changes to Draft (unpublished changes)
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage,
)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
unitInfoMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
legacySidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
@@ -884,7 +863,7 @@ describe('<CourseUnit />', () => {
render(<RootWrapper />);
await waitFor(async () => {
await user.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage }));
});
axiosMock
@@ -908,15 +887,15 @@ describe('<CourseUnit />', () => {
await waitFor(() => {
// check if the sidebar status is Published and Live
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
});
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
unitInfoMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
const videoButton = screen.getByRole('button', {
name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'),
@@ -932,22 +911,22 @@ describe('<CourseUnit />', () => {
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
// after creating video xblock, the sidebar status changes to Draft (unpublished changes)
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage,
)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
unitInfoMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
legacySidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument();
@@ -962,7 +941,7 @@ describe('<CourseUnit />', () => {
render(<RootWrapper />);
await waitFor(async () => {
await user.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage }));
});
axiosMock
@@ -986,13 +965,13 @@ describe('<CourseUnit />', () => {
await waitFor(async () => {
// check if the sidebar status is Published and Live
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
unitInfoMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
const videoButton = screen.getByRole('button', {
name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'),
@@ -1015,22 +994,22 @@ describe('<CourseUnit />', () => {
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
// after creating video xblock, the sidebar status changes to Draft (unpublished changes)
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage,
)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
unitInfoMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
legacySidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
@@ -1039,22 +1018,24 @@ describe('<CourseUnit />', () => {
render(<RootWrapper />);
await waitFor(() => {
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage,
)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage,
)).toBeInTheDocument();
expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
unitInfoMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on)
.replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
legacySidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from),
)).toBeInTheDocument();
});
@@ -1065,10 +1046,10 @@ describe('<CourseUnit />', () => {
const courseUnitLocationId = extractCourseUnitId(courseSectionVerticalMock.xblock_info.id);
await waitFor(() => {
expect(screen.getByText(sidebarMessages.sidebarHeaderUnitLocationTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.unitLocationTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.sidebarHeaderUnitLocationTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(legacySidebarMessages.unitLocationTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseUnitLocationId)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.unitLocationDescription.defaultMessage
expect(screen.getByText(legacySidebarMessages.unitLocationDescription.defaultMessage
.replace('{id}', courseUnitLocationId))).toBeInTheDocument();
});
});
@@ -1119,20 +1100,18 @@ describe('<CourseUnit />', () => {
courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
draftUnpublishedChangesHeading = within(courseUnitSidebar)
.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage);
.getByText(legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage);
expect(draftUnpublishedChangesHeading).toBeInTheDocument();
visibilityCheckbox = within(courseUnitSidebar)
.getByLabelText(sidebarMessages.visibilityCheckboxTitle.defaultMessage);
.getByLabelText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage);
expect(visibilityCheckbox).not.toBeChecked();
await user.click(visibilityCheckbox);
});
axiosMock
.onPost(getXBlockBaseApiUrl(blockId), {
publish: PUBLISH_TYPES.republish,
metadata: { visible_to_staff_only: true, group_access: null },
metadata: { visible_to_staff_only: true },
})
.reply(200, { dummy: 'value' });
axiosMock
@@ -1148,31 +1127,33 @@ describe('<CourseUnit />', () => {
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, true), store.dispatch);
expect(visibilityCheckbox).toBeChecked();
await waitFor(async () => {
expect(visibilityCheckbox).toBeChecked();
});
expect(within(courseUnitSidebar)
.getByText(sidebarMessages.sidebarTitleVisibleToStaffOnly.defaultMessage)).toBeInTheDocument();
.getByText(legacySidebarMessages.sidebarTitleVisibleToStaffOnly.defaultMessage)).toBeInTheDocument();
expect(within(courseUnitSidebar)
.getByText(sidebarMessages.visibilityStaffOnlyTitle.defaultMessage)).toBeInTheDocument();
.getByText(unitInfoMessages.visibilityStaffOnlyTitle.defaultMessage)).toBeInTheDocument();
await user.click(visibilityCheckbox);
const modalNotification = screen.getByRole('dialog');
const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityActionButtonText.defaultMessage });
const cancelBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityCancelButtonText.defaultMessage });
const headingElement = within(modalNotification).getByRole('heading', { name: sidebarMessages.modalMakeVisibilityTitle.defaultMessage, class: 'pgn__modal-title' });
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' });
expect(makeVisibilityBtn).toBeInTheDocument();
expect(cancelBtn).toBeInTheDocument();
expect(headingElement).toBeInTheDocument();
expect(within(modalNotification)
.getByText(sidebarMessages.modalMakeVisibilityDescription.defaultMessage)).toBeInTheDocument();
.getByText(unitInfoMessages.modalMakeVisibilityDescription.defaultMessage)).toBeInTheDocument();
await user.click(makeVisibilityBtn);
axiosMock
.onPost(getXBlockBaseApiUrl(blockId), {
publish: PUBLISH_TYPES.republish,
metadata: { visible_to_staff_only: null, group_access: null },
metadata: { visible_to_staff_only: null },
})
.reply(200, { dummy: 'value' });
axiosMock
@@ -1181,8 +1162,6 @@ describe('<CourseUnit />', () => {
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null), store.dispatch);
expect(within(courseUnitSidebar)
.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(visibilityCheckbox).not.toBeChecked();
expect(draftUnpublishedChangesHeading).toBeInTheDocument();
});
@@ -1195,7 +1174,7 @@ describe('<CourseUnit />', () => {
await waitFor(async () => {
courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
publishBtn = within(courseUnitSidebar).queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage });
publishBtn = within(courseUnitSidebar).queryByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage });
expect(publishBtn).toBeInTheDocument();
await user.click(publishBtn);
@@ -1221,9 +1200,9 @@ describe('<CourseUnit />', () => {
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
expect(within(courseUnitSidebar)
.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
.getByText(legacySidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(within(courseUnitSidebar).getByText(
sidebarMessages.publishLastPublished.defaultMessage
unitInfoMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
@@ -1240,9 +1219,9 @@ describe('<CourseUnit />', () => {
courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
const draftUnpublishedChangesHeading = within(courseUnitSidebar)
.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage);
.getByText(legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage);
expect(draftUnpublishedChangesHeading).toBeInTheDocument();
discardChangesBtn = within(courseUnitSidebar).queryByRole('button', { name: sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage });
discardChangesBtn = await within(courseUnitSidebar).findByRole('button', { name: legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage });
expect(discardChangesBtn).toBeInTheDocument();
await user.click(discardChangesBtn);
@@ -1250,12 +1229,12 @@ describe('<CourseUnit />', () => {
const modalNotification = screen.getByRole('dialog');
expect(modalNotification).toBeInTheDocument();
expect(within(modalNotification)
.getByText(sidebarMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument();
.getByText(unitInfoMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument();
expect(within(modalNotification)
.getByText(sidebarMessages.modalDiscardUnitChangesCancelButtonText.defaultMessage)).toBeInTheDocument();
const headingElement = within(modalNotification).getByRole('heading', { name: sidebarMessages.modalDiscardUnitChangesTitle.defaultMessage, class: 'pgn__modal-title' });
.getByText(unitInfoMessages.modalDiscardUnitChangesCancelButtonText.defaultMessage)).toBeInTheDocument();
const headingElement = within(modalNotification).getByRole('heading', { name: unitInfoMessages.modalDiscardUnitChangesTitle.defaultMessage, class: 'pgn__modal-title' });
expect(headingElement).toBeInTheDocument();
const actionBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalDiscardUnitChangesActionButtonText.defaultMessage });
const actionBtn = within(modalNotification).getByRole('button', { name: unitInfoMessages.modalDiscardUnitChangesActionButtonText.defaultMessage });
expect(actionBtn).toBeInTheDocument();
await user.click(actionBtn);
@@ -1284,7 +1263,7 @@ describe('<CourseUnit />', () => {
), store.dispatch);
expect(within(courseUnitSidebar)
.getByText(sidebarMessages.sidebarTitlePublishedNotYetReleased.defaultMessage)).toBeInTheDocument();
.getByText(legacySidebarMessages.sidebarTitlePublishedNotYetReleased.defaultMessage)).toBeInTheDocument();
expect(discardChangesBtn).not.toBeInTheDocument();
});
@@ -1300,7 +1279,7 @@ describe('<CourseUnit />', () => {
await waitFor(async () => {
courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
sidebarVisibilityCheckbox = within(courseUnitSidebar)
.getByLabelText(sidebarMessages.visibilityCheckboxTitle.defaultMessage);
.getByLabelText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage);
expect(sidebarVisibilityCheckbox).not.toBeChecked();
const headerConfigureBtn = screen.getByRole('button', { name: /settings/i });
@@ -1355,9 +1334,9 @@ describe('<CourseUnit />', () => {
await waitFor(() => {
expect(sidebarVisibilityCheckbox).toBeChecked();
expect(within(courseUnitSidebar)
.getByText(sidebarMessages.sidebarTitleVisibleToStaffOnly.defaultMessage)).toBeInTheDocument();
.getByText(legacySidebarMessages.sidebarTitleVisibleToStaffOnly.defaultMessage)).toBeInTheDocument();
expect(within(courseUnitSidebar)
.getByText(sidebarMessages.visibilityStaffOnlyTitle.defaultMessage)).toBeInTheDocument();
.getByText(unitInfoMessages.visibilityStaffOnlyTitle.defaultMessage)).toBeInTheDocument();
});
});
@@ -1396,7 +1375,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await user.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
let units = null;
@@ -1453,7 +1432,7 @@ describe('<CourseUnit />', () => {
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await user.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await waitFor(() => {
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
@@ -1517,7 +1496,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await user.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
@@ -1570,7 +1549,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await user.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
@@ -1625,7 +1604,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await user.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
await user.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
@@ -2210,41 +2189,41 @@ describe('<CourseUnit />', () => {
// Sidebar
const sidebarContent = [
{ query: screen.queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestAddComponentTitle.defaultMessage },
{ query: screen.queryByText, name: sidebarMessages.sidebarSplitTestSelectComponentType.defaultMessage.replaceAll('{bold_tag}', '') },
{ query: screen.queryByText, name: sidebarMessages.sidebarSplitTestComponentAdded.defaultMessage },
{ query: screen.queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestEditComponentTitle.defaultMessage },
{ query: screen.queryByRole, type: 'heading', name: legacySidebarMessages.sidebarSplitTestAddComponentTitle.defaultMessage },
{ query: screen.queryByText, name: legacySidebarMessages.sidebarSplitTestSelectComponentType.defaultMessage.replaceAll('{bold_tag}', '') },
{ query: screen.queryByText, name: legacySidebarMessages.sidebarSplitTestComponentAdded.defaultMessage },
{ query: screen.queryByRole, type: 'heading', name: legacySidebarMessages.sidebarSplitTestEditComponentTitle.defaultMessage },
{
query: screen.queryByText,
name: sidebarMessages.sidebarSplitTestEditComponentInstruction.defaultMessage
name: legacySidebarMessages.sidebarSplitTestEditComponentInstruction.defaultMessage
.replaceAll('{bold_tag}', ''),
},
{
query: screen.queryByRole,
type: 'heading',
name: sidebarMessages.sidebarSplitTestReorganizeComponentTitle.defaultMessage,
name: legacySidebarMessages.sidebarSplitTestReorganizeComponentTitle.defaultMessage,
},
{
query: screen.queryByText,
name: sidebarMessages.sidebarSplitTestReorganizeComponentInstruction.defaultMessage,
name: legacySidebarMessages.sidebarSplitTestReorganizeComponentInstruction.defaultMessage,
},
{
query: screen.queryByText,
name: sidebarMessages.sidebarSplitTestReorganizeGroupsInstruction.defaultMessage,
name: legacySidebarMessages.sidebarSplitTestReorganizeGroupsInstruction.defaultMessage,
},
{
query: screen.queryByRole,
type: 'heading',
name: sidebarMessages.sidebarSplitTestExperimentComponentTitle.defaultMessage,
name: legacySidebarMessages.sidebarSplitTestExperimentComponentTitle.defaultMessage,
},
{
query: screen.queryByText,
name: sidebarMessages.sidebarSplitTestExperimentComponentInstruction.defaultMessage,
name: legacySidebarMessages.sidebarSplitTestExperimentComponentInstruction.defaultMessage,
},
{
query: screen.queryByRole,
type: 'link',
name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage,
name: legacySidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage,
},
];
@@ -2253,7 +2232,7 @@ describe('<CourseUnit />', () => {
});
expect(
screen.queryByRole('link', { name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }),
screen.queryByRole('link', { name: legacySidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }),
).toHaveAttribute('href', helpLinkUrl);
});
});
@@ -2338,7 +2317,7 @@ describe('<CourseUnit />', () => {
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
const publishButton = within(courseUnitSidebar).getByRole(
'button',
{ name: sidebarMessages.actionButtonPublishTitle.defaultMessage },
{ name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage },
);
expect(publishButton).toBeInTheDocument();
expect(publishButton).toBeEnabled();
@@ -2347,6 +2326,34 @@ describe('<CourseUnit />', () => {
expect(screen.queryByText(addComponentMessages.title.defaultMessage)).not.toBeInTheDocument();
});
it('renders new unit info/settings sidebar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
const user = userEvent.setup();
render(<RootWrapper />);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseSectionVerticalMock,
});
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
expect(await screen.findByRole('tab', { name: /details/i })).toBeInTheDocument();
const settingsTab = screen.getByRole('tab', { name: /settings/i });
expect(settingsTab).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /unit content summary/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /taxonomy alignments/i })).toBeInTheDocument();
await user.click(settingsTab);
expect(screen.getByRole('heading', { name: /visibility/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /access restrictions/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /discussion/i })).toBeInTheDocument();
});
it('displays the live state in the status bar', async () => {
setConfig({
...getConfig(),
@@ -2366,11 +2373,93 @@ describe('<CourseUnit />', () => {
expect(await screen.findByText('Live')).toBeInTheDocument();
});
it('should change the visibility of the unit in the settings sidebar', async () => {
const user = userEvent.setup();
render(<RootWrapper />);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseSectionVerticalMock,
});
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
// Move to settings
expect(await screen.findByRole('heading', { name: /draft \(unpublished changes\)/i })).toBeInTheDocument();
const settingsTab = screen.getByRole('tab', { name: /settings/i });
expect(settingsTab).toBeInTheDocument();
await user.click(settingsTab);
// Change Visibility to Staff Only
expect(screen.getByRole('heading', { name: /visibility/i })).toBeInTheDocument();
const staffOnlyButton = screen.getByRole('button', { name: /staff only/i });
expect(staffOnlyButton).toBeInTheDocument();
await user.click(staffOnlyButton);
axiosMock
.onPost(getXBlockBaseApiUrl(blockId), {
publish: PUBLISH_TYPES.republish,
metadata: { visible_to_staff_only: true },
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: UNIT_VISIBILITY_STATES.staffOnly,
has_explicit_staff_lock: true,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, true), store.dispatch);
// Move to Details
const detailsTab = screen.getByRole('tab', { name: /details/i });
await user.click(detailsTab);
expect(screen.getByRole('heading', { name: /visible to staff only/i })).toBeInTheDocument();
// Move to settings and change visibility to all
const editVisibilityButton = screen.getByRole('button', { name: /edit visibility/i });
await user.click(editVisibilityButton);
const studentVisibleButton = screen.getByRole('button', { name: /student visible/i });
await user.click(studentVisibleButton);
axiosMock
.onPost(getXBlockBaseApiUrl(blockId), {
publish: PUBLISH_TYPES.republish,
metadata: {
visible_to_staff_only: null,
},
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
visibility_state: 'needs_attention',
has_explicit_staff_lock: false,
},
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, false), store.dispatch);
// Move to Details
await user.click(detailsTab);
expect(
screen.getByRole('heading', { name: /draft \(unpublished changes\)/i }),
).toBeInTheDocument();
});
it('displays the staff only state in the status bar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
@@ -2383,7 +2472,208 @@ describe('<CourseUnit />', () => {
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
render(<RootWrapper />);
expect(await screen.findByText('Staff Only')).toBeInTheDocument();
// (1) Chip in the header.
// (2) Status title in the unit sidebar.
expect((await screen.findAllByText('Staff Only')).length).toBe(2);
});
it('should disable discussions in the settings sidebar', async () => {
const user = userEvent.setup();
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
render(<RootWrapper />);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseSectionVerticalMock,
});
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
// Move to settings
expect(await screen.findByRole('heading', { name: /draft \(unpublished changes\)/i })).toBeInTheDocument();
const settingsTab = screen.getByRole('tab', { name: /settings/i });
expect(settingsTab).toBeInTheDocument();
await user.click(settingsTab);
// Disable discussions
const discussionButton = screen.getByRole('checkbox', { name: /enable discussion/i });
expect(discussionButton).toBeChecked();
await user.click(discussionButton);
axiosMock
.onPost(getXBlockBaseApiUrl(blockId), {
publish: PUBLISH_TYPES.republish,
metadata: {
visible_to_staff_only: null,
discussion_enabled: false,
},
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
discussion_enabled: false,
},
});
await executeThunk(editCourseUnitVisibilityAndData(
blockId,
PUBLISH_TYPES.republish,
false,
null,
false,
), store.dispatch);
expect(discussionButton).not.toBeChecked();
});
it('should one group in the visibility field in the unit sidebar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
render(<RootWrapper />);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseSectionVerticalMock,
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
user_partition_info: {
selected_partition_index: 0,
selected_groups_label: 'Group A',
selectable_partitions: [{
id: 10,
name: 'Content Groups',
scheme: 'cohort',
groups: [
{
deleted: false,
id: 1,
name: 'Group A',
selected: true,
},
{
deleted: false,
id: 2,
name: 'Group B',
selected: false,
},
{
deleted: false,
id: 3,
name: 'Group C',
selected: false,
},
],
}],
},
},
});
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId, courseId), store.dispatch);
expect(await screen.findByRole('heading', { name: /draft \(unpublished changes\)/i })).toBeInTheDocument();
expect(await screen.findByText(/this unit is restricted to group a and staff/i)).toBeInTheDocument();
});
it('should multiple groups in the visibility field in the unit sidebar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
render(<RootWrapper />);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseSectionVerticalMock,
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
user_partition_info: {
selected_partition_index: 0,
selected_groups_label: 'Group A, Group B, Group C',
selectable_partitions: [{
id: 10,
name: 'Content Groups',
scheme: 'cohort',
groups: [
{
deleted: false,
id: 1,
name: 'Group A',
selected: true,
},
{
deleted: false,
id: 2,
name: 'Group B',
selected: true,
},
{
deleted: false,
id: 3,
name: 'Group C',
selected: true,
},
],
}],
},
},
});
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId, courseId), store.dispatch);
expect(await screen.findByRole('heading', { name: /draft \(unpublished changes\)/i })).toBeInTheDocument();
expect(await screen.findByText(/access restrictions applied/i)).toBeInTheDocument();
expect(await screen.findByText(
/access to some content in this unit is restricted to specific groups of learners\./i,
));
});
it('should render never published state in the unit sidebar', async () => {
setConfig({
...getConfig(),
ENABLE_UNIT_PAGE_NEW_DESIGN: 'true',
});
render(<RootWrapper />);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, {
...courseSectionVerticalMock,
});
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock_info: {
...courseSectionVerticalMock.xblock_info,
published: false,
released_to_students: false,
currently_visible_to_students: false,
},
});
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId, courseId), store.dispatch);
// Move to settings
expect(await screen.findByRole('heading', { name: /draft \(never published\)/i })).toBeInTheDocument();
});
it('displays the scheduled state in the status bar', async () => {
@@ -2457,9 +2747,33 @@ describe('<CourseUnit />', () => {
xblock_info: {
...courseSectionVerticalMock.xblock_info,
user_partition_info: {
...courseSectionVerticalMock.xblock_info.user_partition_info,
selected_partition_index: 1,
selected_partition_index: 0,
selected_groups_label: 'Visibility group 1',
selectable_partitions: [{
id: 10,
name: 'Content Groups',
scheme: 'cohort',
groups: [
{
deleted: false,
id: 1,
name: 'Visibility group 1',
selected: true,
},
{
deleted: false,
id: 2,
name: 'Visibility group 2',
selected: false,
},
{
deleted: false,
id: 3,
name: 'Visibility group 3',
selected: false,
},
],
}],
},
},
});
@@ -2480,9 +2794,33 @@ describe('<CourseUnit />', () => {
xblock_info: {
...courseSectionVerticalMock.xblock_info,
user_partition_info: {
...courseSectionVerticalMock.xblock_info.user_partition_info,
selected_partition_index: 1,
selected_partition_index: 0,
selected_groups_label: 'Visibility group 1, Visibility group 2, Visibility group 3',
selectable_partitions: [{
id: 10,
name: 'Content Groups',
scheme: 'cohort',
groups: [
{
deleted: false,
id: 1,
name: 'Visibility group 1',
selected: true,
},
{
deleted: false,
id: 2,
name: 'Visibility group 2',
selected: true,
},
{
deleted: false,
id: 3,
name: 'Visibility group 3',
selected: true,
},
],
}],
},
},
});

View File

@@ -3,7 +3,10 @@ import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import type { MessageDescriptor } from 'react-intl';
import {
Alert, Container, Layout, Button, TransitionReplace,
Alert,
Container,
Button,
TransitionReplace,
Stack,
Badge,
Icon,
@@ -37,13 +40,14 @@ import AddComponent from './add-component/AddComponent';
import HeaderTitle from './header-title/HeaderTitle';
import Breadcrumbs from './breadcrumbs/Breadcrumbs';
import Sequence from './course-sequence';
import { useCourseUnit, useLayoutGrid, useScrollToLastPosition } from './hooks';
import { useCourseUnit, useScrollToLastPosition } from './hooks';
import messages from './messages';
import { PasteNotificationAlert } from './clipboard';
import XBlockContainerIframe from './xblock-container-iframe';
import MoveModal from './move-modal';
import IframePreviewLibraryXBlockChanges from './preview-changes';
import CourseUnitHeaderActionsSlot from '../plugin-slots/CourseUnitHeaderActionsSlot';
import { UnitSidebarProvider } from './unit-sidebar/UnitSidebarContext';
import { UNIT_VISIBILITY_STATES } from './constants';
import { isUnitPageNewDesignEnabled } from './utils';
@@ -159,9 +163,20 @@ const StatusBar = ({ courseUnit }: { courseUnit: any }) => {
};
const CourseUnit = () => {
const { blockId } = useParams();
const intl = useIntl();
const { blockId } = useParams();
const { courseId } = useCourseAuthoringContext();
if (courseId === undefined) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Error: route is missing courseId.');
}
if (blockId === undefined) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Error: route is missing blockId.');
}
const {
courseUnit,
isLoading,
@@ -173,7 +188,7 @@ const CourseUnit = () => {
savingStatus,
isTitleEditFormOpen,
isUnitVerticalType,
isUnitLibraryType,
isUnitLegacyLibraryType,
isSplitTestType,
isProblemBankType,
staticFileNotices,
@@ -199,8 +214,6 @@ const CourseUnit = () => {
addComponentTemplateData,
} = useCourseUnit({ courseId, blockId });
const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType);
const readOnly = !!courseUnit.readOnly;
useEffect(() => {
@@ -227,8 +240,8 @@ const CourseUnit = () => {
}
return (
<>
<Container size="xl" className="course-unit px-4">
<UnitSidebarProvider>
<Container fluid className="course-unit px-4">
<section className="course-unit-container mb-4 mt-5">
<TransitionReplace>
{movedXBlockParams.isSuccess ? (
@@ -321,8 +334,8 @@ const CourseUnit = () => {
showPasteUnit={showPasteUnit}
/>
)}
<Layout {...layoutGrid}>
<Layout.Element>
<div className="d-flex align-items-baseline">
<div className="flex-fill">
{currentlyVisibleToStudents && (
<AlertMessage
className="course-unit__alert"
@@ -373,21 +386,19 @@ const CourseUnit = () => {
courseId={courseId}
/>
<IframePreviewLibraryXBlockChanges />
</Layout.Element>
<Layout.Element>
{blockId && (
<CourseAuthoringUnitSidebarSlot
courseId={courseId}
blockId={blockId}
unitTitle={unitTitle}
xBlocks={courseVerticalChildren.children}
readOnly={readOnly}
isUnitVerticalType={isUnitVerticalType}
isSplitTestType={isSplitTestType}
/>
)}
</Layout.Element>
</Layout>
</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">
@@ -400,7 +411,7 @@ const CourseUnit = () => {
errorMessage={errorMessage}
/>
</div>
</>
</UnitSidebarProvider>
);
};

View File

@@ -1,4 +1,4 @@
import messages from './sidebar/messages';
import messages from './legacy-sidebar/messages';
import addComponentMessages from './add-component/messages';
export const getUnitReleaseStatus = (intl) => ({
@@ -16,6 +16,9 @@ export const UNIT_VISIBILITY_STATES = {
export const ICON_COLOR_VARIANTS = {
BLACK: '#000',
GREEN: '#0D7D4D',
ORANGE: '#B4610E',
PRIMARY: '#0A3055',
INFO: '#006DAA',
};
export const PUBLISH_TYPES = {

View File

@@ -49,16 +49,16 @@ export async function handleCourseUnitVisibilityAndData(
unitId: string,
type: string, // The action type (e.g., PUBLISH_TYPES.discardChanges).
isVisible: boolean, // The visibility status for students.
groupAccess: boolean,
isDiscussionEnabled: boolean,
groupAccess: Record<string, any> | null,
): Promise<object> {
const body = {
publish: groupAccess ? null : type,
...(type === PUBLISH_TYPES.republish ? {
metadata: {
visible_to_staff_only: isVisible ? true : null,
group_access: groupAccess || null,
discussion_enabled: isDiscussionEnabled,
...(groupAccess != null && { group_access: groupAccess }),
},
} : {}),
};

View File

@@ -117,8 +117,8 @@ export function editCourseUnitVisibilityAndData(
itemId,
type,
isVisible,
groupAccess,
isDiscussionEnabled,
groupAccess,
).then(async (result) => {
if (result) {
if (callback) {

View File

@@ -1,5 +1,5 @@
import {
useCallback, useEffect, useMemo, useRef, useState,
useCallback, useEffect, useRef, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useSearchParams } from 'react-router-dom';
@@ -71,7 +71,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const { displayName: unitTitle, category: unitCategory } = xblockInfo;
const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id;
const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id;
const isUnitLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
const isUnitLegacyLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
const isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.id;
const isProblemBankType = [
COURSE_BLOCK_NAMES.legacyLibraryContent.id,
@@ -256,7 +256,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
isLoading,
isTitleEditFormOpen,
isUnitVerticalType,
isUnitLibraryType,
isUnitLegacyLibraryType,
isSplitTestType,
isProblemBankType,
sharedClipboardData,
@@ -282,38 +282,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
};
};
/**
* Custom hook to determine the layout grid configuration based on unit category and type.
*
* @param {string} unitCategory - The category of the unit. This may influence future layout logic.
* @param {boolean} isUnitLibraryType - A flag indicating whether the unit is of library content type.
* @returns {Object} - An object representing the layout configuration for different screen sizes.
* The configuration includes keys like 'lg', 'md', 'sm', 'xs', and 'xl',
* each specifying an array of layout spans.
*/
export const useLayoutGrid = (unitCategory, isUnitLibraryType) => (
useMemo(() => {
const layouts = {
fullWidth: {
lg: [{ span: 12 }, { span: 0 }],
md: [{ span: 12 }, { span: 0 }],
sm: [{ span: 12 }, { span: 0 }],
xs: [{ span: 12 }, { span: 0 }],
xl: [{ span: 12 }, { span: 0 }],
},
default: {
lg: [{ span: 8 }, { span: 4 }],
md: [{ span: 8 }, { span: 4 }],
sm: [{ span: 8 }, { span: 3 }],
xs: [{ span: 9 }, { span: 3 }],
xl: [{ span: 9 }, { span: 3 }],
},
};
return isUnitLibraryType ? layouts.fullWidth : layouts.default;
}, [unitCategory])
);
/**
* Custom hook that restores the scroll position from `localStorage` after a page reload.
* It listens for a `plugin.resize` message event and scrolls the window to the saved position

View File

@@ -1,62 +1,10 @@
import React from 'react';
import { act, renderHook } from '@testing-library/react';
import { useScrollToLastPosition, useLayoutGrid } from './hooks';
import { useScrollToLastPosition } from './hooks';
import { iframeMessageTypes } from '../constants';
jest.useFakeTimers();
describe('useLayoutGrid', () => {
it('returns fullWidth layout when isUnitLibraryType is true', () => {
const { result } = renderHook(() => useLayoutGrid('someCategory', true));
expect(result.current).toEqual({
lg: [{ span: 12 }, { span: 0 }],
md: [{ span: 12 }, { span: 0 }],
sm: [{ span: 12 }, { span: 0 }],
xs: [{ span: 12 }, { span: 0 }],
xl: [{ span: 12 }, { span: 0 }],
});
});
it('returns default layout when isUnitLibraryType is false', () => {
const { result } = renderHook(() => useLayoutGrid('someCategory', false));
expect(result.current).toEqual({
lg: [{ span: 8 }, { span: 4 }],
md: [{ span: 8 }, { span: 4 }],
sm: [{ span: 8 }, { span: 3 }],
xs: [{ span: 9 }, { span: 3 }],
xl: [{ span: 9 }, { span: 3 }],
});
});
it('does not recompute layout if unitCategory remains the same', () => {
const { result, rerender } = renderHook(
({ unitCategory, isUnitLibraryType }) => useLayoutGrid(unitCategory, isUnitLibraryType),
{ initialProps: { unitCategory: 'category1', isUnitLibraryType: false } },
);
const firstResult = result.current;
rerender({ unitCategory: 'category1', isUnitLibraryType: false });
expect(result.current).toBe(firstResult);
});
it('recomputes layout when unitCategory changes', () => {
const { result, rerender } = renderHook(
({ unitCategory, isUnitLibraryType }) => useLayoutGrid(unitCategory, isUnitLibraryType),
{ initialProps: { unitCategory: 'category1', isUnitLibraryType: false } },
);
const firstResult = result.current;
rerender({ unitCategory: 'category2', isUnitLibraryType: false });
expect(result.current).not.toBe(firstResult);
});
});
describe('useScrollToLastPosition', () => {
const storageKey = 'createXBlockLastYPosition';
let scrollToSpy;

View File

@@ -21,13 +21,6 @@
padding: 0 var(--pgn-spacing-spacer-base) var(--pgn-spacing-spacer-base);
.course-unit-sidebar-visibility {
.course-unit-sidebar-visibility-title {
font-weight: var(--pgn-typography-font-weight-normal);
color: var(--pgn-color-gray-700);
@extend %base-font-params;
}
.course-unit-sidebar-visibility-section {
@extend %base-font-params;
}

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames';
import { Card } from '@openedx/paragon';
const Sidebar = ({ className = null, children = null, ...props }:SidebarProps) => (
const SidebarSection = ({ className = null, children = null, ...props }:SidebarSectionProps) => (
<Card
className={classNames('course-unit-sidebar', className)}
{...props}
@@ -10,9 +10,9 @@ const Sidebar = ({ className = null, children = null, ...props }:SidebarProps) =
</Card>
);
interface SidebarProps {
interface SidebarSectionProps {
className?: string | null;
children?: React.ReactNode | null;
}
export default Sidebar;
export default SidebarSection;

View File

@@ -13,7 +13,7 @@ const SidebarHeader = ({ title, visibilityState, displayUnitLocation }) => {
const { iconSrc, colorVariant } = getIconVariant(visibilityState, published, hasChanges);
return (
<Stack className="course-unit-sidebar-header" direction="horizontal">
<Stack className="course-unit-sidebar-header" direction="horizontal" gap={2}>
{!displayUnitLocation && (
<Icon
className="course-unit-sidebar-header-icon"

View File

@@ -2,19 +2,21 @@ import { useSelector } from 'react-redux';
import { Button } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Divider } from '../../../../generic/divider';
import { getCanEdit, getCourseUnitData } from '../../../data/selectors';
import { useClipboard } from '../../../../generic/clipboard';
import { Divider } from '@src/generic/divider';
import { getCanEdit, getCourseUnitData } from '@src/course-unit/data/selectors';
import { useClipboard } from '@src/generic/clipboard';
import messages from '../../messages';
interface ActionButtonsProps {
openDiscardModal: () => void,
handlePublishing: () => void,
hideCopyButton?: boolean,
}
const ActionButtons = ({
openDiscardModal,
handlePublishing,
hideCopyButton = false,
}: ActionButtonsProps) => {
const intl = useIntl();
const {
@@ -32,7 +34,7 @@ const ActionButtons = ({
<Button
size="sm"
className="mt-3.5"
variant="outline-primary"
variant="primary"
onClick={handlePublishing}
>
{intl.formatMessage(messages.actionButtonPublishTitle)}
@@ -48,7 +50,7 @@ const ActionButtons = ({
{intl.formatMessage(messages.actionButtonDiscardChangesTitle)}
</Button>
)}
{enableCopyPasteUnits && canEdit && (
{enableCopyPasteUnits && canEdit && !hideCopyButton && (
<>
<Divider className="course-unit-sidebar-footer__divider" />
<Button

View File

@@ -2,25 +2,22 @@ import { Card, Stack } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../../messages';
import UnitVisibilityInfo from './UnitVisibilityInfo';
import ActionButtons from './ActionButtons';
interface SidebarFooterProps {
locationId?: string,
displayUnitLocation?: boolean,
openDiscardModal: () => void,
openVisibleModal: () => void,
handlePublishing: () => void,
visibleToStaffOnly: boolean,
hideCopyButton?: boolean,
}
const SidebarFooter = ({
locationId,
openVisibleModal,
handlePublishing,
openDiscardModal,
visibleToStaffOnly,
displayUnitLocation = false,
hideCopyButton = false,
}: SidebarFooterProps) => {
const intl = useIntl();
@@ -32,16 +29,11 @@ const SidebarFooter = ({
{intl.formatMessage(messages.unitLocationDescription, { id: locationId })}
</small>
) : (
<>
<UnitVisibilityInfo
openVisibleModal={openVisibleModal}
visibleToStaffOnly={visibleToStaffOnly}
/>
<ActionButtons
openDiscardModal={openDiscardModal}
handlePublishing={handlePublishing}
/>
</>
<ActionButtons
openDiscardModal={openDiscardModal}
handlePublishing={handlePublishing}
hideCopyButton={hideCopyButton}
/>
)}
</Stack>
</Card.Footer>

View File

@@ -21,6 +21,15 @@ const useCourseUnitData = ({
: messages.sidebarTitleDraftNeverPublished,
};
const cardClasses = {
[UNIT_VISIBILITY_STATES.staffOnly]: 'only-staff',
[UNIT_VISIBILITY_STATES.live]: 'live',
// eslint-disable-next-line no-nested-ternary
default: published
? (hasChanges ? 'draft' : 'published')
: 'draft',
};
const releaseLabels = {
[UNIT_VISIBILITY_STATES.staffOnly]: releaseStatus.release,
[UNIT_VISIBILITY_STATES.live]: releaseStatus.released,
@@ -30,6 +39,7 @@ const useCourseUnitData = ({
const title = intl.formatMessage(titleMessages[visibilityState] || titleMessages.default);
const releaseLabel = releaseLabels[visibilityState] || releaseLabels.default;
const publishCardClass = cardClasses[visibilityState] || cardClasses.default;
return {
title,
@@ -37,6 +47,7 @@ const useCourseUnitData = ({
releaseLabel,
visibilityState,
visibleToStaffOnly,
publishCardClass,
};
};

View File

@@ -0,0 +1,78 @@
import { useParams } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
import { Stack } from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import TagsSidebarControls from '@src/content-tags-drawer/tags-sidebar-controls';
import SidebarSection from './SidebarSection';
import LocationInfo from './LocationInfo';
import SplitTestSidebarInfo from './SplitTestSidebarInfo';
import PublishControls from '../unit-sidebar/unit-info/PublishControls';
export type XBlock = {
id: string,
name: string,
blockType: string,
};
export interface LegacySidebarProps {
unitTitle: string;
xBlocks: XBlock[];
readOnly: boolean;
isUnitVerticalType: boolean;
isSplitTestType: boolean;
}
/**
* Sidebar that renders the unit details.
*
* This is an old sidebar replaced by src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx
* When the new sidebar is active by default, this sidebar will be removed.
*/
const LegacySidebar = ({
unitTitle,
isUnitVerticalType,
xBlocks,
readOnly,
isSplitTestType,
}: LegacySidebarProps) => {
const { blockId } = useParams();
const { courseId } = useCourseAuthoringContext();
return (
<Stack gap={3} className="px-4 mw-sm">
{isUnitVerticalType && (
<PluginSlot
id="org.openedx.frontend.authoring.course_unit_sidebar.v1"
idAliases={['course_authoring_unit_sidebar_slot']}
pluginProps={{
blockId,
courseId,
unitTitle,
xBlocks,
readOnly,
}}
>
<SidebarSection data-testid="course-unit-sidebar">
<PublishControls blockId={blockId} />
</SidebarSection>
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<SidebarSection className="tags-sidebar">
<TagsSidebarControls readOnly={readOnly} />
</SidebarSection>
)}
<SidebarSection data-testid="course-unit-location-sidebar">
<LocationInfo />
</SidebarSection>
</PluginSlot>
)}
{isSplitTestType && (
<SidebarSection data-testid="course-split-test-sidebar">
<SplitTestSidebarInfo />
</SidebarSection>
)}
</Stack>
);
};
export default LegacySidebar;

View File

@@ -33,14 +33,6 @@ const messages = defineMessages({
id: 'course-authoring.course-unit.publish.info.previously-published',
defaultMessage: 'Previously published',
},
publishInfoDraftSaved: {
id: 'course-authoring.course-unit.publish.info.draft.saved',
defaultMessage: 'Draft saved on {editedOn} by {editedBy}',
},
publishLastPublished: {
id: 'course-authoring.course-unit.publish.info.last.published',
defaultMessage: 'Last published {publishedOn} by {publishedBy}',
},
releaseInfoUnscheduled: {
id: 'course-authoring.course-unit.release.info.unscheduled',
defaultMessage: 'Unscheduled',
@@ -65,22 +57,6 @@ const messages = defineMessages({
id: 'course-authoring.course-unit.unit-location.description',
defaultMessage: 'To create a link to this unit from an HTML component in this course, enter /jump_to_id/{id} as the URL value',
},
visibilityCheckboxTitle: {
id: 'course-authoring.course-unit.visibility.checkbox.title',
defaultMessage: 'Hide from learners',
},
visibilityStaffOnlyTitle: {
id: 'course-authoring.course-unit.visibility.staff-only.title',
defaultMessage: 'Staff only',
},
visibilityStaffAndLearnersTitle: {
id: 'course-authoring.course-unit.visibility.staff-and-learners.title',
defaultMessage: 'Staff and learners',
},
visibilityHasExplicitStaffLockText: {
id: 'course-authoring.course-unit.visibility.has-explicit-staff-lock.text',
defaultMessage: 'with {sectionName}',
},
actionButtonPublishTitle: {
id: 'course-authoring.course-unit.action-buttons.publish.title',
defaultMessage: 'Publish',
@@ -105,38 +81,6 @@ const messages = defineMessages({
id: 'course-authoring.course-unit.status.scheduled.title',
defaultMessage: 'SCHEDULED',
},
modalDiscardUnitChangesTitle: {
id: 'course-authoring.course-unit.modal.discard-unit-changes.title',
defaultMessage: 'Discard changes',
},
modalDiscardUnitChangesActionButtonText: {
id: 'course-authoring.course-unit.modal.discard-unit-changes.btn.action.text',
defaultMessage: 'Discard changes',
},
modalDiscardUnitChangesCancelButtonText: {
id: 'course-authoring.course-unit.modal.discard-unit-changes.btn.cancel.text',
defaultMessage: 'Cancel',
},
modalDiscardUnitChangesDescription: {
id: 'course-authoring.course-unit.modal.discard-unit-changes.description',
defaultMessage: 'Are you sure you want to revert to the last published version of the unit? You cannot undo this action.',
},
modalMakeVisibilityTitle: {
id: 'course-authoring.course-unit.modal.make-visibility.title',
defaultMessage: 'Make visible to students',
},
modalMakeVisibilityActionButtonText: {
id: 'course-authoring.course-unit.modal.make-visibility.btn.action.text',
defaultMessage: 'Make visible to students',
},
modalMakeVisibilityCancelButtonText: {
id: 'course-authoring.course-unit.modal.make-visibility.btn.cancel.text',
defaultMessage: 'Cancel',
},
modalMakeVisibilityDescription: {
id: 'course-authoring.course-unit.modal.make-visibility.description',
defaultMessage: 'If the unit was previously published and released to students, any changes you made to the unit when it was hidden will now be visible to students. Do you want to proceed?',
},
sidebarSplitTestAddComponentTitle: {
id: 'course-authoring.course-unit.split-test.sidebar.add-component.title',
defaultMessage: 'Adding components',

View File

@@ -1,7 +1,8 @@
import {
AccessTimeFilled,
CheckCircle as CheckCircleIcon,
CheckCircleOutline as CheckCircleOutlineIcon,
InfoOutline as InfoOutlineIcon,
Description,
Lock,
} from '@openedx/paragon/icons';
import { ICON_COLOR_VARIANTS, UNIT_VISIBILITY_STATES } from '../constants';
@@ -52,22 +53,6 @@ export const getReleaseInfo = (intl, releaseDate, releaseDateFrom) => {
};
};
/**
* Get the visibility title.
* @param {Object} intl - The internationalization object.
* @param {boolean} releasedToStudents - Indicates if the content is released to students.
* @param {boolean} published - Indicates if the content is published.
* @param {boolean} hasChanges - Indicates if there are unpublished changes.
* @returns {string} The visibility title determined by the provided parameters.
*/
export const getVisibilityTitle = (intl, releasedToStudents, published, hasChanges) => {
if (releasedToStudents && published && !hasChanges) {
return intl.formatMessage(messages.visibilityIsVisibleToTitle);
}
return intl.formatMessage(messages.visibilityWillBeVisibleToTitle);
};
/**
* Get the icon variant based on the provided visibility state and publication status.
* @param {string} visibilityState - The visibility state of the content.
@@ -79,11 +64,11 @@ export const getVisibilityTitle = (intl, releasedToStudents, published, hasChang
*/
export const getIconVariant = (visibilityState, published, hasChanges) => {
const iconVariants = {
[UNIT_VISIBILITY_STATES.staffOnly]: { iconSrc: InfoOutlineIcon, colorVariant: ICON_COLOR_VARIANTS.BLACK },
[UNIT_VISIBILITY_STATES.staffOnly]: { iconSrc: Lock, colorVariant: ICON_COLOR_VARIANTS.PRIMARY },
[UNIT_VISIBILITY_STATES.live]: { iconSrc: CheckCircleIcon, colorVariant: ICON_COLOR_VARIANTS.GREEN },
publishedNoChanges: { iconSrc: CheckCircleOutlineIcon, colorVariant: ICON_COLOR_VARIANTS.BLACK },
publishedWithChanges: { iconSrc: InfoOutlineIcon, colorVariant: ICON_COLOR_VARIANTS.BLACK },
default: { iconSrc: InfoOutlineIcon, colorVariant: ICON_COLOR_VARIANTS.BLACK },
publishedNoChanges: { iconSrc: AccessTimeFilled, colorVariant: ICON_COLOR_VARIANTS.INFO },
publishedWithChanges: { iconSrc: Description, colorVariant: ICON_COLOR_VARIANTS.ORANGE },
default: { iconSrc: Description, colorVariant: ICON_COLOR_VARIANTS.ORANGE },
};
if (visibilityState in iconVariants) {
return iconVariants[visibilityState];

View File

@@ -1,97 +0,0 @@
import { useDispatch, useSelector } from 'react-redux';
import { useToggle } from '@openedx/paragon';
import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import useCourseUnitData from './hooks';
import { useIframe } from '../../generic/hooks/context/hooks';
import { editCourseUnitVisibilityAndData } from '../data/thunk';
import { SidebarBody, SidebarFooter, SidebarHeader } from './components';
import { PUBLISH_TYPES, messageTypes } from '../constants';
import { getCourseUnitData } from '../data/selectors';
import messages from './messages';
import ModalNotification from '../../generic/modal-notification';
interface PublishControlsProps {
blockId?: string,
}
const PublishControls = ({ blockId }: PublishControlsProps) => {
const unitData = useSelector(getCourseUnitData);
const {
title,
locationId,
releaseLabel,
visibilityState,
visibleToStaffOnly,
} = useCourseUnitData(unitData);
const intl = useIntl();
const { sendMessageToIframe } = useIframe();
const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false);
const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false);
const dispatch = useDispatch();
const handleCourseUnitVisibility = () => {
closeVisibleModal();
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null));
};
const handleCourseUnitDiscardChanges = () => {
closeDiscardModal();
dispatch(editCourseUnitVisibilityAndData(
blockId,
PUBLISH_TYPES.discardChanges,
null,
null,
null,
() => sendMessageToIframe(messageTypes.refreshXBlock, null),
));
};
const handleCourseUnitPublish = () => {
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic));
};
return (
<>
<SidebarHeader
title={title}
visibilityState={visibilityState}
/>
<SidebarBody
releaseLabel={releaseLabel}
visibleToStaffOnly={visibleToStaffOnly}
/>
<SidebarFooter
locationId={locationId}
openDiscardModal={openDiscardModal}
openVisibleModal={openVisibleModal}
handlePublishing={handleCourseUnitPublish}
visibleToStaffOnly={visibleToStaffOnly}
/>
<ModalNotification
title={intl.formatMessage(messages.modalDiscardUnitChangesTitle)}
isOpen={isDiscardModalOpen}
actionButtonText={intl.formatMessage(messages.modalDiscardUnitChangesActionButtonText)}
cancelButtonText={intl.formatMessage(messages.modalDiscardUnitChangesCancelButtonText)}
handleAction={handleCourseUnitDiscardChanges}
handleCancel={closeDiscardModal}
message={intl.formatMessage(messages.modalDiscardUnitChangesDescription)}
icon={InfoOutlineIcon}
/>
<ModalNotification
title={intl.formatMessage(messages.modalMakeVisibilityTitle)}
isOpen={isVisibleModalOpen}
actionButtonText={intl.formatMessage(messages.modalMakeVisibilityActionButtonText)}
cancelButtonText={intl.formatMessage(messages.modalMakeVisibilityCancelButtonText)}
handleAction={handleCourseUnitVisibility}
handleCancel={closeVisibleModal}
message={intl.formatMessage(messages.modalMakeVisibilityDescription)}
icon={InfoOutlineIcon}
/>
</>
);
};
export default PublishControls;

View File

@@ -1,69 +0,0 @@
import { useDispatch, useSelector } from 'react-redux';
import { Form } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom';
import { getCourseUnitData } from '../../../data/selectors';
import { editCourseUnitVisibilityAndData } from '../../../data/thunk';
import { PUBLISH_TYPES } from '../../../constants';
import { getVisibilityTitle } from '../../utils';
import messages from '../../messages';
interface UnitVisibilityInfoProps {
openVisibleModal: () => void,
visibleToStaffOnly: boolean,
}
const UnitVisibilityInfo = ({
openVisibleModal,
visibleToStaffOnly,
}: UnitVisibilityInfoProps) => {
const intl = useIntl();
const { blockId } = useParams();
const dispatch = useDispatch();
const {
published,
hasChanges,
staffLockFrom,
releasedToStudents,
hasExplicitStaffLock,
} = useSelector(getCourseUnitData);
const handleCourseUnitVisibility = () => {
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, true));
};
return (
<>
<small className="course-unit-sidebar-visibility-title">
{getVisibilityTitle(intl, releasedToStudents, published, hasChanges)}
</small>
{visibleToStaffOnly ? (
<>
<h6 className="course-unit-sidebar-visibility-copy">
{intl.formatMessage(messages.visibilityStaffOnlyTitle)}
</h6>
{!hasExplicitStaffLock && (
<span className="course-unit-sidebar-visibility-section mb-2">
{intl.formatMessage(messages.visibilityHasExplicitStaffLockText, { sectionName: staffLockFrom })}
</span>
)}
</>
) : (
<h6 className="course-unit-sidebar-visibility-copy">
{intl.formatMessage(messages.visibilityStaffAndLearnersTitle)}
</h6>
)}
<Form.Checkbox
className="course-unit-sidebar-visibility-checkbox"
checked={hasExplicitStaffLock}
onChange={hasExplicitStaffLock ? null : handleCourseUnitVisibility}
onClick={hasExplicitStaffLock ? openVisibleModal : null}
>
{intl.formatMessage(messages.visibilityCheckboxTitle)}
</Form.Checkbox>
</>
);
};
export default UnitVisibilityInfo;

View File

@@ -0,0 +1,36 @@
import { Sidebar } from '@src/generic/sidebar';
import LegacySidebar, { LegacySidebarProps } from '../legacy-sidebar';
import { useUnitSidebarContext } from './UnitSidebarContext';
import { isUnitPageNewDesignEnabled } from '../utils';
import { UNIT_SIDEBAR_PAGES } from './constants';
export type UnitSidebarProps = {
legacySidebarProps: LegacySidebarProps,
};
export const UnitSidebar = ({
legacySidebarProps, // Can be deleted when the legacy sidebar is deprecated
}: UnitSidebarProps) => {
const {
currentPageKey,
setCurrentPageKey,
isOpen,
toggle,
} = useUnitSidebarContext();
if (!isUnitPageNewDesignEnabled()) {
return (
<LegacySidebar {...legacySidebarProps} />
);
}
return (
<Sidebar
pages={UNIT_SIDEBAR_PAGES}
currentPageKey={currentPageKey}
setCurrentPageKey={setCurrentPageKey}
isOpen={isOpen}
toggle={toggle}
/>
);
};

View File

@@ -0,0 +1,67 @@
import {
createContext, useCallback, useContext, useMemo, useState,
} from 'react';
import { SidebarPage } from '@src/generic/sidebar';
import { useToggle } from '@openedx/paragon';
export type UnitSidebarPageKeys = 'info';
export type UnitSidebarPages = Record<UnitSidebarPageKeys, SidebarPage>;
interface UnitSidebarContextData {
currentPageKey: UnitSidebarPageKeys;
setCurrentPageKey: (pageKey: UnitSidebarPageKeys) => void;
currentTabKey?: string;
setCurrentTabKey: (tabKey: string) => void;
isOpen: boolean;
open: () => void;
toggle: () => void;
}
const UnitSidebarContext = createContext<UnitSidebarContextData | undefined>(undefined);
export const UnitSidebarProvider = ({ children }: { children?: React.ReactNode }) => {
const [currentPageKey, setCurrentPageKeyState] = useState<UnitSidebarPageKeys>('info');
const [currentTabKey, setCurrentTabKey] = useState<string>();
const [isOpen, open,, toggle] = useToggle(true);
const setCurrentPageKey = useCallback(/* istanbul ignore next */ (pageKey: UnitSidebarPageKeys) => {
setCurrentPageKeyState(pageKey);
open();
}, [open]);
const context = useMemo<UnitSidebarContextData>(
() => ({
currentPageKey,
setCurrentPageKey,
currentTabKey,
setCurrentTabKey,
isOpen,
open,
toggle,
}),
[
currentPageKey,
setCurrentPageKey,
currentTabKey,
setCurrentTabKey,
isOpen,
open,
toggle,
],
);
return (
<UnitSidebarContext.Provider value={context}>
{children}
</UnitSidebarContext.Provider>
);
};
export function useUnitSidebarContext(): UnitSidebarContextData {
const ctx = useContext(UnitSidebarContext);
if (ctx === undefined) {
/* istanbul ignore next */
throw new Error('useUnitSidebarContext() was used in a component without a <UnitSidebarProvider> ancestor.');
}
return ctx;
}

View File

@@ -0,0 +1,20 @@
import { Info } from '@openedx/paragon/icons';
import { SidebarPage } from '@src/generic/sidebar';
import messages from './messages';
import { UnitInfoSidebar } from './unit-info/UnitInfoSidebar';
export type UnitSidebarPageKeys = 'info';
/**
* 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 UNIT_SIDEBAR_PAGES: Record<UnitSidebarPageKeys, SidebarPage> = {
info: {
component: UnitInfoSidebar,
icon: Info,
title: messages.sidebarButtonInfo,
},
};

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
sidebarButtonInfo: {
id: 'course-authoring.unit-page.sidebar.info.sidebar-button-info',
defaultMessage: 'Info',
description: 'Label of the button for the Info sidebar',
},
});
export default messages;

View File

@@ -0,0 +1,19 @@
.course-unit-publish-controls {
border-radius: 5px;
&.draft {
box-shadow: 0 -7px 0 0 #B4610E;
}
&.only-staff {
box-shadow: 0 -7px 0 0 var(--pgn-color-primary-700);
}
&.live {
box-shadow: 0 -7px 0 0 var(--pgn-color-success-500);
}
&.published {
box-shadow: 0 -7px 0 0 var(--pgn-color-info-500);
}
}

View File

@@ -0,0 +1,174 @@
import { useDispatch, useSelector } from 'react-redux';
import { Icon, Stack, useToggle } from '@openedx/paragon';
import { InfoOutline as InfoOutlineIcon, Person } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import ModalNotification from '@src/generic/modal-notification';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { getCourseUnitData } from '@src/course-unit/data/selectors';
import { editCourseUnitVisibilityAndData } from '@src/course-unit/data/thunk';
import { messageTypes, PUBLISH_TYPES } from '@src/course-unit/constants';
import { SidebarFooter, SidebarHeader } from '@src/course-unit/legacy-sidebar/components';
import useCourseUnitData from '@src/course-unit/legacy-sidebar/hooks';
import ReleaseInfoComponent from '@src/course-unit/legacy-sidebar/components/ReleaseInfoComponent';
import messages from './messages';
import UnitVisibilityInfo from './UnitVisibilityInfo';
interface PublishControlsProps {
blockId?: string,
hideCopyButton?: boolean,
}
const PublishControls = ({
blockId,
hideCopyButton = false,
}: PublishControlsProps) => {
const unitData = useSelector(getCourseUnitData);
const {
title,
locationId,
releaseLabel,
visibilityState,
visibleToStaffOnly,
publishCardClass,
} = useCourseUnitData(unitData);
const intl = useIntl();
const { sendMessageToIframe } = useIframe();
const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false);
const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false);
const {
editedOn,
editedBy,
publishedBy,
publishedOn,
} = unitData;
const dispatch = useDispatch();
const handleCourseUnitVisibility = () => {
closeVisibleModal();
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null));
};
const handleCourseUnitDiscardChanges = () => {
closeDiscardModal();
dispatch(editCourseUnitVisibilityAndData(
blockId,
PUBLISH_TYPES.discardChanges,
null,
null,
null,
/* istanbul ignore next */
() => sendMessageToIframe(messageTypes.refreshXBlock, null),
));
};
const handleCourseUnitPublish = () => {
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic));
};
return (
<div className={`course-unit-publish-controls border p-3 ${publishCardClass}`}>
<div className="text-primary-700 mb-4">
<SidebarHeader
title={title}
visibilityState={visibilityState}
/>
</div>
<Stack gap={4}>
<Stack gap={2}>
{editedOn && (
<div>
<span className="heading-label">
<FormattedMessage {...messages.publishInfoDraftSaved} />
</span>
<Stack direction="horizontal" gap={1} className="text-primary-700">
{editedBy && (
<>
<Icon src={Person} />
<span>
{editedBy}
</span>
<span>
-
</span>
</>
)}
<span>
{editedOn}
</span>
</Stack>
</div>
)}
{publishedOn && (
<div>
<span className="heading-label">
<FormattedMessage {...messages.publishLastPublished} />
</span>
<Stack direction="horizontal" gap={1} className="text-primary-700">
{publishedBy && (
<>
<Icon src={Person} />
<span>
{publishedBy}
</span>
<span>
-
</span>
</>
)}
<span>
{publishedOn}
</span>
</Stack>
</div>
)}
</Stack>
<Stack>
<span className="heading-label">
{releaseLabel}
</span>
<div className="text-primary-700">
<ReleaseInfoComponent />
</div>
</Stack>
<div>
<UnitVisibilityInfo
openVisibleModal={openVisibleModal}
visibleToStaffOnly={visibleToStaffOnly}
userPartitionInfo={unitData.userPartitionInfo}
/>
</div>
</Stack>
<SidebarFooter
locationId={locationId}
openDiscardModal={openDiscardModal}
handlePublishing={handleCourseUnitPublish}
hideCopyButton={hideCopyButton}
/>
<ModalNotification
title={intl.formatMessage(messages.modalDiscardUnitChangesTitle)}
isOpen={isDiscardModalOpen}
actionButtonText={intl.formatMessage(messages.modalDiscardUnitChangesActionButtonText)}
cancelButtonText={intl.formatMessage(messages.modalDiscardUnitChangesCancelButtonText)}
handleAction={handleCourseUnitDiscardChanges}
handleCancel={closeDiscardModal}
message={intl.formatMessage(messages.modalDiscardUnitChangesDescription)}
icon={InfoOutlineIcon}
/>
<ModalNotification
title={intl.formatMessage(messages.modalMakeVisibilityTitle)}
isOpen={isVisibleModalOpen}
actionButtonText={intl.formatMessage(messages.modalMakeVisibilityActionButtonText)}
cancelButtonText={intl.formatMessage(messages.modalMakeVisibilityCancelButtonText)}
handleAction={handleCourseUnitVisibility}
handleCancel={closeVisibleModal}
message={intl.formatMessage(messages.modalMakeVisibilityDescription)}
icon={InfoOutlineIcon}
/>
</div>
);
};
export default PublishControls;

View File

@@ -0,0 +1,238 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom';
import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils';
import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar';
import { useEffect, useMemo } from 'react';
import { Tag } from '@openedx/paragon/icons';
import { ContentTagsSnippet } from '@src/content-tags-drawer';
import configureMessages from '@src/generic/configure-modal/messages';
import {
Button, ButtonGroup, Tab, Tabs,
} from '@openedx/paragon';
import { useDispatch, useSelector } from 'react-redux';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { AccessEditComponent, DiscussionEditComponent } from '@src/generic/configure-modal/UnitTab';
import { Form, Formik } from 'formik';
import { getCourseUnitData, getCourseVerticalChildren } from '@src/course-unit/data/selectors';
import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from '@src/course-unit/constants';
import { editCourseUnitVisibilityAndData } from '@src/course-unit/data/thunk';
import PublishControls from './PublishControls';
import { useUnitSidebarContext } from '../UnitSidebarContext';
import messages from './messages';
/**
* Component to show unit details: Publish status, Component counts and Content Tags.
*
* It's using in the details tab of the unit info sidebar.
*/
const UnitInfoDetails = () => {
const intl = useIntl();
const { blockId } = useParams();
const courseVerticalChildren = useSelector(getCourseVerticalChildren);
if (blockId === undefined) {
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
throw new Error('Error: route is missing blockId.');
}
const componentData: Record<string, number> = useMemo(() => (
// @ts-ignore
courseVerticalChildren.children.reduce<Record<string, number>>(
(acc, { blockType }) => {
acc[blockType] = (acc[blockType] ?? 0) + 1;
return acc;
},
{},
)
), [courseVerticalChildren.children]);
return (
<SidebarContent>
<PublishControls blockId={blockId} hideCopyButton />
<SidebarSection
title={intl.formatMessage(messages.sidebarSectionSummary)}
icon={getItemIcon('unit')}
>
{componentData && <ComponentCountSnippet componentData={componentData} />}
</SidebarSection>
<SidebarSection
title={intl.formatMessage(messages.sidebarSectionTaxonomies)}
icon={Tag}
>
<ContentTagsSnippet contentId={blockId} />
</SidebarSection>
</SidebarContent>
);
};
/**
* Component with forms to edit unit settings.
*
* It's using in the settings tab of the unit info sidebar.
*/
const UnitInfoSettings = () => {
const dispatch = useDispatch();
const intl = useIntl();
const { sendMessageToIframe } = useIframe();
const {
id,
visibilityState,
discussionEnabled,
userPartitionInfo,
} = useSelector(getCourseUnitData);
const visibleToStaffOnly = visibilityState === UNIT_VISIBILITY_STATES.staffOnly;
const handleUpdate = async (
isVisible: boolean,
groupAccess: Object | null,
isDiscussionEnabled: boolean,
) => {
await dispatch(editCourseUnitVisibilityAndData(
id,
PUBLISH_TYPES.republish,
isVisible,
groupAccess,
isDiscussionEnabled,
() => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: id }),
id,
));
};
/* istanbul ignore next */
const handleSaveGroups = async (data, { resetForm }) => {
const groupAccess = {};
if (data.selectedPartitionIndex >= 0) {
const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id;
groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10));
}
await handleUpdate(visibleToStaffOnly, groupAccess, discussionEnabled);
resetForm({ values: data });
};
/* istanbul ignore next */
const getSelectedGroups = () => {
if (userPartitionInfo?.selectedPartitionIndex >= 0) {
return userPartitionInfo?.selectablePartitions[userPartitionInfo?.selectedPartitionIndex]
?.groups
.filter(({ selected }) => selected)
// eslint-disable-next-line @typescript-eslint/no-shadow
.map(({ id }) => `${id}`)
|| [];
}
return [];
};
const initialValues = useMemo(() => (
{
selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex,
selectedGroups: getSelectedGroups(),
}
), [userPartitionInfo]);
return (
<SidebarContent>
<SidebarSection
title={intl.formatMessage(messages.sidebarInfoVisibilityTitle)}
>
<ButtonGroup toggle>
<Button
variant={visibleToStaffOnly ? 'outline-primary' : 'primary'}
onClick={() => handleUpdate(false, null, discussionEnabled)}
>
<FormattedMessage {...messages.sidebarInfoVisibilityStudentLabel} />
</Button>
<Button
variant={visibleToStaffOnly ? 'primary' : 'outline-primary'}
onClick={() => handleUpdate(true, null, discussionEnabled)}
>
<FormattedMessage {...messages.sidebarInfoVisibilityStaffLabel} />
</Button>
</ButtonGroup>
</SidebarSection>
<SidebarSection
title={intl.formatMessage(messages.sidebarInfoAccessTitle)}
>
<Formik
initialValues={initialValues}
onSubmit={handleSaveGroups}
>
{({
values, setFieldValue, dirty,
}) => (
<Form>
<AccessEditComponent
selectedPartitionIndex={values.selectedPartitionIndex}
setFieldValue={setFieldValue}
userPartitionInfo={userPartitionInfo}
selectedGroups={values.selectedGroups}
/>
{dirty && (
<Button className="mt-3" type="submit" variant="primary">
<FormattedMessage {...messages.visibilitySaveGroupsButton} />
</Button>
)}
</Form>
)}
</Formik>
</SidebarSection>
<SidebarSection
title={intl.formatMessage(configureMessages.discussionEnabledSectionTitle)}
>
<DiscussionEditComponent
discussionEnabled={discussionEnabled}
handleDiscussionChange={(e) => handleUpdate(visibleToStaffOnly, null, e.target.checked)}
/>
</SidebarSection>
</SidebarContent>
);
};
/**
* Main component that renders the tabs of the info sidebar.
*/
export const UnitInfoSidebar = () => {
const intl = useIntl();
const currentItemData = useSelector(getCourseUnitData);
const {
currentTabKey,
setCurrentTabKey,
} = useUnitSidebarContext();
useEffect(() => {
// Set default Tab key
setCurrentTabKey('details');
}, []);
return (
<div>
<SidebarTitle
title={currentItemData.displayName}
icon={getItemIcon('unit')}
/>
<Tabs
id="unit-info-sidebar-tabs"
className="my-2 d-flex justify-content-around"
activeKey={currentTabKey}
onSelect={setCurrentTabKey}
>
<Tab
eventKey="details"
title={intl.formatMessage(messages.sidebarInfoDetailsTab)}
>
<div className="mt-4">
<UnitInfoDetails />
</div>
</Tab>
<Tab
eventKey="settings"
title={intl.formatMessage(messages.sidebarInfoSettingsTab)}
>
<div className="mt-4">
<UnitInfoSettings />
</div>
</Tab>
</Tabs>
</div>
);
};

View File

@@ -0,0 +1,165 @@
import { useDispatch, useSelector } from 'react-redux';
import {
Form, Icon, IconButton, Stack,
} from '@openedx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom';
import { getCourseUnitData } from '@src/course-unit/data/selectors';
import { editCourseUnitVisibilityAndData } from '@src/course-unit/data/thunk';
import { PUBLISH_TYPES } from '@src/course-unit/constants';
import { isUnitPageNewDesignEnabled } from '@src/course-unit/utils';
import { Edit, Groups, Lock } from '@openedx/paragon/icons';
import messages from './messages';
import { useUnitSidebarContext } from '../UnitSidebarContext';
interface UnitVisibilityInfoProps {
openVisibleModal: () => void,
visibleToStaffOnly: boolean,
userPartitionInfo?: {
selectablePartitions: Record<string, any>[],
selectedGroupsLabel: string,
selectedPartitionIndex: number,
},
}
interface UnitvisibilityInfoContentProps {
visibleToStaffOnly: boolean,
userPartitionInfo?: {
selectablePartitions: Record<string, any>[],
selectedGroupsLabel: string,
selectedPartitionIndex: number,
},
}
const LegacyVisibilityInfo = ({
visibleToStaffOnly,
openVisibleModal,
}: UnitVisibilityInfoProps) => {
const {
staffLockFrom,
hasExplicitStaffLock,
} = useSelector(getCourseUnitData);
const { blockId } = useParams();
const dispatch = useDispatch();
const handleCourseUnitVisibility = () => {
/* istanbul ignore next */
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, true));
};
return (
<>
{visibleToStaffOnly ? (
<>
<h6 className="course-unit-sidebar-visibility-copy">
<FormattedMessage {...messages.visibilityStaffOnlyTitle} />
</h6>
{/* istanbul ignore next */ !hasExplicitStaffLock && (
<span className="course-unit-sidebar-visibility-section mb-2">
<FormattedMessage
{...messages.visibilityHasExplicitStaffLockText}
values={{ sectionName: staffLockFrom }}
/>
</span>
)}
</>
) : (
<h6 className="course-unit-sidebar-visibility-copy">
<FormattedMessage {...messages.visibilityStaffAndLearnersTitle} />
</h6>
)}
<Form.Checkbox
className="course-unit-sidebar-visibility-checkbox"
checked={hasExplicitStaffLock}
onChange={hasExplicitStaffLock ? null : handleCourseUnitVisibility}
onClick={hasExplicitStaffLock ? openVisibleModal : null}
>
<FormattedMessage {...messages.visibilityCheckboxTitle} />
</Form.Checkbox>
</>
);
};
const UnitvisibilityInfoContent = ({
visibleToStaffOnly,
userPartitionInfo,
}: UnitvisibilityInfoContentProps) => {
const intl = useIntl();
const { setCurrentTabKey } = useUnitSidebarContext();
const { selectedPartitionIndex, selectedGroupsLabel } = userPartitionInfo ?? {};
const hasGroups = selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex) && selectedGroupsLabel;
let groupsCount = 0;
if (hasGroups) {
groupsCount = selectedGroupsLabel.split(',').length;
}
let labelMessages = intl.formatMessage(messages.visibilityAllLearnersTitle);
let secondLabelMessages;
if (visibleToStaffOnly) {
labelMessages = intl.formatMessage(messages.visibilityStaffOnlyTitle);
} else if (hasGroups) {
if (groupsCount === 1) {
labelMessages = selectedGroupsLabel;
secondLabelMessages = intl.formatMessage(
messages.visibilitySingleGroupDetails,
{
groupName: selectedGroupsLabel,
},
);
} else {
labelMessages = intl.formatMessage(messages.visibilityAccessRestrictionsTitle);
secondLabelMessages = intl.formatMessage(messages.visibilityMultipleGroupsDetails);
}
}
return (
<>
<Stack direction="horizontal" gap={2}>
{visibleToStaffOnly ? (
<Icon src={Lock} />
) : (
<Icon src={Groups} />
)}
<span className="font-weight-bold text-primary-700">
{labelMessages}
</span>
<IconButton
src={Edit}
alt={intl.formatMessage(messages.visibilityEditButton)}
size="inline"
onClick={() => setCurrentTabKey('settings')}
/>
</Stack>
{secondLabelMessages}
</>
);
};
const UnitVisibilityInfo = ({
openVisibleModal,
visibleToStaffOnly,
userPartitionInfo,
}: UnitVisibilityInfoProps) => (
<>
<span className="heading-label">
<FormattedMessage {...messages.visibilityVisibleToTitle} />
</span>
{isUnitPageNewDesignEnabled() ? (
<UnitvisibilityInfoContent
visibleToStaffOnly={visibleToStaffOnly}
userPartitionInfo={userPartitionInfo}
/>
) : (
<LegacyVisibilityInfo
visibleToStaffOnly={visibleToStaffOnly}
openVisibleModal={openVisibleModal}
/>
)}
</>
);
export default UnitVisibilityInfo;

View File

@@ -0,0 +1,139 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
publishInfoDraftSaved: {
id: 'course-authoring.course-unit.publish.info.draft.saved',
defaultMessage: 'DRAFT SAVED',
description: 'Label for the draft date in the publish info section',
},
publishLastPublished: {
id: 'course-authoring.course-unit.publish.info.last.published',
defaultMessage: 'LAST PUBLISHED',
description: 'Label for the last published date in the publish info section',
},
modalDiscardUnitChangesTitle: {
id: 'course-authoring.course-unit.modal.discard-unit-changes.title',
defaultMessage: 'Discard changes',
},
modalDiscardUnitChangesActionButtonText: {
id: 'course-authoring.course-unit.modal.discard-unit-changes.btn.action.text',
defaultMessage: 'Discard changes',
},
modalDiscardUnitChangesCancelButtonText: {
id: 'course-authoring.course-unit.modal.discard-unit-changes.btn.cancel.text',
defaultMessage: 'Cancel',
},
modalDiscardUnitChangesDescription: {
id: 'course-authoring.course-unit.modal.discard-unit-changes.description',
defaultMessage: 'Are you sure you want to revert to the last published version of the unit? You cannot undo this action.',
},
modalMakeVisibilityTitle: {
id: 'course-authoring.course-unit.modal.make-visibility.title',
defaultMessage: 'Make visible to students',
},
modalMakeVisibilityActionButtonText: {
id: 'course-authoring.course-unit.modal.make-visibility.btn.action.text',
defaultMessage: 'Make visible to students',
},
modalMakeVisibilityCancelButtonText: {
id: 'course-authoring.course-unit.modal.make-visibility.btn.cancel.text',
defaultMessage: 'Cancel',
},
modalMakeVisibilityDescription: {
id: 'course-authoring.course-unit.modal.make-visibility.description',
defaultMessage: 'If the unit was previously published and released to students, any changes you made to the unit when it was hidden will now be visible to students. Do you want to proceed?',
},
visibilityVisibleToTitle: {
id: 'course-authoring.course-unit.visibility.visible-to.title',
defaultMessage: 'VISIBLE TO',
description: 'Label for visibility section in the unit sidebar',
},
visibilityStaffOnlyTitle: {
id: 'course-authoring.course-unit.visibility.staff-only.title',
defaultMessage: 'Staff only',
},
visibilityHasExplicitStaffLockText: {
id: 'course-authoring.course-unit.visibility.has-explicit-staff-lock.text',
defaultMessage: 'with {sectionName}',
},
visibilityStaffAndLearnersTitle: {
id: 'course-authoring.course-unit.visibility.staff-and-learners.title',
defaultMessage: 'Staff and learners',
},
visibilityCheckboxTitle: {
id: 'course-authoring.course-unit.visibility.checkbox.title',
defaultMessage: 'Hide from learners',
},
visibilityAccessRestrictionsTitle: {
id: 'course-authoring.course-unit.visibility.access-restrictions.title',
defaultMessage: 'Access Restrictions Applied',
description: 'Label for the access restrictions state',
},
visibilityAllLearnersTitle: {
id: 'course-authoring.course-unit.visibility.all-learners.title',
defaultMessage: 'All Learners & Staff',
description: 'Label for the all learners state',
},
visibilitySingleGroupDetails: {
id: 'course-authoring.course-unit.visibility.single-group.details',
defaultMessage: 'This unit is restricted to {groupName} and Staff',
description: 'Details text when the visibility state is access restricted with one group.',
},
visibilityMultipleGroupsDetails: {
id: 'course-authoring.course-unit.visibility.multiple-groups.details',
defaultMessage: 'Access to some content in this unit is restricted to specific groups of learners.',
description: 'Details text when the visibility state is access restricted with multiple groups',
},
visibilityEditButton: {
id: 'course-authoring.course-unit.visibility.edit.label',
defaultMessage: 'Edit Visibility',
description: 'Alt label for the edit visibility icon button',
},
visibilitySaveGroupsButton: {
id: 'course-authoring.course-unit.visibility.save-groups.label',
defaultMessage: 'Save changes',
description: 'Label for the button to save the groups in the settings sidebar.',
},
sidebarSectionSummary: {
id: 'course-authoring.unit-page.sidebar.info.section.summary',
defaultMessage: 'Unit Content Summary',
description: 'Title for the summary section in the Unit info sidebar',
},
sidebarSectionTaxonomies: {
id: 'course-authoring.unit-page.sidebar.info.section.taxonomies',
defaultMessage: 'Taxonomy Alignments',
description: 'Title for the taxonomies section in the Unit info sidebar',
},
sidebarInfoVisibilityStudentLabel: {
id: 'course-authoring.unit-page.sidebar.info.settings.student-visibility',
defaultMessage: 'Student Visible',
description: 'Label the button to make the unit visible to students',
},
sidebarInfoVisibilityStaffLabel: {
id: 'course-authoring.unit-page.sidebar.info.settings.staff-visibility',
defaultMessage: 'Staff Only',
description: 'Label the button to make the unit visible only to staff',
},
sidebarInfoVisibilityTitle: {
id: 'course-authoring.unit-page.sidebar.info.settings.visibility-title',
defaultMessage: 'Visibility',
description: 'Title of the Visibility section of the unit sidebar',
},
sidebarInfoAccessTitle: {
id: 'course-authoring.unit-page.sidebar.info.settings.access-title',
defaultMessage: 'Access Restrictions',
description: 'Title of the access section of the unit sidebar',
},
sidebarInfoDetailsTab: {
id: 'course-authoring.unit-page.sidebar.info.details-tab',
defaultMessage: 'Details',
description: 'Label for the details tab of the unit info sidebar',
},
sidebarInfoSettingsTab: {
id: 'course-authoring.unit-page.sidebar.info.settings-tab',
defaultMessage: 'Settings',
description: 'Label for the settings tab of the unit info sidebar',
},
});
export default messages;

View File

@@ -1,7 +1,11 @@
import React from 'react';
import { Alert } from '@openedx/paragon';
interface Props extends Omit<React.ComponentPropsWithoutRef<typeof Alert>, 'title'> {
interface Props
extends Omit<
React.ComponentPropsWithoutRef<typeof Alert>,
'title' | 'description'
> {
title?: string | React.ReactNode;
description?: string | React.ReactNode;
}

View File

@@ -19,7 +19,7 @@ import messages from './messages';
import BasicTab from './BasicTab';
import VisibilityTab from './VisibilityTab';
import AdvancedTab from './AdvancedTab';
import UnitTab from './UnitTab';
import { UnitTab } from './UnitTab';
const ConfigureModal = ({
isOpen,

View File

@@ -1,201 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Form } from '@openedx/paragon';
import {
FormattedMessage, useIntl,
} from '@edx/frontend-platform/i18n';
import { Field } from 'formik';
import classNames from 'classnames';
import { COURSE_BLOCK_NAMES } from '../../constants';
import messages from './messages';
const UnitTab = ({
isXBlockComponent,
category,
values,
setFieldValue,
showWarning,
userPartitionInfo,
}) => {
const intl = useIntl();
const {
isVisibleToStaffOnly,
selectedPartitionIndex,
selectedGroups,
discussionEnabled,
} = values;
const handleVisibilityChange = (e) => {
setFieldValue('isVisibleToStaffOnly', e.target.checked);
};
const handleDiscussionChange = (e) => {
setFieldValue('discussionEnabled', e.target.checked);
};
const handleSelect = (e) => {
setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10));
setFieldValue('selectedGroups', []);
};
const checkIsDeletedGroup = (group) => {
const isGroupSelected = selectedGroups.includes(group.id.toString());
return group.deleted && isGroupSelected;
};
const getAccessBlockTitle = () => {
switch (category) {
case COURSE_BLOCK_NAMES.libraryContent.id:
return messages.libraryContentAccess;
case COURSE_BLOCK_NAMES.splitTest.id:
return messages.splitTestAccess;
default:
return messages.unitAccess;
}
};
return (
<>
{!isXBlockComponent && (
<>
<h4 className="mt-3"><FormattedMessage {...messages.unitVisibility} /></h4>
<hr />
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleVisibilityChange} data-testid="unit-visibility-checkbox">
<FormattedMessage {...messages.hideFromLearners} />
</Form.Checkbox>
{showWarning && (
<Alert className="mt-2" variant="warning">
<FormattedMessage {...messages.unitVisibilityWarning} />
</Alert>
)}
</>
)}
{userPartitionInfo.selectablePartitions.length > 0 && (
<Form.Group controlId="groupSelect">
<h4 className="mt-3">
<FormattedMessage {...getAccessBlockTitle()} />
</h4>
<hr />
<Form.Label as="legend" className="font-weight-bold">
<FormattedMessage {...messages.restrictAccessTo} />
</Form.Label>
<Form.Control
as="select"
name="groupSelect"
value={selectedPartitionIndex}
onChange={handleSelect}
data-testid="group-type-select"
>
<option value="-1" key="-1">
{userPartitionInfo.selectedPartitionIndex === -1
? intl.formatMessage(messages.unitSelectGroupType)
: intl.formatMessage(messages.unitAllLearnersAndStaff)}
</option>
{userPartitionInfo.selectablePartitions.map((partition, index) => (
<option
key={partition.id}
value={index}
>
{partition.name}
</option>
))}
</Form.Control>
{selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && (
<Form.Group controlId="select-groups-checkboxes">
<Form.Label><FormattedMessage {...messages.unitSelectGroup} /></Form.Label>
<div
role="group"
className="d-flex flex-column"
data-testid="group-checkboxes"
aria-labelledby="select-groups-checkboxes"
>
{userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => (
<Form.Group
key={group.id}
className="pgn__form-checkbox"
>
<Field
as={Form.Control}
className="flex-grow-0 mr-1"
controlClassName="pgn__form-checkbox-input mr-1"
type="checkbox"
value={`${group.id}`}
name="selectedGroups"
/>
<div>
<Form.Label
className={classNames({ 'text-danger': checkIsDeletedGroup(group) })}
isInline
>
{group.name}
</Form.Label>
{group.deleted && (
<Form.Control.Feedback type="invalid" hasIcon={false}>
{intl.formatMessage(messages.unitSelectDeletedGroupErrorMessage)}
</Form.Control.Feedback>
)}
</div>
</Form.Group>
))}
</div>
</Form.Group>
)}
</Form.Group>
)}
{!isXBlockComponent && (
<>
<h4 className="mt-4"><FormattedMessage {...messages.discussionEnabledSectionTitle} /></h4>
<hr />
<Form.Checkbox checked={discussionEnabled} onChange={handleDiscussionChange}>
<FormattedMessage {...messages.discussionEnabledCheckbox} />
</Form.Checkbox>
<p className="x-small font-weight-bold"><FormattedMessage {...messages.discussionEnabledDescription} /></p>
</>
)}
</>
);
};
UnitTab.defaultProps = {
isXBlockComponent: false,
category: undefined,
};
UnitTab.propTypes = {
isXBlockComponent: PropTypes.bool,
category: PropTypes.string,
values: PropTypes.shape({
isVisibleToStaffOnly: PropTypes.bool.isRequired,
discussionEnabled: PropTypes.bool,
selectedPartitionIndex: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
selectedGroups: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.string),
PropTypes.array,
]),
}).isRequired,
setFieldValue: PropTypes.func.isRequired,
showWarning: PropTypes.bool.isRequired,
userPartitionInfo: PropTypes.shape({
selectablePartitions: PropTypes.arrayOf(PropTypes.shape({
groups: PropTypes.arrayOf(PropTypes.shape({
deleted: PropTypes.bool.isRequired,
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
selected: PropTypes.bool.isRequired,
}).isRequired).isRequired,
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
scheme: PropTypes.string.isRequired,
}).isRequired).isRequired,
selectedGroupsLabel: PropTypes.string,
selectedPartitionIndex: PropTypes.number.isRequired,
}).isRequired,
};
export default UnitTab;

View File

@@ -0,0 +1,228 @@
import { Alert, Form } from '@openedx/paragon';
import {
FormattedMessage, useIntl,
} from '@edx/frontend-platform/i18n';
import { Field } from 'formik';
import classNames from 'classnames';
import { COURSE_BLOCK_NAMES } from '../../constants';
import messages from './messages';
export type UserpartitionInfo = {
selectablePartitions: {
groups: {
deleted: boolean,
id: number,
name: string,
selected: boolean,
}[],
id: number,
name: string,
scheme: string,
}[],
selectedGroupsLabel?: string,
selectedPartitionIndex: number,
};
export interface UnitTabProps {
isXBlockComponent: boolean,
category?: string,
values: {
isVisibleToStaffOnly: boolean,
discussionEnabled: boolean,
selectedPartitionIndex: number,
selectedGroups: string[],
},
setFieldValue: (key: string, value: any) => void,
showWarning: boolean,
userPartitionInfo: UserpartitionInfo,
}
export const DiscussionEditComponent = ({
discussionEnabled,
handleDiscussionChange,
}: {
discussionEnabled: boolean,
handleDiscussionChange: (e: any) => void,
}) => (
<>
<Form.Checkbox checked={discussionEnabled} onChange={handleDiscussionChange}>
<FormattedMessage {...messages.discussionEnabledCheckbox} />
</Form.Checkbox>
<p className="x-small font-weight-bold"><FormattedMessage {...messages.discussionEnabledDescription} /></p>
</>
);
export interface AccessEditComponentProps {
selectedPartitionIndex: number,
setFieldValue: (key: string, value: any) => void,
userPartitionInfo: UserpartitionInfo,
selectedGroups: string[],
}
export const AccessEditComponent = ({
selectedPartitionIndex,
setFieldValue,
userPartitionInfo,
selectedGroups,
}: AccessEditComponentProps) => {
const intl = useIntl();
const checkIsDeletedGroup = (group) => {
const isGroupSelected = selectedGroups.includes(group.id.toString());
return group.deleted && isGroupSelected;
};
const handleSelect = (e) => {
setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10));
setFieldValue('selectedGroups', selectedGroups);
};
return (
<>
<Form.Label className="font-weight-bold">
<FormattedMessage {...messages.restrictAccessTo} />
</Form.Label>
<Form.Control
as="select"
name="groupSelect"
value={selectedPartitionIndex}
onChange={handleSelect}
data-testid="group-type-select"
>
<option value="-1" key="-1">
{userPartitionInfo.selectedPartitionIndex === -1
? intl.formatMessage(messages.unitSelectGroupType)
: intl.formatMessage(messages.unitAllLearnersAndStaff)}
</option>
{userPartitionInfo.selectablePartitions.map((partition, index) => (
<option
key={partition.id}
value={index}
>
{partition.name}
</option>
))}
</Form.Control>
{selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && (
<Form.Group controlId="select-groups-checkboxes">
<Form.Label><FormattedMessage {...messages.unitSelectGroup} /></Form.Label>
<div
role="group"
className="d-flex flex-column"
data-testid="group-checkboxes"
aria-labelledby="select-groups-checkboxes"
>
{userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => (
<Form.Group
key={group.id}
className="pgn__form-checkbox"
>
<Field
as={Form.Control}
className="flex-grow-0 mr-1"
controlClassName="pgn__form-checkbox-input mr-1"
type="checkbox"
value={`${group.id}`}
name="selectedGroups"
/>
<div>
<Form.Label
className={classNames({ 'text-danger': checkIsDeletedGroup(group) })}
isInline
>
{group.name}
</Form.Label>
{/* istanbul ignore next */ group.deleted && (
<Form.Control.Feedback type="invalid" hasIcon={false}>
<FormattedMessage {...messages.unitSelectDeletedGroupErrorMessage} />
</Form.Control.Feedback>
)}
</div>
</Form.Group>
))}
</div>
</Form.Group>
)}
</>
);
};
export const UnitTab = ({
isXBlockComponent,
category,
values,
setFieldValue,
showWarning,
userPartitionInfo,
}: UnitTabProps) => {
const {
isVisibleToStaffOnly,
selectedPartitionIndex,
selectedGroups,
discussionEnabled,
} = values;
const handleVisibilityChange = (e) => {
setFieldValue('isVisibleToStaffOnly', e.target.checked);
};
const handleDiscussionChange = (e) => {
setFieldValue('discussionEnabled', e.target.checked);
};
const getAccessBlockTitle = () => {
switch (category) {
case COURSE_BLOCK_NAMES.libraryContent.id:
return messages.libraryContentAccess;
case COURSE_BLOCK_NAMES.splitTest.id:
return messages.splitTestAccess;
default:
return messages.unitAccess;
}
};
return (
<>
{!isXBlockComponent && (
<>
<h4 className="mt-3"><FormattedMessage {...messages.unitVisibility} /></h4>
<hr />
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleVisibilityChange} data-testid="unit-visibility-checkbox">
<FormattedMessage {...messages.hideFromLearners} />
</Form.Checkbox>
{/* istanbul ignore next */ showWarning && (
<Alert className="mt-2" variant="warning">
<FormattedMessage {...messages.unitVisibilityWarning} />
</Alert>
)}
</>
)}
{userPartitionInfo.selectablePartitions.length > 0 && (
<Form.Group controlId="groupSelect">
<h4 className="mt-3">
<FormattedMessage {...getAccessBlockTitle()} />
</h4>
<hr />
<AccessEditComponent
selectedPartitionIndex={selectedPartitionIndex}
setFieldValue={setFieldValue}
userPartitionInfo={userPartitionInfo}
selectedGroups={selectedGroups}
/>
</Form.Group>
)}
{!isXBlockComponent && (
<>
<h4 className="mt-4"><FormattedMessage {...messages.discussionEnabledSectionTitle} /></h4>
<hr />
<DiscussionEditComponent
discussionEnabled={discussionEnabled}
handleDiscussionChange={handleDiscussionChange}
/>
</>
)}
</>
);
};

View File

@@ -91,7 +91,7 @@ export function Sidebar<T extends SidebarPages>({
return (
<Stack direction="horizontal" className="sidebar align-items-baseline ml-3" gap={2}>
{isOpen && !!currentPageKey && (
<div className="sidebar-content p-2 bg-white border-right">
<div className="sidebar-content p-3 bg-white border-right">
<Dropdown data-testid="sidebar-dropdown">
<Dropdown.Toggle
id="dropdown-toggle-with-iconbutton"

View File

@@ -2,7 +2,7 @@
.sidebar-content {
flex: 0 1 auto;
max-width: 700px;
min-width: 650px;
min-width: 400px;
overflow-y: auto;
min-height: 100vh;
height: 100%;

View File

@@ -1,11 +1,5 @@
import { getConfig } from '@edx/frontend-platform';
import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
import { Stack } from '@openedx/paragon';
import TagsSidebarControls from '../../content-tags-drawer/tags-sidebar-controls';
import Sidebar from '../../course-unit/sidebar';
import LocationInfo from '../../course-unit/sidebar/LocationInfo';
import PublishControls from '../../course-unit/sidebar/PublishControls';
import SplitTestSidebarInfo from '../../course-unit/sidebar/SplitTestSidebarInfo';
import { UnitSidebar } from '@src/course-unit/unit-sidebar/UnitSidebar';
export const CourseAuthoringUnitSidebarSlot = (
{
@@ -24,34 +18,15 @@ export const CourseAuthoringUnitSidebarSlot = (
blockId, courseId, unitTitle, xBlocks, readOnly, isUnitVerticalType, isSplitTestType,
}}
>
<Stack gap={3}>
{isUnitVerticalType && (
<PluginSlot
id="org.openedx.frontend.authoring.course_unit_sidebar.v1"
idAliases={['course_authoring_unit_sidebar_slot']}
pluginProps={{
blockId, courseId, unitTitle, xBlocks, readOnly,
}}
>
<Sidebar data-testid="course-unit-sidebar">
<PublishControls blockId={blockId} />
</Sidebar>
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<Sidebar className="tags-sidebar">
<TagsSidebarControls readOnly={readOnly} />
</Sidebar>
)}
<Sidebar data-testid="course-unit-location-sidebar">
<LocationInfo />
</Sidebar>
</PluginSlot>
)}
{isSplitTestType && (
<Sidebar data-testid="course-split-test-sidebar">
<SplitTestSidebarInfo />
</Sidebar>
)}
</Stack>
<UnitSidebar
legacySidebarProps={{
unitTitle,
xBlocks,
readOnly,
isUnitVerticalType,
isSplitTestType,
}}
/>
</PluginSlot>
);