From a1148867cad6ef783fd5f0c9e93dbd59823ee27c Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Wed, 1 Sep 2021 20:40:51 +0530 Subject: [PATCH] feat: Update the post editor to include TinyMCE and connect to API This change adds the TinyMCE editor for editing posts and connects the post editor to the API, allowing adding new posts and editing existing ones. --- .eslintrc.js | 26 +- package-lock.json | 86 ++++- package.json | 7 +- src/components/FormikErrorFeedback.jsx | 35 ++ src/components/TinyMCEEditor.jsx | 40 ++ src/components/index.js | 1 + src/data/constants.js | 10 + src/discussions/comments/reply/Reply.jsx | 2 +- .../discussions-home/DiscussionsHome.jsx | 41 ++- .../navigation/breadcrumb-menu/messages.js | 2 +- .../navigation/navigation-bar/messages.js | 6 +- src/discussions/posts/data/slices.js | 9 +- src/discussions/posts/data/thunks.js | 9 +- .../posts/post-actions-bar/messages.js | 4 +- .../posts/post-editor/PostEditor.jsx | 347 +++++++++++++----- src/discussions/posts/post-editor/messages.js | 16 +- .../posts/post-filter-bar/messages.js | 18 +- src/discussions/posts/post/Post.jsx | 10 +- src/discussions/posts/post/messages.js | 8 +- src/discussions/topics/TopicsView.jsx | 2 +- .../topics/topic-search-bar/messages.js | 6 +- src/discussions/utils.js | 28 ++ 22 files changed, 573 insertions(+), 140 deletions(-) create mode 100644 src/components/FormikErrorFeedback.jsx create mode 100644 src/components/TinyMCEEditor.jsx diff --git a/.eslintrc.js b/.eslintrc.js index 6f4fb65a..2e15316a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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', + } + } +); + diff --git a/package-lock.json b/package-lock.json index 592b9834..340d48ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } + } + } } } } diff --git a/package.json b/package.json index 14fac213..527e60e0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/FormikErrorFeedback.jsx b/src/components/FormikErrorFeedback.jsx new file mode 100644 index 00000000..0f61f4ff --- /dev/null +++ b/src/components/FormikErrorFeedback.jsx @@ -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 ( + + {fieldTouched && fieldError + ? ( + + {fieldError} + + ) + : ( + + )} + + ); +} + +FormikErrorFeedback.propTypes = { + name: PropTypes.string.isRequired, +}; + +export default FormikErrorFeedback; diff --git a/src/components/TinyMCEEditor.jsx b/src/components/TinyMCEEditor.jsx new file mode 100644 index 00000000..8d4d88fd --- /dev/null +++ b/src/components/TinyMCEEditor.jsx @@ -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 ( + + ); +} diff --git a/src/components/index.js b/src/components/index.js index 3cdd07e9..f5819697 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -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'; diff --git a/src/data/constants.js b/src/data/constants.js index 98a6a0c2..558f4775 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -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: [ diff --git a/src/discussions/comments/reply/Reply.jsx b/src/discussions/comments/reply/Reply.jsx index ddb5ed77..d85fc376 100644 --- a/src/discussions/comments/reply/Reply.jsx +++ b/src/discussions/comments/reply/Reply.jsx @@ -17,7 +17,7 @@ function ReplyHeader({ reply, intl }) { return (
- +

diff --git a/src/discussions/discussions-home/DiscussionsHome.jsx b/src/discussions/discussions-home/DiscussionsHome.jsx index 59f44bb6..cb87ed93 100644 --- a/src/discussions/discussions-home/DiscussionsHome.jsx +++ b/src/discussions/discussions-home/DiscussionsHome.jsx @@ -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 (
@@ -29,8 +47,7 @@ export default function DiscussionsHome() { - - +

@@ -38,12 +55,20 @@ export default function DiscussionsHome() {
{ - postEditorVisible ? - : ( + postEditorVisible ? ( + + + + ) : ( + + + + - ) + + ) }
diff --git a/src/discussions/navigation/breadcrumb-menu/messages.js b/src/discussions/navigation/breadcrumb-menu/messages.js index 8b53aa05..ecfc254f 100644 --- a/src/discussions/navigation/breadcrumb-menu/messages.js +++ b/src/discussions/navigation/breadcrumb-menu/messages.js @@ -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', }, diff --git a/src/discussions/navigation/navigation-bar/messages.js b/src/discussions/navigation/navigation-bar/messages.js index 51658258..d2a8f13c 100644 --- a/src/discussions/navigation/navigation-bar/messages.js +++ b/src/discussions/navigation/navigation-bar/messages.js @@ -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', }, diff --git a/src/discussions/posts/data/slices.js b/src/discussions/posts/data/slices.js index fb053f26..b6676627 100644 --- a/src/discussions/posts/data/slices.js +++ b/src/discussions/posts/data/slices.js @@ -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; diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js index f17ac1c6..ca194bfc 100644 --- a/src/discussions/posts/data/thunks.js +++ b/src/discussions/posts/data/thunks.js @@ -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({ diff --git a/src/discussions/posts/post-actions-bar/messages.js b/src/discussions/posts/post-actions-bar/messages.js index 766241f3..30d234a4 100644 --- a/src/discussions/posts/post-actions-bar/messages.js +++ b/src/discussions/posts/post-actions-bar/messages.js @@ -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', }, diff --git a/src/discussions/posts/post-editor/PostEditor.jsx b/src/discussions/posts/post-editor/PostEditor.jsx index bd166a80..2a6f73ee 100644 --- a/src/discussions/posts/post-editor/PostEditor.jsx +++ b/src/discussions/posts/post-editor/PostEditor.jsx @@ -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 ( - - {/* */} - - - - - {icon} - {type} - {description} - - - - - + ); } 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 ( -
-

{intl.formatMessage(messages.heading)}

- - - } - description={intl.formatMessage(messages.discussionDescription)} - /> - } - description={intl.formatMessage(messages.questionDescription)} - /> - - + { + ({ + values, + errors, + touched, + handleSubmit, + handleBlur, + handleChange, + }) => ( + +

+ {editExisting + ? intl.formatMessage(messages.editPostHeading) + : intl.formatMessage(messages.addPostHeading)} +

+ + } + description={intl.formatMessage(messages.discussionDescription)} + /> + } + description={intl.formatMessage(messages.questionDescription)} + /> + + + + {nonCoursewareTopics.map(topic => ( + + ))} + {coursewareTopics.map(topic => ( + + {topic.children.map(subtopic => ( + + ))} + + ))} + + +
+ + + + +
+ + +
- - - {/* TODO: topics has to be filled in another PR */} - - - + {!editExisting + && ( + <> + + {intl.formatMessage(messages.followPost)} + + + {intl.formatMessage(messages.anonymousPost)} + + + )} -
- - - - - - - - - - - - - {intl.formatMessage(messages.followPost)} - - - {intl.formatMessage(messages.anonymousPost)} - - - - -
- - -
- +
+ + +
+ + ) + } + ); } PostEditor.propTypes = { intl: intlShape.isRequired, + editExisting: PropTypes.bool, +}; + +PostEditor.defaultProps = { + editExisting: false, }; export default injectIntl(PostEditor); diff --git a/src/discussions/posts/post-editor/messages.js b/src/discussions/posts/post-editor/messages.js index 5c881a52..45725168 100644 --- a/src/discussions/posts/post-editor/messages.js +++ b/src/discussions/posts/post-editor/messages.js @@ -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)', diff --git a/src/discussions/posts/post-filter-bar/messages.js b/src/discussions/posts/post-filter-bar/messages.js index 6e9de647..58bfa7d3 100644 --- a/src/discussions/posts/post-filter-bar/messages.js +++ b/src/discussions/posts/post-filter-bar/messages.js @@ -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', }, diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 4369c9a7..60a21fd0 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -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 (
- +
@@ -105,7 +102,7 @@ function Post({ return (
-
+
+ {post.following && Following}
); diff --git a/src/discussions/posts/post/messages.js b/src/discussions/posts/post/messages.js index 0d809d70..37d9a910 100644 --- a/src/discussions/posts/post/messages.js +++ b/src/discussions/posts/post/messages.js @@ -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', }, diff --git a/src/discussions/topics/TopicsView.jsx b/src/discussions/topics/TopicsView.jsx index 28c62ac9..b974f128 100644 --- a/src/discussions/topics/TopicsView.jsx +++ b/src/discussions/topics/TopicsView.jsx @@ -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} /> ), diff --git a/src/discussions/topics/topic-search-bar/messages.js b/src/discussions/topics/topic-search-bar/messages.js index 5f6661a3..9c7185d2 100644 --- a/src/discussions/topics/topic-search-bar/messages.js +++ b/src/discussions/topics/topic-search-bar/messages.js @@ -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', }, diff --git a/src/discussions/utils.js b/src/discussions/utils.js index 2a316319..c660b05d 100644 --- a/src/discussions/utils.js +++ b/src/discussions/utils.js @@ -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]; +}