feat: Hook up course apps API and add base components for app configuration

Hooks up the course apps API so that the data returned by the server is being used.
Adds the base components and infrastructure to enable adding pages for configuring
each app.
This commit is contained in:
Kshitij Sobti
2021-03-30 16:42:30 +05:30
committed by Awais Jibran
parent 44d8cfbaa1
commit 0e7340bdda
25 changed files with 575 additions and 215 deletions

1
.env
View File

@@ -6,7 +6,6 @@ CSRF_TOKEN_API_PATH=null
DISCOVERY_API_BASE_URL=
ECOMMERCE_BASE_URL=null
FAVICON_URL=''
FAVICON_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
LOGIN_URL=null

View File

@@ -5,7 +5,6 @@ CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
ECOMMERCE_BASE_URL='http://localhost:18130'
FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'

View File

@@ -15,6 +15,7 @@
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"

13
src/data/constants.js Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-disable import/prefer-default-export */
/**
* Enum for request status.
* @readonly
* @enum {string}
*/
export const RequestStatus = {
IN_PROGRESS: 'in-progress',
SUCCESSFUL: 'successful',
FAILED: 'failed',
DENIED: 'denied',
};

View File

@@ -1,9 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form, SwitchControl } from '@edx/paragon';
import PropTypes from 'prop-types';
import React from 'react';
export default function FormSwitchGroup({
id, label, helpText, className, onChange, onBlur, checked,
id,
name,
label,
helpText,
className,
onChange,
onBlur,
checked,
}) {
const helpTextId = `${id}HelpText`;
@@ -15,28 +22,27 @@ export default function FormSwitchGroup({
controlId={id}
className={className}
>
<div className="d-flex justify-content-between">
<div className="pr-3">
<Form.Label className="h4 mt-2 text-primary-500">
<div className="d-flex flex-column">
<div className="d-flex flex-row justify-content-between align-items-baseline pb-2">
<Form.Label className="h4 text-primary-500 p-0 m-0">
{label}
</Form.Label>
<Form.Text
className="mt-0"
id={helpTextId}
muted
>
{helpText}
</Form.Text>
</div>
<div>
<SwitchControl
id={id}
name={name}
aria-describedby={helpTextId}
onChange={onChange}
onBlur={onBlur}
checked={checked}
/>
</div>
<Form.Text
className="mt-0 pr-3"
id={helpTextId}
muted
>
{helpText}
</Form.Text>
</div>
</Form.Group>
@@ -44,13 +50,16 @@ export default function FormSwitchGroup({
}
FormSwitchGroup.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
helpText: PropTypes.string.isRequired,
label: PropTypes.node.isRequired,
name: PropTypes.string,
helpText: PropTypes.node.isRequired,
className: PropTypes.string,
onChange: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired,
onBlur: PropTypes.func,
checked: PropTypes.bool.isRequired,
};
FormSwitchGroup.defaultProps = {
className: null,
onBlur: null,
name: null,
};

View File

@@ -0,0 +1,29 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Badge } from '@edx/paragon';
import PropTypes from 'prop-types';
import React from 'react';
import messages from './messages';
function StatusBadge({ intl, status, label }) {
return (
<>
{label && `${label} `}
{status
? <Badge className="py-1" variant="success">{intl.formatMessage(messages.enabled)}</Badge>
: <Badge className="py-1" variant="secondary">{intl.formatMessage(messages.disabled)}</Badge>}
</>
);
}
StatusBadge.propTypes = {
intl: intlShape.isRequired,
status: PropTypes.bool.isRequired,
label: PropTypes.string,
};
StatusBadge.defaultProps = {
label: null,
};
export default injectIntl(StatusBadge);

View File

@@ -0,0 +1,15 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
enabled: {
id: 'course-authoring.badge.enabled',
defaultMessage: 'Enabled',
},
disabled: {
id: 'course-authoring.badge.disabled',
defaultMessage: 'Disabled',
},
});
export default messages;

View File

