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:
Kshitij Sobti
2020-08-18 20:13:54 +05:30
parent b90e4f757d
commit 27b5f48907
21 changed files with 6059 additions and 9377 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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