Discussions LTI config UI. (#50)
* Adding font-awesome so we can use it with StatefulButton * Rudimentary discussion config UI with mocked APIs. * Updating Yellowdig logo URL * Bumping and locking dependencies, adding formik and yup * Wiring up the “Enable” button to go to the discussions config. * Refactoring DiscussionConfig to use formik and yup. * Using more paragon components - Card, CardGrid, and DataTable * Adding keys to arrays of rendered components. * Ignore module.config.js file. * Bumping frontend-build to the latest version. * Removing font-awesome again - it’s no longer necessary. The latest version of Paragon uses <FontAwesomeIcon> for its closing “X”, rather than using CSS class names directly. * Splitting discussion app list cards out into their own component. They used to, but were folded in while refactoring to use Card and CardGrid. * Adding comments to FeaturesTable.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,3 +19,4 @@ temp/babel-plugin-react-intl
|
||||
*~
|
||||
/temp
|
||||
/.vscode
|
||||
/module.config.js
|
||||
|
||||
11401
package-lock.json
generated
11401
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -34,36 +34,38 @@
|
||||
"url": "https://github.com/edx/frontend-app-course-authoring/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@^1.1.0",
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "10.0.11",
|
||||
"@edx/frontend-platform": "1.8.0",
|
||||
"@edx/paragon": "12.2.0",
|
||||
"@edx/paragon": "13.10.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.28",
|
||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "5.11.2",
|
||||
"@fortawesome/react-fontawesome": "0.1.9",
|
||||
"@reduxjs/toolkit": "^1.5.0",
|
||||
"classnames": "^2.2.6",
|
||||
"core-js": "^3.8.1",
|
||||
"email-validator": "^2.0.4",
|
||||
"moment": "^2.27.0",
|
||||
"@reduxjs/toolkit": "1.5.0",
|
||||
"classnames": "2.2.6",
|
||||
"core-js": "3.8.1",
|
||||
"email-validator": "2.0.4",
|
||||
"formik": "2.2.6",
|
||||
"moment": "2.27.0",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "^16.14.0",
|
||||
"react-dom": "^16.14.0",
|
||||
"react-redux": "^7.1.3",
|
||||
"react-responsive": "^8.1.0",
|
||||
"react-router": "^5.1.2",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react-redux": "7.1.3",
|
||||
"react-responsive": "8.1.0",
|
||||
"react-router": "5.1.2",
|
||||
"react-router-dom": "5.1.2",
|
||||
"react-transition-group": "4.4.1",
|
||||
"redux": "4.0.5",
|
||||
"regenerator-runtime": "^0.13.7"
|
||||
"regenerator-runtime": "0.13.7",
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "^3.0.0",
|
||||
"@testing-library/react": "^10.4.7",
|
||||
"axios": "^0.21.1",
|
||||
"axios-mock-adapter": "^1.18.1",
|
||||
"@edx/frontend-build": "5.6.9",
|
||||
"@testing-library/react": "10.4.7",
|
||||
"axios": "0.21.1",
|
||||
"axios-mock-adapter": "1.18.1",
|
||||
"codecov": "3.7.1",
|
||||
"es-check": "5.1.0",
|
||||
"glob": "7.1.6",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PageRoute } from '@edx/frontend-platform/react';
|
||||
import CourseAuthoringPage from './CourseAuthoringPage';
|
||||
import { PagesAndResources } from './pages-and-resources';
|
||||
import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings';
|
||||
import DiscussionAppList from './pages-and-resources/discussions/DiscussionAppList';
|
||||
import DiscussionsRoutes from './pages-and-resources/discussions/DiscussionsRoutes';
|
||||
|
||||
/**
|
||||
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||
@@ -32,7 +32,7 @@ export default function CourseAuthoringRoutes({ courseId }) {
|
||||
<PagesAndResources courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/pages-and-resources/discussion`}>
|
||||
<DiscussionAppList courseId={courseId} />
|
||||
<DiscussionsRoutes courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/proctored-exam-settings`}>
|
||||
<ProctoredExamSettings courseId={courseId} />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
@import "~@edx/brand/paragon/fonts";
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@edx/paragon/scss/core/core";
|
||||
|
||||
@@ -10,7 +10,7 @@ import ResourceList from './resources/ResourcesList';
|
||||
// XXX this is just for testing and should be removed ASAP
|
||||
const pages = [
|
||||
{
|
||||
id: 'cp-discussion',
|
||||
id: 'discussion',
|
||||
title: 'Discussion',
|
||||
isEnabled: false,
|
||||
showSettings: false,
|
||||
@@ -19,7 +19,7 @@ const pages = [
|
||||
description: 'Encourage participation and engagement in your course with discussion forums',
|
||||
},
|
||||
{
|
||||
id: 'cp-teams',
|
||||
id: 'teams',
|
||||
title: 'Teams',
|
||||
isEnabled: true,
|
||||
showSettings: true,
|
||||
@@ -28,7 +28,7 @@ const pages = [
|
||||
description: 'Leverage teams to allow learners to connect by topic of interest',
|
||||
},
|
||||
{
|
||||
id: 'cp-progress',
|
||||
id: 'progress',
|
||||
title: 'Progress',
|
||||
isEnabled: false,
|
||||
showSettings: true,
|
||||
@@ -37,7 +37,7 @@ const pages = [
|
||||
description: 'Allow students to track their progress throughout the course lorem ipsum',
|
||||
},
|
||||
{
|
||||
id: 'cp-textbooks',
|
||||
id: 'textbooks',
|
||||
title: 'Textbooks',
|
||||
isEnabled: true,
|
||||
showSettings: true,
|
||||
@@ -46,7 +46,7 @@ const pages = [
|
||||
description: 'Provide links to applicable resources for your course',
|
||||
},
|
||||
{
|
||||
id: 'cp-notes',
|
||||
id: 'notes',
|
||||
title: 'Notes',
|
||||
isEnabled: true,
|
||||
showSettings: true,
|
||||
@@ -55,7 +55,7 @@ const pages = [
|
||||
description: 'Support individual note taking that is visible only to the students',
|
||||
},
|
||||
{
|
||||
id: 'cp-wiki',
|
||||
id: 'wiki',
|
||||
title: 'Wiki',
|
||||
isEnabled: false,
|
||||
showSettings: false,
|
||||
|
||||
@@ -1,72 +1,80 @@
|
||||
import React from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
Image, Col, Input,
|
||||
} from '@edx/paragon';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Card, Input } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function DiscussionAppCard({
|
||||
intl, app, selected, clickHandler,
|
||||
app, clickHandler, intl, selected,
|
||||
}) {
|
||||
return (
|
||||
<Col className="mb-4" xs={12} sm={6} lg={4} xl={3}>
|
||||
<Card
|
||||
key={app.id}
|
||||
tabIndex={app.isAvailable ? '-1' : ''}
|
||||
onClick={() => { if (app.isAvailable) { clickHandler(app.id); } }}
|
||||
onKeyPress={() => { if (app.isAvailable) { clickHandler(app.id); } }}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
className={classNames({
|
||||
'border-primary': selected,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="d-flex position-relative discussion-app-card flex-column p-3 h-100 shadow border border-white"
|
||||
className="position-absolute"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
// This positioning of 0.75rem aligns the checkbox with the top of the logo
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
}}
|
||||
tabIndex={app.isAvailable ? '-1' : ''}
|
||||
onClick={() => { if (app.isAvailable) { clickHandler(app.id); } }}
|
||||
onKeyPress={() => { if (app.isAvailable) { clickHandler(app.id); } }}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
>
|
||||
<div
|
||||
className="position-absolute"
|
||||
style={{
|
||||
// This positioning of 0.75rem aligns the checkbox with the top of the logo
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{app.isAvailable ? (
|
||||
<Input readOnly type="checkbox" checked={selected} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faLock} />
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-row justify-content-center">
|
||||
<div className="d-flex justify-content-center">
|
||||
<Image
|
||||
height={100}
|
||||
src={app.logo}
|
||||
alt={intl.formatMessage(messages.appLogo, {
|
||||
name: app.name,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<br />
|
||||
<div className="py-4">{app.description}</div>
|
||||
<br />
|
||||
<div className="mt-auto font-weight-bold">{app.supportLevel}</div>
|
||||
{app.isAvailable ? (
|
||||
<Input readOnly type="checkbox" checked={selected} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faLock} />
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Card.Img
|
||||
variant="top"
|
||||
style={{
|
||||
maxHeight: 100,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
className="py-3 pl-3 pr-5"
|
||||
src={app.logo}
|
||||
alt={intl.formatMessage(messages.appLogo, {
|
||||
name: app.name,
|
||||
})}
|
||||
/>
|
||||
<Card.Body>
|
||||
<Card.Title>{app.name}</Card.Title>
|
||||
<Card.Text>{app.description}</Card.Text>
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
{app.supportLevel}
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
DiscussionAppCard.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
app: PropTypes.objectOf(PropTypes.any).isRequired,
|
||||
selected: PropTypes.bool.isRequired,
|
||||
app: PropTypes.shape({
|
||||
description: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
isAvailable: PropTypes.bool.isRequired,
|
||||
logo: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
supportLevel: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
clickHandler: PropTypes.func.isRequired,
|
||||
selected: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(DiscussionAppCard);
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Container, Row } from '@edx/paragon';
|
||||
import {
|
||||
Button, CardGrid,
|
||||
} from '@edx/paragon';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { useModel, useModels } from '../../generic/model-store';
|
||||
|
||||
import messages from './messages';
|
||||
import { fetchApps } from './data/thunks';
|
||||
import DiscussionAppCard from './DiscussionAppCard';
|
||||
import FeaturesTable from './FeaturesTable';
|
||||
import { useModel, useModels } from '../../generic/model-store';
|
||||
import { fetchApps } from './data/thunks';
|
||||
|
||||
function DiscussionAppList({ courseId, intl }) {
|
||||
const [selectedAppId, setSelectedAppId] = useState(null);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
@@ -33,11 +39,20 @@ function DiscussionAppList({ courseId, intl }) {
|
||||
}
|
||||
}, [selectedAppId]);
|
||||
|
||||
return (
|
||||
<Container fluid className="text-info-500">
|
||||
<h6 className="my-4 text-center">{intl.formatMessage(messages.heading)}</h6>
|
||||
const handleConfigureApp = () => {
|
||||
history.push(`${pathname}/configure/${selectedAppId}`);
|
||||
};
|
||||
|
||||
<Row>
|
||||
return (
|
||||
<div className="m-5">
|
||||
<h2 className="my-4 text-center">{intl.formatMessage(messages.heading)}</h2>
|
||||
<CardGrid
|
||||
columnSizes={{
|
||||
xs: 12,
|
||||
sm: 6,
|
||||
lg: 4,
|
||||
}}
|
||||
>
|
||||
{apps.map(app => (
|
||||
<DiscussionAppCard
|
||||
key={app.id}
|
||||
@@ -46,14 +61,14 @@ function DiscussionAppList({ courseId, intl }) {
|
||||
clickHandler={handleSelectApp}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
</CardGrid>
|
||||
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<h2 className="my-3">
|
||||
{intl.formatMessage(messages.supportedFeatures)}
|
||||
</h2>
|
||||
{selectedAppId && (
|
||||
<Button variant="primary">
|
||||
<Button variant="primary" onClick={handleConfigureApp}>
|
||||
{intl.formatMessage(messages.configureApp, { name: selectedApp.name })}
|
||||
</Button>
|
||||
)}
|
||||
@@ -63,7 +78,7 @@ function DiscussionAppList({ courseId, intl }) {
|
||||
apps={apps}
|
||||
features={features}
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
46
src/pages-and-resources/discussions/DiscussionConfig.jsx
Normal file
46
src/pages-and-resources/discussions/DiscussionConfig.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useRouteMatch } from 'react-router';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { fetchAppConfig, saveAppConfig } from './data/thunks';
|
||||
import DiscussionsConfigForm from './DiscussionsConfigForm';
|
||||
|
||||
export default function DiscussionConfig({ courseId }) {
|
||||
const { params: { appId } } = useRouteMatch();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(fetchAppConfig(courseId, appId));
|
||||
}, [courseId]);
|
||||
|
||||
const { activeAppId, activeAppConfigId } = useSelector(state => state.discussions);
|
||||
|
||||
const app = useModel('apps', activeAppId);
|
||||
const appConfig = useModel('appConfigs', activeAppConfigId);
|
||||
|
||||
const handleSubmit = useCallback((values) => {
|
||||
dispatch(saveAppConfig(courseId, appId, values)).then(() => {
|
||||
history.push(`/course/${courseId}/pages-and-resources`);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!appConfig || !app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DiscussionsConfigForm
|
||||
courseId={courseId}
|
||||
app={app}
|
||||
appConfig={appConfig}
|
||||
submitHandler={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
DiscussionConfig.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
123
src/pages-and-resources/discussions/DiscussionsConfigForm.jsx
Normal file
123
src/pages-and-resources/discussions/DiscussionsConfigForm.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
StatefulButton, Form, Button, Hyperlink,
|
||||
} from '@edx/paragon';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import messages from './messages';
|
||||
|
||||
function DiscussionConfigForm({
|
||||
courseId, appConfig, app, submitHandler, intl,
|
||||
}) {
|
||||
const {
|
||||
handleSubmit,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
values,
|
||||
errors,
|
||||
isSubmitting,
|
||||
} = useFormik({
|
||||
initialValues: appConfig,
|
||||
validationSchema: Yup.object().shape({
|
||||
consumerKey: Yup.string().required(intl.formatMessage(messages.consumerKeyRequired)),
|
||||
consumerSecret: Yup.string().required(intl.formatMessage(messages.consumerSecretRequired)),
|
||||
launchUrl: Yup.string().required(intl.formatMessage(messages.launchUrlRequired)),
|
||||
}),
|
||||
onSubmit: submitHandler,
|
||||
});
|
||||
|
||||
const submitButtonState = isSubmitting ? 'pending' : 'default';
|
||||
|
||||
return (
|
||||
<Form className="m-5" onSubmit={handleSubmit}>
|
||||
<h1>{intl.formatMessage(messages.configureApp, { name: app.name })}</h1>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="authoring.discussions.appDocInstructions"
|
||||
defaultMessage="Please visit the {documentationPageLink} for {name} to set up the tool, then paste your consumer key and consumer secret here."
|
||||
description="Instructions for the user to go visit a third party app's documentation to learn how to generate a set of values needed in this form."
|
||||
values={{
|
||||
documentationPageLink: (
|
||||
<Hyperlink destination={app.documentationUrl}>
|
||||
{intl.formatMessage(messages.documentationPage)}
|
||||
</Hyperlink>
|
||||
),
|
||||
name: app.name,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<Form.Group controlId="consumerKey">
|
||||
<Form.Label>{intl.formatMessage(messages.consumerKey)}</Form.Label>
|
||||
<Form.Control
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
value={values.consumerKey}
|
||||
className={{ 'is-invalid': !!errors.consumerKey }}
|
||||
aria-describedby="consumerKeyFeedback"
|
||||
/>
|
||||
<Form.Control.Feedback id="consumerKeyFeedback" type="invalid">
|
||||
{intl.formatMessage(messages.consumerKeyRequired)}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="consumerSecret">
|
||||
<Form.Label>{intl.formatMessage(messages.consumerSecret)}</Form.Label>
|
||||
<Form.Control
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
value={values.consumerSecret}
|
||||
className={{ 'is-invalid': !!errors.consumerSecret }}
|
||||
aria-describedby="consumerSecretFeedback"
|
||||
/>
|
||||
<Form.Control.Feedback id="consumerSecretFeedback" type="invalid">
|
||||
{intl.formatMessage(messages.consumerSecretRequired)}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="launchUrl">
|
||||
<Form.Label>{intl.formatMessage(messages.launchUrl)}</Form.Label>
|
||||
<Form.Control
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
value={values.launchUrl}
|
||||
className={{ 'is-invalid': !!errors.launchUrl }}
|
||||
aria-describedby="launchUrlFeedback"
|
||||
/>
|
||||
<Form.Control.Feedback id="launchUrlFeedback" type="invalid">
|
||||
{intl.formatMessage(messages.launchUrlRequired)}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<StatefulButton
|
||||
labels={{
|
||||
default: intl.formatMessage(messages.saveConfig),
|
||||
pending: intl.formatMessage(messages.savingConfig),
|
||||
complete: intl.formatMessage(messages.savedConfig),
|
||||
}}
|
||||
type="submit"
|
||||
state={submitButtonState}
|
||||
className="mr-3"
|
||||
/>
|
||||
<Button variant="link" onClick={() => history.push(`/course/${courseId}/pages-and-resources/discussion`)}>{intl.formatMessage(messages.backButton)}</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
DiscussionConfigForm.propTypes = {
|
||||
app: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
documentationUrl: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
appConfig: PropTypes.shape({
|
||||
consumerKey: PropTypes.string.isRequired,
|
||||
consumerSecret: PropTypes.string.isRequired,
|
||||
launchUrl: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
submitHandler: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(DiscussionConfigForm);
|
||||
40
src/pages-and-resources/discussions/DiscussionsRoutes.jsx
Normal file
40
src/pages-and-resources/discussions/DiscussionsRoutes.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Switch, useRouteMatch } from 'react-router';
|
||||
import { PageRoute } from '@edx/frontend-platform/react';
|
||||
|
||||
import DiscussionAppList from './DiscussionAppList';
|
||||
import DiscussionConfig from './DiscussionConfig';
|
||||
|
||||
/**
|
||||
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||
*
|
||||
* /course/:courseId
|
||||
*
|
||||
* Meaning that their absolute paths look like:
|
||||
*
|
||||
* /course/:courseId/course-pages
|
||||
* /course/:courseId/proctored-exam-settings
|
||||
*
|
||||
* This component and CourseAuthoringPage should maybe be combined once we no longer need to have
|
||||
* CourseAuthoringPage split out for use in LegacyProctoringRoute. Once that route is removed, we
|
||||
* can move the Header/Footer rendering to this component and likely pull the course detail loading
|
||||
* in as well, and it'd feel a bit better-factored and the roles would feel more clear.
|
||||
*/
|
||||
export default function DiscussionsRoutes({ courseId }) {
|
||||
const { path } = useRouteMatch();
|
||||
return (
|
||||
<Switch>
|
||||
<PageRoute exact path={`${path}`}>
|
||||
<DiscussionAppList courseId={courseId} />
|
||||
</PageRoute>
|
||||
<PageRoute path={`${path}/configure/:appId`}>
|
||||
<DiscussionConfig courseId={courseId} />
|
||||
</PageRoute>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
DiscussionsRoutes.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -2,39 +2,51 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCheck } from '@fortawesome/free-solid-svg-icons';
|
||||
import { DataTable } from '@edx/paragon';
|
||||
|
||||
export default function FeaturesTable({ apps, features }) {
|
||||
return (
|
||||
<div className="table-responsive features-table border border-info-300 p-3 mb-4">
|
||||
<table className="w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
{apps.map(app => (
|
||||
<th key={app.id} className="text-center py-3">
|
||||
<h5>{app.name}</h5>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{features.map(feature => (
|
||||
<tr key={feature.id}>
|
||||
<th key={feature.id} className="py-3">
|
||||
{feature.name}
|
||||
</th>
|
||||
{apps.map(app => (
|
||||
<td className="text-center py-3" key={app.id}>
|
||||
{app.featureIds.includes(feature.id) && (
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<>
|
||||
<DataTable
|
||||
itemCount={features.length}
|
||||
data={features.map(feature => {
|
||||
const appCheckmarkCells = {};
|
||||
// DataTable wants 'data' to be an array of objects where each property of an object
|
||||
// represents a cell in that row, identified by its key.
|
||||
apps.forEach(app => {
|
||||
// If our app's set of feature Ids includes this feature, return a checkmark.
|
||||
// i.e, if this app has the current feature, check it!
|
||||
appCheckmarkCells[app.id] = app.featureIds.includes(feature.id) ? (
|
||||
<div key={`${app.id}&${feature.id}`}>
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
</div>
|
||||
) : null;
|
||||
});
|
||||
|
||||
return {
|
||||
feature: feature.name, // 'feature' is the identifier for cells in the first column.
|
||||
// This is spreading the app IDs from appCheckmarkCells into the return array, creating
|
||||
// one object with 'feature' and the app.id keys from above. The values are the JSX
|
||||
// above with the font awesome checkmarks in 'em
|
||||
...appCheckmarkCells,
|
||||
};
|
||||
})}
|
||||
columns={[
|
||||
{
|
||||
Header: '',
|
||||
accessor: 'feature',
|
||||
},
|
||||
// We're converting our apps array into a bunch of objects with "Header" and "accessor"
|
||||
// keys, like DataTable expects.
|
||||
...apps.map(app => ({
|
||||
Header: app.name,
|
||||
accessor: app.id,
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<DataTable.Table />
|
||||
</DataTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export function getDiscussionApps() {
|
||||
const edXForumsApp = {
|
||||
id: 'edx-forums',
|
||||
name: 'edX Forum',
|
||||
logo: 'https://cdn-blog.lawrencemcdaniel.com/wp-content/uploads/2018/01/22125436/edx-logo.png',
|
||||
description: 'Start conversations with other learners, ask questions, and interact with other learners in the course.',
|
||||
supportLevel: 'Full support',
|
||||
isAvailable: true,
|
||||
documentationUrl: 'https://localhost/docs',
|
||||
featureIds: [
|
||||
'lti',
|
||||
'discussion-page',
|
||||
'embedded-course-sections',
|
||||
'embedded-course-units',
|
||||
'wcag-2.1',
|
||||
],
|
||||
};
|
||||
|
||||
const piazzaApp = {
|
||||
id: 'piazza',
|
||||
name: 'Piazza',
|
||||
logo: 'https://piazza.com/images/splash2/topbar/piazza_logo_blue.png',
|
||||
description: 'Piazza is designed to connect students, TAs, and professors so every student can get the help they need when they need it.',
|
||||
supportLevel: 'Partial support',
|
||||
isAvailable: true,
|
||||
documentationUrl: 'https://localhost/docs',
|
||||
featureIds: [
|
||||
'lti',
|
||||
'discussion-page',
|
||||
'embedded-course-sections',
|
||||
'wcag-2.1',
|
||||
],
|
||||
};
|
||||
|
||||
const yellowdigApp = {
|
||||
id: 'yellowdig',
|
||||
name: 'Yellowdig',
|
||||
logo: 'https://static.wixstatic.com/media/e53d7e_f8a17bd41db64a57a8d62bea4fdf3174~mv2.png/v1/crop/x_5,y_0,w_895,h_196/fill/w_366,h_80,al_c,q_85,usm_0.66_1.00_0.01/yellowdig-logo.webp',
|
||||
description: 'Yellowdig is the digital solution that impacts the entire student lifecycle and enables lifelong learning.',
|
||||
supportLevel: 'Coming soon',
|
||||
isAvailable: false,
|
||||
documentationUrl: 'https://localhost/docs',
|
||||
featureIds: [
|
||||
'lti',
|
||||
'discussion-page',
|
||||
'embedded-course-sections',
|
||||
'wcag-2.1',
|
||||
],
|
||||
};
|
||||
|
||||
export function getApps() {
|
||||
return Promise.resolve({
|
||||
features: [
|
||||
{
|
||||
@@ -24,64 +72,107 @@ export function getDiscussionApps() {
|
||||
},
|
||||
],
|
||||
apps: [
|
||||
edXForumsApp,
|
||||
piazzaApp,
|
||||
yellowdigApp,
|
||||
],
|
||||
activeAppId: 'piazza',
|
||||
});
|
||||
}
|
||||
|
||||
export function getAppConfig(courseId, appId) {
|
||||
let app = null;
|
||||
switch (appId) {
|
||||
case 'piazza':
|
||||
app = piazzaApp;
|
||||
break;
|
||||
case 'yellowdig':
|
||||
app = yellowdigApp;
|
||||
break;
|
||||
default:
|
||||
app = edXForumsApp;
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
app,
|
||||
appConfig: {
|
||||
id: 'appConfig1',
|
||||
consumerSecret: 'its-a-secret-to-everybody',
|
||||
consumerKey: 'abc123',
|
||||
launchUrl: 'https://localhost/launch',
|
||||
},
|
||||
features: [
|
||||
{
|
||||
id: 'edx-forums',
|
||||
name: 'edX Forum',
|
||||
logo: 'https://cdn-blog.lawrencemcdaniel.com/wp-content/uploads/2018/01/22125436/edx-logo.png',
|
||||
description: 'Start conversations with other learners, ask questions, and interact with other learners in the course.',
|
||||
supportLevel: 'Full support',
|
||||
isAvailable: true,
|
||||
featureIds: [
|
||||
'lti',
|
||||
'discussion-page',
|
||||
'embedded-course-sections',
|
||||
'embedded-course-units',
|
||||
'wcag-2.1',
|
||||
],
|
||||
id: 'lti',
|
||||
name: 'LTI Integration',
|
||||
},
|
||||
{
|
||||
id: 'piazza',
|
||||
name: 'Piazza',
|
||||
logo: 'https://cdn-blog.lawrencemcdaniel.com/wp-content/uploads/2018/01/22125436/edx-logo.png',
|
||||
description: 'Piazza is designed to connect students, TAs, and professors so every student can get the help they need when they need it',
|
||||
supportLevel: 'Partial support',
|
||||
isAvailable: true,
|
||||
featureIds: [
|
||||
'lti',
|
||||
'discussion-page',
|
||||
'embedded-course-sections',
|
||||
'wcag-2.1',
|
||||
],
|
||||
id: 'discussion-page',
|
||||
name: 'Discussion Page',
|
||||
},
|
||||
{
|
||||
id: 'yellowdig',
|
||||
name: 'Yellowdig',
|
||||
logo: 'https://cdn-blog.lawrencemcdaniel.com/wp-content/uploads/2018/01/22125436/edx-logo.png',
|
||||
description: 'Yellowdig is the digital solution that impacts the entire student lifecycle and enables lifelong learning.',
|
||||
supportLevel: 'Coming soon',
|
||||
isAvailable: false,
|
||||
featureIds: [
|
||||
'lti',
|
||||
'discussion-page',
|
||||
'embedded-course-sections',
|
||||
'wcag-2.1',
|
||||
],
|
||||
id: 'embedded-course-sections',
|
||||
name: 'Embedded Course Sections',
|
||||
},
|
||||
{
|
||||
id: 'untitled-forum',
|
||||
name: 'Untitled Forum',
|
||||
logo: 'https://cdn-blog.lawrencemcdaniel.com/wp-content/uploads/2018/01/22125436/edx-logo.png',
|
||||
description: 'Start conversations with other learners, ask questions, and interact with other learners in the course.',
|
||||
supportLevel: 'Full support',
|
||||
isAvailable: true,
|
||||
featureIds: [
|
||||
'lti',
|
||||
'discussion-page',
|
||||
'embedded-course-sections',
|
||||
'embedded-course-units',
|
||||
'wcag-2.1',
|
||||
],
|
||||
id: 'embedded-course-units',
|
||||
name: 'Embedded Course Units',
|
||||
},
|
||||
{
|
||||
id: 'wcag-2.1',
|
||||
name: 'WCAG 2.1 Support',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function postAppConfig(courseId, appId, drafts) {
|
||||
let app = null;
|
||||
switch (appId) {
|
||||
case 'piazza':
|
||||
app = piazzaApp;
|
||||
break;
|
||||
case 'yellowdig':
|
||||
app = yellowdigApp;
|
||||
break;
|
||||
default:
|
||||
app = edXForumsApp;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
app,
|
||||
appConfig: {
|
||||
id: 'appConfig1',
|
||||
consumerSecret: 'its-a-secret-to-everybody',
|
||||
consumerKey: 'abc123',
|
||||
launchUrl: 'https://localhost/launch',
|
||||
documentationUrl: 'https://localhost/docs',
|
||||
...drafts,
|
||||
},
|
||||
features: [
|
||||
{
|
||||
id: 'lti',
|
||||
name: 'LTI Integration',
|
||||
},
|
||||
{
|
||||
id: 'discussion-page',
|
||||
name: 'Discussion Page',
|
||||
},
|
||||
{
|
||||
id: 'embedded-course-sections',
|
||||
name: 'Embedded Course Sections',
|
||||
},
|
||||
{
|
||||
id: 'embedded-course-units',
|
||||
name: 'Embedded Course Units',
|
||||
},
|
||||
{
|
||||
id: 'wcag-2.1',
|
||||
name: 'WCAG 2.1 Support',
|
||||
},
|
||||
],
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,11 +4,16 @@ import { createSlice } from '@reduxjs/toolkit';
|
||||
export const LOADING = 'LOADING';
|
||||
export const LOADED = 'LOADED';
|
||||
export const FAILED = 'FAILED';
|
||||
export const SAVING = 'SAVING';
|
||||
export const SAVED = 'SAVED';
|
||||
export const DIRTY = 'DIRTY';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'discussions',
|
||||
initialState: {
|
||||
appIds: [],
|
||||
activeAppId: null,
|
||||
activeAppConfigId: null,
|
||||
featureIds: [],
|
||||
status: LOADING,
|
||||
},
|
||||
@@ -16,7 +21,11 @@ const slice = createSlice({
|
||||
fetchAppsSuccess: (state, { payload }) => {
|
||||
state.appIds = payload.appIds;
|
||||
state.featureIds = payload.featureIds;
|
||||
state.status = LOADED;
|
||||
},
|
||||
fetchAppConfigSuccess: (state, { payload }) => {
|
||||
state.activeAppId = payload.activeAppId;
|
||||
state.activeAppConfigId = payload.activeAppConfigId;
|
||||
state.featureIds = payload.featureIds;
|
||||
},
|
||||
updateStatus: (state, { payload }) => {
|
||||
state.status = payload.status;
|
||||
@@ -26,6 +35,7 @@ const slice = createSlice({
|
||||
|
||||
export const {
|
||||
fetchAppsSuccess,
|
||||
fetchAppConfigSuccess,
|
||||
updateStatus,
|
||||
} = slice.actions;
|
||||
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { getDiscussionApps } from './api';
|
||||
import { addModels } from '../../../generic/model-store';
|
||||
import {
|
||||
FAILED, fetchAppsSuccess, LOADING, updateStatus,
|
||||
getApps,
|
||||
getAppConfig,
|
||||
postAppConfig,
|
||||
} from './api';
|
||||
import { addModel, addModels } from '../../../generic/model-store';
|
||||
import {
|
||||
FAILED,
|
||||
fetchAppsSuccess,
|
||||
fetchAppConfigSuccess,
|
||||
LOADING,
|
||||
updateStatus,
|
||||
SAVING,
|
||||
SAVED,
|
||||
LOADED,
|
||||
} from './slice';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
@@ -10,7 +21,7 @@ export function fetchApps(courseId) {
|
||||
dispatch(updateStatus({ courseId, status: LOADING }));
|
||||
|
||||
try {
|
||||
const { apps, features } = await getDiscussionApps(courseId);
|
||||
const { apps, features } = await getApps(courseId);
|
||||
|
||||
dispatch(addModels({ modelType: 'apps', models: apps }));
|
||||
dispatch(addModels({ modelType: 'features', models: features }));
|
||||
@@ -18,6 +29,7 @@ export function fetchApps(courseId) {
|
||||
appIds: apps.map(app => app.id),
|
||||
featureIds: features.map(feature => feature.id),
|
||||
}));
|
||||
dispatch(updateStatus({ courseId, status: LOADED }));
|
||||
} 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
|
||||
@@ -26,3 +38,51 @@ export function fetchApps(courseId) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchAppConfig(courseId, appId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateStatus({ courseId, status: LOADING }));
|
||||
|
||||
try {
|
||||
const { app, appConfig, features } = await getAppConfig(courseId, appId);
|
||||
|
||||
dispatch(addModel({ modelType: 'apps', model: app }));
|
||||
dispatch(addModels({ modelType: 'features', models: features }));
|
||||
dispatch(addModel({ modelType: 'appConfigs', model: appConfig }));
|
||||
dispatch(fetchAppConfigSuccess({
|
||||
activeAppId: app.id,
|
||||
activeAppConfigId: appConfig.id,
|
||||
featureIds: features.map(feature => feature.id),
|
||||
}));
|
||||
dispatch(updateStatus({ courseId, status: LOADED }));
|
||||
} 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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function saveAppConfig(courseId, appId, drafts) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateStatus({ courseId, status: SAVING }));
|
||||
|
||||
try {
|
||||
const { app, appConfig, features } = await postAppConfig(courseId, appId, drafts);
|
||||
|
||||
dispatch(addModel({ modelType: 'apps', model: app }));
|
||||
dispatch(addModels({ modelType: 'features', models: features }));
|
||||
dispatch(addModel({ modelType: 'appConfigs', model: appConfig }));
|
||||
|
||||
dispatch(fetchAppConfigSuccess({
|
||||
activeAppId: app.id,
|
||||
activeAppConfigId: appConfig.id,
|
||||
featureIds: features.map(feature => feature.id),
|
||||
}));
|
||||
dispatch(updateStatus({ courseId, status: SAVED }));
|
||||
} catch (error) {
|
||||
dispatch(updateStatus({ courseId, status: FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,64 @@ const messages = defineMessages({
|
||||
id: 'authoring.discussions.appLogo',
|
||||
defaultMessage: '{name} Logo',
|
||||
},
|
||||
configModalTitle: {
|
||||
id: 'authoring.discussions.modalTitle',
|
||||
defaultMessage: 'Configure {name}',
|
||||
},
|
||||
saveConfig: {
|
||||
id: 'authoring.discussions.saveConfig',
|
||||
defaultMessage: 'Save',
|
||||
description: 'Button allowing a user to save their discussions config.',
|
||||
},
|
||||
savingConfig: {
|
||||
id: 'authoring.discussions.savingConfig',
|
||||
defaultMessage: 'Saving',
|
||||
description: 'Button text shown while a discussion config is being saved.',
|
||||
},
|
||||
savedConfig: {
|
||||
id: 'authoring.discussions.savedConfig',
|
||||
defaultMessage: 'Saved',
|
||||
description: 'Button text shown once a discussion config has been saved to the server.',
|
||||
},
|
||||
documentationPage: {
|
||||
id: 'authoring.discussions.documentationPage',
|
||||
defaultMessage: 'documentation page',
|
||||
},
|
||||
consumerKey: {
|
||||
id: 'authoring.discussions.consumerKey',
|
||||
defaultMessage: 'Consumer Key',
|
||||
description: 'Label for the Consumer Key field.',
|
||||
},
|
||||
consumerSecret: {
|
||||
id: 'authoring.discussions.consumerSecret',
|
||||
defaultMessage: 'Consumer Secret',
|
||||
description: 'Label for the Consumer Secret field.',
|
||||
},
|
||||
launchUrl: {
|
||||
id: 'authoring.discussions.launchUrl',
|
||||
defaultMessage: 'Launch URL',
|
||||
description: 'Label for the Launch URL field.',
|
||||
},
|
||||
consumerKeyRequired: {
|
||||
id: 'authoring.discussions.consumerKey.required',
|
||||
defaultMessage: 'Consumer Key is a required field.',
|
||||
description: 'Tells the user that the Consumer Key field is required and must have a value.',
|
||||
},
|
||||
consumerSecretRequired: {
|
||||
id: 'authoring.discussions.consumerSecret.required',
|
||||
defaultMessage: 'Consumer Secret is a required field.',
|
||||
description: 'Tells the user that the Consumer Secret field is required and must have a value.',
|
||||
},
|
||||
launchUrlRequired: {
|
||||
id: 'authoring.discussions.launchUrl.required',
|
||||
defaultMessage: 'Launch URL is a required field.',
|
||||
description: 'Tells the user that the Launch URL field is required and must have a value.',
|
||||
},
|
||||
backButton: {
|
||||
id: 'authoring.discussions.backButton',
|
||||
defaultMessage: 'Back',
|
||||
description: 'Back button allowing the user to return to discussion app selection.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Button } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCog } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import { useLocation } from 'react-router';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import messages from '../messages';
|
||||
|
||||
const CoursePageShape = PropTypes.shape({
|
||||
@@ -21,6 +23,8 @@ const CoursePageShape = PropTypes.shape({
|
||||
export { CoursePageShape };
|
||||
|
||||
function PageCard({ intl, page }) {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const pageStatusMsgId = page.isEnabled ? 'pageStatus.enabled' : 'pageStatus.disabled';
|
||||
const componentClasses = classNames(
|
||||
'd-flex flex-column align-content-stretch',
|
||||
@@ -28,6 +32,10 @@ function PageCard({ intl, page }) {
|
||||
{ 'border-info-300': page.isEnabled, 'border-gray-100': !page.isEnabled },
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
history.push(`${pathname}/${page.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="d-flex flex-column align-content-stretch p-3 col-sm-12 col-md-6 col-lg-4"
|
||||
@@ -52,11 +60,11 @@ function PageCard({ intl, page }) {
|
||||
</div>
|
||||
|
||||
{page.showEnable && !page.isEnabled && (
|
||||
<div className="d-flex justify-content-center">
|
||||
<Button variant="outline-primary">
|
||||
{intl.formatMessage(messages['enable.button'])}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="d-flex justify-content-center">
|
||||
<Button variant="outline-primary" onClick={handleClick}>
|
||||
{intl.formatMessage(messages['enable.button'])}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user