feat: add support for anonymous posting
Hooks up the configuration API to allow posting threads anonymously.
This commit is contained in:
22
src/discussions/data/api.js
Normal file
22
src/discussions/data/api.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
ensureConfig([
|
||||
'LMS_BASE_URL',
|
||||
], 'Posts API service');
|
||||
|
||||
const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
|
||||
export const courseConfigApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`;
|
||||
|
||||
/**
|
||||
* Get discussions course config
|
||||
* @param {string} courseId
|
||||
*/
|
||||
export async function getDiscussionsConfig(courseId) {
|
||||
const url = `${courseConfigApiUrl}${courseId}/`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
}
|
||||
7
src/discussions/data/selectors.js
Normal file
7
src/discussions/data/selectors.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export const selectAnonymousPostingConfig = state => ({
|
||||
allowAnonymous: state.config.allowAnonymous,
|
||||
allowAnonymousToPeers: state.config.allowAnonymousToPeers,
|
||||
}
|
||||
);
|
||||
38
src/discussions/data/slices.js
Normal file
38
src/discussions/data/slices.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable no-param-reassign,import/prefer-default-export */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const configSlice = createSlice({
|
||||
name: 'config',
|
||||
initialState: {
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
blackouts: [],
|
||||
allowAnonymous: false,
|
||||
allowAnonymousToPeers: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchConfigRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchConfigSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
Object.assign(state, payload);
|
||||
},
|
||||
fetchConfigFailed: (state) => {
|
||||
state.status = RequestStatus.FAILED;
|
||||
},
|
||||
fetchConfigDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchConfigDenied,
|
||||
fetchConfigFailed,
|
||||
fetchConfigRequest,
|
||||
fetchConfigSuccess,
|
||||
} = configSlice.actions;
|
||||
|
||||
export const configReducer = configSlice.reducer;
|
||||
31
src/discussions/data/thunks.js
Normal file
31
src/discussions/data/thunks.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getHttpErrorStatus } from '../utils';
|
||||
import { getDiscussionsConfig } from './api';
|
||||
import {
|
||||
fetchConfigDenied, fetchConfigFailed, fetchConfigRequest, fetchConfigSuccess,
|
||||
} from './slices';
|
||||
|
||||
/**
|
||||
* Fetches the configuration data for the course
|
||||
* @param {string} courseId The course ID for the course to fetch config for.
|
||||
* @returns {(function(*): Promise<void>)|*}
|
||||
*/
|
||||
export function fetchCourseConfig(courseId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchConfigRequest());
|
||||
const data = await getDiscussionsConfig(courseId);
|
||||
dispatch(fetchConfigSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchConfigDenied());
|
||||
} else {
|
||||
dispatch(fetchConfigFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { PostActionsBar } from '../../components';
|
||||
import { ALL_ROUTES, Routes } from '../../data/constants';
|
||||
import { CommentsView } from '../comments';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import { BreadcrumbMenu, NavigationBar } from '../navigation';
|
||||
import { PostEditor, PostsView } from '../posts';
|
||||
import { clearRedirect } from '../posts/data';
|
||||
@@ -28,6 +29,9 @@ export default function DiscussionsHome() {
|
||||
},
|
||||
} = useRouteMatch(ALL_ROUTES);
|
||||
const redirectToThread = useSelector(state => state.threads.redirectToThread);
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseConfig(courseId));
|
||||
}, [courseId]);
|
||||
useEffect(() => {
|
||||
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
|
||||
// stored in redirectToThread
|
||||
|
||||
@@ -30,7 +30,7 @@ function NavigationBar({ intl }) {
|
||||
<Nav variant="pills" className="py-2">
|
||||
{navLinks.map(link => (
|
||||
<Nav.Item key={link.route}>
|
||||
<Nav.Link as={NavLink} to={generatePath(link.route, { courseId })}>
|
||||
<Nav.Link as={NavLink} to={generatePath(link.route, { courseId })} className="border">
|
||||
{intl.formatMessage(link.labelMessage)}
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
|
||||
@@ -8,7 +8,7 @@ const messages = defineMessages({
|
||||
},
|
||||
allTopics: {
|
||||
id: 'discussions.navigation.navigationBar.allTopics',
|
||||
defaultMessage: 'All Topics',
|
||||
defaultMessage: 'Topics',
|
||||
description: 'Option in navbar to show all topics',
|
||||
},
|
||||
myPosts: {
|
||||
|
||||
@@ -78,9 +78,23 @@ export async function getThread(threadId) {
|
||||
* @param {string} title
|
||||
* @param {string} content
|
||||
* @param {boolean} following Follow the thread after creating
|
||||
* @param {boolean} anonymous Should the thread be anonymous to all users
|
||||
* @param {boolean} anonymousToPeers Should the thread be anonymous to peers
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function postThread(courseId, topicId, type, title, content, following = false, cohort) {
|
||||
export async function postThread(
|
||||
courseId,
|
||||
topicId,
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
{
|
||||
following,
|
||||
cohort,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
} = {},
|
||||
) {
|
||||
const postData = snakeCaseObject({
|
||||
courseId,
|
||||
topicId,
|
||||
@@ -88,10 +102,13 @@ export async function postThread(courseId, topicId, type, title, content, follow
|
||||
title,
|
||||
raw_body: content,
|
||||
following,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
groupId: cohort,
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().post(threadsApiUrl, postData);
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(threadsApiUrl, postData);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -146,7 +163,8 @@ export async function updateThread(threadId, {
|
||||
*/
|
||||
export async function deleteThread(threadId) {
|
||||
const url = `${threadsApiUrl}${threadId}/`;
|
||||
await getAuthenticatedHttpClient().delete(url);
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(url);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -170,7 +170,9 @@ export function createNewThread({
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
following = false,
|
||||
following,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
cohort,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
@@ -182,9 +184,16 @@ export function createNewThread({
|
||||
title,
|
||||
content,
|
||||
following,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
cohort,
|
||||
}));
|
||||
const data = await postThread(courseId, topicId, type, title, content, following, cohort);
|
||||
const data = await postThread(courseId, topicId, type, title, content, {
|
||||
cohort,
|
||||
following,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
});
|
||||
dispatch(postThreadSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { TinyMCEEditor } from '../../../components';
|
||||
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
|
||||
import { selectCourseCohorts } from '../../cohorts/data/selectors';
|
||||
import { fetchCourseCohorts } from '../../cohorts/data/thunks';
|
||||
import { selectAnonymousPostingConfig } from '../../data/selectors';
|
||||
import { selectCourseTopics } from '../../topics/data/selectors';
|
||||
import { fetchCourseTopics } from '../../topics/data/thunks';
|
||||
import { formikCompatibleHandler, isFormikFieldInvalid, useCommentsPagePath } from '../../utils';
|
||||
@@ -72,6 +73,10 @@ function PostEditor({
|
||||
coursewareTopics,
|
||||
nonCoursewareTopics,
|
||||
} = useSelector(selectCourseTopics());
|
||||
const {
|
||||
allowAnonymous,
|
||||
allowAnonymousToPeers,
|
||||
} = useSelector(selectAnonymousPostingConfig);
|
||||
const cohorts = useSelector(selectCourseCohorts);
|
||||
const post = useSelector(selectThread(postId));
|
||||
const initialValues = {
|
||||
@@ -80,6 +85,8 @@ function PostEditor({
|
||||
title: post?.title || '',
|
||||
comment: post?.rawBody || '',
|
||||
follow: post?.following ?? true,
|
||||
anonymous: allowAnonymous ? false : undefined,
|
||||
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
|
||||
};
|
||||
const canSelectCohort = authenticatedUser.administrator && !editExisting;
|
||||
const hideEditor = () => {
|
||||
@@ -114,6 +121,8 @@ function PostEditor({
|
||||
title: values.title,
|
||||
content: values.comment,
|
||||
following: values.following,
|
||||
anonymous: values.anonymous,
|
||||
anonymousToPeers: values.anonymousToPeers,
|
||||
cohort,
|
||||
}));
|
||||
}
|
||||
@@ -144,8 +153,12 @@ function PostEditor({
|
||||
.required(intl.formatMessage(messages.titleError)),
|
||||
comment: Yup.string()
|
||||
.required(intl.formatMessage(messages.commentError)),
|
||||
follow: Yup.bool(),
|
||||
anonymous: Yup.bool(),
|
||||
follow: Yup.bool()
|
||||
.default(true),
|
||||
anonymous: Yup.bool().default(false)
|
||||
.nullable(),
|
||||
anonymousToPeers: Yup.bool().default(false)
|
||||
.nullable(),
|
||||
cohort: Yup.string(),
|
||||
})}
|
||||
initialErrors={{}}
|
||||
@@ -265,27 +278,47 @@ function PostEditor({
|
||||
</div>
|
||||
|
||||
{!editExisting
|
||||
&& (
|
||||
<div className="d-flex flex-row mt-3">
|
||||
<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>
|
||||
)}
|
||||
&& (
|
||||
<div className="d-flex flex-row mt-3">
|
||||
<Form.Group>
|
||||
<Form.Checkbox
|
||||
name="follow"
|
||||
checked={values.follow}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className="mr-4"
|
||||
>
|
||||
{intl.formatMessage(messages.followPost)}
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
{allowAnonymous && (
|
||||
<Form.Group>
|
||||
<Form.Checkbox
|
||||
name="anonymous"
|
||||
checked={values.anonymous}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className="mr-4"
|
||||
>
|
||||
{intl.formatMessage(messages.anonymousPost)}
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
)}
|
||||
{allowAnonymousToPeers
|
||||
&& (
|
||||
<Form.Group>
|
||||
<Form.Checkbox
|
||||
name="anonymousToPeers"
|
||||
checked={values.anonymousToPeers}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
{intl.formatMessage(messages.anonymousToPeersPost)}
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex justify-content-end">
|
||||
<StatefulButton
|
||||
|
||||
@@ -85,6 +85,10 @@ const messages = defineMessages({
|
||||
id: 'discussions.post.editor.anonymousPost',
|
||||
defaultMessage: 'Post anonymously',
|
||||
},
|
||||
anonymousToPeersPost: {
|
||||
id: 'discussions.post.editor.anonymousToPeersPost',
|
||||
defaultMessage: 'Post anonymously to peers',
|
||||
},
|
||||
submit: {
|
||||
id: 'discussions.editor.submit',
|
||||
defaultMessage: 'Submit',
|
||||
|
||||
@@ -65,7 +65,7 @@ function PostHeader({
|
||||
</div>
|
||||
)
|
||||
: <h3 className="mb-0">{post.title}</h3>}
|
||||
<AuthorLabel author={post.author} authorLabel={post.authorLabel} />
|
||||
<AuthorLabel author={post.author ?? intl.formatMessage(messages.anonymous)} authorLabel={post.authorLabel} />
|
||||
</div>
|
||||
</div>
|
||||
{!preview
|
||||
|
||||
@@ -61,11 +61,15 @@ function PostLink({
|
||||
</div>
|
||||
{showAnsweredBadge
|
||||
&& (
|
||||
<div className="ml-auto"><Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AuthorLabel author={post.author} authorLabel={post.authorLabel} />
|
||||
<AuthorLabel
|
||||
author={post.author ?? intl.formatMessage(messages.anonymous)}
|
||||
authorLabel={post.authorLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>{post.previewBody}</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
anonymous: {
|
||||
id: 'discussions.post.author.anonymous',
|
||||
defaultMessage: 'anonymous',
|
||||
description: 'Author name displayed when a post is anonymous',
|
||||
},
|
||||
lastResponse: {
|
||||
id: 'discussions.post.lastResponse',
|
||||
defaultMessage: 'Last response {time}',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { configureStore } from '@reduxjs/toolkit';
|
||||
|
||||
import { cohortsReducer } from './discussions/cohorts/data';
|
||||
import { commentsReducer } from './discussions/comments/data';
|
||||
import { configReducer } from './discussions/data/slices';
|
||||
import { threadsReducer } from './discussions/posts/data';
|
||||
import { topicsReducer } from './discussions/topics/data';
|
||||
|
||||
@@ -12,6 +13,7 @@ export function initializeStore(preloadedState = undefined) {
|
||||
threads: threadsReducer,
|
||||
comments: commentsReducer,
|
||||
cohorts: cohortsReducer,
|
||||
config: configReducer,
|
||||
},
|
||||
preloadedState,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user