From 139457087b42f2704880250cd081200cae8a50a4 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Tue, 27 Jun 2023 16:26:35 -0400 Subject: [PATCH] feat: add custom pages (#510) --- .env.development | 2 +- src/CourseAuthoringPage.jsx | 16 +- src/CourseAuthoringRoutes.jsx | 7 +- src/CourseAuthoringRoutes.test.jsx | 5 + src/custom-pages/CustomPageCard.jsx | 134 +++++++++ src/custom-pages/CustomPageCard.test.jsx | 148 ++++++++++ src/custom-pages/CustomPages.jsx | 264 ++++++++++++++++++ src/custom-pages/CustomPages.test.jsx | 120 ++++++++ src/custom-pages/CustomPagesProvider.jsx | 25 ++ src/custom-pages/EditModal.jsx | 42 +++ src/custom-pages/data/api.js | 72 +++++ .../data/images/previewLmsStaticPages.png | Bin 0 -> 7937 bytes src/custom-pages/data/selectors.js | 8 + src/custom-pages/data/slice.js | 54 ++++ src/custom-pages/data/thunks.js | 160 +++++++++++ .../factories/mockApiResponses.jsx | 64 +++++ src/custom-pages/index.js | 1 + src/custom-pages/messages.js | 110 ++++++++ src/data/constants.js | 1 + src/data/slice.js | 2 - src/data/thunks.js | 10 +- src/pages-and-resources/pages/PageCard.jsx | 13 +- .../pages/PageCard.test.jsx | 73 ++++- src/store.js | 2 + 24 files changed, 1311 insertions(+), 22 deletions(-) create mode 100644 src/custom-pages/CustomPageCard.jsx create mode 100644 src/custom-pages/CustomPageCard.test.jsx create mode 100644 src/custom-pages/CustomPages.jsx create mode 100644 src/custom-pages/CustomPages.test.jsx create mode 100644 src/custom-pages/CustomPagesProvider.jsx create mode 100644 src/custom-pages/EditModal.jsx create mode 100644 src/custom-pages/data/api.js create mode 100644 src/custom-pages/data/images/previewLmsStaticPages.png create mode 100644 src/custom-pages/data/selectors.js create mode 100644 src/custom-pages/data/slice.js create mode 100644 src/custom-pages/data/thunks.js create mode 100644 src/custom-pages/factories/mockApiResponses.jsx create mode 100644 src/custom-pages/index.js create mode 100644 src/custom-pages/messages.js 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.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 0000000000000000000000000000000000000000..3b05ae2ee16089773c7dae656599c397abd0f15a GIT binary patch literal 7937 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 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,