feat: add custom pages (#510)
This commit is contained in:
@@ -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=''
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
134
src/custom-pages/CustomPageCard.jsx
Normal file
134
src/custom-pages/CustomPageCard.jsx
Normal 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);
|
||||
148
src/custom-pages/CustomPageCard.test.jsx
Normal file
148
src/custom-pages/CustomPageCard.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
264
src/custom-pages/CustomPages.jsx
Normal file
264
src/custom-pages/CustomPages.jsx
Normal 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);
|
||||
120
src/custom-pages/CustomPages.test.jsx
Normal file
120
src/custom-pages/CustomPages.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
25
src/custom-pages/CustomPagesProvider.jsx
Normal file
25
src/custom-pages/CustomPagesProvider.jsx
Normal 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;
|
||||
42
src/custom-pages/EditModal.jsx
Normal file
42
src/custom-pages/EditModal.jsx
Normal 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;
|
||||
72
src/custom-pages/data/api.js
Normal file
72
src/custom-pages/data/api.js
Normal 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);
|
||||
}
|
||||
BIN
src/custom-pages/data/images/previewLmsStaticPages.png
Normal file
BIN
src/custom-pages/data/images/previewLmsStaticPages.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
8
src/custom-pages/data/selectors.js
Normal file
8
src/custom-pages/data/selectors.js
Normal 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
|
||||
// );
|
||||
54
src/custom-pages/data/slice.js
Normal file
54
src/custom-pages/data/slice.js
Normal 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;
|
||||
160
src/custom-pages/data/thunks.js
Normal file
160
src/custom-pages/data/thunks.js
Normal 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 }));
|
||||
}
|
||||
};
|
||||
64
src/custom-pages/factories/mockApiResponses.jsx
Normal file
64
src/custom-pages/factories/mockApiResponses.jsx
Normal 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,
|
||||
});
|
||||
1
src/custom-pages/index.js
Normal file
1
src/custom-pages/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './CustomPages';
|
||||
110
src/custom-pages/messages.js
Normal file
110
src/custom-pages/messages.js
Normal 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;
|
||||
@@ -10,6 +10,7 @@ export const RequestStatus = {
|
||||
SUCCESSFUL: 'successful',
|
||||
FAILED: 'failed',
|
||||
DENIED: 'denied',
|
||||
PENDING: 'pending',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user