Basic discussions forum framework
Adds the basic structure for the Discussions MFE around which future development will happen.
This commit is contained in:
17
src/discussions/posts/PostsView.jsx
Normal file
17
src/discussions/posts/PostsView.jsx
Normal 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;
|
||||
24
src/discussions/posts/PostsViewContainer.jsx
Normal file
24
src/discussions/posts/PostsViewContainer.jsx
Normal 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;
|
||||
35
src/discussions/posts/data/api.js
Normal file
35
src/discussions/posts/data/api.js
Normal 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;
|
||||
}
|
||||
1
src/discussions/posts/data/index.js
Normal file
1
src/discussions/posts/data/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './slices';
|
||||
4
src/discussions/posts/data/selectors.js
Normal file
4
src/discussions/posts/data/selectors.js
Normal 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;
|
||||
55
src/discussions/posts/data/slices.js
Normal file
55
src/discussions/posts/data/slices.js
Normal 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;
|
||||
17
src/discussions/posts/data/thunks.js
Normal file
17
src/discussions/posts/data/thunks.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
2
src/discussions/posts/index.js
Normal file
2
src/discussions/posts/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as PostsViewContainer } from './PostsViewContainer';
|
||||
95
src/discussions/posts/post/Post.jsx
Normal file
95
src/discussions/posts/post/Post.jsx
Normal 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);
|
||||
14
src/discussions/posts/post/messages.js
Normal file
14
src/discussions/posts/post/messages.js
Normal 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;
|
||||
Reference in New Issue
Block a user