feat: add custom pages (#510)

This commit is contained in:
Kristin Aoki
2023-06-27 16:26:35 -04:00
committed by GitHub
parent 3a26285bd1
commit 139457087b
24 changed files with 1311 additions and 22 deletions

View File

@@ -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=''

View File

@@ -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 (
<PermissionDeniedAlert />
);
}
return (
<div className={pathname.includes('/editor/') ? '' : 'bg-light-200'}>
{/* 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/') && <Loading />
: (
{inProgress ? showHeader && <Loading />
: (showHeader && (
<AppHeader
courseNumber={courseNumber}
courseOrg={courseOrg}
courseTitle={courseTitle}
courseId={courseId}
/>
)}
)
)}
{children}
{!inProgress && <AppFooter />}
{!inProgress && showHeader && <AppFooter />}
</div>
);
};

View File

@@ -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 }) => {
<PageRoute path={`${path}/proctored-exam-settings`}>
<ProctoredExamSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/custom_pages`}>
<PageRoute path={`${path}/custom-pages`}>
{process.env.ENABLE_NEW_CUSTOM_PAGES === 'true'
&& (
<Placeholder />
<CustomPages courseId={courseId} />
)}
</PageRoute>
<PageRoute path={`${path}//:blockType/:blockId?`}>
<PageRoute path={`${path}/container/:blockId`}>
{process.env.ENABLE_UNIT_PAGE === 'true'
&& (
<Placeholder />

View File

@@ -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('<CourseAuthoringRoutes>', () => {
beforeEach(() => {

View File

@@ -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: <Icon src={SpinnerSimple} className="icon-spin" />,
},
disabledStates: ['pending'],
};
return (
<>
<ActionRow>
<div className="h4" data-testid="card-title">
{page?.name || intl.formatMessage(messages.newPageTitle)}
</div>
<ActionRow.Spacer />
<IconButtonWithTooltip
key={intl.formatMessage(messages.editTooltipContent)}
tooltipPlacement="top"
tooltipContent={intl.formatMessage(messages.editTooltipContent)}
src={EditOutline}
iconAs={Icon}
alt={intl.formatMessage(messages.editTooltipContent)}
onClick={handleEditOpen}
data-testid="edit-modal-icon"
/>
<IconButtonWithTooltip
key={intl.formatMessage(messages.visibilityTooltipContent)}
tooltipPlacement="top"
tooltipContent={intl.formatMessage(messages.visibilityTooltipContent)}
src={page.courseStaffOnly ? VisibilityOff : Visibility}
iconAs={Icon}
alt={intl.formatMessage(messages.visibilityTooltipContent)}
onClick={toggleVisibilty}
data-testid="visibility-toggle-icon"
/>
<IconButtonWithTooltip
key={intl.formatMessage(messages.deleteTooltipContent)}
tooltipPlacement="top"
tooltipContent={intl.formatMessage(messages.deleteTooltipContent)}
src={DeleteOutline}
iconAs={Icon}
alt={intl.formatMessage(messages.deleteTooltipContent)}
onClick={openDeleteConfirmation}
data-testid="delete-modal-icon"
/>
</ActionRow>
<AlertModal
title={intl.formatMessage(messages.deleteConfirmationTitle)}
isOpen={isDeleteConfirmationOpen}
onClose={closeDeleteConfirmation}
footerNode={(
<ActionRow>
<Button variant="tertiary" onClick={closeDeleteConfirmation}>
{intl.formatMessage(messages.cancelButtonLabel)}
</Button>
<StatefulButton onClick={handleDelete} state={deletePageStatus} {...deletePageStateProps} />
</ActionRow>
)}
>
{intl.formatMessage(messages.deleteConfirmationMessage)}
</AlertModal>
</>
);
};
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);

View File

@@ -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(
<IntlProvider locale="en">
<AppProvider store={store}>
<CustomPagesProvider courseId={courseId}>
<CustomPageCard {...defaultProps} page={{ ...defaultProps.page, courseStaffOnly }} />
</CustomPagesProvider>
</AppProvider>
</IntlProvider>,
);
};
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();
});
});

View File

@@ -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: <Icon src={Add} />,
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
},
disabledStates: ['pending'],
};
useEffect(() => { setOrderedPages(pages); }, [customPagesIds, savingStatus]);
if (loadingStatus === RequestStatus.IN_PROGRESS) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return (<></>);
}
return (
<CustomPagesProvider courseId={courseId}>
<main className="container container-mw-xl p-4 pt-5">
<div className="small gray-700">
<Breadcrumb
ariaLabel="Custom Page breadcrumbs"
links={[
{ label: 'Content', href: `${config.STUDIO_BASE_URL}/course/${courseId}` },
{ label: 'Pages and Resources', href: `/course/${courseId}/pages-and-resources` },
]}
/>
</div>
<ActionRow>
<div className="h2">
<FormattedMessage {...messages.heading} />
</div>
<ActionRow.Spacer />
<Button
iconBefore={Add}
onClick={handleAddPage}
data-testid="header-add-button"
>
<FormattedMessage {...messages.addPageHeaderLabel} />
</Button>
<Hyperlink
destination={learningCourseURL}
target="_blank"
rel="noopener noreferrer"
showLaunchIcon={false}
data-testid="header-view-live-button"
>
<Button>
<FormattedMessage {...messages.viewLiveLabel} />
</Button>
</Hyperlink>
</ActionRow>
<hr />
<Layout
lg={[{ span: 9, offset: 0 }, { span: 3, offset: 0 }]}
md={[{ span: 9, offset: 0 }, { span: 3, offset: 0 }]}
sm={[{ span: 9, offset: 0 }, { span: 3, offset: 0 }]}
xs={[{ span: 9, offset: 0 }, { span: 3, offset: 0 }]}
xl={[{ span: 9, offset: 0 }, { span: 3, offset: 0 }]}
>
<Layout.Element>
<ErrorAlert hideHeading isError={deletePageStatus === RequestStatus.FAILED}>
{intl.formatMessage(messages.errorAlertMessage, { actionName: 'delete' })}
</ErrorAlert>
<ErrorAlert hideHeading isError={addPageStatus === RequestStatus.FAILED}>
{intl.formatMessage(messages.errorAlertMessage, { actionName: 'add' })}
</ErrorAlert>
<ErrorAlert hideHeading isError={savingStatus === RequestStatus.FAILED}>
{intl.formatMessage(messages.errorAlertMessage, { actionName: 'save' })}
</ErrorAlert>
<div className="small gray-700 mb-4">
<FormattedMessage {...messages.note} />
</div>
<DraggableList itemList={orderedPages} setState={setOrderedPages} updateOrder={handleReorder}>
{orderedPages.map((page) => (
<SortableItem
id={page.id}
key={page.id}
componentStyle={{
background: 'white',
borderRadius: '6px',
padding: '24px',
marginBottom: '16px',
boxShadow: '0px 1px 5px #ADADAD',
}}
>
<CustomPageCard
{...{
page,
dispatch,
deletePageStatus,
courseId,
setCurrentPage,
openEditModal,
}}
/>
</SortableItem>
))}
</DraggableList>
<StatefulButton
data-testid="body-add-button"
onClick={handleAddPage}
state={addPageStatus}
{...addPageStateProps}
/>
</Layout.Element>
<Layout.Element>
<div className="h4">
<FormattedMessage {...messages.pageExplanationHeader} />
</div>
<div className="small gray-700">
<FormattedMessage {...messages.pageExplanationBody} />
</div>
<hr />
<div className="h4">
<FormattedMessage {...messages.customPagesExplanationHeader} />
</div>
<div className="small gray-700">
<FormattedMessage {...messages.customPagesExplanationBody} />
</div>
<hr />
<div className="h4">
<FormattedMessage {...messages.studentViewExplanationHeader} />
</div>
<div className="small gray-700">
<FormattedMessage {...messages.studentViewExplanationBody} />
</div>
<Button
data-testid="student-view-example-button"
variant="link"
size="sm"
onClick={open}
className="pl-0"
>
<FormattedMessage {...messages.studentViewExampleButton} />
</Button>
</Layout.Element>
</Layout>
<ModalDialog
isOpen={isOpen}
onClose={close}
size="lg"
title={intl.formatMessage(messages.studentViewModalTitle)}
>
<ModalDialog.Header>
<ModalDialog.Title>
<FormattedMessage {...messages.studentViewModalTitle} />
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<Image src={previewLmsStaticPages} fluid className="mb-3" />
<div className="small">
<FormattedMessage {...messages.studentViewModalBody} />
</div>
</ModalDialog.Body>
</ModalDialog>
<Switch>
<PageRoute path={`${path}/editor`}>
{currentPage && (
<EditModal courseId={courseId} isOpen={isEditModalOpen} pageId={currentPage} onClose={handleEditClose} />
)}
</PageRoute>
</Switch>
</main>
</CustomPagesProvider>
);
};
CustomPages.propTypes = {
courseId: PropTypes.string.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(CustomPages);

View File

@@ -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(
<IntlProvider locale="en">
<AppProvider store={store}>
<CustomPages courseId={courseId} />
</AppProvider>
</IntlProvider>,
);
};
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);
});
});

View File

@@ -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 (
<CustomPagesContext.Provider
value={contextValue}
>
{children}
</CustomPagesContext.Provider>
);
};
CustomPagesProvider.propTypes = {
courseId: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};
export default CustomPagesProvider;

View File

@@ -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,
}) => (
<div
style={{
position: 'fixed',
width: '100%',
height: '100%',
top: 0,
left: 0,
right: 0,
bottom: 0,
'background-color': 'white',
'z-index': 100,
}}
>
<EditorPage
courseId={courseId}
blockType="html"
blockId={pageId}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
returnFunction={onClose}
/>
</div>
);
EditModal.propTypes = {
pageId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
courseId: PropTypes.string.isRequired,
};
export default EditModal;

View File

@@ -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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -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
// );

View File

@@ -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;

View File

@@ -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 }));
}
};

View File

@@ -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: '<p>test</p>',
});
export const generateUpdateVisiblityApiResponse = (
blockId,
visibility,
) => ({
id: blockId,
metadata: { display_name: 'test', course_staff_only: visibility },
});
export const generateNewPageApiResponse = () => ({
locator: 'mOckID2',
courseKey: courseId,
});

View File

@@ -0,0 +1 @@
export { default } from './CustomPages';

View File

@@ -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;

View File

@@ -10,6 +10,7 @@ export const RequestStatus = {
SUCCESSFUL: 'successful',
FAILED: 'failed',
DENIED: 'denied',
PENDING: 'pending',
};
/**

View File

@@ -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',

View File

@@ -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 }));
}
};
}

View File

@@ -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 (
<Hyperlink destination="custom-pages">
<IconButton
src={ArrowForward}
iconAs={Icon}
size="inline"
alt={intl.formatMessage(messages.settings)}
/>
</Hyperlink>
);
}
return (
<Hyperlink destination={page.legacyLink}>
<IconButton

View File

@@ -1,5 +1,74 @@
describe('PageCard', () => {
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(
<IntlProvider locale="en">
<AppProvider store={store}>
<PagesAndResourcesProvider courseId="id">
<PageGrid
pages={[
{ legacyLink: 'SomeUrl', name: 'Custom pages', id: '1' },
{
legacyLink: 'SomeUrl',
name: 'Textbook',
id: '2',
enabled: true,
},
{ name: 'Page', allowedOperations: { enable: true }, id: '3' },
]}
/>
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
);
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');
});
});

View File

@@ -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,