Backing discussions with data API/thunks/reducer. (#47)

* Backing discussions with data API/thunks/reducer.

This pulls all the data loading logic out of the React components and makes it significantly more flexible.

- Both apps and features have IDs and can be looked up in the store.
- The API layer is currently just returning hard coded data.
- LOADED and LOADING statuses are available to implement loading spinners and feedback.
- The taxonomy has been changed a bit - “forums and “tools” are now consistently referred to as “apps” - this code is almost completely agnostic to discussions, meaning that it could easily be repurposed for other kinds of apps, such as proctoring providers.

* Using ‘app’ and ‘name language in DiscussionAppCard messages.

* Using the selectedApp’s name for the Configure button.

* Misc review fixes

- better comment on error handling
- Fixing some CSS class names.
This commit is contained in:
David Joy
2021-01-11 15:52:24 -05:00
committed by GitHub
parent cb11c1dd01
commit 9b2ad5e95d
9 changed files with 228 additions and 89 deletions

View File

@@ -9,19 +9,19 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
function DiscussionToolOption({
intl, forum, selected, onSelect,
function DiscussionAppCard({
intl, app, selected, clickHandler,
}) {
return (
<Col className="mb-4" xs={12} sm={6} lg={4} xl={3}>
<div
className="d-flex position-relative discussion-tool flex-column p-3 h-100 shadow border border-white"
className="d-flex position-relative discussion-app-card flex-column p-3 h-100 shadow border border-white"
style={{
cursor: 'pointer',
}}
tabIndex={forum.isAvailable ? '-1' : ''}
onClick={() => { if (forum.isAvailable) { onSelect(forum.forumId); } }}
onKeyPress={() => { if (forum.isAvailable) { onSelect(forum.forumId); } }}
tabIndex={app.isAvailable ? '-1' : ''}
onClick={() => { if (app.isAvailable) { clickHandler(app.id); } }}
onKeyPress={() => { if (app.isAvailable) { clickHandler(app.id); } }}
role="radio"
aria-checked={selected}
>
@@ -33,7 +33,7 @@ function DiscussionToolOption({
right: '0.75rem',
}}
>
{forum.isAvailable ? (
{app.isAvailable ? (
<Input readOnly type="checkbox" checked={selected} />
) : (
<FontAwesomeIcon icon={faLock} />
@@ -45,28 +45,28 @@ function DiscussionToolOption({
<div className="d-flex justify-content-center">
<Image
height={100}
src={forum.logo}
alt={intl.formatMessage(messages.toolLogo, {
toolName: forum.forumId,
src={app.logo}
alt={intl.formatMessage(messages.appLogo, {
name: app.name,
})}
/>
</div>
</div>
<br />
<div className="py-4">{forum.description}</div>
<div className="py-4">{app.description}</div>
<br />
<div className="mt-auto font-weight-bold">{forum.supportLevel}</div>
<div className="mt-auto font-weight-bold">{app.supportLevel}</div>
</div>
</Col>
);
}
DiscussionToolOption.propTypes = {
DiscussionAppCard.propTypes = {
intl: intlShape.isRequired,
forum: PropTypes.objectOf(PropTypes.any).isRequired,
app: PropTypes.objectOf(PropTypes.any).isRequired,
selected: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
clickHandler: PropTypes.func.isRequired,
};
export default injectIntl(DiscussionToolOption);
export default injectIntl(DiscussionAppCard);

View File

@@ -1,71 +1,49 @@
import React, { useState } from 'react';
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 { useDispatch, useSelector } from 'react-redux';
import messages from './messages';
import DiscussionAppCard from './DiscussionAppCard';
import FeaturesTable from './FeaturesTable';
import { useModel, useModels } from '../../generic/model-store';
import { fetchApps } from './data/thunks';
// XXX this is just for testing and should be removed ASAP
const forums = [
{
forumId: '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,
features: ['LTI Integration', 'Discussion Page', 'Embedded Course Sections', 'Embedded Course Units', 'WCAG 2.1 Support'],
},
{
forumId: '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,
features: ['LTI Integration', 'Discussion Page', 'Embedded Course Sections', 'WCAG 2.1 Support'],
},
{
forumId: '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,
features: ['LTI Integration', 'Discussion Page', 'Embedded Course Sections', 'WCAG 2.1 Support'],
},
{
forumId: '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,
features: ['LTI Integration', 'Discussion Page', 'Embedded Course Sections', 'Embedded Course Units', 'WCAG 2.1 Support'],
},
];
function DiscussionAppList({ courseId, intl }) {
const [selectedAppId, setSelectedAppId] = useState(null);
const featuresList = ['LTI Integration', 'Discussion Page', 'Embedded Course Sections', 'Embedded Course Units', 'WCAG 2.1 Support'];
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchApps(courseId));
}, [courseId]);
function DiscussionAppList({ intl }) {
const [selectedForumId, setSelectedForumId] = useState(null);
const appIds = useSelector(state => state.discussions.appIds);
const featureIds = useSelector(state => state.discussions.featureIds);
const apps = useModels('apps', appIds);
const features = useModels('features', featureIds);
const onSelectForum = (forumId) => {
if (selectedForumId === forumId) {
setSelectedForumId(null);
const selectedApp = useModel('apps', selectedAppId);
const handleSelectApp = useCallback((appId) => {
if (selectedAppId === appId) {
setSelectedAppId(null);
} else {
setSelectedForumId(forumId);
setSelectedAppId(appId);
}
};
}, [selectedAppId]);
return (
<Container fluid className="text-info-500">
<h6 className="my-4 text-center">{intl.formatMessage(messages.heading)}</h6>
<Row>
{forums.map(forum => (
{apps.map(app => (
<DiscussionAppCard
key={forum.forumId}
forum={forum}
selected={forum.forumId === selectedForumId}
onSelect={onSelectForum}
key={app.id}
app={app}
selected={app.id === selectedAppId}
clickHandler={handleSelectApp}
/>
))}
</Row>
@@ -74,19 +52,23 @@ function DiscussionAppList({ intl }) {
<h2 className="my-3">
{intl.formatMessage(messages.supportedFeatures)}
</h2>
{selectedForumId && (
{selectedAppId && (
<Button variant="primary">
{intl.formatMessage(messages.configureTool, { toolName: selectedForumId })}
{intl.formatMessage(messages.configureApp, { name: selectedApp.name })}
</Button>
)}
</div>
<FeaturesTable forums={forums} featuresList={featuresList} />
<FeaturesTable
apps={apps}
features={features}
/>
</Container>
);
}
DiscussionAppList.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};

View File

@@ -1,4 +1,4 @@
.discussion-tool {
.discussion-app-card {
&:hover,
&:focus {
border-color: theme-color("primary", "hover") !important;

View File

@@ -3,25 +3,31 @@ import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
export default function FeaturesTable({ forums, featuresList }) {
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>
{forums.map(forum => (
<th className="text-center py-3" key={forum.forumId}><h5>{forum.forumId}</h5></th>
{apps.map(app => (
<th key={app.id} className="text-center py-3">
<h5>{app.name}</h5>
</th>
))}
</tr>
</thead>
<tbody>
{featuresList.map(feature => (
<tr key={feature}>
<th key={feature} className="py-3">{feature}</th>
{forums.map(forum => (
<td className="text-center py-3" key={forum.forumId}>
{forum.features.includes(feature) && <FontAwesomeIcon icon={faCheck} />}
{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>
@@ -33,6 +39,6 @@ export default function FeaturesTable({ forums, featuresList }) {
}
FeaturesTable.propTypes = {
forums: PropTypes.arrayOf(PropTypes.object).isRequired,
featuresList: PropTypes.arrayOf(PropTypes.string).isRequired,
apps: PropTypes.arrayOf(PropTypes.object).isRequired,
features: PropTypes.arrayOf(PropTypes.object).isRequired,
};

View File

@@ -0,0 +1,87 @@
/* eslint-disable import/prefer-default-export */
export function getDiscussionApps() {
return Promise.resolve({
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',
},
],
apps: [
{
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: '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: '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: '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',
],
},
],
});
}

View File

@@ -0,0 +1,34 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'LOADING';
export const LOADED = 'LOADED';
export const FAILED = 'FAILED';
const slice = createSlice({
name: 'discussions',
initialState: {
appIds: [],
featureIds: [],
status: LOADING,
},
reducers: {
fetchAppsSuccess: (state, { payload }) => {
state.appIds = payload.appIds;
state.featureIds = payload.featureIds;
state.status = LOADED;
},
updateStatus: (state, { payload }) => {
state.status = payload.status;
},
},
});
export const {
fetchAppsSuccess,
updateStatus,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,28 @@
import { getDiscussionApps } from './api';
import { addModels } from '../../../generic/model-store';
import {
FAILED, fetchAppsSuccess, LOADING, updateStatus,
} from './slice';
/* eslint-disable import/prefer-default-export */
export function fetchApps(courseId) {
return async (dispatch) => {
dispatch(updateStatus({ courseId, status: LOADING }));
try {
const { apps, features } = await getDiscussionApps(courseId);
dispatch(addModels({ modelType: 'apps', models: apps }));
dispatch(addModels({ modelType: 'features', models: features }));
dispatch(fetchAppsSuccess({
appIds: apps.map(app => app.id),
featureIds: features.map(feature => feature.id),
}));
} 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 }));
}
};
}

View File

@@ -2,20 +2,20 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
heading: {
id: 'authoring.forumSelectorTool.heading',
id: 'authoring.discussions.heading',
defaultMessage: 'Which discussion tool would you like to use for this course?',
},
supportedFeatures: {
id: 'authoring.forumSelectorTool.supportedFeatures',
id: 'authoring.discussions.supportedFeatures',
defaultMessage: 'Supported Features',
},
configureTool: {
id: 'authoring.forumSelectorTool.configureTool',
defaultMessage: 'Configure {toolName}',
configureApp: {
id: 'authoring.discussions.configureApp',
defaultMessage: 'Configure {name}',
},
toolLogo: {
id: 'authoring.forumSelectorTool.toolLogo',
defaultMessage: '{toolName} Logo',
appLogo: {
id: 'authoring.discussions.appLogo',
defaultMessage: '{name} Logo',
},
});

View File

@@ -2,11 +2,13 @@ import { configureStore } from '@reduxjs/toolkit';
import { reducer as modelsReducer } from './generic/model-store';
import { reducer as courseDetailReducer } from './data/slice';
import { reducer as discussionsReducer } from './pages-and-resources/discussions/data/slice';
export default function initializeStore() {
return configureStore({
reducer: {
courseDetail: courseDetailReducer,
discussions: discussionsReducer,
models: modelsReducer,
},
});