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]; +}