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:
David Joy
2021-02-26 13:45:56 -05:00
committed by GitHub
parent 275013f914
commit 013aba58a6
17 changed files with 8291 additions and 3943 deletions

1
.gitignore vendored
View File

@@ -19,3 +19,4 @@ temp/babel-plugin-react-intl
*~
/temp
/.vscode
/module.config.js

11401
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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} />

View File

@@ -1,4 +1,3 @@
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";

View File

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

View File

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

View File

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

View 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,
};

View 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);

View 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,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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