diff --git a/.env.development b/.env.development
index 76be662fd..b7006f0ad 100644
--- a/.env.development
+++ b/.env.development
@@ -41,7 +41,7 @@ ENABLE_NEW_ADVANCED_SETTINGS_PAGE = false
ENABLE_NEW_IMPORT_PAGE = false
ENABLE_NEW_EXPORT_PAGE = false
ENABLE_UNIT_PAGE = false
-ENABLE_NEW_CUSTOM_PAGES = false
+ENABLE_NEW_CUSTOM_PAGES = true
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false
BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID=''
diff --git a/src/CourseAuthoringPage.jsx b/src/CourseAuthoringPage.jsx
index 8c524c0be..5805bed66 100644
--- a/src/CourseAuthoringPage.jsx
+++ b/src/CourseAuthoringPage.jsx
@@ -10,7 +10,7 @@ import Header from './studio-header/Header';
import { fetchCourseDetail } from './data/thunks';
import { useModel } from './generic/model-store';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
-import { getCourseAppsApiStatus, getLoadingStatus } from './pages-and-resources/data/selectors';
+import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors';
import { RequestStatus } from './data/constants';
import Loading from './generic/Loading';
@@ -56,31 +56,33 @@ const CourseAuthoringPage = ({ courseId, children }) => {
const courseOrg = courseDetail ? courseDetail.org : null;
const courseTitle = courseDetail ? courseDetail.name : courseId;
const courseAppsApiStatus = useSelector(getCourseAppsApiStatus);
- const inProgress = useSelector(getLoadingStatus) === RequestStatus.IN_PROGRESS;
+ const inProgress = useSelector(state => state.courseDetail.status) === RequestStatus.IN_PROGRESS;
const { pathname } = useLocation();
+ const showHeader = !pathname.includes('/editor');
+
if (courseAppsApiStatus === RequestStatus.DENIED) {
return (
);
}
-
return (
{/* While V2 Editors are tempoarily served from thier own pages
using url pattern containing /editor/,
we shouldn't have the header and footer on these pages.
This functionality will be removed in TNL-9591 */}
- {inProgress ? !pathname.includes('/editor/') &&
- : (
+ {inProgress ? showHeader &&
+ : (showHeader && (
- )}
+ )
+ )}
{children}
- {!inProgress &&
}
+ {!inProgress && showHeader &&
}
);
};
diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx
index f40c8da0d..6f08a711e 100644
--- a/src/CourseAuthoringRoutes.jsx
+++ b/src/CourseAuthoringRoutes.jsx
@@ -8,6 +8,7 @@ import { PagesAndResources } from './pages-and-resources';
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
import EditorContainer from './editors/EditorContainer';
import VideoSelectorContainer from './selectors/VideoSelectorContainer';
+import CustomPages from './custom-pages';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -60,13 +61,13 @@ const CourseAuthoringRoutes = ({ courseId }) => {
-
+
{process.env.ENABLE_NEW_CUSTOM_PAGES === 'true'
&& (
-
+
)}
-
+
{process.env.ENABLE_UNIT_PAGE === 'true'
&& (
diff --git a/src/CourseAuthoringRoutes.test.jsx b/src/CourseAuthoringRoutes.test.jsx
index e8b214d2b..99adaeb8e 100644
--- a/src/CourseAuthoringRoutes.test.jsx
+++ b/src/CourseAuthoringRoutes.test.jsx
@@ -11,6 +11,7 @@ const pagesAndResourcesMockText = 'Pages And Resources';
const proctoredExamSeetingsMockText = 'Proctored Exam Settings';
const editorContainerMockText = 'Editor Container';
const videoSelectorContainerMockText = 'Video Selector Container';
+const customPagesMockText = 'Custom Pages';
let store;
const mockComponentFn = jest.fn();
jest.mock('react-router', () => ({
@@ -35,6 +36,10 @@ jest.mock('./selectors/VideoSelectorContainer', () => (props) => {
mockComponentFn(props);
return videoSelectorContainerMockText;
});
+jest.mock('./custom-pages/CustomPages', () => (props) => {
+ mockComponentFn(props);
+ return customPagesMockText;
+});
describe('', () => {
beforeEach(() => {
diff --git a/src/custom-pages/CustomPageCard.jsx b/src/custom-pages/CustomPageCard.jsx
new file mode 100644
index 000000000..c0abfa7b6
--- /dev/null
+++ b/src/custom-pages/CustomPageCard.jsx
@@ -0,0 +1,134 @@
+import React, { useContext } from 'react';
+import PropTypes from 'prop-types';
+import { history } from '@edx/frontend-platform';
+import { intlShape, injectIntl } from '@edx/frontend-platform/i18n';
+import {
+ ActionRow,
+ IconButtonWithTooltip,
+ Icon,
+ AlertModal,
+ Button,
+ StatefulButton,
+ useToggle,
+} from '@edx/paragon';
+import {
+ DeleteOutline,
+ EditOutline,
+ SpinnerSimple,
+ Visibility,
+ VisibilityOff,
+} from '@edx/paragon/icons';
+import { deleteSingleCustomPage, updateCustomPageVisibility } from './data/thunks';
+import messages from './messages';
+import { CustomPagesContext } from './CustomPagesProvider';
+
+const CustomPageCard = ({
+ page,
+ dispatch,
+ deletePageStatus,
+ setCurrentPage,
+ // injected
+ intl,
+}) => {
+ const [isDeleteConfirmationOpen, openDeleteConfirmation, closeDeleteConfirmation] = useToggle(false);
+ const { path: customPagesPath } = useContext(CustomPagesContext);
+ const handleDelete = () => {
+ dispatch(deleteSingleCustomPage({
+ blockId: page.id,
+ closeConfirmation: closeDeleteConfirmation,
+ }));
+ };
+
+ const toggleVisibilty = () => {
+ dispatch(updateCustomPageVisibility({
+ blockId: page.id,
+ metadata: { course_staff_only: !page.courseStaffOnly },
+ }));
+ };
+ const handleEditOpen = () => {
+ setCurrentPage(page.id);
+ history.push(`${customPagesPath}/editor`);
+ };
+
+ const deletePageStateProps = {
+ labels: {
+ default: intl.formatMessage(messages.deletePageLabel),
+ pending: intl.formatMessage(messages.deletingPageBodyLabel),
+ },
+ icons: {
+ pending: ,
+ },
+ disabledStates: ['pending'],
+ };
+
+ return (
+ <>
+
+
+ {page?.name || intl.formatMessage(messages.newPageTitle)}
+
+
+
+
+
+
+
+
+ {intl.formatMessage(messages.cancelButtonLabel)}
+
+
+
+ )}
+ >
+ {intl.formatMessage(messages.deleteConfirmationMessage)}
+
+ >
+ );
+};
+
+CustomPageCard.propTypes = {
+ page: PropTypes.shape({
+ name: PropTypes.string,
+ id: PropTypes.string.isRequired,
+ courseStaffOnly: PropTypes.bool.isRequired,
+ }).isRequired,
+ dispatch: PropTypes.func.isRequired,
+ deletePageStatus: PropTypes.string.isRequired,
+ setCurrentPage: PropTypes.func.isRequired,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(CustomPageCard);
diff --git a/src/custom-pages/CustomPageCard.test.jsx b/src/custom-pages/CustomPageCard.test.jsx
new file mode 100644
index 000000000..0fa1b9bb4
--- /dev/null
+++ b/src/custom-pages/CustomPageCard.test.jsx
@@ -0,0 +1,148 @@
+import {
+ render,
+ act,
+ fireEvent,
+ screen,
+ within,
+} from '@testing-library/react';
+
+import {
+ initializeMockApp,
+} from '@edx/frontend-platform';
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import initializeStore from '../store';
+import { executeThunk } from '../utils';
+import { RequestStatus } from '../data/constants';
+import CustomPageCard from './CustomPageCard';
+import {
+ generateUpdateVisiblityApiResponse,
+ courseId,
+ initialState,
+ generateXblockData,
+} from './factories/mockApiResponses';
+
+import { deleteSingleCustomPage, updateCustomPageVisibility } from './data/thunks';
+import { getApiBaseUrl } from './data/api';
+import messages from './messages';
+import CustomPagesProvider from './CustomPagesProvider';
+
+const defaultProps = {
+ courseId,
+ page: {
+ id: 'mOckID1',
+ name: 'test',
+ courseStaffOnly: false,
+ },
+ dispatch: jest.fn(),
+ deletePageStatus: '',
+ setCurrentPage: jest.fn(),
+};
+
+let axiosMock;
+let store;
+
+const renderComponent = (courseStaffOnly) => {
+ render(
+
+
+
+
+
+
+ ,
+ );
+};
+
+const mockStore = async ({
+ blockId,
+ visibility,
+}) => {
+ const xblockEditUrl = `${getApiBaseUrl()}/xblock/${blockId}`;
+
+ axiosMock.onDelete(xblockEditUrl).reply(204);
+ axiosMock.onPut(xblockEditUrl).reply(200, generateUpdateVisiblityApiResponse(blockId, visibility));
+ axiosMock.onGet(xblockEditUrl).reply(200, generateXblockData(blockId));
+
+ await executeThunk(deleteSingleCustomPage({
+ blockId,
+ closeConfirmation: jest.fn(),
+ }), store.dispatch);
+ await executeThunk(updateCustomPageVisibility({
+ blockId,
+ metadata: { courseStaffOnly: visibility },
+ }), store.dispatch);
+ };
+
+describe('CustomPageCard', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: false,
+ roles: [],
+ },
+ });
+ store = initializeStore(initialState);
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ });
+ it('should have title from redux store', async () => {
+ renderComponent();
+ const { getByText } = within(screen.getByTestId('card-title'));
+ expect(getByText('test')).toBeInTheDocument();
+ });
+ it('should contain icon row', () => {
+ renderComponent();
+ expect(screen.getByTestId('edit-modal-icon')).toBeVisible();
+ expect(screen.getByTestId('visibility-toggle-icon')).toBeVisible();
+ expect(screen.getByTestId('delete-modal-icon')).toBeVisible();
+ });
+ it('should open delete confirmation modal and handle cancel', () => {
+ renderComponent();
+ const deleteButton = screen.getByTestId('delete-modal-icon');
+ fireEvent.click(deleteButton);
+ expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
+ const cancelButton = screen.getByText(messages.cancelButtonLabel.defaultMessage);
+ fireEvent.click(cancelButton);
+ expect(screen.queryByText(messages.deleteConfirmationTitle.defaultMessage)).toBeNull();
+ });
+ it('should open delete confirmation modal and handle delete', async () => {
+ renderComponent();
+ const deleteButton = screen.getByTestId('delete-modal-icon');
+ fireEvent.click(deleteButton);
+ expect(screen.getByText(messages.deleteConfirmationTitle.defaultMessage)).toBeVisible();
+ expect(screen.queryByTestId('delete-confirmation-alert-modal')).toBeNull();
+ const confirmButton = screen.getByText(messages.deletePageLabel.defaultMessage);
+ await mockStore({ blockId: 'mOckID1' });
+ await act(async () => { fireEvent.click(confirmButton); });
+ const deleteStatus = store.getState().customPages.deletingStatus;
+ expect(deleteStatus).toEqual(RequestStatus.SUCCESSFUL);
+ });
+ it('should open edit modal', async () => {
+ renderComponent();
+ const editButton = screen.getByTestId('edit-modal-icon');
+ await mockStore({ blockId: 'mOckID1' });
+ await act(async () => { fireEvent.click(editButton); });
+ expect(defaultProps.setCurrentPage).toHaveBeenCalled();
+ });
+ it('should open update courseStaffOnly to true', async () => {
+ renderComponent(false);
+ const visibilityButton = screen.getByTestId('visibility-toggle-icon');
+ await mockStore({ blockId: 'mOckID1', visibility: true });
+ await act(async () => { fireEvent.click(visibilityButton); });
+ const { courseStaffOnly } = store.getState().models.customPages[defaultProps.page.id];
+ expect(courseStaffOnly).toBeTruthy();
+ });
+ it('should open update courseStaffOnly to false', async () => {
+ renderComponent(true);
+ const visibilityButton = screen.getByTestId('visibility-toggle-icon');
+ await mockStore({ blockId: 'mOckID1', visibility: false });
+ await act(async () => { fireEvent.click(visibilityButton); });
+ const { courseStaffOnly } = store.getState().models.customPages[defaultProps.page.id];
+ expect(courseStaffOnly).toBeFalsy();
+ });
+});
diff --git a/src/custom-pages/CustomPages.jsx b/src/custom-pages/CustomPages.jsx
new file mode 100644
index 000000000..87e3ead4c
--- /dev/null
+++ b/src/custom-pages/CustomPages.jsx
@@ -0,0 +1,264 @@
+import React, { useEffect, useContext, useState } from 'react';
+import PropTypes from 'prop-types';
+import { Switch, useRouteMatch } from 'react-router';
+import { useDispatch, useSelector } from 'react-redux';
+import { history } from '@edx/frontend-platform';
+import { AppContext, PageRoute } from '@edx/frontend-platform/react';
+import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
+import {
+ ActionRow,
+ Breadcrumb,
+ Button,
+ Layout,
+ Hyperlink,
+ StatefulButton,
+ Icon,
+ useToggle,
+ Image,
+ ModalDialog,
+} from '@edx/paragon';
+import { Add, SpinnerSimple } from '@edx/paragon/icons';
+import {
+ DraggableList,
+ SortableItem,
+ ErrorAlert,
+} from '@edx/frontend-lib-content-components';
+
+import { RequestStatus } from '../data/constants';
+import { useModels } from '../generic/model-store';
+import { getLoadingStatus, getSavingStatus } from './data/selectors';
+import {
+ addSingleCustomPage,
+ fetchCustomPages,
+ updatePageOrder,
+ updateSingleCustomPage,
+} from './data/thunks';
+
+import previewLmsStaticPages from './data/images/previewLmsStaticPages.png';
+import CustomPageCard from './CustomPageCard';
+import messages from './messages';
+import CustomPagesProvider from './CustomPagesProvider';
+import EditModal from './EditModal';
+
+const CustomPages = ({
+ courseId,
+ // injected
+ intl,
+}) => {
+ const dispatch = useDispatch();
+ const [orderedPages, setOrderedPages] = useState([]);
+ const [currentPage, setCurrentPage] = useState();
+ const [isOpen, open, close] = useToggle(false);
+ const [isEditModalOpen, openEditModal, closeEditModal] = useToggle(false);
+
+ const { config } = useContext(AppContext);
+ const { path, url } = useRouteMatch();
+ const learningCourseURL = `${config.LEARNING_BASE_URL}/course/${courseId}`;
+
+ useEffect(() => {
+ dispatch(fetchCustomPages(courseId));
+ }, [courseId]);
+
+ const customPagesIds = useSelector(state => state.customPages.customPagesIds);
+ const addPageStatus = useSelector(state => state.customPages.addingStatus);
+ const deletePageStatus = useSelector(state => state.customPages.deletingStatus);
+ const savingStatus = useSelector(getSavingStatus);
+ const loadingStatus = useSelector(getLoadingStatus);
+
+ const pages = useModels('customPages', customPagesIds);
+
+ const handleAddPage = () => { dispatch(addSingleCustomPage(courseId)); };
+ const handleReorder = () => (newPageOrder) => {
+ dispatch(updatePageOrder(courseId, newPageOrder));
+ };
+ const handleEditClose = () => (content) => {
+ history.push(url);
+ if (!content?.metadata) {
+ closeEditModal();
+ return;
+ }
+ dispatch(updateSingleCustomPage({
+ blockId: currentPage,
+ metadata: { displayName: content.metadata.display_name },
+ onClose: closeEditModal,
+ setCurrentPage,
+ }));
+ };
+
+ const addPageStateProps = {
+ labels: {
+ default: intl.formatMessage(messages.addPageBodyLabel),
+ pending: intl.formatMessage(messages.addingPageBodyLabel),
+ },
+ icons: {
+ default: ,
+ pending: ,
+ },
+ disabledStates: ['pending'],
+ };
+
+ useEffect(() => { setOrderedPages(pages); }, [customPagesIds, savingStatus]);
+ if (loadingStatus === RequestStatus.IN_PROGRESS) {
+ // eslint-disable-next-line react/jsx-no-useless-fragment
+ return (<>>);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {intl.formatMessage(messages.errorAlertMessage, { actionName: 'delete' })}
+
+
+ {intl.formatMessage(messages.errorAlertMessage, { actionName: 'add' })}
+
+
+ {intl.formatMessage(messages.errorAlertMessage, { actionName: 'save' })}
+
+
+
+
+
+ {orderedPages.map((page) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {currentPage && (
+
+ )}
+
+
+
+
+ );
+};
+
+CustomPages.propTypes = {
+ courseId: PropTypes.string.isRequired,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(CustomPages);
diff --git a/src/custom-pages/CustomPages.test.jsx b/src/custom-pages/CustomPages.test.jsx
new file mode 100644
index 000000000..e6739e933
--- /dev/null
+++ b/src/custom-pages/CustomPages.test.jsx
@@ -0,0 +1,120 @@
+import {
+ render,
+ act,
+ fireEvent,
+ screen,
+} from '@testing-library/react';
+import ReactDOM from 'react-dom';
+
+import { initializeMockApp } from '@edx/frontend-platform';
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import initializeStore from '../store';
+import { executeThunk } from '../utils';
+import { RequestStatus } from '../data/constants';
+import CustomPages from './CustomPages';
+import {
+ generateFetchPageApiResponse,
+ generateNewPageApiResponse,
+ courseId,
+ initialState,
+} from './factories/mockApiResponses';
+
+import {
+ addSingleCustomPage,
+ fetchCustomPages,
+ updatePageOrder,
+} from './data/thunks';
+import { getApiBaseUrl, getTabHandlerUrl } from './data/api';
+import messages from './messages';
+
+let axiosMock;
+let store;
+ReactDOM.createPortal = jest.fn(node => node);
+
+const renderComponent = () => {
+ render(
+
+
+
+
+ ,
+ );
+};
+
+const mockStore = async () => {
+ const xblockAddUrl = `${getApiBaseUrl()}/xblock/`;
+ const reorderUrl = `${getTabHandlerUrl(courseId)}/reorder`;
+ const fetchPagesUrl = `${getTabHandlerUrl(courseId)}`;
+
+ axiosMock.onGet(fetchPagesUrl).reply(200, generateFetchPageApiResponse());
+ axiosMock.onPost(reorderUrl).reply(204);
+ axiosMock.onPut(xblockAddUrl).reply(200, generateNewPageApiResponse());
+
+ await executeThunk(fetchCustomPages(courseId), store.dispatch);
+ await executeThunk(addSingleCustomPage(courseId), store.dispatch);
+ await executeThunk(updatePageOrder(courseId, [{ id: 'mOckID2' }, { id: 'mOckID1' }]), store.dispatch);
+ };
+
+describe('CustomPages', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: false,
+ roles: [],
+ },
+ });
+ store = initializeStore(initialState);
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ });
+ it('should have breadecrumbs', async () => {
+ renderComponent();
+ await mockStore();
+ expect(screen.getByLabelText('Custom Page breadcrumbs')).toBeVisible();
+ });
+ it('should contain header row with title, add button and view live button', async () => {
+ renderComponent();
+ await mockStore();
+ expect(screen.getByText(messages.heading.defaultMessage)).toBeVisible();
+ expect(screen.getByTestId('header-add-button')).toBeVisible();
+ expect(screen.getByTestId('header-view-live-button')).toBeVisible();
+ });
+ it('should add new page when "add a new page button" is clicked', async () => {
+ renderComponent();
+ await mockStore();
+ const addButton = screen.getByTestId('body-add-button');
+ expect(addButton).toBeVisible();
+ await act(async () => { fireEvent.click(addButton); });
+ const addStatus = store.getState().customPages.addingStatus;
+ expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
+ });
+ it('should open student view modal when "add a new page button" is clicked', async () => {
+ renderComponent();
+ await mockStore();
+ const viewButton = screen.getByTestId('student-view-example-button');
+ expect(viewButton).toBeVisible();
+ expect(screen.queryByLabelText(messages.studentViewModalTitle.defaultMessage)).toBeNull();
+ fireEvent.click(viewButton);
+ expect(screen.getByText(messages.studentViewModalTitle.defaultMessage)).toBeVisible();
+ });
+ it('should update page order on drag', async () => {
+ renderComponent();
+ await mockStore();
+ const buttons = await screen.queryAllByRole('button');
+ const draggableButton = buttons[9];
+ expect(draggableButton).toBeVisible();
+ await act(async () => {
+ fireEvent.click(draggableButton);
+ fireEvent.keyDown(draggableButton, { key: '' });
+ fireEvent.keyDown(draggableButton, { key: 'ArrowDown' });
+ fireEvent.keyDown(draggableButton, { key: '' });
+ });
+ const saveStatus = store.getState().customPages.savingStatus;
+ expect(saveStatus).toEqual(RequestStatus.SUCCESSFUL);
+ });
+});
diff --git a/src/custom-pages/CustomPagesProvider.jsx b/src/custom-pages/CustomPagesProvider.jsx
new file mode 100644
index 000000000..8ac4a4d73
--- /dev/null
+++ b/src/custom-pages/CustomPagesProvider.jsx
@@ -0,0 +1,25 @@
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+
+export const CustomPagesContext = React.createContext({});
+
+const CustomPagesProvider = ({ courseId, children }) => {
+ const contextValue = useMemo(() => ({
+ courseId,
+ path: `/course/${courseId}/custom-pages`,
+ }), []);
+ return (
+
+ {children}
+
+ );
+};
+
+CustomPagesProvider.propTypes = {
+ courseId: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+};
+
+export default CustomPagesProvider;
diff --git a/src/custom-pages/EditModal.jsx b/src/custom-pages/EditModal.jsx
new file mode 100644
index 000000000..134a4b865
--- /dev/null
+++ b/src/custom-pages/EditModal.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { getConfig } from '@edx/frontend-platform';
+
+import { EditorPage } from '@edx/frontend-lib-content-components';
+
+const EditModal = ({
+ pageId,
+ courseId,
+ onClose,
+}) => (
+
+
+
+);
+
+EditModal.propTypes = {
+ pageId: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ courseId: PropTypes.string.isRequired,
+};
+
+export default EditModal;
diff --git a/src/custom-pages/data/api.js b/src/custom-pages/data/api.js
new file mode 100644
index 000000000..d8b3e4a95
--- /dev/null
+++ b/src/custom-pages/data/api.js
@@ -0,0 +1,72 @@
+/* eslint-disable import/prefer-default-export */
+import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+ensureConfig([
+ 'STUDIO_BASE_URL',
+], 'Course Apps API service');
+
+export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
+export const getTabHandlerUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v0/tabs/${courseId}`;
+
+/**
+ * Fetches the course custom pages for provided course
+ * @param {string} courseId
+ * @returns {Promise<[{}]>}
+ */
+export async function getCustomPages(courseId) {
+ const { data } = await getAuthenticatedHttpClient()
+ .get(`${getTabHandlerUrl(courseId)}`);
+ return camelCaseObject(data);
+}
+
+/**
+ * Delete custom page for provided block.
+ * @param {blockId} courseId Course ID for the course to operate on
+
+ */
+export async function deleteCustomPage(blockId) {
+ await getAuthenticatedHttpClient()
+ .delete(`${getApiBaseUrl()}/xblock/${blockId}`);
+}
+
+/**
+ * Add custom page for provided block.
+ * @param {blockId} courseId Course ID for the course to operate on
+
+ */
+export async function addCustomPage(courseId) {
+ const v1CourseId = courseId.substring(7);
+ const courseBlockId = `block-${v1CourseId}+type@course+block@course`;
+ const { data } = await getAuthenticatedHttpClient()
+ .put(`${getApiBaseUrl()}/xblock/`, {
+ category: 'static_tab',
+ parent_locator: courseBlockId,
+ });
+ return camelCaseObject(data);
+}
+
+/**
+ * Update custom page html for provided block.
+ * @param {blockId} courseId Course ID for the course to operate on
+
+ */
+export async function updateCustomPage({ blockId, htmlString, metadata }) {
+ const { data } = await getAuthenticatedHttpClient()
+ .put(`${getApiBaseUrl()}/xblock/${blockId}`, {
+ id: blockId,
+ data: htmlString,
+ metadata,
+ });
+ return camelCaseObject(data);
+}
+
+/**
+ * Update order of custom pages.
+ * @param {blockId} courseId Course ID for the course to operate on
+
+ */
+export async function updateCustomPageOrder(courseId, tabs) {
+ await getAuthenticatedHttpClient()
+ .post(`${getTabHandlerUrl(courseId)}/reorder`, tabs);
+}
diff --git a/src/custom-pages/data/images/previewLmsStaticPages.png b/src/custom-pages/data/images/previewLmsStaticPages.png
new file mode 100644
index 000000000..3b05ae2ee
Binary files /dev/null and b/src/custom-pages/data/images/previewLmsStaticPages.png differ
diff --git a/src/custom-pages/data/selectors.js b/src/custom-pages/data/selectors.js
new file mode 100644
index 000000000..3381e9cc7
--- /dev/null
+++ b/src/custom-pages/data/selectors.js
@@ -0,0 +1,8 @@
+/* eslint-disable import/prefer-default-export */
+
+export const getLoadingStatus = (state) => state.customPages.loadingStatus;
+export const getSavingStatus = (state) => state.customPages.savingStatus;
+export const getCustomPagesApiStatus = (state) => state.customPages.customPagesApiStatus;
+// export const getCourseAppSettingValue = (setting) => (state) => (
+// state.pagesAndResources.courseAppSettings[setting]?.value
+// );
diff --git a/src/custom-pages/data/slice.js b/src/custom-pages/data/slice.js
new file mode 100644
index 000000000..cc1f24728
--- /dev/null
+++ b/src/custom-pages/data/slice.js
@@ -0,0 +1,54 @@
+/* eslint-disable no-param-reassign */
+import { createSlice } from '@reduxjs/toolkit';
+
+import { RequestStatus } from '../../data/constants';
+
+const slice = createSlice({
+ name: 'customPages',
+ initialState: {
+ customPagesIds: [],
+ loadingStatus: RequestStatus.IN_PROGRESS,
+ savingStatus: '',
+ addingStatus: 'default',
+ deletingStatus: '',
+ customPagesApiStatus: {},
+ },
+ reducers: {
+ setPageIds: (state, { payload }) => {
+ state.customPagesIds = payload.customPagesIds;
+ },
+ updateLoadingStatus: (state, { payload }) => {
+ state.loadingStatus = payload.status;
+ },
+ updateSavingStatus: (state, { payload }) => {
+ state.savingStatus = payload.status;
+ },
+ updateAddingStatus: (state, { payload }) => {
+ state.addingStatus = payload.status;
+ },
+ updateDeletingStatus: (state, { payload }) => {
+ state.deletingStatus = payload.status;
+ },
+ deleteCustomPageSuccess: (state, { payload }) => {
+ state.customPagesIds = state.customPagesIds.filter(id => id !== payload.customPageId);
+ },
+ addCustomPageSuccess: (state, { payload }) => {
+ state.customPagesIds = [...state.customPagesIds, payload.customPageId];
+ },
+ },
+});
+
+export const {
+ setPageIds,
+ updateLoadingStatus,
+ updateSavingStatus,
+ updateCustomPagesApiStatus,
+ deleteCustomPageSuccess,
+ updateDeletingStatus,
+ addCustomPageSuccess,
+ updateAddingStatus,
+} = slice.actions;
+
+export const {
+ reducer,
+} = slice;
diff --git a/src/custom-pages/data/thunks.js b/src/custom-pages/data/thunks.js
new file mode 100644
index 000000000..620c68484
--- /dev/null
+++ b/src/custom-pages/data/thunks.js
@@ -0,0 +1,160 @@
+import { RequestStatus } from '../../data/constants';
+import {
+ addModel,
+ addModels,
+ removeModel,
+ updateModel,
+ updateModels,
+ } from '../../generic/model-store';
+import {
+ getCustomPages,
+ deleteCustomPage,
+ addCustomPage,
+ updateCustomPage,
+ updateCustomPageOrder,
+} from './api';
+import {
+ setPageIds,
+ updateCustomPagesApiStatus,
+ updateLoadingStatus,
+ updateSavingStatus,
+ updateAddingStatus,
+ updateDeletingStatus,
+ deleteCustomPageSuccess,
+ addCustomPageSuccess,
+} from './slice';
+
+/* eslint-disable import/prefer-default-export */
+export function fetchCustomPages(courseId) {
+ return async (dispatch) => {
+ dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
+
+ try {
+ const customPages = await getCustomPages(courseId);
+
+ dispatch(addModels({ modelType: 'customPages', models: customPages }));
+ dispatch(setPageIds({
+ customPagesIds: customPages.map(page => page.id),
+ }));
+ dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ if (error.response && error.response.status === 403) {
+ dispatch(updateCustomPagesApiStatus({ status: RequestStatus.DENIED }));
+ }
+ dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
+ }
+ };
+}
+
+export function deleteSingleCustomPage({ blockId, closeConfirmation }) {
+ return async (dispatch) => {
+ dispatch(updateDeletingStatus({ status: RequestStatus.PENDING }));
+
+ try {
+ await deleteCustomPage(blockId);
+ dispatch(removeModel({ modelType: 'customPages', model: blockId }));
+ dispatch(deleteCustomPageSuccess({
+ customPageId: blockId,
+ }));
+ dispatch(updateDeletingStatus({ status: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ if (error.response && error.response.status === 403) {
+ dispatch(updateDeletingStatus({ status: RequestStatus.DENIED }));
+ }
+ dispatch(updateDeletingStatus({ status: RequestStatus.FAILED }));
+ }
+ closeConfirmation();
+ };
+}
+
+export function addSingleCustomPage(courseId) {
+ return async (dispatch) => {
+ dispatch(updateAddingStatus({ status: RequestStatus.PENDING }));
+
+ try {
+ const pageData = await addCustomPage(courseId);
+ dispatch(addModel({
+ modelType: 'customPages',
+ model: {
+ id: pageData.locator,
+ courseStaffOnly: false,
+ ...pageData,
+ },
+ }));
+ dispatch(addCustomPageSuccess({
+ customPageId: pageData.locator,
+ }));
+ dispatch(updateAddingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ if (error.response && error.response.status === 403) {
+ dispatch(updateAddingStatus({ status: RequestStatus.DENIED }));
+ }
+ dispatch(updateAddingStatus({ status: RequestStatus.FAILED }));
+ }
+ };
+}
+
+export function updatePageOrder(courseId, pages) {
+ const tabs = [];
+ pages.forEach(page => {
+ const currentTab = {};
+ currentTab.tab_locator = page.id;
+ tabs.push(currentTab);
+ });
+ return async (dispatch) => {
+ try {
+ await updateCustomPageOrder(courseId, tabs);
+ dispatch(updateModels({ modelType: 'customPages', models: pages }));
+ dispatch(setPageIds({
+ customPagesIds: pages.map(page => page.id),
+ }));
+ dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ if (error.response && error.response.status === 403) {
+ dispatch(updateCustomPagesApiStatus({ status: RequestStatus.DENIED }));
+ }
+ dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
+ }
+ };
+}
+
+export function updateCustomPageVisibility({ blockId, metadata }) {
+ return async (dispatch) => {
+ dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
+
+ try {
+ const pageData = await updateCustomPage({ blockId, metadata });
+ dispatch(updateModel({
+ modelType: 'customPages',
+ model: {
+ id: blockId,
+ courseStaffOnly: pageData.metadata.courseStaffOnly,
+ },
+ }));
+ dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
+ }
+ };
+}
+
+export const updateSingleCustomPage = ({
+ blockId,
+ metadata,
+ setCurrentPage,
+}) => (dispatch) => {
+ dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
+ try {
+ dispatch(updateModel({
+ modelType: 'customPages',
+ model: {
+ id: blockId,
+ name: metadata.displayName,
+ },
+ }));
+ setCurrentPage(null);
+ dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
+ }
+};
diff --git a/src/custom-pages/factories/mockApiResponses.jsx b/src/custom-pages/factories/mockApiResponses.jsx
new file mode 100644
index 000000000..6671d13e8
--- /dev/null
+++ b/src/custom-pages/factories/mockApiResponses.jsx
@@ -0,0 +1,64 @@
+export const courseId = 'course-v1:edX+DemoX+Demo_Course';
+
+export const initialState = {
+ courseDetail: {
+ courseId,
+ status: 'sucessful',
+ },
+ customPages: {
+ customPagesIds: [
+ 'mOckID1',
+ ],
+ loadingStatus: 'successful',
+ savingStatus: '',
+ deletingStatus: '',
+ addingStatus: '',
+ customPagesApiStatus: {},
+ },
+ models: {
+ customPages: {
+ mOckID1: {
+ id: 'mOckID1',
+ name: 'test',
+ courseStaffOnly: false,
+ tabId: 'static_tab_1',
+ },
+ },
+ },
+};
+
+export const generateFetchPageApiResponse = () => ([{
+ type: 'static_tab',
+ title: null,
+ is_hideable: false,
+ is_hidden: false,
+ is_movable: true,
+ course_staff_only: false,
+ name: 'test',
+ tab_id: 'static_tab_1',
+ settings: {
+ url_slug: '1',
+ },
+ id: 'mOckID1',
+}]);
+
+export const generateXblockData = (
+ blockId,
+) => ({
+ id: blockId,
+ display_name: 'test',
+ data: 'test
',
+});
+
+export const generateUpdateVisiblityApiResponse = (
+ blockId,
+ visibility,
+) => ({
+ id: blockId,
+ metadata: { display_name: 'test', course_staff_only: visibility },
+});
+
+export const generateNewPageApiResponse = () => ({
+ locator: 'mOckID2',
+ courseKey: courseId,
+});
diff --git a/src/custom-pages/index.js b/src/custom-pages/index.js
new file mode 100644
index 000000000..0e4450041
--- /dev/null
+++ b/src/custom-pages/index.js
@@ -0,0 +1 @@
+export { default } from './CustomPages';
diff --git a/src/custom-pages/messages.js b/src/custom-pages/messages.js
new file mode 100644
index 000000000..af6b7ca6d
--- /dev/null
+++ b/src/custom-pages/messages.js
@@ -0,0 +1,110 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ heading: {
+ id: 'course-authoring.custom-pages.heading',
+ defaultMessage: 'Custom Pages',
+ },
+ errorAlertMessage: {
+ id: 'course-authoring.custom-pages.errorAlert.message',
+ defaultMessage: 'Unable to {actionName} page. Please try again.',
+ },
+ note: {
+ id: 'course-authoring.custom-pages.note',
+ defaultMessage: `Note: Pages are publicly visible. If users know the URL
+ of a page, they can view the page even if they are not registered for
+ or logged in to your course.`,
+ },
+ addPageHeaderLabel: {
+ id: 'course-authoring.custom-pages.header.addPage.label',
+ defaultMessage: 'New page',
+ },
+ viewLiveLabel: {
+ id: 'course-authoring.custom-pages.header.viewLive.label',
+ defaultMessage: 'View live',
+ },
+ pageExplanationHeader: {
+ id: 'course-authoring.custom-pages.pageExplanation.header',
+ defaultMessage: 'What are pages?',
+ },
+ pageExplanationBody: {
+ id: 'course-authoring.custom-pages.pageExplanation.body',
+ defaultMessage: `Pages are listed horizontally at the top of your course. Default pages (Home, Course, Discussion, Wiki, and Progress)
+ are followed by textbooks and custom pages that you create.`,
+ },
+ customPagesExplanationHeader: {
+ id: 'course-authoring.custom-pages.customPagesExplanation.header',
+ defaultMessage: 'Custom pages',
+ },
+ customPagesExplanationBody: {
+ id: 'course-authoring.custom-pages.customPagesExplanation.body',
+ defaultMessage: `You can create and edit custom pages to probide students with additional course content. For example, you can create
+ pages for the grading policy, course slide, and a course calendar.`,
+ },
+ studentViewExplanationHeader: {
+ id: 'course-authoring.custom-pages.studentViewExplanation.header',
+ defaultMessage: 'How do pages look to students in my course?',
+ },
+ studentViewExplanationBody: {
+ id: 'course-authoring.custom-pages.studentViewExplanation.body',
+ defaultMessage: 'Students see the default and custom pages at the top of your course and use the links to navigate.',
+ },
+ studentViewExampleButton: {
+ id: 'course-authoring.custom-pages.studentViewExampleButton.label',
+ defaultMessage: 'See an example',
+ },
+ studentViewModalTitle: {
+ id: 'course-authoring.custom-pages.studentViewModal.title',
+ defaultMessage: 'Pages in Your Course',
+ },
+ studentViewModalBody: {
+ id: 'course-authoring.custom-pages.studentViewModal.Body',
+ defaultMessage: "Pages appear in your course's top navigation bar. The default pages (Home, Course, Discussion, Wiki, and Progress) are followed by textbooks and custom pages.",
+ },
+ newPageTitle: {
+ id: 'course-authoring.custom-pages.page.newPage.title',
+ defaultMessage: 'Empty',
+ },
+ editTooltipContent: {
+ id: 'course-authoring.custom-pages.editTooltip.content',
+ defaultMessage: 'Edit',
+ },
+ deleteTooltipContent: {
+ id: 'course-authoring.custom-pages.deleteTooltip.content',
+ defaultMessage: 'Delete',
+ },
+ visibilityTooltipContent: {
+ id: 'course-authoring.custom-pages.visibilityTooltip.content',
+ defaultMessage: 'Hide/show page from learners',
+ },
+ addPageBodyLabel: {
+ id: 'course-authoring.custom-pages.body.addPage.label',
+ defaultMessage: 'Add a new page',
+ },
+ addingPageBodyLabel: {
+ id: 'course-authoring.custom-pages.body.addingPage.label',
+ defaultMessage: 'Adding a new page',
+ },
+ deleteConfirmationTitle: {
+ id: 'course-authoring.custom-pages..deleteConfirmation.title',
+ defaultMessage: 'Delete Page Confirmation',
+ },
+ deleteConfirmationMessage: {
+ id: 'course-authoring.custom-pages..deleteConfirmation.message',
+ defaultMessage: 'Are you sure you want to delete this page? This action cannot be undone.',
+ },
+ deletePageLabel: {
+ id: 'course-authoring.custom-pages.deleteConfirmation.deletePage.label',
+ defaultMessage: 'Ok',
+ },
+ deletingPageBodyLabel: {
+ id: 'course-authoring.custom-pages.deleteConfirmation.deletingPage.label',
+ defaultMessage: 'Deleting',
+ },
+ cancelButtonLabel: {
+ id: 'course-authoring.custom-pages.deleteConfirmation.cancelButton.label',
+ defaultMessage: 'Cancel',
+ },
+});
+
+export default messages;
diff --git a/src/data/constants.js b/src/data/constants.js
index eafcef4f9..42e156253 100644
--- a/src/data/constants.js
+++ b/src/data/constants.js
@@ -10,6 +10,7 @@ export const RequestStatus = {
SUCCESSFUL: 'successful',
FAILED: 'failed',
DENIED: 'denied',
+ PENDING: 'pending',
};
/**
diff --git a/src/data/slice.js b/src/data/slice.js
index 574ff318a..749e53f2b 100644
--- a/src/data/slice.js
+++ b/src/data/slice.js
@@ -1,9 +1,7 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
-export const LOADING = 'LOADING';
export const LOADED = 'LOADED';
-export const FAILED = 'FAILED';
const slice = createSlice({
name: 'courseDetail',
diff --git a/src/data/thunks.js b/src/data/thunks.js
index 3abe30df0..9a52d4d89 100644
--- a/src/data/thunks.js
+++ b/src/data/thunks.js
@@ -4,26 +4,24 @@ import { getCourseDetail } from './api';
import {
updateStatus,
updateCanChangeProviders,
- LOADING,
- LOADED,
- FAILED,
} from './slice';
+import { RequestStatus } from './constants';
/* eslint-disable import/prefer-default-export */
export function fetchCourseDetail(courseId) {
return async (dispatch) => {
- dispatch(updateStatus({ courseId, status: LOADING }));
+ dispatch(updateStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
try {
const courseDetail = await getCourseDetail(courseId, getAuthenticatedUser().username);
- dispatch(updateStatus({ courseId, status: LOADED }));
+ dispatch(updateStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
dispatch(addModel({ modelType: 'courseDetails', model: courseDetail }));
dispatch(updateCanChangeProviders({
canChangeProviders: getAuthenticatedUser().administrator || new Date(courseDetail.start) > new Date(),
}));
} catch (error) {
- dispatch(updateStatus({ courseId, status: FAILED }));
+ dispatch(updateStatus({ courseId, status: RequestStatus.FAILED }));
}
};
}
diff --git a/src/pages-and-resources/pages/PageCard.jsx b/src/pages-and-resources/pages/PageCard.jsx
index e8132d2f3..8d9f85a44 100644
--- a/src/pages-and-resources/pages/PageCard.jsx
+++ b/src/pages-and-resources/pages/PageCard.jsx
@@ -32,10 +32,21 @@ const PageCard = ({
}) => {
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
const isDesktop = useIsDesktop();
-
// eslint-disable-next-line react/no-unstable-nested-components
const SettingsButton = () => {
if (page.legacyLink) {
+ if (process.env.ENABLE_NEW_CUSTOM_PAGES && page.name === 'Custom pages') {
+ return (
+
+
+
+ );
+ }
return (
{
- it('will pass because it is an example', () => {
+import {
+ render,
+ queryAllByRole,
+} from '@testing-library/react';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import initializeStore from '../../store';
+import PageGrid from './PageGrid';
+
+import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
+
+let container;
+let store;
+
+const renderComponent = () => {
+ const wrapper = render(
+
+
+
+
+
+
+ ,
+ );
+ container = wrapper.container;
+};
+
+describe('LiveSettings', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: false,
+ roles: [],
+ },
+ });
+ store = initializeStore({
+ courseDetail: {
+ courseId: 'id',
+ status: 'sucessful',
+ },
+ });
+ });
+
+ it('should render three cards', async () => {
+ renderComponent();
+ expect(queryAllByRole(container, 'button')).toHaveLength(3);
+ });
+ it('should navigate to custom-pages', async () => {
+ renderComponent();
+ const [customPagesSettingsButton] = queryAllByRole(container, 'link');
+ expect(customPagesSettingsButton).toHaveAttribute('href', 'custom-pages');
+ });
+ it('should navigate to legacyLink', async () => {
+ renderComponent();
+ const textbookSettingsButton = queryAllByRole(container, 'link')[1];
+ expect(textbookSettingsButton).toHaveAttribute('href', 'SomeUrl');
});
});
diff --git a/src/store.js b/src/store.js
index a4a53b9dd..6871e4202 100644
--- a/src/store.js
+++ b/src/store.js
@@ -4,12 +4,14 @@ import { reducer as modelsReducer } from './generic/model-store';
import { reducer as courseDetailReducer } from './data/slice';
import { reducer as discussionsReducer } from './pages-and-resources/discussions';
import { reducer as pagesAndResourcesReducer } from './pages-and-resources/data/slice';
+import { reducer as customPagesReducer } from './custom-pages/data/slice';
import { reducer as liveReducer } from './pages-and-resources/live/data/slice';
export default function initializeStore(preloadedState = undefined) {
return configureStore({
reducer: {
courseDetail: courseDetailReducer,
+ customPages: customPagesReducer,
discussions: discussionsReducer,
pagesAndResources: pagesAndResourcesReducer,
models: modelsReducer,