-
+
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 (
-
-
- }
- description={intl.formatMessage(messages.discussionDescription)}
- />
- }
- description={intl.formatMessage(messages.questionDescription)}
- />
-
-
+
{
+ ({
+ values,
+ errors,
+ touched,
+ handleSubmit,
+ handleBlur,
+ handleChange,
+ }) => (
+
+ }
+ description={intl.formatMessage(messages.discussionDescription)}
+ />
+ }
+ description={intl.formatMessage(messages.questionDescription)}
+ />
+
+
+
+ {nonCoursewareTopics.map(topic => (
+
+ ))}
+ {coursewareTopics.map(topic => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
-
-
- {/* 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];
+}