Merge pull request #13 from edx/kshitij/bb-2900/post-editor

feat: Update the post editor to include TinyMCE and connect to API [BD-38] [TNL-7353]  [BB-2900]
This commit is contained in:
Kshitij Sobti
2021-09-14 13:49:32 +00:00
committed by GitHub
22 changed files with 573 additions and 140 deletions

View File

@@ -1,3 +1,27 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('eslint');
module.exports = createConfig('eslint',
{
"plugins": ["simple-import-sort"],
"rules": {
'simple-import-sort/imports': [
'error', {
groups: [
// These packages provide polyfills so should always be first
['core-js', 'regenerator-runtime'],
// React packages should come at the top
['^react$', '^react-dom$', '^prop-types'],
// Non-react third-party packages come next
['^@?\\w'],
// Packages from the @edx namespace come after that
['^@edx?\\w'],
// Finally we have internal, relative imports
['^\\.\\.(?!/?$)', '^\\.\\./?$', '^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
],
},
],
'simple-import-sort/exports': 'error',
}
}
);

86
package-lock.json generated
View File

@@ -5170,6 +5170,15 @@
}
}
},
"@tinymce/tinymce-react": {
"version": "3.12.6",
"resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-3.12.6.tgz",
"integrity": "sha512-a7/Ns7uVsSr2N0fCxn+OhDx8f9JqfywTlHbXsgcwlWB6vIBMIjjRBJ6PGo/5H0y2vfzO6fBzd4gc6h05Cm5R7A==",
"requires": {
"prop-types": "^15.6.2",
"tinymce": "^5.5.1"
}
},
"@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -9557,6 +9566,12 @@
"integrity": "sha512-iXTCFcOmlWvw4+TOE8CLWj6yX1GwzT0Y6cUfHHZqWnSk144VmVIRcVGtUAzrLES7C798lmvnt02C7rxaOX1HNA==",
"dev": true
},
"eslint-plugin-simple-import-sort": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-7.0.0.tgz",
"integrity": "sha512-U3vEDB5zhYPNfxT5TYR7u01dboFZp+HNpnGhkDB2g/2E4wZ/g1Q9Ton8UwCLfRV9yAKyYqDh62oHOamvkFxsvw==",
"dev": true
},
"eslint-scope": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@@ -10440,6 +10455,32 @@
"integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==",
"dev": true
},
"formik": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/formik/-/formik-2.2.6.tgz",
"integrity": "sha512-Kxk2zQRafy56zhLmrzcbryUpMBvT0tal5IvcifK5+4YNGelKsnrODFJ0sZQRMQboblWNym4lAW3bt+tf2vApSA==",
"requires": {
"deepmerge": "^2.1.1",
"hoist-non-react-statics": "^3.3.0",
"lodash": "^4.17.14",
"lodash-es": "^4.17.14",
"react-fast-compare": "^2.0.1",
"tiny-warning": "^1.0.2",
"tslib": "^1.10.0"
},
"dependencies": {
"deepmerge": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA=="
},
"react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
}
}
},
"forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -16088,8 +16129,12 @@
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
},
"lodash.camelcase": {
"version": "4.3.0",
@@ -18491,6 +18536,11 @@
"warning": "^4.0.0"
}
},
"property-expr": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.4.tgz",
"integrity": "sha512-sFPkHQjVKheDNnPvotjQmm3KD3uk1fWKUN7CrpdbwmUx3CrG3QiM8QpTSimvig5vTXmTvjz7+TDvXOI9+4rkcg=="
},
"proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
@@ -21421,6 +21471,11 @@
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"tinymce": {
"version": "5.9.1",
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.9.1.tgz",
"integrity": "sha512-GaG15OhQB1zR2L63fywhEAViTxkAlhX5JbA+48+O0zCo1FwDuQ8iUVi/FzaOX9Uo7ULp+Y2gu4r3P4ZNueDVPQ=="
},
"tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@@ -21497,6 +21552,11 @@
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==",
"dev": true
},
"toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha1-riF2gXXRVZ1IvvNUILL0li8JwzA="
},
"tough-cookie": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz",
@@ -22809,6 +22869,28 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true
},
"yup": {
"version": "0.31.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-0.31.1.tgz",
"integrity": "sha512-Lf6648jDYOWR75IlWkVfwesPyW6oj+50NpxlKvsQlpPsB8eI+ndI7b4S1VrwbmeV9hIZDu1MzrlIL4W+gK1jPw==",
"requires": {
"@babel/runtime": "^7.10.5",
"lodash": "^4.17.20",
"lodash-es": "^4.17.11",
"property-expr": "^2.0.4",
"toposort": "^2.0.2"
},
"dependencies": {
"@babel/runtime": {
"version": "7.15.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz",
"integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
}
}
}
}
}