@@ -45,6 +45,7 @@ initialize({
config: () => {
mergeConfig({
SUPPORT_URL: process.env.SUPPORT_URL || null,
CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null,
}, 'CourseAuthoringConfig');
},
},

View File

@@ -7,4 +7,4 @@
@import "~@edx/frontend-component-footer/dist/footer";
@import "proctored-exam-settings/proctoredExamSettings";
@import "pages-and-resources/discussions/app-list/AppList";
@import "pages-and-resources/discussions/app-list/AppList";

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, Suspense } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { PageRoute } from '@edx/frontend-platform/react';
@@ -12,40 +12,60 @@ import DiscussionsSettings from './discussions';
import PageGrid from './pages/PageGrid';
import ResourceList from './resources/ResourcesList';
import { fetchPages } from './data/thunks';
import { fetchCourseApps } from './data/thunks';
import { useModels } from '../generic/model-store';
import PagesAndResourcesProvider from './PagesAndResourcesProvider';
function PagesAndResources({ courseId, intl }) {
const { path } = useRouteMatch();
const { path, url } = useRouteMatch();
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchPages(courseId));
dispatch(fetchCourseApps(courseId));
}, [courseId]);
const pageIds = useSelector(state => state.pagesAndResources.pageIds);
const pages = useModels('pages', pageIds);
const courseAppIds = useSelector(state => state.pagesAndResources.courseAppIds);
// Each page here is driven by a course app
const pages = useModels('courseApps', courseAppIds);
return (
<PagesAndResourcesProvider courseId={courseId}>
<main>
<div className="container-fluid pb-3">
<div className="d-flex justify-content-between align-items-center border-bottom">
<h1 className="mt-3">{intl.formatMessage(messages.heading)}</h1>
</div>
<PageGrid pages={pages} />
<ResourceList />
</div>
<main className="container container-mw-md">
<h3 className="mt-5 mb-5">{intl.formatMessage(messages.heading)}</h3>
<PageGrid pages={pages} />
<ResourceList />
<Switch>
<PageRoute
path={[
`${path}/discussions/configure/:appId`,
`${path}/discussions`,
`${path}/discussion/configure/:appId`,
`${path}/discussion`,
]}
>
<DiscussionsSettings courseId={courseId} />
</PageRoute>
<PageRoute path={`${path}/:appId/settings`}>
{
({ match, history }) => {
const SettingsComponent = React.lazy(async () => {
try {
// There seems to be a bug in babel-eslint that causes the checker to crash with the following error
// if we use a template string here:
// TypeError: Cannot read property 'range' of null with using template strings here.
// Ref: https://github.com/babel/babel-eslint/issues/530
return await import('./' + match.params.appId + '/Settings.jsx'); // eslint-disable-line
} catch (error) {
console.trace(error); // eslint-disable-line no-console
return null;
}
});
return (
<Suspense fallback="...">
<SettingsComponent onClose={() => history.push(url)} />
</Suspense>
);
}
}
</PageRoute>
</Switch>
</main>
</PagesAndResourcesProvider>

View File

@@ -7,6 +7,7 @@ export default function PagesAndResourcesProvider({ courseId, children }) {
return (
<PagesAndResourcesContext.Provider
value={{
courseId,
path: `/course/${courseId}/pages-and-resources`,
}}
>

View File

@@ -0,0 +1,176 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Form, Hyperlink, ModalLayer, Spinner, TransitionReplace,
} from '@edx/paragon';
import { Formik } from 'formik';
import PropTypes from 'prop-types';
import React, { useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import * as Yup from 'yup';
import { RequestStatus } from '../../data/constants';
import FormSwitchGroup from '../../generic/FormSwitchGroup';
import { useModel } from '../../generic/model-store';
import StatusBadge from '../../generic/status-badge/StatusBadge';
import { getLoadingStatus } from '../data/selectors';
import { updateAppStatus } from '../data/thunks';
import AppConfigFormDivider from '../discussions/app-config-form/apps/shared/AppConfigFormDivider';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
import messages from './messages';
function AppSettingsForm({ formikProps, children }) {
return children && (
<TransitionReplace>
{formikProps.values.enabled
? (
<React.Fragment key="app-enabled">
{children(formikProps)}
</React.Fragment>
) : (
<React.Fragment key="app-disabled" />
)}
</TransitionReplace>
);
}
AppSettingsForm.propTypes = {
formikProps: PropTypes.shape({
values: PropTypes.shape({ enabled: PropTypes.bool.isRequired }),
}).isRequired,
children: PropTypes.func,
};
AppSettingsForm.defaultProps = {
children: null,
};
function AppSettingsModal({
intl,
appId,
title,
children,
initialValues,
validationSchema,
onClose,
onSettingsSave,
enableAppLabel,
enableAppHelp,
learnMoreURL,
learnMoreText,
}) {
const { courseId } = useContext(PagesAndResourcesContext);
const loadingStatus = useSelector(getLoadingStatus);
const appInfo = useModel('courseApps', appId);
const dispatch = useDispatch();
const handleFormSubmit = (values) => {
// If the app's enabled/disabled loadingStatus has changed, set that first.
if (appInfo.enabled !== values.enabled) {
dispatch(updateAppStatus(courseId, appInfo.id, values.enabled, true));
}
// Call the submit handler for the settings component to save its settings
if (onSettingsSave) {
onSettingsSave();
}
};
const learnMoreLink = (
<Hyperlink
destination={learnMoreURL}
target="_blank"
rel="noreferrer noopener"
>
{learnMoreText}
</Hyperlink>
);
return (
<ModalLayer
isOpen
closeText={intl.formatMessage(messages.cancel)}
dialogClassName="modal-dialog-centered modal-lg"
onClose={onClose}
>
<div
role="dialog"
aria-label={title}
className="bg-white d-flex flex-column mw-xs p-3"
>
{
loadingStatus === RequestStatus.SUCCESSFUL && (
<Formik
initialValues={{
enabled: !!appInfo?.enabled,
...initialValues,
}}
validationSchema={
Yup.object()
.shape({
enabled: Yup.boolean(),
...validationSchema,
})
}
onSubmit={handleFormSubmit}
>{(formikProps) => (
<Form onSubmit={formikProps.handleSubmit}>
<h3>{title}</h3>
<FormSwitchGroup
id={`enable-${appId}-toggle`}
name="enabled"
onChange={formikProps.handleChange}
onBlur={formikProps.handleBlur}
checked={formikProps.values.enabled}
label={(
<>
{enableAppLabel}&nbsp;
<StatusBadge status={formikProps.values.enabled} />
</>
)}
helpText={(<p>{enableAppHelp}<br />{learnMoreLink}</p>)}
/>
<AppSettingsForm formikProps={formikProps}>
{children}
</AppSettingsForm>
{formikProps.values.enabled && children
&& <AppConfigFormDivider marginAdj={{ default: 3, sm: null }} />}
<div className="d-flex justify-content-end">
<Button variant="cancel" className="btn btn-link" onClick={onClose}>
{intl.formatMessage(messages.cancel)}
</Button>
<Button type="submit" variant="success" data-autofocus>
{intl.formatMessage(messages.apply)}
</Button>
</div>
</Form>
)}
</Formik>
)
}
{loadingStatus === RequestStatus.IN_PROGRESS && (
<Spinner animation="border" variant="primary" className="align-self-center" />
)}
</div>
</ModalLayer>
);
}
AppSettingsModal.propTypes = {
intl: intlShape.isRequired,
title: PropTypes.string.isRequired,
appId: PropTypes.string.isRequired,
children: PropTypes.func,
onSettingsSave: PropTypes.func,
initialValues: PropTypes.objectOf(PropTypes.any),
validationSchema: PropTypes.objectOf(PropTypes.func),
onClose: PropTypes.func.isRequired,
enableAppLabel: PropTypes.string.isRequired,
enableAppHelp: PropTypes.string.isRequired,
learnMoreURL: PropTypes.string.isRequired,
learnMoreText: PropTypes.string.isRequired,
};
AppSettingsModal.defaultProps = {
children: null,
onSettingsSave: null,
initialValues: {},
validationSchema: {},
};
export default injectIntl(AppSettingsModal);

View File

@@ -0,0 +1,35 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
cancel: {
id: 'course-authoring.pages-resources.app-settings-modal.button.cancel',
defaultMessage: 'Cancel',
},
apply: {
id: 'course-authoring.pages-resources.app-settings-modal.button.apply',
defaultMessage: 'Apply',
},
applying: {
id: 'course-authoring.pages-resources.app-settings-modal.button.applying',
defaultMessage: 'Applying',
},
applied: {
id: 'course-authoring.pages-resources.app-settings-modal.button.applied',
defaultMessage: 'Applied',
},
retry: {
id: 'course-authoring.pages-resources.app-settings-modal.button.retry',
defaultMessage: 'Retry',
},
enabled: {
id: 'course-authoring.pages-resources.app-settings-modal.badge.enabled',
defaultMessage: 'Enabled',
},
disabled: {
id: 'course-authoring.pages-resources.app-settings-modal.badge.disabled',
defaultMessage: 'Disabled',
},
});
export default messages;

View File

@@ -0,0 +1,31 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import React from 'react';
import AppSettingsModal from '../app-settings-modal/AppSettingsModal';
import messages from './messages';
const CALCULATOR_HELP_URL = getConfig().CALCULATOR_HELP_URL
|| 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/calculator.html';
function CalculatorSettings({ intl, onClose }) {
return (
<AppSettingsModal
appId="calculator"
title={intl.formatMessage(messages.heading)}
enableAppHelp={intl.formatMessage(messages.enableProgressHelp)}
enableAppLabel={intl.formatMessage(messages.enableProgressLabel)}
learnMoreText={intl.formatMessage(messages.enableProgressLink)}
learnMoreURL={CALCULATOR_HELP_URL}
onClose={onClose}
/>
);
}
CalculatorSettings.propTypes = {
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
export default injectIntl(CalculatorSettings);

View File

@@ -0,0 +1,24 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'course-authoring.pages-resources.calculator.heading',
defaultMessage: 'Configure calculator',
},
enableProgressLabel: {
id: 'course-authoring.pages-resources.calculator.enable-calculator.label',
defaultMessage: 'Calculator',
},
enableProgressHelp: {
id: 'course-authoring.pages-resources.calculator.enable-calculator.help',
defaultMessage: `The calculator supports numbers, operators, constants,
functions, and other mathematical concepts. When enabled, an icon to
access the calculator appears on all pages in the body of your course.`,
},
enableProgressLink: {
id: 'course-authoring.pages-resources.calculator.enable-calculator.link',
defaultMessage: 'Learn more about the calculator',
},
});
export default messages;

