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

1
src/components/index.js Normal file
View File

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

View File

@@ -0,0 +1,55 @@
import { Dropdown } from '@edx/paragon';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
function SelectableDropdown({
options, defaultOption, onChange, label,
}) {
const [selected, setSelected] = useState(options.find(option => (option.value === defaultOption)));
return (
<Dropdown>
<Dropdown.Toggle>
{ label || selected.label }
</Dropdown.Toggle>
<Dropdown.Menu>
{ options
.map(option => (
<Dropdown.Item
type="button"
key={option.value}
onClick={
() => {
setSelected(option);
if (onChange) {
onChange(option);
}
}
}
>
{ option.label }
</Dropdown.Item>
)) }
</Dropdown.Menu>
</Dropdown>
);
}
SelectableDropdown.propTypes = {
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string,
label: PropTypes.string,
}),
).isRequired,
defaultOption: PropTypes.string.isRequired,
onChange: PropTypes.func,
label: PropTypes.node,
};
SelectableDropdown.defaultProps = {
onChange: null,
label: null,
};
export default SelectableDropdown;

View File

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

51
src/data/constants.js Normal file
View File

@@ -0,0 +1,51 @@
import { getConfig } from '@edx/frontend-platform';
export const API_BASE_URL = getConfig().LMS_BASE_URL;
export const LoadingStatus = {
LOADING: 'loading',
LOADED: 'loaded',
FAILED: 'failed',
DENIED: 'denied',
};
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 = {
UNREAD: 'unread',
UNANSWERED: 'unanswered',
};
export const MyPostsFilter = {
MY_POSTS: 'my_posts',
MY_DISCUSSIONS: 'my_discussions',
MY_QUESTIONS: 'my_questions',
};
export const AllPostsFilter = {
ALL_POSTS: 'all_posts',
ALL_DISCUSSIONS: 'all_discussions',
ALL_QUESTIONS: 'all_questions',
};
export const TopicsFilter = {
ALL: 'all_topics',
COURSE_SECTION: 'course_section_topics',
GENERAL: 'general_topics',
};
export const Routes = {
TOPICS: {
PATH: '/discussions/:courseId/topics',
ALL: '/discussions/:courseId/topics',
},
POSTS: {
PATH: '/discussions/:courseId/posts/:discussionId/:threadId?',
MY_POSTS: '/discussions/:courseId/posts/mine',
ALL_POSTS: '/discussions/:courseId/posts/all',
},
};

View File

@@ -0,0 +1,19 @@
import PropTypes from 'prop-types';
import React from 'react';
import Comment, { commentShape } from './comment/Comment';
function CommentsView({ comments }) {
return (
<div className="discussion-comments d-flex flex-column">
{
comments.map(comment => <Comment comment={comment} key={comment.id} />)
}
</div>
);
}
CommentsView.propTypes = {
comments: PropTypes.arrayOf(commentShape).isRequired,
};
export default CommentsView;

View File

@@ -0,0 +1,24 @@
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';
function CommentsViewContainer() {
const { threadId } = useParams();
const dispatch = useDispatch();
const comments = useSelector(selectTopicComments(threadId));
useEffect(() => {
// The courseId from the URL is the course we WANT to load.
dispatch(fetchTopicComments(threadId));
}, [threadId]);
return (
<CommentsView comments={comments} />
);
}
CommentsViewContainer.propTypes = {};
export default CommentsViewContainer;

View File