View File

@@ -41,8 +41,10 @@
"@edx/frontend-platform": "1.12.5",
"@edx/paragon": "16.12.0",
"@reduxjs/toolkit": "1.6.1",
"@tinymce/tinymce-react": "3.12.6",
"babel-polyfill": "6.26.0",
"core-js": "3.17.2",
"formik": "2.2.6",
"lodash.snakecase": "4.1.1",
"prop-types": "15.7.2",
"react": "16.14.0",
@@ -52,12 +54,15 @@
"react-router-dom": "5.3.0",
"redux": "4.1.1",
"regenerator-runtime": "0.13.9",
"timeago.js": "4.0.2"
"timeago.js": "4.0.2",
"tinymce": "5.9.1",
"yup": "0.31.1"
},
"devDependencies": {
"@edx/frontend-build": "8.0.4",
"codecov": "3.8.3",
"es-check": "5.2.3",
"eslint-plugin-simple-import-sort": "7.0.0",
"glob": "7.1.7",
"husky": "7.0.2",
"jest": "27.1.0",

View File

@@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getIn, useFormikContext } from 'formik';
import { Form, TransitionReplace } from '@edx/paragon';
function FormikErrorFeedback({ name }) {
const {
touched,
errors,
} = useFormikContext();
const fieldTouched = getIn(touched, name);
const fieldError = getIn(errors, name);
return (
<TransitionReplace>
{fieldTouched && fieldError
? (
<Form.Control.Feedback type="invalid" hasIcon={false} key={`${name}-error-feedback`}>
{fieldError}
</Form.Control.Feedback>
)
: (
<React.Fragment key={`${name}-no-error-feedback`} />
)}
</TransitionReplace>
);
}
FormikErrorFeedback.propTypes = {
name: PropTypes.string.isRequired,
};
export default FormikErrorFeedback;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Editor } from '@tinymce/tinymce-react';
/* eslint import/no-webpack-loader-syntax: off */
import contentCss from 'tinymce/skins/content/default/content.min.css';
import contentUiCss from 'tinymce/skins/ui/oxide/content.min.css';
// TinyMCE so the global var exists
// eslint-disable-next-line no-unused-vars,import/no-extraneous-dependencies
import tinymce from 'tinymce/tinymce';
import 'tinymce/plugins/code';
// Theme
import 'tinymce/themes/silver';
// Toolbar icons
import 'tinymce/icons/default';
// Editor styles
import 'tinymce/skins/ui/oxide/skin.min.css';
// importing the plugin js.
import 'tinymce/plugins/autolink';
import 'tinymce/plugins/codesample';
import 'tinymce/plugins/link';
import 'tinymce/plugins/lists';
export default function TinyMCEEditor(props) {
// note that skin and content_css is disabled to avoid the normal
// loading process and is instead loaded as a string via content_style
return (
<Editor
init={{
skin: false,
menubar: false,
plugins: 'codesample link lists code',
toolbar: 'formatselect | bold italic underline | link | bullist numlist outdent indent | code |',
content_css: false,
content_style: [contentCss, contentUiCss].join('\n'),
}}
{...props}
/>
);
}

View File

@@ -1,2 +1,3 @@
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
export { default as SelectableDropdown } from './SelectableDropdown';
export { default as TinyMCEEditor } from './TinyMCEEditor';

View File

