feat: add support for anonymous posting

Hooks up the configuration API to allow posting threads anonymously.
This commit is contained in:
Kshitij Sobti
2021-11-09 18:49:23 +05:30
parent 6bd7ce929d
commit 18d19369a9
15 changed files with 211 additions and 34 deletions

View 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;
}

View File

@@ -0,0 +1,7 @@
/* eslint-disable import/prefer-default-export */
export const selectAnonymousPostingConfig = state => ({
allowAnonymous: state.config.allowAnonymous,
allowAnonymousToPeers: state.config.allowAnonymousToPeers,
}
);

View 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;

View 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);
}
};
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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);
}
/**

View File

@@ -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) {

View File

@@ -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

View File

@@ -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',

View File

@@ -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

View File

@@ -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>

View File

@@ -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}',

View File

@@ -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,
});