@@ -0,0 +1,56 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import Button from '@edx/paragon/dist/Button';
import { faFlag } from '@fortawesome/free-regular-svg-icons';
import { faEllipsisV, faStar } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import React from 'react';
import messages from './messages';
function Comment({ intl, comment }) {
return (
<div className="discussion-comment d-flex flex-column m-2 card">
<div className="header d-flex m-1 card-header">
<div className="avatar">
[A]
</div>
<div className="d-flex flex-column m-1">
<div className="title">
{/* TODO: Get title from thread */ }
Some title
</div>
<div className="status">
{/* TODO: get type from thread */ }
discussion posted about { comment.posted_on } by { comment.author }
</div>
</div>
<div className="d-flex icons m-1">
<FontAwesomeIcon icon={faStar} />
{ comment.abuse_flagged && <FontAwesomeIcon icon={faFlag} /> }
<FontAwesomeIcon icon={faEllipsisV} />
</div>
</div>
<div className="comment-body d-flex" dangerouslySetInnerHTML={{ __html: comment.rendered_body }} />
<div className="visibility-comment d-flex">
{/* TODO: Add parent group info */ }
</div>
<div className="actions d-flex">
<Button>{ intl.formatMessage(messages.add_response) }</Button>
</div>
</div>
);
}
export const commentShape = PropTypes.shape({
posted_on: PropTypes.string,
abuse_flagged: PropTypes.bool,
rendered_body: PropTypes.string,
author: PropTypes.string,
});
Comment.propTypes = {
intl: intlShape.isRequired,
comment: commentShape.isRequired,
};
export default injectIntl(Comment);

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
add_response: {
id: 'discussions.comments.comment.add-response',
defaultMessage: 'Add a response',
description: 'Button to add a response in a thread of forum posts',
},
});
export default messages;

View File

@@ -0,0 +1,31 @@
/* eslint-disable import/prefer-default-export */
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { API_BASE_URL } from '../../../data/constants';
export async function getThreadComments(
threadId, {
commentId, page, pageSize, requestedFields,
} = {},
) {
const url = new URL(`${API_BASE_URL}/api/discussion/v1/comments/`);
const paramsMap = {
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);
return data;
}

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
/* eslint-disable no-param-reassign,import/prefer-default-export */
import { createSlice } from '@reduxjs/toolkit';
import { LoadingStatus } from '../../../data/constants';
const commentsSlice = createSlice({
name: 'comments',
initialState: {
status: LoadingStatus.LOADING,
page: null,
comments: {
// Map thread ids to comments
},
totalPages: null,
totalThreads: null,
},
reducers: {
fetchCommentsRequest: (state) => {
state.status = LoadingStatus.LOADING;
},
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;
},
fetchCommentsFailed: (state) => {
state.status = LoadingStatus.FAILED;
},
fetchCommentsDenied: (state) => {
state.status = LoadingStatus.DENIED;
},
},
});
export const {
fetchCommentsRequest,
fetchCommentsSuccess,
fetchCommentsFailed,
} = commentsSlice.actions;
export const commentsReducer = commentsSlice.reducer;

View File

@@ -0,0 +1,17 @@
/* eslint-disable import/prefer-default-export */
import { logError } from '@edx/frontend-platform/logging';
import { getThreadComments } from './api';
import { fetchCommentsFailed, fetchCommentsRequest, fetchCommentsSuccess } from './slices';
export function fetchTopicComments(topicId) {
return async (dispatch) => {
try {
dispatch(fetchCommentsRequest({ topicId }));
const data = await getThreadComments(topicId);
dispatch(fetchCommentsSuccess({ topicId, data }));
} catch (error) {
dispatch(fetchCommentsFailed());
logError(error);
}
};
}