@@ -96,6 +96,16 @@ export const Routes = {
PATH: `${BASE_PATH}/topics/:topicId`,
MY_POSTS: `${BASE_PATH}/my-posts(/:postId)?`,
ALL_POSTS: `${BASE_PATH}/posts(/:postId)?`,
NEW_POST: [
`${BASE_PATH}/topics/:topicId/posts/:postId`,
`${BASE_PATH}/topics/:topicId`,
`${BASE_PATH}`,
],
EDIT_POST: [
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
`${BASE_PATH}/posts/:postId/edit`,
`${BASE_PATH}/my-posts/:postId/edit`,
],
},
COMMENTS: {
PATH: [

View File

@@ -17,7 +17,7 @@ function ReplyHeader({ reply, intl }) {
return (
<div className="d-flex flex-row justify-content-between">
<div className="align-items-center d-flex flex-row">
<Avatar className="m-2" alt={reply.author} src={reply.users[reply.author].profile.image.image_url_small} />
<Avatar className="m-2" alt={reply.author} src={reply.users?.[reply.author]?.profile.image.image_url_small} />
<div className="status small">
<a href="#nowhere">
<h1 className="font-weight-normal text-info-300 mr-1 small">

View File

@@ -1,17 +1,35 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Route, Switch } from 'react-router';
import { useDispatch, useSelector } from 'react-redux';
import {
generatePath, Route, Switch, useHistory, useRouteMatch,
} from 'react-router';
import { PostActionsBar } from '../../components';
import { Routes } from '../../data/constants';
import { CommentsView } from '../comments';
import { BreadcrumbMenu, NavigationBar } from '../navigation';
import { PostEditor, PostsView } from '../posts';
import { clearRedirect } from '../posts/data';
import { TopicsView } from '../topics';
export default function DiscussionsHome() {
const dispatch = useDispatch();
const history = useHistory();
const { params } = useRouteMatch(Routes.DISCUSSIONS.PATH);
const postEditorVisible = useSelector(state => state.threads.postEditorVisible);
const redirectToThread = useSelector(state => state.threads.redirectToThread);
useEffect(() => {
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
// stored in redirectToThread
if (redirectToThread) {
dispatch(clearRedirect());
history.push(generatePath(Routes.COMMENTS.PAGES['my-posts'], {
courseId: params.courseId,
postId: redirectToThread.threadId,
}));
}
}, [redirectToThread]);
return (
<main className="container my-4 d-flex flex-row">
@@ -29,8 +47,7 @@ export default function DiscussionsHome() {
<Route path={Routes.POSTS.MY_POSTS}>
<PostsView showOwnPosts />
</Route>
<Route path={Routes.POSTS.ALL_POSTS} component={PostsView} />
<Route path={Routes.POSTS.PATH} component={PostsView} />
<Route path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS]} component={PostsView} />
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
</Switch>
</div>
@@ -38,12 +55,20 @@ export default function DiscussionsHome() {
<div className="d-flex w-50 pl-1 flex-column">
<PostActionsBar />
{
postEditorVisible ? <PostEditor />
: (
postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}>
<PostEditor />
</Route>
) : (
<Switch>
<Route path={Routes.POSTS.EDIT_POST}>
<PostEditor editExisting />
</Route>
<Route path={Routes.COMMENTS.PATH}>
<CommentsView />
</Route>
)
</Switch>
)
}
</div>
</main>

View File

@@ -2,7 +2,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
allTopics: {
id: 'discussions.navigation.breadcrumb-menu.all-topics',
id: 'discussions.navigation.breadcrumbMenu.allTopics',
defaultMessage: 'Topics',
description: 'Topics from Breadcrumb Menu item',
},

View File

@@ -2,17 +2,17 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
allPosts: {
id: 'discussions.navigation.navigation-bar.all-posts',
id: 'discussions.navigation.navigationBar.allPosts',
defaultMessage: 'All posts',
description: 'Option in navbar to show all posts',
},
allTopics: {
id: 'discussions.navigation.navigation-bar.all-topics',
id: 'discussions.navigation.navigationBar.allTopics',
defaultMessage: 'All Topics',
description: 'Option in navbar to show all topics',
},
myPosts: {
id: 'discussions.navigation.navigation-bar.my-posts',
id: 'discussions.navigation.navigationBar.myPosts',
defaultMessage: 'My posts',
description: 'Option in navbar to show a user\'s posts',
},

View File

@@ -12,7 +12,7 @@ import {
function normaliseProfileImage(currentThread, newThread) {
newThread.authorAvatars = newThread.users
? newThread.users?.[newThread.author].profile.image
: currentThread.authorAvatars;
: currentThread?.authorAvatars;
return newThread;
}
@@ -56,6 +56,7 @@ const threadsSlice = createSlice({
search: '',
},
postEditorVisible: false,
redirectToThread: null,
sortedBy: ThreadOrdering.BY_LAST_ACTIVITY,
},
reducers: {
@@ -97,6 +98,7 @@ const threadsSlice = createSlice({
postThreadSuccess: (state, { payload }) => {
state.postStatus = RequestStatus.SUCCESSFUL;
normaliseThreads(state, [payload]);
state.redirectToThread = { topicId: payload.topicId, threadId: payload.id };
state.threadDraft = null;
},
postThreadFailed: (state) => {
@@ -153,10 +155,14 @@ const threadsSlice = createSlice({
},
showPostEditor: (state) => {
state.postEditorVisible = true;
state.redirectToThread = null;
},
hidePostEditor: (state) => {
state.postEditorVisible = false;
},
clearRedirect: (state) => {
state.redirectToThread = null;
},
},
});
@@ -188,6 +194,7 @@ export const {
setSearchQuery,
showPostEditor,
hidePostEditor,
clearRedirect,
} = threadsSlice.actions;
export const threadsReducer = threadsSlice.reducer;

View File

@@ -113,7 +113,14 @@ export function markThreadAsRead(threadId) {
};
}
export function createNewThread(courseId, topicId, type, title, content, following = false) {
export function createNewThread({
courseId,
topicId,
type,
title,
content,
following = false,
}) {
return async (dispatch) => {
try {
dispatch(postThreadRequest({

View File

@@ -2,12 +2,12 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
searchAllPosts: {
id: 'discussions.posts.action-bar.search',
id: 'discussions.posts.actionBar.search',
defaultMessage: 'Search all posts',
description: 'Placeholder text in search box',
},
addAPost: {
id: 'discussion.posts.action-bar.add',
id: 'discussion.posts.actionBar.add',
defaultMessage: 'Add post',
description: 'Button to add a new discussion post',
},

View File

@@ -1,134 +1,293 @@
import React from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { Formik } from 'formik';
import { useDispatch, useSelector } from 'react-redux';
import { generatePath, useHistory, useParams } from 'react-router';
import * as Yup from 'yup';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Card, Form, StatefulButton } from '@edx/paragon';
import { Help, Post } from '@edx/paragon/icons';
import { TinyMCEEditor } from '../../../components';
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
import { selectCourseTopics } from '../../topics/data/selectors';
import { fetchCourseTopics } from '../../topics/data/thunks';
import { isFormikFieldInvalid, useCommentsPagePath } from '../../utils';
import { hidePostEditor } from '../data';
import { selectThread } from '../data/selectors';
import { createNewThread, fetchThread, updateExistingThread } from '../data/thunks';
import messages from './messages';
function DiscussionPostType({
value,
type,
selected,
description,
icon,
}) {
// Need to use regular label since Form.Label doesn't support overriding htmlFor
return (
<Form.Check type="radio" value={value} className="d-flex p-0 my-0 mr-3">
{/* <Form.Check.Input type="radio" className=""/> */}
<Form.Check.Label>
<Card>
<Card.Body>
<Card.Text className="d-flex flex-column align-items-center">
<span className="text-gray-900">{icon}</span>
<span>{type}</span>
<span className="x-small text-gray-500">{description}</span>
</Card.Text>
</Card.Body>
</Card>
</Form.Check.Label>
</Form.Check>
<label htmlFor={`post-type-${value}`} className="d-flex p-0 my-0 mr-3">
<Form.Radio value={value} id={`post-type-${value}`} className="sr-only">{type}</Form.Radio>
<Card className={selected ? 'border border-primary border-2' : ''}>
<Card.Body>
<Card.Text className="d-flex flex-column align-items-center">
<span className="text-gray-900">{icon}</span>
<span>{type}</span>
<span className="x-small text-gray-500">{description}</span>
</Card.Text>
</Card.Body>
</Card>
</label>
);
}
DiscussionPostType.propTypes = {
value: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
selected: PropTypes.bool.isRequired,
description: PropTypes.string.isRequired,
icon: PropTypes.element.isRequired,
};
function PostEditor({ intl }) {
const formikCompatibleHandler = (formikHandler, name) => (value) => formikHandler({
target: {
name,
value,
},
});
function PostEditor({
intl,
editExisting,
}) {
const dispatch = useDispatch();
const cancelAdding = () => dispatch(hidePostEditor());
const history = useHistory();
const commentsPagePath = useCommentsPagePath();
const {
courseId,
topicId,
postId,
} = useParams();
const {
coursewareTopics,
nonCoursewareTopics,
} = useSelector(selectCourseTopics());
const post = useSelector(selectThread(postId));
const initialValues = {
postType: post?.type || 'discussion',
topic: post?.topicId || topicId,
title: post?.title || '',
comment: post?.rawBody || '',
follow: post?.following ?? true,
};
const hideEditor = () => {
if (editExisting) {
history.push(generatePath(commentsPagePath, {
courseId,
topicId,
postId,
}));
}
dispatch(hidePostEditor());
};
const submitForm = async (values) => {
if (editExisting) {
dispatch(updateExistingThread(postId, {
topicId: values.topic,
type: values.postType,
title: values.title,
content: values.comment,
}));
} else {
dispatch(createNewThread({
courseId,
topicId: values.topic,
type: values.postType,
title: values.title,
content: values.comment,
following: values.following,
}));
}
hideEditor();
};
useEffect(() => {
dispatch(fetchCourseTopics(courseId));
if (editExisting) {
dispatch(fetchThread(postId));
}
}, [courseId, editExisting]);
return (
<Form className="mx-4 my-2">
<h3>{intl.formatMessage(messages.heading)}</h3>
<Form.Group>
<Form.CheckboxSet className="d-flex flex-row my-3">
<DiscussionPostType
value="discussion"
type={intl.formatMessage(messages.discussionType)}
icon={<Post />}
description={intl.formatMessage(messages.discussionDescription)}
/>
<DiscussionPostType
value="question"
type={intl.formatMessage(messages.questionType)}
icon={<Help />}
description={intl.formatMessage(messages.questionDescription)}
/>
</Form.CheckboxSet>
</Form.Group>
<Formik
enableReinitialize
initialValues={initialValues}
validationSchema={Yup.object()
.shape({
postType: Yup.mixed()
.oneOf(['discussion', 'question']),
topic: Yup.string()
.required(),
title: Yup.string()
.required(intl.formatMessage(messages.titleError)),
comment: Yup.string()
.required(intl.formatMessage(messages.commentError)),
follow: Yup.bool(),
anonymous: Yup.bool(),
})}
initialErrors={{}}
onSubmit={submitForm}
>{
({
values,
errors,
touched,
handleSubmit,
handleBlur,
handleChange,
}) => (
<Form className="mx-4 my-2" onSubmit={handleSubmit}>
<h3>
{editExisting
? intl.formatMessage(messages.editPostHeading)
: intl.formatMessage(messages.addPostHeading)}
</h3>
<Form.RadioSet
name="postType"
className="d-flex flex-row my-3"
value={values.postType}
onChange={handleChange}
onBlur={handleBlur}
aria-label={intl.formatMessage(messages.postTitle)}
>
<DiscussionPostType
value="discussion"
selected={values.postType === 'discussion'}
type={intl.formatMessage(messages.discussionType)}
icon={<Post />}
description={intl.formatMessage(messages.discussionDescription)}
/>
<DiscussionPostType
value="question"
selected={values.postType === 'question'}
type={intl.formatMessage(messages.questionType)}
icon={<Help />}
description={intl.formatMessage(messages.questionDescription)}
/>
</Form.RadioSet>
<Form.Group className="py-2 w-50">
<Form.Control
name="topic"
as="select"
value={values.topic}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="topicAreaInput"
floatingLabel={intl.formatMessage(messages.topicArea)}
>
{nonCoursewareTopics.map(topic => (
<option key={topic.id} value={topic.id}>{topic.name}</option>
))}
{coursewareTopics.map(topic => (
<optgroup label={topic.name} key={topic.name}>
{topic.children.map(subtopic => (
<option
key={subtopic.id}
value={subtopic.id}
>
{subtopic.name}
</option>
))}
</optgroup>
))}
</Form.Control>
</Form.Group>
<div className="border-bottom my-4" />
<Form.Group
className="py-2"
isInvalid={isFormikFieldInvalid('title', {
errors,
touched,
})}
>
<Form.Control
name="title"
type="text"
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="titleInput"
floatingLabel={intl.formatMessage(messages.postTitle)}
value={values.title}
/>
<FormikErrorFeedback name="title" />
</Form.Group>
<div className="py-2">
<TinyMCEEditor
value={values.comment}
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
/>
<FormikErrorFeedback name="comment" />
</div>
<Form.Group className="py-2 w-50">
<Form.Control
as="select"
defaultValue="General"
aria-describedby="topicAreaInput"
floatingLabel={intl.formatMessage(messages.topicArea)}
className=""
>
{/* TODO: topics has to be filled in another PR */}
<option>General</option>
</Form.Control>
</Form.Group>
{!editExisting
&& (
<>
<Form.Checkbox
name="follow"
value={values.follow}
onChange={handleChange}
onBlur={handleBlur}
className="mr-4"
>
{intl.formatMessage(messages.followPost)}
</Form.Checkbox>
<Form.Checkbox
name="anonymous"
value={values.anonymous}
onChange={handleChange}
onBlur={handleBlur}
>
{intl.formatMessage(messages.anonymousPost)}
</Form.Checkbox>
</>
)}
<div className="border-bottom my-4" />
<Form.Group className="py-2">
<Form.Control
type="text"
aria-describedby="titleInput"
floatingLabel={intl.formatMessage(messages.postTitle)}
/>
</Form.Group>
<Form.Group className="py-2">
<Form.Control as="textarea" rows="3" />
</Form.Group>
<Form.Group>
<Form.CheckboxSet
name="post-options"
defaultValue={[messages.followPost.id]}
isInline
>
<Form.Checkbox value={messages.followPost.id}>
{intl.formatMessage(messages.followPost)}
</Form.Checkbox>
<Form.Checkbox value={messages.anonymousPost.id}>
{intl.formatMessage(messages.anonymousPost)}
</Form.Checkbox>
</Form.CheckboxSet>
</Form.Group>
<div className="d-flex justify-content-end">
<StatefulButton
labels={{
default: intl.formatMessage(messages.cancel),
}}
variant="outline-primary"
onClick={cancelAdding}
/>
<StatefulButton
labels={{
default: intl.formatMessage(messages.submit),
}}
className="ml-2"
variant="primary"
/>
</div>
</Form>
<div className="d-flex justify-content-end">
<StatefulButton
labels={{
default: intl.formatMessage(messages.cancel),
}}
variant="outline-primary"
onClick={hideEditor}
/>
<StatefulButton
labels={{
default: intl.formatMessage(messages.submit),
}}
className="ml-2"
variant="primary"
onClick={handleSubmit}
/>
</div>
</Form>
)
}
</Formik>
);
}
PostEditor.propTypes = {
intl: intlShape.isRequired,
editExisting: PropTypes.bool,
};
PostEditor.defaultProps = {
editExisting: false,
};
export default injectIntl(PostEditor);

View File

@@ -5,10 +5,14 @@ const messages = defineMessages({
id: 'discussions.post.editor.type',
defaultMessage: 'Post type',
},
heading: {
id: 'discussions.post.editor.heading',
addPostHeading: {
id: 'discussions.post.editor.addPostHeading',
defaultMessage: 'Add a post',
},
editPostHeading: {
id: 'discussions.post.editor.editPostHeading',
defaultMessage: 'Edit post',
},
typeDescription: {
id: 'discussions.post.editor.typeDescription',
defaultMessage: 'Questions raise issues that need answers. Discussions share ideas and start conversations.',
@@ -49,6 +53,14 @@ const messages = defineMessages({
id: 'discussions.post.editor.titleDescription',
defaultMessage: 'Add a clear and descriptive title to encourage participation.',
},
titleError: {
id: 'discussions.post.editor.title.error',
defaultMessage: 'Post title cannot be empty.',
},
commentError: {
id: 'discussions.post.editor.comment.error',
defaultMessage: 'Post content cannot be empty.',
},
questionText: {
id: 'discussions.post.editor.questionText',
defaultMessage: 'Your question or idea (required)',

View File

@@ -2,17 +2,17 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
allPosts: {
id: 'discussions.posts.filter.all-posts',
id: 'discussions.posts.filter.allPosts',
defaultMessage: 'All posts',
description: 'Option in dropdown to filter to all posts',
},
allDiscussions: {
id: 'discussions.posts.filter.all-discussions',
id: 'discussions.posts.filter.allDscussions',
defaultMessage: 'All discussions',
description: 'Option in dropdown to filter to all discussions',
},
allQuestions: {
id: 'discussions.posts.filter.all-questions',
id: 'discussions.posts.filter.allQuestions',
defaultMessage: 'All questions',
description: 'Option in dropdown to filter to all questions',
},
@@ -42,17 +42,17 @@ const messages = defineMessages({
description: 'Option in dropdown to filter to flagged posts',
},
myPosts: {
id: 'discussions.posts.filter.my-posts',
id: 'discussions.posts.filter.myPosts',
defaultMessage: 'My posts',
description: 'Option in dropdown to filter to all a user\'s posts',
},
myDiscussions: {
id: 'discussions.posts.filter.my-discussions',
id: 'discussions.posts.filter.myDiscussions',
defaultMessage: 'My discussions',
description: 'Option in dropdown to filter to all a user\'s discussions',
},
myQuestions: {
id: 'discussions.posts.filter.my-questions',
id: 'discussions.posts.filter.myQuestions',
defaultMessage: 'My questions',
description: 'Option in dropdown to filter to all a user\'s questions',
},
@@ -62,17 +62,17 @@ const messages = defineMessages({
description: 'Display text used to indicate how posts are sorted',
},
lastActivityAt: {
id: 'discussions.posts.sort.last-activity',
id: 'discussions.posts.sort.lastActivity',
defaultMessage: 'Recent activity',
description: 'Option in dropdown to sort posts by recent activity',
},
commentCount: {
id: 'discussions.posts.sort.comment-count',
id: 'discussions.posts.sort.commentCount',
defaultMessage: 'Most activity',
description: 'Option in dropdown to sort posts by most activity',
},
voteCount: {
id: 'discussions.posts.sort.vote-count',
id: 'discussions.posts.sort.voteCount',
defaultMessage: 'Most votes',
description: 'Option in dropdown to sort posts by most votes',
},

View File

@@ -9,10 +9,7 @@ import {
Avatar, Icon, IconButton, OverlayTrigger, Tooltip,
} from '@edx/paragon';
import {
Pin,
QuestionAnswer,
StarFilled,
StarOutline,
Pin, QuestionAnswer, StarFilled, StarOutline,
} from '@edx/paragon/icons';
import { updateExistingThread } from '../data/thunks';
@@ -60,7 +57,7 @@ function PostHeader({
}) {
return (
<div className="d-flex flex-fill justify-content-between">
<Avatar className="m-2" alt={post.author} src={post.authorAvatars.imageUrlSmall} />
<Avatar className="m-2" alt={post.author} src={post?.authorAvatars?.imageUrlSmall} />
<PostTypeIcon type={post.type} pinned={post.pinned} />
<div className="align-items-center d-flex flex-row flex-fill">
<div className="d-flex flex-column flex-fill">
@@ -105,7 +102,7 @@ function Post({
return (
<div className="d-flex flex-column p-2.5 w-100">
<PostHeader post={post} intl={intl} />
<div className="d-flex mt-2 mb-0 p-0" dangerouslySetInnerHTML={{ __html: post.renderedBody }} />
<div className="mt-2 mb-0 p-0" dangerouslySetInnerHTML={{ __html: post.renderedBody }} />
<div className="d-flex align-items-center">
<LikeButton
count={post.voteCount}
@@ -131,6 +128,7 @@ function Post({
src={post.following ? StarFilled : StarOutline}
/>
</OverlayTrigger>
{post.following && <span>Following</span>}
</div>
</div>
);

View File

@@ -2,15 +2,15 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
lastResponse: {
id: 'discussions.post.last-response',
id: 'discussions.post.lastResponse',
defaultMessage: 'Last response {time}',
},
postedOn: {
id: 'discussions.post.posted-on',
id: 'discussions.post.postedOn',
defaultMessage: 'Posted {time} by {author} {authorLabel}',
},
contentReported: {
id: 'discussions.post.content-reported',
id: 'discussions.post.contentReported',
defaultMessage: 'Content reported for staff review',
},
follow: {
@@ -29,7 +29,7 @@ const messages = defineMessages({
description: 'Tooltip/alttext for button to like a discussion post',
},
removeLike: {
id: 'discussions.post.remove-like',
id: 'discussions.post.removeLike',
defaultMessage: 'Remove like',
description: 'Tooltip/alttext for button to remove the like applied to a discussion post',
},

View File

@@ -32,7 +32,7 @@ function TopicsView() {
id={topicGroup.name}
name={topicGroup.name}
subtopics={topicGroup.children}
key={topicGroup.name}
key={topicGroup.id ?? topicGroup.name}
filter={filter}
/>
),

View File

@@ -7,17 +7,17 @@ const messages = defineMessages({
description: 'Display text used to indicate how topics are sorted',
},
sortByLastActivity: {
id: 'discussions.topics.sort.last-activity',
id: 'discussions.topics.sort.lastActivity',
defaultMessage: 'Recent activity',
description: 'Option in dropdown to sort topics by recent activity',
},
sortByCommentCount: {
id: 'discussions.topics.sort.comment-count',
id: 'discussions.topics.sort.commentCount',
defaultMessage: 'Most activity',
description: 'Option in dropdown to sort topics by most activity',
},
sortByCourseStructure: {
id: 'discussions.topics.sort.course-structure',
id: 'discussions.topics.sort.courseStructure',
defaultMessage: 'Course Structure',
description: 'Option in dropdown to sort topics by course structure',
},

View File

@@ -1,4 +1,9 @@
/* eslint-disable import/prefer-default-export */
import { getIn } from 'formik';
import { useRouteMatch } from 'react-router';
import { Routes } from '../data/constants';
export function buildIntlSelectionList(options, intl, messages) {
return Object.values(options)
.map(
@@ -17,3 +22,26 @@ export function buildIntlSelectionList(options, intl, messages) {
* @returns {number|undefined}
*/
export const getHttpErrorStatus = error => error && error.customAttributes && error.customAttributes.httpErrorStatus;
/**
* Return true if a field has been modified and is no longer valid
* @param {string} field Name of field
* @param {{}} errors formik error object
* @param {{}} touched formik touched object
* @returns {boolean}
*/
export function isFormikFieldInvalid(field, {
errors,
touched,
}) {
return Boolean(getIn(touched, field) && getIn(errors, field));
}
/**
* Hook to return the path for the current comments page
* @returns {string}
*/
export function useCommentsPagePath() {
const { params } = useRouteMatch(Routes.COMMENTS.PAGE);
return Routes.COMMENTS.PAGES[params.page];
}