Basic discussions forum framework

Adds the basic structure for the Discussions MFE around which future development
will happen.
This commit is contained in:
Kshitij Sobti
2020-08-18 16:58:48 +05:30
parent 56d68e76ad
commit 491f7b7acd
58 changed files with 9313 additions and 4194 deletions

View File

@@ -0,0 +1,17 @@
import PropTypes from 'prop-types';
import React from 'react';
import Post, { postShape } from './post/Post';
function PostsView({ posts }) {
return (
<div className="discussion-posts d-flex flex-column">
{ posts.map(post => <Post post={post} key={post.id} />) }
</div>
);
}
PostsView.propTypes = {
posts: PropTypes.arrayOf(postShape).isRequired,
};
export default PostsView;

View File

@@ -0,0 +1,24 @@
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 PostsView from './PostsView';
function PostsViewContainer() {
const { courseId, discussionId } = useParams();
const dispatch = useDispatch();
const posts = useSelector(selectCourseThreads(discussionId));
useEffect(() => {
// The courseId from the URL is the course we WANT to load.
dispatch(fetchCourseThreads(courseId));
}, [courseId]);
return (
<PostsView posts={posts} />
);
}
PostsViewContainer.propTypes = {};
export default PostsViewContainer;

View File

@@ -0,0 +1,35 @@
/* eslint-disable import/prefer-default-export */
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { API_BASE_URL } from '../../../data/constants';
export async function getCourseThreads(
courseId, topicIds, {
page, pageSize, textSearch, orderBy, following, view, requestedFields,
} = {},
) {
const url = new URL(`${API_BASE_URL}/api/discussion/v1/threads/`);
const paramsMap = {
page,
page_size: pageSize,
topic_id: topicIds && topicIds.join(','),
text_search: textSearch,
order_by: orderBy,
following,
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);
return data;
}

View File

@@ -0,0 +1 @@
export * from './slices';

View File

@@ -0,0 +1,4 @@
/* eslint-disable import/prefer-default-export */
export const selectCourseThreads = topicId => state => state.threads.threads[topicId] || [];
export const courseTopicsStatus = state => state.topics.status;

View File

@@ -0,0 +1,55 @@
/* eslint-disable no-param-reassign,import/prefer-default-export */
import { createSlice } from '@reduxjs/toolkit';
import { LoadingStatus } from '../../../data/constants';
function normaliseThreads(rawThreadsData) {
const topicThreadMap = {};
rawThreadsData.forEach(
thread => {
if (!topicThreadMap[thread.topic_id]) {
topicThreadMap[thread.topic_id] = [];
}
topicThreadMap[thread.topic_id].push(thread);
},
);
return topicThreadMap;
}
const courseThreadsSlice = createSlice({
name: 'courseThreads',
initialState: {
status: LoadingStatus.LOADING,
page: null,
threads: {
// Mapping of topic ids to threads in them
},
totalPages: null,
totalThreads: null,
},
reducers: {
fetchCourseThreadsRequest: (state) => {
state.status = LoadingStatus.LOADING;
},
fetchCourseThreadsSuccess: (state, { payload }) => {
state.status = LoadingStatus.LOADED;
state.threads = normaliseThreads(payload.results);
state.page = payload.pagination.page;
state.totalPages = payload.pagination.num_pages;
state.totalThreads = payload.pagination.count;
},
fetchCourseThreadsFailed: (state) => {
state.status = LoadingStatus.FAILED;
},
fetchCourseThreadsDenied: (state) => {
state.status = LoadingStatus.DENIED;
},
},
});
export const {
fetchCourseThreadsRequest,
fetchCourseThreadsSuccess,
fetchCourseThreadsFailed,
} = courseThreadsSlice.actions;
export const courseThreadsReducer = courseThreadsSlice.reducer;

View File

@@ -0,0 +1,17 @@
/* eslint-disable import/prefer-default-export */
import { logError } from '@edx/frontend-platform/logging';
import { getCourseThreads } from './api';
import { fetchCourseThreadsFailed, fetchCourseThreadsRequest, fetchCourseThreadsSuccess } from './slices';
export function fetchCourseThreads(courseId, topicIds) {
return async (dispatch) => {
try {
dispatch(fetchCourseThreadsRequest({ courseId }));
const data = await getCourseThreads(courseId, topicIds);
dispatch(fetchCourseThreadsSuccess(data));
} catch (error) {
dispatch(fetchCourseThreadsFailed());
logError(error);
}
};
}

View File

@@ -0,0 +1,2 @@
/* eslint-disable import/prefer-default-export */
export { default as PostsViewContainer } from './PostsViewContainer';

View File

@@ -0,0 +1,95 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { faQuestionCircle, faStar as faEmptyStar } from '@fortawesome/free-regular-svg-icons';
import {
faCircle, faComments, faFlag, faStar as faSolidStar, faThumbtack,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import React from 'react';
import { Link } from 'react-router-dom';
import { Routes } from '../../../data/constants';
import messages from './messages';
function Post({ post, intl }) {
return (
<div className="discussion-post d-flex border-bottom pl-2 pt-1 pb-1" data-post-id={post.id}>
<div className="d-flex post-unread-status m-1">
{ post.read || <FontAwesomeIcon icon={faCircle} /> }
</div>
<div className="d-flex flex-column">
<div className="d-flex post-header">
<div className="d-flex user-avatar m-1">
[A]
</div>
<div className="d-flex post-type-icon m-1">
{ post.type === 'question' && <FontAwesomeIcon icon={faQuestionCircle} /> }
{ post.type === 'discussion' && <FontAwesomeIcon icon={faComments} /> }
</div>
<div className="d-flex m-1 flex-column">
<Link
className="post-title d-flex post-tile"
to={
Routes.POSTS.PATH.replace(':discussionId', post.topic_id)
.replace(':courseId', post.course_id)
.replace(':threadId', post.id)
}
>
{ post.title }
</Link>
<div className="d-flex">
<div className="post-author">
{ post.author }
</div>
<div className="post-datetime">
{
post.updated_at
? intl.formatMessage(messages.last_response, { time: post.updated_at })
: intl.formatMessage(messages.posted_on, { time: post.created_at })
}
</div>
</div>
</div>
<div className="status-icons">
{ post.abuse_flagged && <FontAwesomeIcon icon={faFlag} /> }
{ post.pinned && <FontAwesomeIcon icon={faThumbtack} /> }
</div>
</div>
<div className="d-flex">
{ post.raw_body }
</div>
<div className="d-flex">
{ post.following
? <FontAwesomeIcon icon={faSolidStar} />
: <FontAwesomeIcon icon={faEmptyStar} /> }
<span className="badge">
<FontAwesomeIcon icon={faComments} /> { post.comment_count }
</span>
</div>
</div>
</div>
);
}
export const postShape = PropTypes.shape({
abuse_flagged: PropTypes.bool,
author: PropTypes.string,
comment_count: PropTypes.number,
course_id: PropTypes.string,
following: PropTypes.bool,
id: PropTypes.string,
pinned: PropTypes.bool,
raw_body: PropTypes.string,
read: PropTypes.bool,
title: PropTypes.string,
topic_id: PropTypes.string,
type: PropTypes.string,
updated_at: PropTypes.string,
});
Post.propTypes = {
intl: intlShape.isRequired,
post: postShape.isRequired,
};
export default injectIntl(Post);

View File

@@ -0,0 +1,14 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
last_response: {
id: 'discussions.post.last-response',
defaultMessage: 'Last response {time}',
},
posted_on: {
id: 'discussions.post.posted-on',
defaultMessage: 'Posted {time}',
},
});
export default messages;