Add code for interacting with Discussions API
This commit adds code for interacting with the Discussions API, along with the associated reducers, thunks, selectors etc.
This commit is contained in:
@@ -2,36 +2,66 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export const API_BASE_URL = getConfig().LMS_BASE_URL;
|
||||
|
||||
export const LoadingStatus = {
|
||||
LOADING: 'loading',
|
||||
LOADED: 'loaded',
|
||||
/**
|
||||
* Enum for request status.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const RequestStatus = {
|
||||
IN_PROGRESS: 'in-progress',
|
||||
SUCCESSFUL: 'successful',
|
||||
FAILED: 'failed',
|
||||
DENIED: 'denied',
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for thread ordering.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const ThreadOrdering = {
|
||||
BY_LAST_ACTIVITY: 'sort_by_last_activity',
|
||||
BY_COMMENT_COUNT: 'sort_by_comment_count',
|
||||
BY_VOTE_COUNT: 'sort_by_vote_count',
|
||||
};
|
||||
|
||||
export const ThreadView = {
|
||||
/**
|
||||
* Enum for thread view status filtering.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const ThreadViewStatus = {
|
||||
UNREAD: 'unread',
|
||||
UNANSWERED: 'unanswered',
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for filtering user posts.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const MyPostsFilter = {
|
||||
MY_POSTS: 'my_posts',
|
||||
MY_DISCUSSIONS: 'my_discussions',
|
||||
MY_QUESTIONS: 'my_questions',
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for filtering all posts.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const AllPostsFilter = {
|
||||
ALL_POSTS: 'all_posts',
|
||||
ALL_DISCUSSIONS: 'all_discussions',
|
||||
ALL_QUESTIONS: 'all_questions',
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for filtering topics.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const TopicsFilter = {
|
||||
ALL: 'all_topics',
|
||||
COURSE_SECTION: 'course_section_topics',
|
||||
@@ -44,8 +74,11 @@ export const Routes = {
|
||||
ALL: '/discussions/:courseId/topics',
|
||||
},
|
||||
POSTS: {
|
||||
PATH: '/discussions/:courseId/posts/:discussionId/:threadId?',
|
||||
PATH: '/discussions/:courseId/posts/:topicId/:threadId?',
|
||||
MY_POSTS: '/discussions/:courseId/posts/mine',
|
||||
ALL_POSTS: '/discussions/:courseId/posts/all',
|
||||
},
|
||||
COMMENTS: {
|
||||
PATH: '/discussions/:courseId/posts/:topicId/:threadId',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,16 +2,16 @@ import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import CommentsView from './CommentsView';
|
||||
import { selectTopicComments } from './data/selectors';
|
||||
import { fetchTopicComments } from './data/thunks';
|
||||
import { selectThreadComments } from './data/selectors';
|
||||
import { fetchThreadComments } from './data/thunks';
|
||||
|
||||
function CommentsViewContainer() {
|
||||
const { threadId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
const comments = useSelector(selectTopicComments(threadId));
|
||||
const comments = useSelector(selectThreadComments(threadId));
|
||||
useEffect(() => {
|
||||
// The courseId from the URL is the course we WANT to load.
|
||||
dispatch(fetchTopicComments(threadId));
|
||||
dispatch(fetchThreadComments(threadId));
|
||||
}, [threadId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import messages from './messages';
|
||||
|
||||
function Comment({ intl, comment }) {
|
||||
return (
|
||||
<div className="discussion-comment d-flex flex-column m-2 card">
|
||||
<div className="discussion-comment d-flex flex-column m-2 card" data-comment-id={comment.id}>
|
||||
<div className="header d-flex m-1 card-header">
|
||||
<div className="avatar">
|
||||
[A]
|
||||
|
||||
@@ -1,31 +1,98 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { API_BASE_URL } from '../../../data/constants';
|
||||
|
||||
ensureConfig([
|
||||
'LMS_BASE_URL',
|
||||
], 'Comments API service');
|
||||
|
||||
const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
|
||||
const commentsApiUrl = `${apiBaseUrl}/api/discussion/v1/comments/`;
|
||||
|
||||
/**
|
||||
* Returns all the comments for the specified thread.
|
||||
* @param {string} threadId
|
||||
* @param {number=} page
|
||||
* @param {number=} pageSize
|
||||
* @param {[string]=} requestedFields
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getThreadComments(
|
||||
threadId, {
|
||||
commentId, page, pageSize, requestedFields,
|
||||
page, pageSize, requestedFields,
|
||||
} = {},
|
||||
) {
|
||||
const url = new URL(`${API_BASE_URL}/api/discussion/v1/comments/`);
|
||||
const paramsMap = {
|
||||
const params = {
|
||||
thread_id: threadId,
|
||||
comment_id: commentId,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
requested_fields: requestedFields,
|
||||
};
|
||||
Object.keys(paramsMap)
|
||||
.forEach(
|
||||
(param) => {
|
||||
const paramValue = paramsMap[param];
|
||||
if (paramValue) {
|
||||
url.searchParams.append(param, paramValue);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(commentsApiUrl, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single comment.
|
||||
* @param {string} commentId
|
||||
* @param {number=} page
|
||||
* @param {number=} pageSize
|
||||
* @param {[string]=} requestedFields
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getComment(
|
||||
commentId, {
|
||||
page, pageSize, requestedFields,
|
||||
} = {},
|
||||
) {
|
||||
const url = `${commentsApiUrl}${commentId}/`;
|
||||
const params = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
requested_fields: requestedFields,
|
||||
};
|
||||
const { data } = await getAuthenticatedHttpClient().get(url, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a comment.
|
||||
* @param {string} comment Raw comment data to post.
|
||||
* @param {string} threadId Thread ID for thread in which to post comment.
|
||||
* @param {string=} parentId ID for a comments parent.
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function postComment(comment, threadId, parentId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(commentsApiUrl, {
|
||||
thread_id: threadId,
|
||||
raw_body: comment,
|
||||
parent_id: parentId,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates existing comment.
|
||||
* @param {string} commentId ID of comment to update.
|
||||
* @param {string} comment Raw updated comment data to post.
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function updateComment(commentId, comment) {
|
||||
const url = `${commentsApiUrl}${commentId}/`;
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(url, { raw_body: comment }, { headers: { 'Content-Type': 'application/merge-patch+json' } });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes existing comment.
|
||||
* @param {string} commentId ID of comment to delete
|
||||
*/
|
||||
export async function deleteComment(commentId) {
|
||||
const url = `${commentsApiUrl}${commentId}/`;
|
||||
await getAuthenticatedHttpClient().delete(url);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const selectTopicComments = topicId => state => state.comments.comments[topicId] || [];
|
||||
export const selectThreadComments = threadId => state => (state.comments.threadCommentMap[threadId] || []).map(
|
||||
commentId => state.comments.comments[commentId],
|
||||
);
|
||||
|
||||
export const courseTopicsStatus = state => state.comments.status;
|
||||
export const commentsStatus = state => state.comments.status;
|
||||
|
||||
@@ -1,43 +1,140 @@
|
||||
/* eslint-disable no-param-reassign,import/prefer-default-export */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { LoadingStatus } from '../../../data/constants';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
function normaliseComments(state, rawCommentData) {
|
||||
const { threadCommentMap: threads, comments } = state;
|
||||
rawCommentData.forEach(
|
||||
comment => {
|
||||
if (!threads[comment.thread_id]) {
|
||||
threads[comment.thread_id] = [];
|
||||
}
|
||||
if (!threads[comment.thread_id].includes(comment.id)) {
|
||||
threads[comment.thread_id].push(comment.id);
|
||||
}
|
||||
comments[comment.id] = comment;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const commentsSlice = createSlice({
|
||||
name: 'comments',
|
||||
initialState: {
|
||||
status: LoadingStatus.LOADING,
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
page: null,
|
||||
comments: {
|
||||
// Map thread ids to comments
|
||||
threadCommentMap: {
|
||||
// Maps threads to comment ids in them.
|
||||
},
|
||||
comments: {
|
||||
// Map comment ids to comments.
|
||||
},
|
||||
// Stores the comment being posted in case it needs to be reposted due to network failure.
|
||||
// TODO: save in localstorage so user can continue editing?
|
||||
commentDraft: null,
|
||||
totalPages: null,
|
||||
totalThreads: null,
|
||||
postStatus: RequestStatus.SUCCESSFUL,
|
||||
},
|
||||
reducers: {
|
||||
fetchCommentsRequest: (state) => {
|
||||
state.status = LoadingStatus.LOADING;
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchCommentsSuccess: (state, { payload }) => {
|
||||
const { data, topicId } = payload;
|
||||
state.status = LoadingStatus.LOADED;
|
||||
state.comments[topicId] = data.results;
|
||||
state.page = data.pagination.page;
|
||||
state.totalPages = data.pagination.num_pages;
|
||||
state.totalThreads = data.pagination.count;
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
normaliseComments(state, payload.results);
|
||||
state.page = payload.pagination.page;
|
||||
state.totalPages = payload.pagination.num_pages;
|
||||
state.totalThreads = payload.pagination.count;
|
||||
},
|
||||
fetchCommentsFailed: (state) => {
|
||||
state.status = LoadingStatus.FAILED;
|
||||
state.status = RequestStatus.FAILED;
|
||||
},
|
||||
fetchCommentsDenied: (state) => {
|
||||
state.status = LoadingStatus.DENIED;
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
fetchCommentRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchCommentFailed: (state) => {
|
||||
state.status = RequestStatus.FAILED;
|
||||
},
|
||||
fetchCommentDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
fetchCommentSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
normaliseComments(state, payload.results);
|
||||
},
|
||||
postCommentRequest: (state, { payload }) => {
|
||||
state.postStatus = RequestStatus.IN_PROGRESS;
|
||||
state.commentDraft = payload;
|
||||
},
|
||||
postCommentDenied: (state) => {
|
||||
state.postStatus = RequestStatus.DENIED;
|
||||
},
|
||||
postCommentFailed: (state) => {
|
||||
state.postStatus = RequestStatus.FAILED;
|
||||
},
|
||||
postCommentSuccess: (state, { payload }) => {
|
||||
state.postStatus = RequestStatus.SUCCESSFUL;
|
||||
normaliseComments(state, [payload]);
|
||||
state.commentDraft = null;
|
||||
},
|
||||
updateCommentRequest: (state, { payload }) => {
|
||||
state.postStatus = RequestStatus.IN_PROGRESS;
|
||||
state.commentDraft = payload;
|
||||
},
|
||||
updateCommentDenied: (state) => {
|
||||
state.postStatus = RequestStatus.DENIED;
|
||||
},
|
||||
updateCommentFailed: (state) => {
|
||||
state.postStatus = RequestStatus.FAILED;
|
||||
},
|
||||
updateCommentSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
normaliseComments(state, [payload]);
|
||||
state.commentDraft = null;
|
||||
},
|
||||
deleteCommentRequest: (state) => {
|
||||
state.postStatus = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
deleteCommentDenied: (state) => {
|
||||
state.postStatus = RequestStatus.DENIED;
|
||||
},
|
||||
deleteCommentFailed: (state) => {
|
||||
state.postStatus = RequestStatus.FAILED;
|
||||
},
|
||||
deleteCommentSuccess: (state, { payload }) => {
|
||||
const { commentId } = payload;
|
||||
state.postStatus = RequestStatus.SUCCESSFUL;
|
||||
const threadId = state.comments[commentId].thread_id;
|
||||
state.threadCommentMap[threadId] = state.threadCommentMap[threadId].filter(item => item !== commentId);
|
||||
delete state.comments[commentId];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchCommentDenied,
|
||||
fetchCommentFailed,
|
||||
fetchCommentRequest,
|
||||
fetchCommentsDenied,
|
||||
fetchCommentsFailed,
|
||||
fetchCommentsRequest,
|
||||
fetchCommentsSuccess,
|
||||
fetchCommentsFailed,
|
||||
fetchCommentSuccess,
|
||||
postCommentDenied,
|
||||
postCommentFailed,
|
||||
postCommentRequest,
|
||||
postCommentSuccess,
|
||||
updateCommentDenied,
|
||||
updateCommentFailed,
|
||||
updateCommentRequest,
|
||||
updateCommentSuccess,
|
||||
deleteCommentDenied,
|
||||
deleteCommentFailed,
|
||||
deleteCommentRequest,
|
||||
deleteCommentSuccess,
|
||||
} = commentsSlice.actions;
|
||||
|
||||
export const commentsReducer = commentsSlice.reducer;
|
||||
|
||||
@@ -1,16 +1,119 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { getThreadComments } from './api';
|
||||
import { fetchCommentsFailed, fetchCommentsRequest, fetchCommentsSuccess } from './slices';
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import {
|
||||
deleteComment, getComment, getThreadComments, postComment, updateComment,
|
||||
} from './api';
|
||||
import {
|
||||
deleteCommentDenied,
|
||||
deleteCommentFailed,
|
||||
deleteCommentRequest,
|
||||
deleteCommentSuccess,
|
||||
fetchCommentDenied,
|
||||
fetchCommentFailed,
|
||||
fetchCommentRequest,
|
||||
fetchCommentsDenied,
|
||||
fetchCommentsFailed,
|
||||
fetchCommentsRequest,
|
||||
fetchCommentsSuccess,
|
||||
fetchCommentSuccess,
|
||||
postCommentDenied,
|
||||
postCommentFailed,
|
||||
postCommentRequest,
|
||||
postCommentSuccess,
|
||||
updateCommentDenied,
|
||||
updateCommentFailed,
|
||||
updateCommentRequest,
|
||||
updateCommentSuccess,
|
||||
} from './slices';
|
||||
|
||||
export function fetchTopicComments(topicId) {
|
||||
export function fetchThreadComments(threadId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchCommentsRequest({ topicId }));
|
||||
const data = await getThreadComments(topicId);
|
||||
dispatch(fetchCommentsSuccess({ topicId, data }));
|
||||
dispatch(fetchCommentsRequest({ threadId }));
|
||||
const data = await getThreadComments(threadId);
|
||||
dispatch(fetchCommentsSuccess(data));
|
||||
} catch (error) {
|
||||
dispatch(fetchCommentsFailed());
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchCommentsDenied());
|
||||
} else {
|
||||
dispatch(fetchCommentsFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchComment(commentId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchCommentRequest({ commentId }));
|
||||
const data = await getComment(commentId);
|
||||
dispatch(fetchCommentSuccess(data));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchCommentDenied());
|
||||
} else {
|
||||
dispatch(fetchCommentFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function editComment(commentId, comment) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(updateCommentRequest({ commentId }));
|
||||
const data = await updateComment(commentId, comment);
|
||||
dispatch(updateCommentSuccess(data));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(updateCommentDenied());
|
||||
} else {
|
||||
dispatch(updateCommentFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function addComment(comment, threadId, parentId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(postCommentRequest({
|
||||
comment,
|
||||
threadId,
|
||||
parentId,
|
||||
}));
|
||||
const data = await postComment(comment, threadId, parentId);
|
||||
dispatch(postCommentSuccess(data));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(postCommentDenied());
|
||||
} else {
|
||||
dispatch(postCommentFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function removeComment(commentId, threadId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(deleteCommentRequest({ commentId }));
|
||||
await deleteComment(commentId);
|
||||
dispatch(deleteCommentSuccess({
|
||||
commentId,
|
||||
threadId,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(deleteCommentDenied());
|
||||
} else {
|
||||
dispatch(deleteCommentFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function DiscussionsHome() {
|
||||
</div>
|
||||
<div className="d-flex">
|
||||
<Switch>
|
||||
<Route path={Routes.POSTS.PATH}>
|
||||
<Route path={Routes.COMMENTS.PATH}>
|
||||
<CommentsViewContainer />
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import { selectCourseThreads } from './data/selectors';
|
||||
import { fetchCourseThreads } from './data/thunks';
|
||||
import { selectThreads } from './data/selectors';
|
||||
import { fetchThreads } from './data/thunks';
|
||||
import PostsView from './PostsView';
|
||||
|
||||
function PostsViewContainer() {
|
||||
const { courseId, discussionId } = useParams();
|
||||
const { courseId, topicId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
const posts = useSelector(selectCourseThreads(discussionId));
|
||||
const posts = useSelector(selectThreads(topicId));
|
||||
useEffect(() => {
|
||||
// The courseId from the URL is the course we WANT to load.
|
||||
dispatch(fetchCourseThreads(courseId));
|
||||
dispatch(fetchThreads(courseId));
|
||||
}, [courseId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { API_BASE_URL } from '../../../data/constants';
|
||||
|
||||
export async function getCourseThreads(
|
||||
ensureConfig([
|
||||
'LMS_BASE_URL',
|
||||
], 'Posts API service');
|
||||
|
||||
const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
|
||||
const threadsApiUrl = `${apiBaseUrl}/api/discussion/v1/threads/`;
|
||||
|
||||
/**
|
||||
* Fetches all the threads in the given course and topic.
|
||||
* @param {string} courseId
|
||||
* @param {[string]} topicIds List of topics to limit threads to
|
||||
* @param {number} page
|
||||
* @param {number} pageSize
|
||||
* @param {string} textSearch A search string to match.
|
||||
* @param {ThreadOrdering} orderBy The results wil be sorted on this basis.
|
||||
* @param {boolean} following If true, only threads followed by the current user will be returned.
|
||||
* @param {ThreadViewStatus} view Set to "unread" on "unanswered" to filter to only those statuses.
|
||||
* @param {string} requestedFields List of additional field to include in returned data.
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getThreads(
|
||||
courseId, topicIds, {
|
||||
page, pageSize, textSearch, orderBy, following, view, requestedFields,
|
||||
} = {},
|
||||
) {
|
||||
const url = new URL(`${API_BASE_URL}/api/discussion/v1/threads/`);
|
||||
const paramsMap = {
|
||||
const params = {
|
||||
course_id: courseId,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
topic_id: topicIds && topicIds.join(','),
|
||||
@@ -18,18 +39,84 @@ export async function getCourseThreads(
|
||||
view,
|
||||
requested_fields: requestedFields,
|
||||
};
|
||||
url.searchParams.append('course_id', courseId);
|
||||
Object.keys(paramsMap)
|
||||
.forEach(
|
||||
(param) => {
|
||||
const paramValue = paramsMap[param];
|
||||
if (paramValue) {
|
||||
url.searchParams.append(param, paramValue);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(threadsApiUrl, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single thread.
|
||||
* @param {string} threadId
|
||||
* @param {string} requestedFields List of additional field to include in returned data.
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getThread(threadId, requestedFields) {
|
||||
const params = { requested_fields: requestedFields };
|
||||
const url = `${threadsApiUrl}${threadId}/`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a new thread.
|
||||
* @param {string} courseId
|
||||
* @param {string} topicId
|
||||
* @param {string} type The thread's type (either "question" or "discussion")
|
||||
* @param {string} title
|
||||
* @param {string} content
|
||||
* @param {boolean} following Follow the thread after creating
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function postThread(courseId, topicId, type, title, content, following = false) {
|
||||
const postData = {
|
||||
course_id: courseId,
|
||||
topic_id: topicId,
|
||||
type,
|
||||
title,
|
||||
raw_body: content,
|
||||
following,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().post(threadsApiUrl, postData);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing thread.
|
||||
* @param {string} threadId
|
||||
* @param {string} topicId
|
||||
* @param {string} type The thread's type (either "question" or "discussion")
|
||||
* @param {string} title
|
||||
* @param {string} content
|
||||
* @param {boolean} flagged
|
||||
* @param {boolean} voted
|
||||
* @param {boolean} read
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function updateThread(threadId, {
|
||||
flagged, voted, read, topicId, type, title, content,
|
||||
} = {}) {
|
||||
const url = `${threadsApiUrl}${threadId}/`;
|
||||
const patchData = {
|
||||
topic_id: topicId,
|
||||
abuse_flagged: flagged,
|
||||
voted,
|
||||
read,
|
||||
type,
|
||||
title,
|
||||
raw_body: content,
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.patch(url, patchData, { headers: { 'Content-Type': 'application/merge-patch+json' } });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a thread.
|
||||
* @param {string} threadId
|
||||
*/
|
||||
export async function deleteThread(threadId) {
|
||||
const url = `${threadsApiUrl}${threadId}/`;
|
||||
await getAuthenticatedHttpClient().delete(url);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const selectCourseThreads = topicId => state => state.threads.threads[topicId] || [];
|
||||
export const selectThreads = topicId => state => (state.threads.topicThreadMap[topicId] || []).map(
|
||||
threadId => state.threads.threads[threadId],
|
||||
);
|
||||
|
||||
export const courseTopicsStatus = state => state.topics.status;
|
||||
export const threadsStatus = state => state.threads.status;
|
||||
|
||||
@@ -1,55 +1,138 @@
|
||||
/* eslint-disable no-param-reassign,import/prefer-default-export */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { LoadingStatus } from '../../../data/constants';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
function normaliseThreads(rawThreadsData) {
|
||||
const topicThreadMap = {};
|
||||
function normaliseThreads(state, rawThreadsData) {
|
||||
const { topicThreadMap: topics, threads } = state;
|
||||
rawThreadsData.forEach(
|
||||
thread => {
|
||||
if (!topicThreadMap[thread.topic_id]) {
|
||||
topicThreadMap[thread.topic_id] = [];
|
||||
if (!topics[thread.topic_id]) {
|
||||
topics[thread.topic_id] = [];
|
||||
}
|
||||
topicThreadMap[thread.topic_id].push(thread);
|
||||
if (!topics[thread.topic_id].includes(thread.id)) {
|
||||
topics[thread.topic_id].push(thread.id);
|
||||
}
|
||||
threads[thread.id] = thread;
|
||||
},
|
||||
);
|
||||
return topicThreadMap;
|
||||
}
|
||||
|
||||
const courseThreadsSlice = createSlice({
|
||||
name: 'courseThreads',
|
||||
const threadsSlice = createSlice({
|
||||
name: 'thread',
|
||||
initialState: {
|
||||
status: LoadingStatus.LOADING,
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
page: null,
|
||||
threads: {
|
||||
// Mapping of topic ids to threads in them
|
||||
topicThreadMap: {
|
||||
// Mapping of topic ids to thread ids in them
|
||||
},
|
||||
threads: {
|
||||
// Mapping of threads ids to threads in them
|
||||
},
|
||||
threadDraft: null,
|
||||
totalPages: null,
|
||||
totalThreads: null,
|
||||
postStatus: RequestStatus.SUCCESSFUL,
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseThreadsRequest: (state) => {
|
||||
state.status = LoadingStatus.LOADING;
|
||||
fetchThreadsRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchCourseThreadsSuccess: (state, { payload }) => {
|
||||
state.status = LoadingStatus.LOADED;
|
||||
state.threads = normaliseThreads(payload.results);
|
||||
fetchThreadsSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
normaliseThreads(state, payload.results);
|
||||
state.page = payload.pagination.page;
|
||||
state.totalPages = payload.pagination.num_pages;
|
||||
state.totalThreads = payload.pagination.count;
|
||||
},
|
||||
fetchCourseThreadsFailed: (state) => {
|
||||
state.status = LoadingStatus.FAILED;
|
||||
fetchThreadsFailed: (state) => {
|
||||
state.status = RequestStatus.FAILED;
|
||||
},
|
||||
fetchCourseThreadsDenied: (state) => {
|
||||
state.status = LoadingStatus.DENIED;
|
||||
fetchThreadsDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
fetchThreadRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchThreadSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
normaliseThreads(state, [payload]);
|
||||
},
|
||||
fetchThreadFailed: (state) => {
|
||||
state.status = RequestStatus.FAILED;
|
||||
},
|
||||
fetchThreadDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
postThreadRequest: (state, { payload }) => {
|
||||
state.postStatus = RequestStatus.IN_PROGRESS;
|
||||
state.threadDraft = payload;
|
||||
},
|
||||
postThreadSuccess: (state, { payload }) => {
|
||||
state.postStatus = RequestStatus.SUCCESSFUL;
|
||||
normaliseThreads(state, [payload]);
|
||||
state.threadDraft = null;
|
||||
},
|
||||
postThreadFailed: (state) => {
|
||||
state.postStatus = RequestStatus.FAILED;
|
||||
},
|
||||
postThreadDenied: (state) => {
|
||||
state.postStatus = RequestStatus.DENIED;
|
||||
},
|
||||
updateThreadRequest: (state, { payload }) => {
|
||||
state.postStatus = RequestStatus.IN_PROGRESS;
|
||||
state.threadDraft = payload;
|
||||
},
|
||||
updateThreadSuccess: (state, { payload }) => {
|
||||
state.postStatus = RequestStatus.SUCCESSFUL;
|
||||
normaliseThreads(state, [payload]);
|
||||
state.threadDraft = null;
|
||||
},
|
||||
updateThreadFailed: (state) => {
|
||||
state.postStatus = RequestStatus.FAILED;
|
||||
},
|
||||
updateThreadDenied: (state) => {
|
||||
state.postStatus = RequestStatus.DENIED;
|
||||
},
|
||||
deleteThreadRequest: (state) => {
|
||||
state.postStatus = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
deleteThreadSuccess: (state, { payload }) => {
|
||||
const { threadId } = payload;
|
||||
const topicId = state.threads[threadId].topic_id;
|
||||
state.postStatus = RequestStatus.SUCCESSFUL;
|
||||
state.topicThreadMap[topicId] = state.topicThreadMap[topicId].filter(item => item !== threadId);
|
||||
delete state.threads[threadId];
|
||||
},
|
||||
deleteThreadFailed: (state) => {
|
||||
state.postStatus = RequestStatus.FAILED;
|
||||
},
|
||||
deleteThreadDenied: (state) => {
|
||||
state.postStatus = RequestStatus.DENIED;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchCourseThreadsRequest,
|
||||
fetchCourseThreadsSuccess,
|
||||
fetchCourseThreadsFailed,
|
||||
} = courseThreadsSlice.actions;
|
||||
deleteThreadDenied,
|
||||
deleteThreadFailed,
|
||||
deleteThreadRequest,
|
||||
deleteThreadSuccess,
|
||||
fetchThreadDenied,
|
||||
fetchThreadFailed,
|
||||
fetchThreadRequest,
|
||||
fetchThreadsDenied,
|
||||
fetchThreadsFailed,
|
||||
fetchThreadsRequest,
|
||||
fetchThreadsSuccess,
|
||||
fetchThreadSuccess,
|
||||
postThreadDenied,
|
||||
postThreadFailed,
|
||||
postThreadRequest,
|
||||
postThreadSuccess,
|
||||
updateThreadDenied,
|
||||
updateThreadFailed,
|
||||
updateThreadRequest,
|
||||
updateThreadSuccess,
|
||||
} = threadsSlice.actions;
|
||||
|
||||
export const courseThreadsReducer = courseThreadsSlice.reducer;
|
||||
export const threadsReducer = threadsSlice.reducer;
|
||||
|
||||
@@ -1,16 +1,138 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { getCourseThreads } from './api';
|
||||
import { fetchCourseThreadsFailed, fetchCourseThreadsRequest, fetchCourseThreadsSuccess } from './slices';
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import {
|
||||
deleteThread, getThread, getThreads, postThread, updateThread,
|
||||
} from './api';
|
||||
import {
|
||||
deleteThreadDenied,
|
||||
deleteThreadFailed,
|
||||
deleteThreadRequest,
|
||||
deleteThreadSuccess,
|
||||
fetchThreadDenied,
|
||||
fetchThreadFailed,
|
||||
fetchThreadRequest,
|
||||
fetchThreadsDenied,
|
||||
fetchThreadsFailed,
|
||||
fetchThreadsRequest,
|
||||
fetchThreadsSuccess,
|
||||
fetchThreadSuccess,
|
||||
postThreadDenied,
|
||||
postThreadFailed,
|
||||
postThreadRequest,
|
||||
postThreadSuccess,
|
||||
updateThreadDenied,
|
||||
updateThreadFailed,
|
||||
updateThreadRequest,
|
||||
updateThreadSuccess,
|
||||
} from './slices';
|
||||
|
||||
export function fetchCourseThreads(courseId, topicIds) {
|
||||
export function fetchThreads(courseId, topicIds) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchCourseThreadsRequest({ courseId }));
|
||||
const data = await getCourseThreads(courseId, topicIds);
|
||||
dispatch(fetchCourseThreadsSuccess(data));
|
||||
dispatch(fetchThreadsRequest({ courseId }));
|
||||
const data = await getThreads(courseId, topicIds);
|
||||
dispatch(fetchThreadsSuccess(data));
|
||||
} catch (error) {
|
||||
dispatch(fetchCourseThreadsFailed());
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchThreadsDenied());
|
||||
} else {
|
||||
dispatch(fetchThreadsFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchThread(threadId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchThreadRequest({ threadId }));
|
||||
const data = await getThread(threadId);
|
||||
dispatch(fetchThreadSuccess(data));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchThreadDenied());
|
||||
} else {
|
||||
dispatch(fetchThreadFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createNewThread(courseId, topicId, type, title, content, following = false) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(postThreadRequest({
|
||||
courseId,
|
||||
topicId,
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
following,
|
||||
}));
|
||||
const data = await postThread(courseId, topicId, type, title, content, following);
|
||||
dispatch(postThreadSuccess(data));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(postThreadDenied());
|
||||
} else {
|
||||
dispatch(postThreadFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateExistingThread(threadId, {
|
||||
flagged, voted, read, topicId, type, title, content,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(updateThreadRequest({
|
||||
threadId,
|
||||
flagged,
|
||||
voted,
|
||||
read,
|
||||
topicId,
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
}));
|
||||
const data = await updateThread(threadId, {
|
||||
flagged,
|
||||
voted,
|
||||
read,
|
||||
topicId,
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
});
|
||||
dispatch(updateThreadSuccess(data));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(updateThreadDenied());
|
||||
} else {
|
||||
dispatch(updateThreadFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function removeThread(threadId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(deleteThreadRequest({ threadId }));
|
||||
await deleteThread(threadId);
|
||||
dispatch(deleteThreadSuccess({ threadId }));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(deleteThreadDenied());
|
||||
} else {
|
||||
dispatch(deleteThreadFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -30,8 +30,8 @@ function Post({ post, intl }) {
|
||||
<Link
|
||||
className="post-title d-flex post-tile"
|
||||
to={
|
||||
Routes.POSTS.PATH.replace(':discussionId', post.topic_id)
|
||||
.replace(':courseId', post.course_id)
|
||||
Routes.POSTS.PATH.replace(':courseId', post.course_id)
|
||||
.replace(':topicId', post.topic_id)
|
||||
.replace(':threadId', post.id)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -3,9 +3,10 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { API_BASE_URL } from '../../../data/constants';
|
||||
|
||||
export async function getCourseTopics(courseId, topicIds) {
|
||||
const url = new URL(`${API_BASE_URL}/api/discussion/v1/course_topics/${courseId}`);
|
||||
const url = `${API_BASE_URL}/api/discussion/v1/course_topics/${courseId}`;
|
||||
const params = {};
|
||||
if (topicIds) {
|
||||
url.searchParams.append('topic_id', topicIds.join(','));
|
||||
params.topic_id = topicIds.join(',');
|
||||
}
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(url);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* eslint-disable no-param-reassign,import/prefer-default-export */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { LoadingStatus } from '../../../data/constants';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
const topicsSLice = createSlice({
|
||||
name: 'courseTopics',
|
||||
initialState: {
|
||||
status: LoadingStatus.LOADING,
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
topics: {
|
||||
courseware_topics: [],
|
||||
non_courseware_topics: [],
|
||||
@@ -13,17 +13,17 @@ const topicsSLice = createSlice({
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseTopicsRequest: (state) => {
|
||||
state.status = LoadingStatus.LOADING;
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchCourseTopicsSuccess: (state, { payload }) => {
|
||||
state.status = LoadingStatus.LOADED;
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
state.topics = payload;
|
||||
},
|
||||
fetchCourseTopicsFailed: (state) => {
|
||||
state.status = LoadingStatus.FAILED;
|
||||
state.status = RequestStatus.FAILED;
|
||||
},
|
||||
fetchCourseTopicsDenied: (state) => {
|
||||
state.status = LoadingStatus.DENIED;
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,8 +17,8 @@ function Topic({ id, name, topics }) {
|
||||
<Link
|
||||
className="topic-name"
|
||||
to={
|
||||
Routes.POSTS.PATH.replace(':discussionId', id)
|
||||
.replace(':courseId', courseId)
|
||||
Routes.POSTS.PATH.replace(':courseId', courseId)
|
||||
.replace(':topicId', id)
|
||||
.replace(':threadId', '')
|
||||
}
|
||||
>
|
||||
|
||||
@@ -10,3 +10,10 @@ export function buildIntlSelectionList(options, intl, messages) {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HTTP Error status from generic error.
|
||||
* @param error Generic caught errot.
|
||||
* @returns {number|undefined}
|
||||
*/
|
||||
export const getHttpErrorStatus = error => error && error.customAttributes && error.customAttributes.httpErrorStatus;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { commentsReducer } from './discussions/comments/data';
|
||||
import { courseThreadsReducer } from './discussions/posts/data';
|
||||
import { threadsReducer } from './discussions/posts/data';
|
||||
import { topicsReducer } from './discussions/topics/data';
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
topics: topicsReducer,
|
||||
threads: courseThreadsReducer,
|
||||
threads: threadsReducer,
|
||||
comments: commentsReducer,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user