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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.discussion-tool {
|
||||
.discussion-app-card {
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: theme-color("primary", "hover") !important;
|
||||
|
||||
@@ -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> </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,
|
||||
};
|
||||
|
||||
87
src/pages-and-resources/discussions/data/api.js
Normal file
87
src/pages-and-resources/discussions/data/api.js
Normal 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',
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
34
src/pages-and-resources/discussions/data/slice.js
Normal file
34
src/pages-and-resources/discussions/data/slice.js
Normal 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;
|
||||
28
src/pages-and-resources/discussions/data/thunks.js
Normal file
28
src/pages-and-resources/discussions/data/thunks.js
Normal 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 }));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user