View File

@@ -1,61 +1,39 @@
/* eslint-disable import/prefer-default-export */
export function getPages() {
return Promise.resolve({
pages: [
{
id: 'discussions',
title: 'Discussions',
isEnabled: false,
showSettings: false,
showStatus: false,
showEnable: true,
description: 'Encourage participation and engagement in your course with discussion forums.',
},
{
id: 'teams',
title: 'Teams',
isEnabled: true,
showSettings: true,
showStatus: true,
showEnable: false,
description: 'Leverage teams to allow learners to connect by topic of interest.',
},
{
id: 'progress',
title: 'Progress',
isEnabled: false,
showSettings: true,
showStatus: true,
showEnable: false,
description: 'Allow students to track their progress throughout the course lorem ipsum.',
},
{
id: 'textbooks',
title: 'Textbooks',
isEnabled: true,
showSettings: true,
showStatus: true,
showEnable: false,
description: 'Provide links to applicable resources for your course.',
},
{
id: 'notes',
title: 'Notes',
isEnabled: true,
showSettings: true,
showStatus: true,
showEnable: false,
description: 'Support individual note taking that is visible only to the students.',
},
{
id: 'wiki',
title: 'Wiki',
isEnabled: false,
showSettings: false,
showStatus: false,
showEnable: true,
description: 'Share your wiki content to provide additional course material.',
},
],
});
import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
ensureConfig([
'STUDIO_BASE_URL',
], 'Course Apps API service');
const apiBaseUrl = getConfig().STUDIO_BASE_URL;
const courseAppsApiUrl = `${apiBaseUrl}/api/course_apps/v1/apps`;
/**
* Fetches the course apps installed for provided course
* @param {string} courseId
* @returns {Promise<[{}]>}
*/
export async function getCourseApps(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${courseAppsApiUrl}/${courseId}`);
return camelCaseObject(data);
}
/**
* Updates the status of a course app.
* @param {string} courseId Course ID for the course to operate on
* @param {string} appId ID for the application to operate on
* @param {boolean} state The new state
*/
export async function updateCourseApp(courseId, appId, state) {
await getAuthenticatedHttpClient()
.patch(
`${courseAppsApiUrl}/${courseId}`,
{
id: appId,
enabled: state,
},
);
}

View File

@@ -0,0 +1,4 @@
/* eslint-disable import/prefer-default-export */
export const getLoadingStatus = (status) => status.pagesAndResources.loadingStatus;
export const getSavingStatus = (status) => status.pagesAndResources.savingStatus;

View File

@@ -1,29 +1,31 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'LOADING';
export const LOADED = 'LOADED';
export const FAILED = 'FAILED';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
name: 'pagesAndResources',
initialState: {
pageIds: [],
status: LOADING,
courseAppIds: [],
loadingStatus: RequestStatus.IN_PROGRESS,
savingStatus: RequestStatus.SUCCESSFUL,
},
reducers: {
fetchPagesSuccess: (state, { payload }) => {
state.pageIds = payload.pageIds;
fetchCourseAppsSuccess: (state, { payload }) => {
state.courseAppIds = payload.courseAppIds;
},
updateStatus: (state, { payload }) => {
state.status = payload.status;
updateLoadingStatus: (state, { payload }) => {
state.loadingStatus = payload.status;
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
},
});
export const {
fetchPagesSuccess,
updateStatus,
fetchCourseAppsSuccess,
updateLoadingStatus,
updateSavingStatus,
} = slice.actions;
export const {

View File

@@ -1,33 +1,47 @@
import { RequestStatus } from '../../data/constants';
import {
getPages,
getCourseApps,
updateCourseApp,
} from './api';
import { addModels } from '../../generic/model-store';
import { addModels, updateModel } from '../../generic/model-store';
import {
FAILED,
fetchPagesSuccess,
LOADING,
updateStatus,
LOADED,
fetchCourseAppsSuccess,
updateLoadingStatus,
updateSavingStatus,
} from './slice';
/* eslint-disable import/prefer-default-export */
export function fetchPages(courseId) {
export function fetchCourseApps(courseId) {
return async (dispatch) => {
dispatch(updateStatus({ courseId, status: LOADING }));
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
try {
const { pages } = await getPages(courseId);
const courseApps = await getCourseApps(courseId);
dispatch(addModels({ modelType: 'pages', models: pages }));
dispatch(fetchPagesSuccess({
pageIds: pages.map(page => page.id),
dispatch(addModels({ modelType: 'courseApps', models: courseApps }));
dispatch(fetchCourseAppsSuccess({
courseAppIds: courseApps.map(courseApp => courseApp.id),
}));
dispatch(updateStatus({ courseId, status: LOADED }));
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
} catch (error) {
// TODO: We need generic error handling in the app for when a request just fails... in other
// parts of the app (proctored exam settings) we show a nice message and ask the user to
// reload/try again later.
dispatch(updateStatus({ courseId, status: FAILED }));
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.FAILED }));
}
};
}
export function updateAppStatus(courseId, appId, state) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await updateCourseApp(courseId, appId, state);
dispatch(updateModel({ modelType: 'courseApps', model: { id: appId, enabled: state } }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
};
}

View File

@@ -36,7 +36,7 @@ function DiscussionsSettings({ courseId, intl }) {
dispatch(fetchApps(courseId));
}, [courseId]);
const discussionsPath = `${pagesAndResourcesPath}/discussions`;
const discussionsPath = `${pagesAndResourcesPath}/discussion`;
const { params: { appId } } = useRouteMatch();
const startStep = appId ? SETTINGS_STEP : SELECTION_STEP;

View File

@@ -40,8 +40,8 @@ function renderComponent() {
<Switch>
<PageRoute
path={[
`/course/${courseId}/pages-and-resources/discussions/configure/:appId`,
`/course/${courseId}/pages-and-resources/discussions`,
`/course/${courseId}/pages-and-resources/discussion/configure/:appId`,
`/course/${courseId}/pages-and-resources/discussion`,
]}
>
<DiscussionsSettings courseId={courseId} />
@@ -79,7 +79,7 @@ describe('DiscussionsSettings', () => {
});
test('sets selection step from routes', async () => {
history.push(`/course/${courseId}/pages-and-resources/discussions`);
history.push(`/course/${courseId}/pages-and-resources/discussion`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
@@ -90,7 +90,7 @@ describe('DiscussionsSettings', () => {
});
test('sets settings step from routes', async () => {
history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
history.push(`/course/${courseId}/pages-and-resources/discussion/configure/piazza`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
@@ -101,7 +101,7 @@ describe('DiscussionsSettings', () => {
});
test('successfully advances to settings step for lti', async () => {
history.push(`/course/${courseId}/pages-and-resources/discussions`);
history.push(`/course/${courseId}/pages-and-resources/discussion`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
@@ -119,7 +119,7 @@ describe('DiscussionsSettings', () => {
test('successfully advances to settings step for legacy', async () => {
axiosMock.onGet(getAppsUrl(courseId)).reply(200, legacyApiResponse);
renderComponent();
history.push(`/course/${courseId}/pages-and-resources/discussions`);
history.push(`/course/${courseId}/pages-and-resources/discussion`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
@@ -135,7 +135,7 @@ describe('DiscussionsSettings', () => {
});
test('successfully goes back to first step', async () => {
history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
history.push(`/course/${courseId}/pages-and-resources/discussion/configure/piazza`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
@@ -150,7 +150,7 @@ describe('DiscussionsSettings', () => {
});
test('successfully closes the modal', async () => {
history.push(`/course/${courseId}/pages-and-resources/discussions`);
history.push(`/course/${courseId}/pages-and-resources/discussion`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
@@ -167,7 +167,7 @@ describe('DiscussionsSettings', () => {
});
test('successfully submit the modal', async () => {
history.push(`/course/${courseId}/pages-and-resources/discussions`);
history.push(`/course/${courseId}/pages-and-resources/discussion`);
axiosMock.onPost(getAppsUrl(courseId)).reply(200, piazzaApiResponse);
@@ -205,7 +205,7 @@ describe('DiscussionsSettings', () => {
});
test('shows connection error alert', async () => {
history.push(`/course/${courseId}/pages-and-resources/discussions`);
history.push(`/course/${courseId}/pages-and-resources/discussion`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
@@ -232,7 +232,7 @@ describe('DiscussionsSettings', () => {
});
test('shows connection error alert at top of form', async () => {
history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
history.push(`/course/${courseId}/pages-and-resources/discussion/configure/piazza`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
@@ -262,7 +262,7 @@ describe('DiscussionsSettings', () => {
});
test('shows permission denied alert', async () => {
history.push(`/course/${courseId}/pages-and-resources/discussions`);
history.push(`/course/${courseId}/pages-and-resources/discussion`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
@@ -283,7 +283,7 @@ describe('DiscussionsSettings', () => {
});
test('shows permission denied alert at top of form', async () => {
history.push(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
history.push(`/course/${courseId}/pages-and-resources/discussion/configure/piazza`);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
@@ -300,7 +300,7 @@ describe('DiscussionsSettings', () => {
expect(queryByTestId(container, 'appConfigForm')).not.toBeInTheDocument();
// We don't technically leave the route in this case, though the modal is hidden.
expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources/discussions/configure/piazza`);
expect(window.location.pathname).toEqual(`/course/${courseId}/pages-and-resources/discussion/configure/piazza`);
const alert = queryByRole(container, 'alert');
expect(alert).toBeInTheDocument();

View File

@@ -1,10 +1,17 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
export default function AppConfigFormDivider({ thick }) {
export default function AppConfigFormDivider({ thick, marginAdj }) {
return (
<hr
className="my-2 mx-sm-n5 mx-n4 border-light-300"
className={classNames(
'my-2 mx-n4 border-light-300',
{
[`mx-sm-n${marginAdj.sm}`]: marginAdj.sm !== null,
[`mx-n${marginAdj.default}`]: marginAdj.default !== null,
},
)}
style={{
borderTopWidth: thick ? '3px' : '1px',
}}
@@ -14,8 +21,16 @@ export default function AppConfigFormDivider({ thick }) {
AppConfigFormDivider.propTypes = {
thick: PropTypes.bool,
marginAdj: PropTypes.shape({
default: PropTypes.number,
sm: PropTypes.number,
}),
};
AppConfigFormDivider.defaultProps = {
thick: false,
marginAdj: {
default: 4,
sm: 5,
},
};

View File

@@ -37,6 +37,10 @@ const messages = defineMessages({
id: 'course-authoring.pages-resources.resources.newPage.button',
defaultMessage: 'New Page',
},
settings: {
id: 'course-authoring.pages-resources.resources.settings.button',
defaultMessage: 'settings',
},
});
export default messages;

View File

@@ -1,73 +1,75 @@
import { history } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Card, Icon, IconButton,
} from '@edx/paragon';
import { Settings } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import React, { useContext } from 'react';
import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCog } from '@fortawesome/free-solid-svg-icons';
import { history } from '@edx/frontend-platform';
import StatusBadge from '../../generic/status-badge/StatusBadge';
import messages from '../messages';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
const CoursePageShape = PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
isEnabled: PropTypes.bool.isRequired,
showSettings: PropTypes.bool.isRequired,
showStatus: PropTypes.bool.isRequired,
showEnable: PropTypes.bool.isRequired,
enabled: PropTypes.bool.isRequired,
allowedOperations: PropTypes.shape({
enable: PropTypes.bool.isRequired,
configure: PropTypes.bool.isRequired,
}).isRequired,
});
export { CoursePageShape };
function PageCard({ intl, page }) {
function PageCard({
intl,
page,
}) {
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
const pageStatusMsgId = page.isEnabled ? 'pageStatus.enabled' : 'pageStatus.disabled';
const componentClasses = classNames(
'd-flex flex-column align-content-stretch',
'bg-light-100 p-3 border shadow',
{ 'border-gray-500': page.isEnabled, 'border-gray-100': !page.isEnabled },
);
const handleClick = () => {
history.push(`${pagesAndResourcesPath}/${page.id}`);
};
return (
<div
className="d-flex flex-column align-content-stretch p-3 col-sm-12 col-md-6 col-lg-4"
<Card
className="shadow card"
style={{
width: '19rem',
height: '14rem',
}}
>
<div
className={componentClasses}
style={{
flexBasis: '100%',
}}
>
<div className="d-flex flex-row">
<span className="font-weight-bold">{page.title}</span>
{page.showSettings && <FontAwesomeIcon icon={faCog} className="ml-auto" />}
</div>
<Card.Body className="d-flex flex-column">
<Card.Title className="d-flex mb-0 align-items-center justify-content-between">
<h4 className="m-0 p-0">{page.name}</h4>
{(page.allowedOperations.configure || page.allowedOperations.enable)
&& (
<IconButton
className="mb-0 mr-1"
src={Settings}
iconAs={Icon}
size="inline"
alt={intl.formatMessage(messages.settings)}
onClick={() => history.push(`${pagesAndResourcesPath}/${page.id}/settings`)}
/>
)}
</Card.Title>
<div>
{page.showStatus && <span>{intl.formatMessage(messages[pageStatusMsgId])}</span>}
</div>
<div className="mb-2"><StatusBadge status={page.enabled} /></div>
<div className="mt-3">
<p>{page.description}</p>
</div>
<Card.Text className="flex-grow-1 m-0">
{page.description}
</Card.Text>
{page.showEnable && !page.isEnabled && (
<div className="d-flex justify-content-center">
<Button variant="outline-primary" onClick={handleClick}>
{intl.formatMessage(messages['enable.button'])}
</Button>
</div>
{(page.allowedOperations.enable && !page.enabled) && (
<Button variant="outline-primary" size="sm" onClick={handleClick} className="align-self-end">
{intl.formatMessage(messages['enable.button'])}
</Button>
)}
</div>
</div>
</Card.Body>
</Card>
);
}

View File

@@ -1,38 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PageCard from './PageCard';
import React from 'react';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { CardGrid } from '@edx/paragon';
import PageCard, { CoursePageShape } from './PageCard';
import messages from '../messages';
function PageGrid({ intl, pages }) {
function PageGrid({ pages }) {
return (
<div>
<h3 className="mt-3">
{intl.formatMessage(messages['pages.subheading'])}
</h3>
<div className="d-flex flex-wrap align-items-stretch justify-content-stretch">
{pages.map((page) => (
<PageCard key={page.id} page={page} />
))}
</div>
</div>
<CardGrid columnSizes={{
xs: 12,
lg: 4,
xl: 4,
}}
>
{pages.map((page) => (
<PageCard key={page.id} page={page} />
))}
</CardGrid>
);
}
const pageShape = PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
isEnabled: PropTypes.bool.isRequired,
showSettings: PropTypes.bool.isRequired,
showStatus: PropTypes.bool.isRequired,
showEnable: PropTypes.bool.isRequired,
description: PropTypes.string.isRequired,
});
PageGrid.propTypes = {
intl: intlShape.isRequired,
pages: PropTypes.arrayOf(pageShape).isRequired,
pages: PropTypes.arrayOf(CoursePageShape.isRequired).isRequired,
};
export default injectIntl(PageGrid);