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:
26
.eslintrc.js
26
.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',
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
86
package-lock.json
generated
86
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
35
src/components/FormikErrorFeedback.jsx
Normal file
35
src/components/FormikErrorFeedback.jsx
Normal 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;
|
||||
40
src/components/TinyMCEEditor.jsx
Normal file
40
src/components/TinyMCEEditor.jsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user