View File

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

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { Route, Switch, useParams } from 'react-router';
import { Routes } from '../../data/constants';
import CommentsViewContainer from '../comments/CommentsViewContainer';
import { NavigationBar } from '../navigation-bar';
import PostsViewContainer from '../posts/PostsViewContainer';
import { TopicsViewContainer } from '../topics';
export default function DiscussionsHome() {
const { courseId } = useParams();
return (
<main>
<div className="d-flex flex-row">
<div className="d-flex flex-column border">
<NavigationBar courseId={courseId} />
<Switch>
<Route path={Routes.POSTS.PATH} component={PostsViewContainer} />
<Route path={Routes.TOPICS.PATH} component={TopicsViewContainer} />
</Switch>
</div>
<div className="d-flex">
<Switch>
<Route path={Routes.POSTS.PATH}>
<CommentsViewContainer />
</Route>
</Switch>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,8 @@
import React from 'react';
import DiscussionsHome from './DiscussionsHome';
export default function DiscussionsHomeContainer() {
return (
<DiscussionsHome />
);
}

View File

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

4
src/discussions/index.js Normal file
View File

@@ -0,0 +1,4 @@
export * from './topics';
export * from './comments';
export * from './discussions-home';
export * from './posts';

View File

@@ -0,0 +1,55 @@
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import SearchField from '@edx/paragon/dist/SearchField';
import { faSortAmountDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import React from 'react';
import { NavLink } from 'react-router-dom';
import { SelectableDropdown } from '../../components';
import { Routes, ThreadOrdering } from '../../data/constants';
import { buildIntlSelectionList } from '../utils';
import messages from './messages';
function NavigationBar({ intl, courseId }) {
const threadOrderingOptions = buildIntlSelectionList(ThreadOrdering, intl, messages);
return (
<div className="navigation-bar d-flex flex-column">
<ul className="nav">
<li className="nav-item">
<NavLink className="nav-link" to={Routes.POSTS.MY_POSTS.replace(':courseId', courseId)}>
{ intl.formatMessage(messages.my_posts) }
</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link" to={Routes.POSTS.ALL_POSTS.replace(':courseId', courseId)}>
{ intl.formatMessage(messages.all_posts) }
</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link" to={Routes.TOPICS.ALL.replace(':courseId', courseId)}>
{ intl.formatMessage(messages.all_topics) }
</NavLink>
</li>
</ul>
<div className="d-flex">
<SearchField onSubmit={() => null} />
<SelectableDropdown
label={<FontAwesomeIcon icon={faSortAmountDown} />}
defaultOption={ThreadOrdering.BY_LAST_ACTIVITY}
options={threadOrderingOptions}
/>
</div>
<div className="d-flex">
{/* TODO: hook into store */ }
{ intl.formatMessage(messages.sorted_by, { sortBy: 'something' }) }
</div>
</div>
);
}
NavigationBar.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(NavigationBar);

View File

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

View File

@@ -0,0 +1,87 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
my_posts: {
id: 'discussions.navigation-bar.filter.my-posts',
defaultMessage: 'My posts',
description: 'Option in dropdown to filter to all a user\'s posts',
},
my_discussions: {
id: 'discussions.navigation-bar.filter.my-discussions',
defaultMessage: 'My discussions',
description: 'Option in dropdown to filter to all a user\'s discussions',
},
my_questions: {
id: 'discussions.navigation-bar.filter.my-questions',
defaultMessage: 'My questions',
description: 'Option in dropdown to filter to all a user\'s questions',
},
all_posts: {
id: 'discussions.navigation-bar.filter.all-posts',
defaultMessage: 'All posts',
description: 'Option in dropdown to filter to all posts',
},
all_discussions: {
id: 'discussions.navigation-bar.filter.all-discussions',
defaultMessage: 'All discussions',
description: 'Option in dropdown to filter to all discussions',
},
all_questions: {
id: 'discussions.navigation-bar.filter.all-questions',
defaultMessage: 'All questions',
description: 'Option in dropdown to filter to all questions',
},
all_topics: {
id: 'discussions.navigation-bar.filter.all-topics',
defaultMessage: 'All topics',
description: 'Option in dropdown to view all topics',
},
general_topics: {
id: 'discussions.navigation-bar.filter.general-topics',
defaultMessage: 'General topics',
description: 'Option in dropdown to view general topics',
},
course_section_topics: {
id: 'discussions.navigation-bar.filter.course-section-topics',
defaultMessage: 'Course section topics',
description: 'Option in dropdown to view course section topics',
},
search_results: {
id: 'discussions.navigation-bar.filter.search-results',
defaultMessage: '{resultCount} results',
},
filter_all: {
id: 'discussions.navigation-bar.filter.all',
defaultMessage: 'All',
},
filter_unread: {
id: 'discussions.navigation-bar.filter.unread',
defaultMessage: 'Unread',
},
filter_following: {
id: 'discussions.navigation-bar.filter.following',
defaultMessage: 'Following',
},
filter_flagged: {
id: 'discussions.navigation-bar.filter.flagged',
defaultMessage: 'Flagged',
},
sorted_by: {
id: 'discussions.navigation-bar.sort.message',
defaultMessage: 'Sorted by {sortBy}',
},
sort_by_last_activity: {
id: 'discussions.navigation-bar.sort.last-activity',
defaultMessage: 'Recent activity',
},
sort_by_comment_count: {
id: 'discussions.navigation-bar.sort.comment-count',
defaultMessage: 'Most activity',
},
sort_by_vote_count: {
id: 'discussions.navigation-bar.sort.vote--count',
defaultMessage: 'Most votes',
},
});
export default messages;

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;

View File

@@ -0,0 +1,30 @@
import PropTypes from 'prop-types';
import React from 'react';
import { topicShape } from './topic-group/topic/Topic';
import TopicGroup from './topic-group/TopicGroup';
function TopicsView({ coursewareTopics, nonCoursewareTopics }) {
return (
<div className="discussion-topics d-flex flex-column">
{ nonCoursewareTopics
&& <TopicGroup topics={nonCoursewareTopics} /> }
{ coursewareTopics.map(
topicGroup => (
<TopicGroup
id={topicGroup.id}
name={topicGroup.name}
topics={topicGroup.children}
key={topicGroup.name}
/>
),
) }
</div>
);
}
TopicsView.propTypes = {
coursewareTopics: PropTypes.arrayOf(PropTypes.shape(topicShape)).isRequired,
nonCoursewareTopics: PropTypes.arrayOf(PropTypes.shape(topicShape)).isRequired,
};
export default TopicsView;

View File

@@ -0,0 +1,24 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { selectCourseTopics } from './data/selectors';
import { fetchCourseTopics } from './data/thunks';
import TopicsView from './TopicsView';
function TopicsViewContainer() {
const { courseId } = useParams();
const dispatch = useDispatch();
const topics = useSelector(selectCourseTopics);
useEffect(() => {
// The courseId from the URL is the course we WANT to load.
dispatch(fetchCourseTopics(courseId));
}, [courseId]);
return (
<TopicsView coursewareTopics={topics.courseware_topics} nonCoursewareTopics={topics.non_courseware_topics} />
);
}
TopicsViewContainer.propTypes = {};
export default TopicsViewContainer;

View File

@@ -0,0 +1,13 @@
/* eslint-disable import/prefer-default-export */
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}`);
if (topicIds) {
url.searchParams.append('topic_id', topicIds.join(','));
}
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 selectCourseTopics = state => state.topics.topics;
export const courseTopicsStatus = state => state.topics.status;

View File

@@ -0,0 +1,37 @@
/* eslint-disable no-param-reassign,import/prefer-default-export */
import { createSlice } from '@reduxjs/toolkit';
import { LoadingStatus } from '../../../data/constants';
const topicsSLice = createSlice({
name: 'courseTopics',
initialState: {
status: LoadingStatus.LOADING,
topics: {
courseware_topics: [],
non_courseware_topics: [],
},
},
reducers: {
fetchCourseTopicsRequest: (state) => {
state.status = LoadingStatus.LOADING;
},
fetchCourseTopicsSuccess: (state, { payload }) => {
state.status = LoadingStatus.LOADED;
state.topics = payload;
},
fetchCourseTopicsFailed: (state) => {
state.status = LoadingStatus.FAILED;
},
fetchCourseTopicsDenied: (state) => {
state.status = LoadingStatus.DENIED;
},
},
});
export const {
fetchCourseTopicsRequest,
fetchCourseTopicsSuccess,
fetchCourseTopicsFailed,
} = topicsSLice.actions;
export const topicsReducer = topicsSLice.reducer;

View File

@@ -0,0 +1,17 @@
/* eslint-disable import/prefer-default-export */
import { logError } from '@edx/frontend-platform/logging';
import { getCourseTopics } from './api';
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
export function fetchCourseTopics(courseId) {
return async (dispatch) => {
try {
dispatch(fetchCourseTopicsRequest({ courseId }));
const data = await getCourseTopics(courseId);
dispatch(fetchCourseTopicsSuccess(data));
} catch (error) {
dispatch(fetchCourseTopicsFailed());
logError(error);
}
};
}

View File

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

View File

@@ -0,0 +1,28 @@
import React from 'react';
import Topic, { topicShape } from './topic/Topic';
function TopicGroup({ id, name, topics }) {
return (
<div className="discussion-topic-group d-flex flex-column" data-topic-id={id}>
{ name && (
<div className="topic-name border-bottom pl-2 pt-1 pb-1">
{ name }
</div>
) }
{
topics.map(
topic => <Topic id={topic.id} name={topic.name} topics={topic.children} key={topic.id} />,
)
}
</div>
);
}
TopicGroup.propTypes = topicShape;
TopicGroup.defaultProps = {
id: null,
name: null,
};
export default TopicGroup;

View File

@@ -0,0 +1,58 @@
import { faQuestionCircle } from '@fortawesome/free-regular-svg-icons';
import { faComments, faFlag } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import React from 'react';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { Routes } from '../../../../data/constants';
// eslint-disable-next-line no-unused-vars
function Topic({ id, name, topics }) {
const { courseId } = useParams();
return (
<div className="discussion-topic d-flex flex-column border-bottom pl-2 pt-1 pb-1" data-topic-id={id}>
<Link
className="topic-name"
to={
Routes.POSTS.PATH.replace(':discussionId', id)
.replace(':courseId', courseId)
.replace(':threadId', '')
}
>
{ name }
</Link>
<div className="d-flex">
<span className="badge mr-1">
<FontAwesomeIcon icon={faQuestionCircle} />
22
</span>
<span className="badge mr-1">
<FontAwesomeIcon icon={faComments} />
33
</span>
<span className="badge">
<FontAwesomeIcon icon={faFlag} />
5
</span>
</div>
</div>
);
}
export const topicShape = {
name: PropTypes.string,
id: PropTypes.string,
topics: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
};
topicShape.topics = PropTypes.arrayOf(PropTypes.shape(topicShape)).isRequired;
Topic.propTypes = topicShape;
Topic.defaultProps = {
id: null,
name: null,
};
export default Topic;

12
src/discussions/utils.js Normal file
View File

@@ -0,0 +1,12 @@
/* eslint-disable import/prefer-default-export */
export function buildIntlSelectionList(options, intl, messages) {
return Object.values(options)
.map(
option => (
{
label: intl.formatMessage(messages[option]),
value: option,
}
),
);
}

View File

@@ -1,12 +0,0 @@
import React from 'react';
export default function ExamplePage() {
return (
<main>
<div className="container-fluid">
<h1>Example Page</h1>
<p>Hello world!</p>
</div>
</main>
);
}

View File

@@ -1,5 +0,0 @@
describe('example', () => {
it('will pass because it is an example', () => {
});
});

View File

@@ -1,4 +0,0 @@
data folder
===========
This folder is the home for non-component files, such as redux reducers, actions, selectors, API client services, etc. See `Feature-based Application Organization <https://github.com/edx/frontend-template-application/blob/master/docs/decisions/0002-feature-based-application-organization.rst>`_. for more detail.

