Basic discussions forum framework
Adds the basic structure for the Discussions MFE around which future development will happen.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
NODE_ENV='development'
|
||||
PORT=8080
|
||||
PORT=2002
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:8080'
|
||||
BASE_URL='localhost:2002'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:1995'
|
||||
BASE_URL='localhost:2002'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
@@ -9,6 +9,7 @@ LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
PORT=2002
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
coverage/*
|
||||
dist/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
jest.config.js
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('eslint');
|
||||
module.exports = createConfig('eslint');
|
||||
|
||||
19
.gitignore
vendored
Executable file
19
.gitignore
vendored
Executable file
@@ -0,0 +1,19 @@
|
||||
.directory
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
.vscode
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
temp/babel-plugin-react-intl
|
||||
|
||||
### pyenv ###
|
||||
.python-version
|
||||
|
||||
### Emacs ###
|
||||
*~
|
||||
/temp
|
||||
12367
package-lock.json
generated
12367
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -34,31 +34,34 @@
|
||||
"url": "https://github.com/edx/frontend-app-discussions/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/frontend-component-footer": "10.0.10",
|
||||
"@edx/frontend-component-header": "2.0.5",
|
||||
"@edx/frontend-platform": "1.3.7",
|
||||
"@edx/paragon": "7.2.1",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.29",
|
||||
"@fortawesome/free-brands-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-regular-svg-icons": "5.11.2",
|
||||
"@fortawesome/free-solid-svg-icons": "5.11.2",
|
||||
"@fortawesome/react-fontawesome": "0.1.11",
|
||||
"babel-polyfill": "6.26.0",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.12.0",
|
||||
"react-dom": "16.12.0",
|
||||
"react-redux": "7.1.3",
|
||||
"react-router": "5.1.2",
|
||||
"react-router-dom": "5.1.2",
|
||||
"redux": "4.0.5"
|
||||
"@edx/frontend-component-footer": "^10.0.11",
|
||||
"@edx/frontend-component-header": "^2.0.5",
|
||||
"@edx/frontend-platform": "^1.5.2",
|
||||
"@edx/paragon": "^10.0.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.30",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.14.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.14.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.14.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.11",
|
||||
"@reduxjs/toolkit": "^1.2.3",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"core-js": "^3.6.2",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-redux": "^7.2.1",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"redux": "^4.0.5",
|
||||
"regenerator-runtime": "^0.13.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "3.0.0",
|
||||
"codecov": "3.6.5",
|
||||
"es-check": "5.1.0",
|
||||
"glob": "7.1.6",
|
||||
"husky": "3.1.0",
|
||||
"jest": "24.9.0",
|
||||
"reactifex": "1.1.1"
|
||||
"@edx/frontend-build": "^5.2.0",
|
||||
"codecov": "^3.7.2",
|
||||
"es-check": "^5.1.0",
|
||||
"glob": "^7.1.6",
|
||||
"husky": "^3.1.0",
|
||||
"jest": "^24.9.0",
|
||||
"reactifex": "^1.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Application Template | edX</title>
|
||||
<title>Discussions | edX</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
|
||||
1
src/components/index.js
Normal file
1
src/components/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './selectable-dropdown';
|
||||
55
src/components/selectable-dropdown/SelectableDropdown.jsx
Normal file
55
src/components/selectable-dropdown/SelectableDropdown.jsx
Normal 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;
|
||||
2
src/components/selectable-dropdown/index.js
Normal file
2
src/components/selectable-dropdown/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as SelectableDropdown } from './SelectableDropdown';
|
||||
51
src/data/constants.js
Normal file
51
src/data/constants.js
Normal 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',
|
||||
},
|
||||
};
|
||||
19
src/discussions/comments/CommentsView.jsx
Normal file
19
src/discussions/comments/CommentsView.jsx
Normal 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;
|
||||
24
src/discussions/comments/CommentsViewContainer.jsx
Normal file
24
src/discussions/comments/CommentsViewContainer.jsx
Normal 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;
|
||||
56
src/discussions/comments/comment/Comment.jsx
Normal file
56
src/discussions/comments/comment/Comment.jsx
Normal 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);
|
||||
11
src/discussions/comments/comment/messages.js
Normal file
11
src/discussions/comments/comment/messages.js
Normal 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;
|
||||
31
src/discussions/comments/data/api.js
Normal file
31
src/discussions/comments/data/api.js
Normal 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;
|
||||
}
|
||||
1
src/discussions/comments/data/index.js
Normal file
1
src/discussions/comments/data/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './slices';
|
||||
4
src/discussions/comments/data/selectors.js
Normal file
4
src/discussions/comments/data/selectors.js
Normal 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;
|
||||
43
src/discussions/comments/data/slices.js
Normal file
43
src/discussions/comments/data/slices.js
Normal 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;
|
||||
17
src/discussions/comments/data/thunks.js
Normal file
17
src/discussions/comments/data/thunks.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
2
src/discussions/comments/index.js
Normal file
2
src/discussions/comments/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CommentsViewContainer } from './CommentsViewContainer';
|
||||
31
src/discussions/discussions-home/DiscussionsHome.jsx
Normal file
31
src/discussions/discussions-home/DiscussionsHome.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import DiscussionsHome from './DiscussionsHome';
|
||||
|
||||
export default function DiscussionsHomeContainer() {
|
||||
return (
|
||||
<DiscussionsHome />
|
||||
);
|
||||
}
|
||||
2
src/discussions/discussions-home/index.js
Normal file
2
src/discussions/discussions-home/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as DiscussionsHomeContainer } from './DiscussionsHome';
|
||||
4
src/discussions/index.js
Normal file
4
src/discussions/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './topics';
|
||||
export * from './comments';
|
||||
export * from './discussions-home';
|
||||
export * from './posts';
|
||||
55
src/discussions/navigation-bar/NavigationBar.jsx
Normal file
55
src/discussions/navigation-bar/NavigationBar.jsx
Normal 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);
|
||||
2
src/discussions/navigation-bar/index.js
Normal file
2
src/discussions/navigation-bar/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as NavigationBar } from './NavigationBar';
|
||||
87
src/discussions/navigation-bar/messages.js
Normal file
87
src/discussions/navigation-bar/messages.js
Normal 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;
|
||||
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;
|
||||
30
src/discussions/topics/TopicsView.jsx
Normal file
30
src/discussions/topics/TopicsView.jsx
Normal 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;
|
||||
24
src/discussions/topics/TopicsViewContainer.jsx
Normal file
24
src/discussions/topics/TopicsViewContainer.jsx
Normal 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;
|
||||
13
src/discussions/topics/data/api.js
Normal file
13
src/discussions/topics/data/api.js
Normal 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;
|
||||
}
|
||||
1
src/discussions/topics/data/index.js
Normal file
1
src/discussions/topics/data/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './slices';
|
||||
4
src/discussions/topics/data/selectors.js
Normal file
4
src/discussions/topics/data/selectors.js
Normal 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;
|
||||
37
src/discussions/topics/data/slices.js
Normal file
37
src/discussions/topics/data/slices.js
Normal 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;
|
||||
17
src/discussions/topics/data/thunks.js
Normal file
17
src/discussions/topics/data/thunks.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
2
src/discussions/topics/index.js
Normal file
2
src/discussions/topics/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as TopicsViewContainer } from './TopicsViewContainer';
|
||||
28
src/discussions/topics/topic-group/TopicGroup.jsx
Normal file
28
src/discussions/topics/topic-group/TopicGroup.jsx
Normal 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;
|
||||
58
src/discussions/topics/topic-group/topic/Topic.jsx
Normal file
58
src/discussions/topics/topic-group/topic/Topic.jsx
Normal 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
12
src/discussions/utils.js
Normal 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,
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
describe('example', () => {
|
||||
it('will pass because it is an example', () => {
|
||||
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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
14
src/store.js
Normal 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;
|
||||
Reference in New Issue
Block a user