From e4d88fb1faa760961fe502b09ec73364cfe9dd8a Mon Sep 17 00:00:00 2001
From: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com>
Date: Mon, 29 Jan 2024 14:42:04 +0200
Subject: [PATCH] feat: [AXIMST-24] Course unit - Sidebar buttons functional
(#134)
* feat: [AXIMST-24] sidebar buttons functional
* refactor: removed modal extra className
* refactor: refactoring after review
---
src/constants.js | 4 +
src/course-unit/CourseUnit.jsx | 4 +-
src/course-unit/CourseUnit.test.jsx | 171 +++++++++++++++++-
src/course-unit/constants.js | 6 +
src/course-unit/data/api.js | 25 +++
src/course-unit/data/thunk.js | 30 +++
src/course-unit/data/utils.js | 25 +++
src/course-unit/sidebar/Sidebar.scss | 1 +
.../sidebar-footer/ActionButtons.jsx | 26 +--
.../sidebar-footer/UnitVisibilityInfo.jsx | 23 ++-
.../components/sidebar-footer/index.jsx | 25 ++-
src/course-unit/sidebar/index.jsx | 58 +++++-
src/course-unit/sidebar/messages.js | 32 ++++
.../export-modal-error/ExportModalError.jsx | 7 +-
.../index.jsx} | 20 +-
src/i18n/messages/ar.json | 31 ++++
src/i18n/messages/de.json | 33 +++-
src/i18n/messages/de_DE.json | 33 +++-
src/i18n/messages/es_419.json | 33 +++-
src/i18n/messages/fa_IR.json | 33 +++-
src/i18n/messages/fr.json | 33 +++-
src/i18n/messages/fr_CA.json | 33 +++-
src/i18n/messages/hi.json | 33 +++-
src/i18n/messages/it.json | 33 +++-
src/i18n/messages/it_IT.json | 33 +++-
src/i18n/messages/pt.json | 33 +++-
src/i18n/messages/pt_PT.json | 33 +++-
src/i18n/messages/ru.json | 33 +++-
src/i18n/messages/uk.json | 33 +++-
src/i18n/messages/zh_CN.json | 33 +++-
30 files changed, 896 insertions(+), 54 deletions(-)
rename src/generic/{modal-error/ModalError.jsx => modal-notification/index.jsx} (72%)
diff --git a/src/constants.js b/src/constants.js
index eb1b17b37..2913884a9 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -26,6 +26,10 @@ export const NOTIFICATION_MESSAGES = {
deleting: 'Deleting',
copying: 'Copying',
pasting: 'Pasting',
+ discardChanges: 'Discarding changes',
+ publishing: 'Publishing',
+ hidingFromStudents: 'Hiding from students',
+ makingVisibleToStudents: 'Making visible to students',
empty: '',
};
diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx
index 682e991b1..f94bf3497 100644
--- a/src/course-unit/CourseUnit.jsx
+++ b/src/course-unit/CourseUnit.jsx
@@ -113,8 +113,8 @@ const CourseUnit = ({ courseId }) => {
-
-
+
+
diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx
index 555aa1028..75d657862 100644
--- a/src/course-unit/CourseUnit.test.jsx
+++ b/src/course-unit/CourseUnit.test.jsx
@@ -1,6 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import {
- act, render, waitFor, fireEvent,
+ act, render, waitFor, fireEvent, within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -17,6 +17,7 @@ import {
postXBlockBaseApiUrl,
} from './data/api';
import {
+ editCourseUnitVisibilityAndData,
fetchCourseSectionVerticalData,
fetchCourseUnitQuery,
fetchCourseVerticalChildrenData,
@@ -40,6 +41,7 @@ import { extractCourseUnitId } from './sidebar/utils';
import deleteModalMessages from '../generic/delete-modal/messages';
import courseXBlockMessages from './course-xblock/messages';
import addComponentMessages from './add-component/messages';
+import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
let axiosMock;
let store;
@@ -47,6 +49,7 @@ const courseId = '123';
const blockId = '567890';
const unitDisplayName = courseUnitIndexMock.metadata.display_name;
const mockedUsedNavigate = jest.fn();
+const userName = 'edx';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -418,4 +421,170 @@ describe('', () => {
expect(getByText('New Cloned XBlock')).toBeInTheDocument();
});
});
+
+ it('should toggle visibility and update course unit state accordingly', async () => {
+ const { getByRole, getByTestId } = render();
+ let courseUnitSidebar;
+ let draftUnpublishedChangesHeading;
+ let visibilityCheckbox;
+
+ await waitFor(() => {
+ courseUnitSidebar = getByTestId('course-unit-sidebar');
+
+ draftUnpublishedChangesHeading = within(courseUnitSidebar)
+ .getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage);
+ expect(draftUnpublishedChangesHeading).toBeInTheDocument();
+
+ visibilityCheckbox = within(courseUnitSidebar)
+ .getByLabelText(sidebarMessages.visibilityCheckboxTitle.defaultMessage);
+ expect(visibilityCheckbox).not.toBeChecked();
+
+ userEvent.click(visibilityCheckbox);
+ });
+
+ axiosMock
+ .onPost(getXBlockBaseApiUrl(blockId), {
+ publish: PUBLISH_TYPES.republish,
+ metadata: { visible_to_staff_only: true },
+ })
+ .reply(200, { dummy: 'value' });
+ axiosMock
+ .onGet(getCourseUnitApiUrl(blockId))
+ .reply(200, {
+ ...courseUnitIndexMock,
+ visibility_state: UNIT_VISIBILITY_STATES.staffOnly,
+ has_explicit_staff_lock: true,
+ });
+
+ await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, true), store.dispatch);
+
+ expect(visibilityCheckbox).toBeChecked();
+ expect(within(courseUnitSidebar)
+ .getByText(sidebarMessages.sidebarTitleVisibleToStaffOnly.defaultMessage)).toBeInTheDocument();
+ expect(within(courseUnitSidebar)
+ .getByText(sidebarMessages.visibilityStaffOnlyTitle.defaultMessage)).toBeInTheDocument();
+
+ userEvent.click(visibilityCheckbox);
+
+ const modalNotification = 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' });
+
+ expect(makeVisibilityBtn).toBeInTheDocument();
+ expect(cancelBtn).toBeInTheDocument();
+ expect(headingElement).toBeInTheDocument();
+ expect(within(modalNotification)
+ .getByText(sidebarMessages.modalMakeVisibilityDescription.defaultMessage)).toBeInTheDocument();
+
+ userEvent.click(makeVisibilityBtn);
+
+ axiosMock
+ .onPost(getXBlockBaseApiUrl(blockId), {
+ publish: PUBLISH_TYPES.republish,
+ metadata: { visible_to_staff_only: null },
+ })
+ .reply(200, { dummy: 'value' });
+ axiosMock
+ .onGet(getCourseUnitApiUrl(blockId))
+ .reply(200, courseUnitIndexMock);
+
+ 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();
+ });
+
+ it('should publish course unit after click on the "Publish" button', async () => {
+ const { getByTestId } = render();
+ let courseUnitSidebar;
+ let publishBtn;
+
+ await waitFor(() => {
+ courseUnitSidebar = getByTestId('course-unit-sidebar');
+ publishBtn = within(courseUnitSidebar).queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage });
+ expect(publishBtn).toBeInTheDocument();
+
+ userEvent.click(publishBtn);
+ });
+
+ axiosMock
+ .onPost(getXBlockBaseApiUrl(blockId), {
+ publish: PUBLISH_TYPES.makePublic,
+ })
+ .reply(200, { dummy: 'value' });
+ axiosMock
+ .onGet(getCourseUnitApiUrl(blockId))
+ .reply(200, {
+ ...courseUnitIndexMock,
+ visibility_state: UNIT_VISIBILITY_STATES.live,
+ has_changes: false,
+ published_by: userName,
+ });
+
+ await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
+
+ expect(within(courseUnitSidebar)
+ .getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
+ expect(within(courseUnitSidebar).getByText(
+ sidebarMessages.publishLastPublished.defaultMessage
+ .replace('{publishedOn}', courseUnitIndexMock.published_on)
+ .replace('{publishedBy}', userName),
+ )).toBeInTheDocument();
+ expect(publishBtn).not.toBeInTheDocument();
+ });
+
+ it('should discard changes after click on the "Discard changes" button', async () => {
+ const { getByTestId, getByRole } = render();
+ let courseUnitSidebar;
+ let discardChangesBtn;
+
+ await waitFor(() => {
+ courseUnitSidebar = getByTestId('course-unit-sidebar');
+
+ const draftUnpublishedChangesHeading = within(courseUnitSidebar)
+ .getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage);
+ expect(draftUnpublishedChangesHeading).toBeInTheDocument();
+ discardChangesBtn = within(courseUnitSidebar).queryByRole('button', { name: sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage });
+ expect(discardChangesBtn).toBeInTheDocument();
+
+ userEvent.click(discardChangesBtn);
+
+ const modalNotification = getByRole('dialog');
+ expect(modalNotification).toBeInTheDocument();
+ expect(within(modalNotification)
+ .getByText(sidebarMessages.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' });
+ expect(headingElement).toBeInTheDocument();
+ const actionBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalDiscardUnitChangesActionButtonText.defaultMessage });
+ expect(actionBtn).toBeInTheDocument();
+
+ userEvent.click(actionBtn);
+ });
+
+ axiosMock
+ .onPost(getXBlockBaseApiUrl(blockId), {
+ publish: PUBLISH_TYPES.discardChanges,
+ })
+ .reply(200, { dummy: 'value' });
+ axiosMock
+ .onGet(getCourseUnitApiUrl(blockId))
+ .reply(200, {
+ ...courseUnitIndexMock, published: true, has_changes: false,
+ });
+
+ await executeThunk(editCourseUnitVisibilityAndData(
+ blockId,
+ PUBLISH_TYPES.discardChanges,
+ true,
+ ), store.dispatch);
+
+ expect(within(courseUnitSidebar)
+ .getByText(sidebarMessages.sidebarTitlePublishedNotYetReleased.defaultMessage)).toBeInTheDocument();
+ expect(discardChangesBtn).not.toBeInTheDocument();
+ });
});
diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js
index ecf07815f..c32d12379 100644
--- a/src/course-unit/constants.js
+++ b/src/course-unit/constants.js
@@ -62,3 +62,9 @@ export const COLORS = {
BLACK: '#000',
GREEN: '#0D7D4D',
};
+
+export const PUBLISH_TYPES = {
+ republish: 'republish',
+ discardChanges: 'discard_changes',
+ makePublic: 'make_public',
+};
diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js
index 43992f576..f7adc33b4 100644
--- a/src/course-unit/data/api.js
+++ b/src/course-unit/data/api.js
@@ -2,6 +2,7 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { PUBLISH_TYPES } from '../constants';
import {
normalizeLearningSequencesData,
normalizeMetadata,
@@ -130,6 +131,30 @@ export async function createCourseXblock({
return data;
}
+/**
+ * Handles the visibility and data of a course unit, such as publishing, resetting to default values,
+ * and toggling visibility to students.
+ * @param {string} unitId - The ID of the course unit.
+ * @param {string} type - The action type (e.g., PUBLISH_TYPES.discardChanges).
+ * @param {boolean} isVisible - The visibility status for students.
+ * @returns {Promise} A promise that resolves with the response data.
+ */
+export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible) {
+ const body = {
+ publish: type,
+ ...(type === PUBLISH_TYPES.republish ? {
+ metadata: {
+ visible_to_staff_only: isVisible,
+ },
+ } : {}),
+ };
+
+ const { data } = await getAuthenticatedHttpClient()
+ .post(getXBlockBaseApiUrl(unitId), body);
+
+ return camelCaseObject(data);
+}
+
/**
* Get an object containing course section vertical children data.
* @param {string} itemId
diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js
index b6e247784..7117acd43 100644
--- a/src/course-unit/data/thunk.js
+++ b/src/course-unit/data/thunk.js
@@ -18,6 +18,7 @@ import {
getCourseSectionVerticalData,
createCourseXblock,
getCourseVerticalChildren,
+ handleCourseUnitVisibilityAndData,
deleteUnitItem,
duplicateUnitItem,
} from './api';
@@ -37,9 +38,11 @@ import {
updateLoadingCourseXblockStatus,
updateCourseVerticalChildren,
updateCourseVerticalChildrenLoadingStatus,
+ updateQueryPendingStatus,
deleteXBlock,
duplicateXBlock,
} from './slice';
+import { getNotificationMessage } from './utils';
export function fetchCourseUnitQuery(courseId) {
return async (dispatch) => {
@@ -117,6 +120,31 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) {
};
}
+export function editCourseUnitVisibilityAndData(itemId, type, isVisible) {
+ return async (dispatch) => {
+ dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
+ dispatch(updateQueryPendingStatus(true));
+ const notificationMessage = getNotificationMessage(type, isVisible);
+ dispatch(showProcessingNotification(notificationMessage));
+
+ try {
+ await handleCourseUnitVisibilityAndData(itemId, type, isVisible).then(async (result) => {
+ if (result) {
+ const courseUnit = await getCourseUnitData(itemId);
+ dispatch(fetchCourseItemSuccess(courseUnit));
+ const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
+ dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
+ dispatch(hideProcessingNotification());
+ dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
+ }
+ });
+ } catch (error) {
+ dispatch(hideProcessingNotification());
+ dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
+ }
+ };
+}
+
export function fetchCourse(courseId) {
return async (dispatch) => {
dispatch(fetchCourseRequest({ courseId }));
@@ -221,6 +249,8 @@ export function createNewCourseXBlock(body, callback, blockId) {
callback(result);
}
}
+ const courseUnit = await getCourseUnitData(blockId);
+ dispatch(fetchCourseItemSuccess(courseUnit));
});
} catch (error) {
dispatch(hideProcessingNotification());
diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js
index c41db3a85..afd6e0a03 100644
--- a/src/course-unit/data/utils.js
+++ b/src/course-unit/data/utils.js
@@ -1,5 +1,8 @@
import { camelCaseObject } from '@edx/frontend-platform';
+import { NOTIFICATION_MESSAGES } from '../../constants';
+import { PUBLISH_TYPES } from '../constants';
+
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
@@ -211,3 +214,25 @@ export function normalizeCourseSectionVerticalData(metadata) {
})),
};
}
+
+/**
+ * Get the notification message based on the publishing type and visibility.
+ * @param {string} type - The publishing type.
+ * @param {boolean} isVisible - The visibility status.
+ * @returns {string} The corresponding notification message.
+ */
+export const getNotificationMessage = (type, isVisible) => {
+ let notificationMessage;
+
+ if (type === PUBLISH_TYPES.discardChanges) {
+ notificationMessage = NOTIFICATION_MESSAGES.discardChanges;
+ } else if (type === PUBLISH_TYPES.makePublic) {
+ notificationMessage = NOTIFICATION_MESSAGES.publishing;
+ } else if (type === PUBLISH_TYPES.republish && !isVisible) {
+ notificationMessage = NOTIFICATION_MESSAGES.makingVisibleToStudents;
+ } else if (type === PUBLISH_TYPES.republish && isVisible) {
+ notificationMessage = NOTIFICATION_MESSAGES.hidingFromStudents;
+ }
+
+ return notificationMessage;
+};
diff --git a/src/course-unit/sidebar/Sidebar.scss b/src/course-unit/sidebar/Sidebar.scss
index 537e52108..eb4bd0ada 100644
--- a/src/course-unit/sidebar/Sidebar.scss
+++ b/src/course-unit/sidebar/Sidebar.scss
@@ -31,6 +31,7 @@
.course-unit-sidebar-location-description {
font-size: $font-size-xs;
line-height: $line-height-base;
+ word-break: break-word;
}
.course-unit-sidebar-visibility-copy {
diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx
index 3fb98f5cc..ac0a63287 100644
--- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx
+++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx
@@ -1,3 +1,4 @@
+import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { Button } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -5,7 +6,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { getCourseUnitData } from '../../../data/selectors';
import messages from '../../messages';
-const ActionButtons = () => {
+const ActionButtons = ({ openDiscardModal, handlePublishing }) => {
const intl = useIntl();
const {
published,
@@ -16,29 +17,17 @@ const ActionButtons = () => {
return (
<>
{(!published || hasChanges) && (
-