View File

@@ -1,26 +1,27 @@
import 'babel-polyfill';
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
import Header, { messages as headerMessages } from '@edx/frontend-component-header';
import {
APP_INIT_ERROR, APP_READY, subscribe, initialize,
APP_INIT_ERROR, APP_READY, initialize, subscribe,
} from '@edx/frontend-platform';
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
import React from 'react';
import ReactDOM from 'react-dom';
import Header, { messages as headerMessages } from '@edx/frontend-component-header';
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
import appMessages from './i18n';
import ExamplePage from './example/ExamplePage';
import './index.scss';
import './assets/favicon.ico';
import { DiscussionsHomeContainer } from './discussions';
import appMessages from './i18n';
import './index.scss';
import store from './store';
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider>
<AppProvider store={store}>
<Header />
<ExamplePage />
<DiscussionsHomeContainer />
<Footer />
</AppProvider>,
document.getElementById('root'),
@@ -32,6 +33,7 @@ subscribe(APP_INIT_ERROR, (error) => {
});
initialize({
requireAuthenticatedUser: true,
messages: [
appMessages,
headerMessages,

View File

@@ -1,6 +1,6 @@
@import '~@edx/paragon/scss/edx/theme.scss';
@import './example/index.scss';
@import 'discussions/index.scss';
@import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer";

14
src/store.js Normal file
View File

@@ -0,0 +1,14 @@
import { configureStore } from '@reduxjs/toolkit';
import { commentsReducer } from './discussions/comments/data';
import { courseThreadsReducer } from './discussions/posts/data';
import { topicsReducer } from './discussions/topics/data';
const store = configureStore({
reducer: {
topics: topicsReducer,
threads: courseThreadsReducer,
comments: commentsReducer,
},
});
export default store;