Compare commits
13 Commits
jawayria/n
...
kshitij/th
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ab53d793 | ||
|
|
a77b947e8a | ||
|
|
34a0ae8939 | ||
|
|
dfec88de20 | ||
|
|
c57dfc1fc5 | ||
|
|
d1dce4f2ea | ||
|
|
e8a3e4eaa8 | ||
|
|
7e5ae2a298 | ||
|
|
36ff2fad27 | ||
|
|
7a864ed14e | ||
|
|
dbade5dbd1 | ||
|
|
d9f085279e | ||
|
|
59f97fff7d |
1
.env
1
.env
@@ -20,3 +20,4 @@ REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
THEME_LOADER_URL=''
|
||||
|
||||
@@ -21,3 +21,4 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=localhost
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
THEME_LOADER_URL='https://xitij2000.github.io/frontend-theme-prototype/themes.js'
|
||||
|
||||
25716
package-lock.json
generated
25716
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,7 @@
|
||||
"yup": "0.31.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "9.1.2",
|
||||
"@edx/frontend-build": "9.1.4",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
"@testing-library/react": "12.1.4",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
|
||||
@@ -92,6 +92,8 @@ export default function TinyMCEEditor(props) {
|
||||
content_css: false,
|
||||
content_style: contentStyle,
|
||||
body_class: 'm-2',
|
||||
default_link_target: '_blank',
|
||||
target_list: false,
|
||||
images_upload_handler: uploadHandler,
|
||||
setup,
|
||||
}}
|
||||
|
||||
@@ -179,6 +179,7 @@ export const Routes = {
|
||||
`${BASE_PATH}`,
|
||||
],
|
||||
EDIT_POST: [
|
||||
`${BASE_PATH}/category/:category/posts/:postId/edit`,
|
||||
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
|
||||
`${BASE_PATH}/posts/:postId/edit`,
|
||||
`${BASE_PATH}/my-posts/:postId/edit`,
|
||||
@@ -186,12 +187,14 @@ export const Routes = {
|
||||
},
|
||||
COMMENTS: {
|
||||
PATH: [
|
||||
`${BASE_PATH}/category/:category/posts/:postId`,
|
||||
`${BASE_PATH}/topics/:topicId/posts/:postId`,
|
||||
`${BASE_PATH}/posts/:postId`,
|
||||
`${BASE_PATH}/my-posts/:postId`,
|
||||
],
|
||||
PAGE: `${BASE_PATH}/:page`,
|
||||
PAGES: {
|
||||
category: `${BASE_PATH}/category/:category/posts/:postId`,
|
||||
topics: `${BASE_PATH}/topics/:topicId/posts/:postId`,
|
||||
posts: `${BASE_PATH}/posts/:postId`,
|
||||
'my-posts': `${BASE_PATH}/my-posts/:postId`,
|
||||
@@ -200,15 +203,18 @@ export const Routes = {
|
||||
TOPICS: {
|
||||
PATH: [
|
||||
`${BASE_PATH}/topics/:topicId?`,
|
||||
`${BASE_PATH}/category/:category`,
|
||||
`${BASE_PATH}/topics`,
|
||||
],
|
||||
ALL: `${BASE_PATH}/topics`,
|
||||
CATEGORY: `${BASE_PATH}/category/:category`,
|
||||
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
|
||||
TOPIC: `${BASE_PATH}/topics/:topicId`,
|
||||
},
|
||||
};
|
||||
|
||||
export const ALL_ROUTES = []
|
||||
.concat([Routes.TOPICS.CATEGORY])
|
||||
.concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY])
|
||||
.concat(Routes.COMMENTS.PATH)
|
||||
.concat(Routes.TOPICS.PATH)
|
||||
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS])
|
||||
|
||||
@@ -112,6 +112,7 @@ function Comment({
|
||||
threadId: comment.threadId,
|
||||
parentId: comment.id,
|
||||
}}
|
||||
edit={false}
|
||||
onCloseEditor={() => setReplying(false)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ function CommentEditor({
|
||||
intl,
|
||||
comment,
|
||||
onCloseEditor,
|
||||
edit,
|
||||
}) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const userIsPrivileged = useSelector(selectUserIsPrivileged);
|
||||
@@ -65,7 +66,9 @@ function CommentEditor({
|
||||
<Form onSubmit={handleSubmit}>
|
||||
{(reasonCodesEnabled
|
||||
&& userIsPrivileged
|
||||
&& comment.author !== authenticatedUser.username) && (
|
||||
&& comment.author !== authenticatedUser.username
|
||||
&& edit
|
||||
) && (
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
name="editReasonCode"
|
||||
@@ -142,6 +145,11 @@ CommentEditor.propTypes = {
|
||||
}).isRequired,
|
||||
onCloseEditor: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
edit: PropTypes.bool,
|
||||
};
|
||||
|
||||
CommentEditor.defaultProps = {
|
||||
edit: true,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentEditor);
|
||||
|
||||
@@ -14,7 +14,11 @@ function ResponseEditor({
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
return addingResponse
|
||||
? (
|
||||
<CommentEditor comment={{ threadId: postId }} onCloseEditor={() => setAddingResponse(false)} />
|
||||
<CommentEditor
|
||||
comment={{ threadId: postId }}
|
||||
edit={false}
|
||||
onCloseEditor={() => setAddingResponse(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="actions d-flex">
|
||||
<Button variant="primary" onClick={() => setAddingResponse(true)}>
|
||||
|
||||
@@ -132,12 +132,13 @@ export async function deleteComment(commentId) {
|
||||
* }
|
||||
|
||||
*/
|
||||
export async function getUserComments(courseId, username) {
|
||||
export async function getUserComments(courseId, username, { page }) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(commentsApiUrl, {
|
||||
params: {
|
||||
course_id: courseId,
|
||||
username,
|
||||
page,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import React from 'react';
|
||||
|
||||
export const DiscussionContext = React.createContext({
|
||||
page: null,
|
||||
courseId: null,
|
||||
postId: null,
|
||||
category: null,
|
||||
commentId: null,
|
||||
learnerUsername: null,
|
||||
topicId: null,
|
||||
inContext: false,
|
||||
category: null,
|
||||
learnerUsername: null,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Routes } from '../../data/constants';
|
||||
import { CommentsView } from '../comments';
|
||||
import { useContainerSizeForParent } from '../data/hooks';
|
||||
import { LearnersContentView } from '../learners';
|
||||
import LearnerPageHeader from '../learners/LearnerPageHeader';
|
||||
import { PostEditor } from '../posts';
|
||||
|
||||
export default function DiscussionContent() {
|
||||
@@ -16,6 +17,9 @@ export default function DiscussionContent() {
|
||||
|
||||
return (
|
||||
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center h-100 overflow-auto">
|
||||
<Route path={Routes.LEARNERS.LEARNER}>
|
||||
<LearnerPageHeader />
|
||||
</Route>
|
||||
<div className="d-flex flex-column w-100 mw-xl" ref={refContainer}>
|
||||
{postEditorVisible ? (
|
||||
<Route path={Routes.POSTS.NEW_POST}>
|
||||
|
||||
@@ -24,11 +24,8 @@ export default function DiscussionSidebar({ displaySidebar }) {
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<Switch>
|
||||
<Route path={Routes.POSTS.MY_POSTS}>
|
||||
<PostsView showOwnPosts />
|
||||
</Route>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY]}
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY, Routes.POSTS.MY_POSTS]}
|
||||
component={PostsView}
|
||||
/>
|
||||
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
import { PostActionsBar } from '../../components';
|
||||
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
|
||||
import { useTheme } from '../../theme-hooks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useSidebarVisible,
|
||||
@@ -21,6 +22,7 @@ import DiscussionSidebar from './DiscussionSidebar';
|
||||
|
||||
export default function DiscussionsHome() {
|
||||
const location = useLocation();
|
||||
const ready = useTheme('red_theme');
|
||||
const postEditorVisible = useSelector(
|
||||
(state) => state.threads.postEditorVisible,
|
||||
);
|
||||
@@ -59,6 +61,9 @@ export default function DiscussionsHome() {
|
||||
postMessageToParent('discussions.navigate', { path });
|
||||
}
|
||||
}, [path]);
|
||||
if (!ready) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DiscussionContext.Provider value={{
|
||||
|
||||
70
src/discussions/learners/LearnerPageHeader.jsx
Normal file
70
src/discussions/learners/LearnerPageHeader.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath, NavLink } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Avatar, ButtonGroup, Icon } from '@edx/paragon';
|
||||
import { Report } from '@edx/paragon/icons';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { selectLearner, selectLearnerAvatar, selectLearnerProfile } from './data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
function LearnerPageHeader({ intl }) {
|
||||
const { courseId, learnerUsername } = useContext(DiscussionContext);
|
||||
const params = { courseId, learnerUsername };
|
||||
const learner = useSelector(selectLearner(learnerUsername));
|
||||
const profile = useSelector(selectLearnerProfile(learnerUsername));
|
||||
const avatar = useSelector(selectLearnerAvatar(learnerUsername));
|
||||
|
||||
const activeTabClass = (active) => classNames('btn', { 'btn-primary': active, 'btn-outline-primary': !active });
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column w-100 bg-white shadow-sm">
|
||||
<div className="d-flex flex-row align-items-center m-4">
|
||||
<Avatar src={avatar} alt={learnerUsername} />
|
||||
<span className="font-weight-bold mx-3">
|
||||
{profile.username}
|
||||
</span>
|
||||
</div>
|
||||
<div className="d-flex pb-0 bg-light-200 justify-content-center p-2 flex-fill">
|
||||
<ButtonGroup className="my-2 bg-white">
|
||||
<NavLink
|
||||
className={activeTabClass}
|
||||
to={generatePath(Routes.LEARNERS.TABS.posts, params)}
|
||||
>
|
||||
{intl.formatMessage(messages.postsTab)} <span className="ml-3">{learner.threads}</span>
|
||||
{
|
||||
learner.activeFlags ? (
|
||||
<span className="ml-3">
|
||||
<Icon src={Report} />
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={activeTabClass}
|
||||
to={generatePath(Routes.LEARNERS.TABS.responses, params)}
|
||||
>
|
||||
{intl.formatMessage(messages.responsesTab)} <span className="ml-3">{learner.responses}</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={activeTabClass}
|
||||
to={generatePath(Routes.LEARNERS.TABS.comments, params)}
|
||||
>
|
||||
{intl.formatMessage(messages.commentsTab)} <span className="ml-3">{learner.replies}</span>
|
||||
</NavLink>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LearnerPageHeader.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearnerPageHeader);
|
||||
@@ -1,81 +1,25 @@
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
generatePath, NavLink, Redirect, Route, Switch,
|
||||
generatePath, Redirect, Route, Switch,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Avatar, ButtonGroup, Card, Icon, IconButton, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { MoreHoriz, Report } from '@edx/paragon/icons';
|
||||
import { Spinner } from '@edx/paragon';
|
||||
|
||||
import { LearnerTabs, RequestStatus, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
learnersLoadingStatus, selectLearner, selectLearnerAvatar, selectLearnerProfile,
|
||||
} from './data/selectors';
|
||||
import { learnersLoadingStatus } from './data/selectors';
|
||||
import CommentsTabContent from './learner/CommentsTabContent';
|
||||
import PostsTabContent from './learner/PostsTabContent';
|
||||
import messages from './messages';
|
||||
|
||||
function LearnersContentView({ intl }) {
|
||||
function LearnersContentView() {
|
||||
const { courseId, learnerUsername } = useContext(DiscussionContext);
|
||||
const params = { courseId, learnerUsername };
|
||||
const apiStatus = useSelector(learnersLoadingStatus());
|
||||
const learner = useSelector(selectLearner(learnerUsername));
|
||||
const profile = useSelector(selectLearnerProfile(learnerUsername));
|
||||
const avatar = useSelector(selectLearnerAvatar(learnerUsername));
|
||||
|
||||
const activeTabClass = (active) => classNames('btn', { 'btn-primary': active, 'btn-outline-primary': !active });
|
||||
|
||||
return (
|
||||
<div className="learner-content d-flex flex-column">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className="d-flex flex-row align-items-center m-3">
|
||||
<Avatar src={avatar} alt={learnerUsername} />
|
||||
<span className="font-weight-bold mx-3">
|
||||
{profile.username}
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<IconButton iconAs={Icon} src={MoreHoriz} alt="Options" />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
<Card.Footer className="pb-0 bg-light-200 justify-content-center">
|
||||
<ButtonGroup className="my-2">
|
||||
<NavLink
|
||||
className={activeTabClass}
|
||||
to={generatePath(Routes.LEARNERS.TABS.posts, params)}
|
||||
>
|
||||
{intl.formatMessage(messages.postsTab)} <span className="ml-3">{learner.threads}</span>
|
||||
{
|
||||
learner.activeFlags ? (
|
||||
<span className="ml-3">
|
||||
<Icon src={Report} />
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={activeTabClass}
|
||||
to={generatePath(Routes.LEARNERS.TABS.responses, params)}
|
||||
>
|
||||
{intl.formatMessage(messages.responsesTab)} <span className="ml-3">{learner.responses}</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={activeTabClass}
|
||||
to={generatePath(Routes.LEARNERS.TABS.comments, params)}
|
||||
>
|
||||
{intl.formatMessage(messages.commentsTab)} <span className="ml-3">{learner.replies}</span>
|
||||
</NavLink>
|
||||
</ButtonGroup>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
|
||||
<Switch>
|
||||
<Route path={Routes.LEARNERS.LEARNER} exact>
|
||||
<Redirect to={generatePath(Routes.LEARNERS.TABS.posts, params)} />
|
||||
@@ -104,7 +48,6 @@ function LearnersContentView({ intl }) {
|
||||
}
|
||||
|
||||
LearnersContentView.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearnersContentView);
|
||||
export default LearnersContentView;
|
||||
|
||||
@@ -16,14 +16,14 @@ import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { commentsApiUrl } from '../comments/data/api';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
import { threadsApiUrl } from '../posts/data/api';
|
||||
import { coursesApiUrl, userProfileApiUrl } from './data/api';
|
||||
import { fetchLearners, fetchUserComments } from './data/thunks';
|
||||
import LearnersContentView from './LearnersContentView';
|
||||
import { fetchLearners } from './data/thunks';
|
||||
|
||||
import './data/__factories__';
|
||||
import '../comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
import './data/__factories__';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
@@ -37,7 +37,7 @@ function renderComponent(username = testUsername) {
|
||||
<DiscussionContext.Provider value={{ learnerUsername: username, courseId }}>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/learners/${username}/${LearnerTabs.POSTS}`]}>
|
||||
<Route path="/:courseId/learners/:learnerUsername">
|
||||
<LearnersContentView />
|
||||
<DiscussionContent />
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
@@ -64,28 +64,34 @@ describe('LearnersContentView', () => {
|
||||
Factory.resetAll();
|
||||
|
||||
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
|
||||
.reply(() => [200, Factory.build('learnersResult', {}, {
|
||||
count: learnerCount,
|
||||
pageSize: 5,
|
||||
})]);
|
||||
.reply(
|
||||
200,
|
||||
Factory.build('learnersResult', {}, {
|
||||
count: learnerCount,
|
||||
pageSize: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=${testUsername}`)
|
||||
.reply(() => [200, Factory.build('learnersProfile', {}, {
|
||||
username: [testUsername],
|
||||
}).profiles]);
|
||||
.reply(
|
||||
200,
|
||||
Factory.build('learnersProfile', {}, {
|
||||
username: [testUsername],
|
||||
}).profiles,
|
||||
);
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
|
||||
axiosMock.onGet(threadsApiUrl, { params: { course_id: courseId, author: testUsername } })
|
||||
axiosMock.onGet(threadsApiUrl)
|
||||
.reply(200, Factory.build('threadsResult', {}, {
|
||||
topicId: undefined,
|
||||
count: 5,
|
||||
pageSize: 6,
|
||||
count: 6,
|
||||
pageSize: 5,
|
||||
}));
|
||||
|
||||
axiosMock.onGet(commentsApiUrl, { params: { course_id: courseId, username: testUsername } })
|
||||
axiosMock.onGet(commentsApiUrl)
|
||||
.reply(200, Factory.build('commentsResult', {}, {
|
||||
count: 8,
|
||||
pageSize: 10,
|
||||
count: 9,
|
||||
pageSize: 8,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -109,19 +115,17 @@ describe('LearnersContentView', () => {
|
||||
});
|
||||
|
||||
test('it renders all the comments with parent id in comments tab', async () => {
|
||||
axiosMock.onGet(commentsApiUrl, { params: { course_id: courseId, username: testUsername } })
|
||||
axiosMock.onGet(commentsApiUrl)
|
||||
.reply(200, Factory.build('commentsResult', {}, {
|
||||
count: 4,
|
||||
parentId: 'test_parent_id',
|
||||
}));
|
||||
executeThunk(fetchUserComments(courseId, testUsername), store.dispatch, store.state);
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Comments', { exact: false }));
|
||||
fireEvent.click(screen.getByRole('link', { name: /Comments \d+/i }));
|
||||
});
|
||||
|
||||
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(4);
|
||||
});
|
||||
|
||||
@@ -130,12 +134,12 @@ describe('LearnersContentView', () => {
|
||||
await renderComponent();
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Responses', { exact: false }));
|
||||
fireEvent.click(screen.getByRole('link', { name: /Responses \d+/i }));
|
||||
});
|
||||
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Posts', { exact: false }));
|
||||
fireEvent.click(screen.getByRole('link', { name: /Posts \d+/i }));
|
||||
});
|
||||
expect(screen.queryAllByTestId('post')).toHaveLength(5);
|
||||
});
|
||||
@@ -145,7 +149,7 @@ describe('LearnersContentView', () => {
|
||||
await act(async () => {
|
||||
await renderComponent('leaner-2');
|
||||
});
|
||||
const button = screen.getByText('Posts', { exact: false });
|
||||
const button = screen.getByRole('link', { name: /Posts/i });
|
||||
expect(button.innerHTML).not.toContain('svg');
|
||||
});
|
||||
|
||||
@@ -165,7 +169,7 @@ describe('LearnersContentView', () => {
|
||||
await act(async () => {
|
||||
await renderComponent('leaner-2');
|
||||
});
|
||||
const button = screen.getByText('Posts', { exact: false });
|
||||
const button = screen.getByRole('link', { name: /Posts/i });
|
||||
expect(button.innerHTML).toContain('svg');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,8 +46,8 @@ function LearnersView() {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="d-flex flex-column">
|
||||
<div className="list-group list-group-flush">
|
||||
<div className="d-flex flex-column border-right border-light-300 h-100">
|
||||
<div className="list-group list-group-flush ">
|
||||
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && (
|
||||
<Redirect
|
||||
to={{
|
||||
|
||||
@@ -10,19 +10,20 @@ const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
|
||||
export const coursesApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`;
|
||||
export const userProfileApiUrl = `${apiBaseUrl}/api/user/v1/accounts`;
|
||||
export const postsApiUrl = `${apiBaseUrl}/api/discussion/v1/threads/`;
|
||||
export const commentsApiUrl = `${apiBaseUrl}/api/discussion/v1/comments/`;
|
||||
|
||||
/**
|
||||
* Fetches all the learners in the given course.
|
||||
* @param {string} courseId
|
||||
* @param {number} page
|
||||
* @param {string} orderBy
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getLearners(
|
||||
courseId,
|
||||
courseId, { page, orderBy },
|
||||
) {
|
||||
const params = { page, orderBy };
|
||||
const url = `${coursesApiUrl}${courseId}/activity_stats/`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { createSelector } from '@reduxjs/toolkit';
|
||||
import { LearnerTabs } from '../../../data/constants';
|
||||
|
||||
export const selectAllLearners = createSelector(
|
||||
state => state.learners,
|
||||
learners => learners.learners,
|
||||
state => state.learners.pages,
|
||||
pages => pages.flat(),
|
||||
);
|
||||
|
||||
export const learnersLoadingStatus = () => state => state.learners.status;
|
||||
@@ -17,6 +17,14 @@ export const selectLearnerFilters = () => state => state.learners.filters;
|
||||
|
||||
export const selectLearnerNextPage = () => state => state.learners.nextPage;
|
||||
|
||||
export const selectLearnerCommentsNextPage = (learner) => state => (
|
||||
state.learners.commentPaginationByUser?.[learner]?.nextPage
|
||||
);
|
||||
|
||||
export const selectLearnerPostsNextPage = (learner) => state => (
|
||||
state.learners.postPaginationByUser?.[learner]?.nextPage
|
||||
);
|
||||
|
||||
export const selectLearnerAvatar = author => state => (
|
||||
state.learners.learnerProfiles[author]?.profileImage?.imageUrlSmall
|
||||
);
|
||||
@@ -32,7 +40,7 @@ export const selectLearner = (username) => createSelector(
|
||||
|
||||
export const selectLearnerProfile = (username) => state => state.learners.learnerProfiles[username] || {};
|
||||
|
||||
export const selectUserPosts = username => state => state.learners.postsByUser[username] || [];
|
||||
export const selectUserPosts = username => state => (state.learners.postsByUser[username] || []).flat();
|
||||
|
||||
/**
|
||||
* Get the comments of a post.
|
||||
@@ -42,8 +50,8 @@ export const selectUserPosts = username => state => state.learners.postsByUser[u
|
||||
*/
|
||||
export const selectUserComments = (username, commentType) => state => (
|
||||
commentType === LearnerTabs.COMMENTS
|
||||
? (state.learners.commentsByUser[username] || []).filter(c => c.parentId)
|
||||
: (state.learners.commentsByUser[username] || []).filter(c => !c.parentId)
|
||||
? (state.learners.commentsByUser[username] || []).flat().filter(c => c.parentId)
|
||||
: (state.learners.commentsByUser[username] || []).flat().filter(c => !c.parentId)
|
||||
);
|
||||
|
||||
export const flaggedCommentCount = (username) => state => state.learners.flaggedCommentsByUser[username] || 0;
|
||||
|
||||
@@ -11,35 +11,34 @@ const learnersSlice = createSlice({
|
||||
initialState: {
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
avatars: {},
|
||||
learners: [],
|
||||
learnerProfiles: {},
|
||||
pages: [],
|
||||
nextPage: null,
|
||||
totalPages: null,
|
||||
totalLearners: null,
|
||||
sortedBy: LearnersOrdering.BY_LAST_ACTIVITY,
|
||||
commentPaginationByUser: {
|
||||
|
||||
},
|
||||
commentsByUser: {
|
||||
// Map username to comments
|
||||
},
|
||||
postPaginationByUser: {
|
||||
|
||||
},
|
||||
postsByUser: {
|
||||
// Map username to posts
|
||||
},
|
||||
commentCountByUser: {
|
||||
// Map of username and comment count
|
||||
},
|
||||
postCountByUser: {
|
||||
// Map of username and post count
|
||||
},
|
||||
},
|
||||
reducers: {
|
||||
fetchLearnersSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
state.learners = payload.results;
|
||||
state.pages[payload.page - 1] = payload.results;
|
||||
state.learnerProfiles = {
|
||||
...state.learnerProfiles,
|
||||
...(payload.learnerProfiles || {}),
|
||||
};
|
||||
state.nextPage = payload.pagination.next;
|
||||
state.nextPage = (payload.page < payload.pagination.numPages) ? payload.page + 1 : null;
|
||||
state.totalPages = payload.pagination.numPages;
|
||||
state.totalLearners = payload.pagination.count;
|
||||
},
|
||||
@@ -54,15 +53,17 @@ const learnersSlice = createSlice({
|
||||
},
|
||||
setSortedBy: (state, { payload }) => {
|
||||
state.sortedBy = payload;
|
||||
state.pages = [];
|
||||
},
|
||||
fetchUserCommentsRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchUserCommentsSuccess: (state, { payload }) => {
|
||||
state.commentsByUser[payload.username] = payload.comments;
|
||||
state.commentCountByUser[payload.username] = payload.pagination.count;
|
||||
state.status = RequestStatus.SUCCESS;
|
||||
state.commentsByUser[payload.username] ??= [];
|
||||
state.commentsByUser[payload.username][payload.page - 1] = payload.comments;
|
||||
state.commentPaginationByUser[payload.username] = {
|
||||
nextPage: (payload.page < payload.pagination.numPages) ? payload.page + 1 : null,
|
||||
totalPages: payload.pagination.numPages,
|
||||
};
|
||||
},
|
||||
fetchUserCommentsDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
@@ -71,8 +72,12 @@ const learnersSlice = createSlice({
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchUserPostsSuccess: (state, { payload }) => {
|
||||
state.postsByUser[payload.username] = payload.posts;
|
||||
state.postCountByUser[payload.username] = payload.pagination.count;
|
||||
state.postsByUser[payload.username] ??= [];
|
||||
state.postsByUser[payload.username][payload.page - 1] = payload.posts;
|
||||
state.postPaginationByUser[payload.username] = {
|
||||
nextPage: (payload.page < payload.pagination.numPages) ? payload.page + 1 : null,
|
||||
totalPages: payload.pagination.numPages,
|
||||
};
|
||||
state.status = RequestStatus.SUCCESS;
|
||||
},
|
||||
fetchUserPostsDenied: (state) => {
|
||||
|
||||
@@ -24,17 +24,19 @@ import {
|
||||
/**
|
||||
* Fetches the learners for the course courseId.
|
||||
* @param {string} courseId The course ID for the course to fetch data for.
|
||||
* @param {string} orderBy
|
||||
* @param {number} page
|
||||
* @returns {(function(*): Promise<void>)|*}
|
||||
*/
|
||||
export function fetchLearners(courseId, {
|
||||
orderBy,
|
||||
page = 1,
|
||||
} = {}) {
|
||||
const options = {
|
||||
orderBy,
|
||||
page,
|
||||
};
|
||||
return async (dispatch) => {
|
||||
const options = {
|
||||
orderBy,
|
||||
page,
|
||||
};
|
||||
try {
|
||||
dispatch(fetchLearnersRequest({ courseId }));
|
||||
const learnerStats = await getLearners(courseId, options);
|
||||
@@ -45,7 +47,7 @@ export function fetchLearners(courseId, {
|
||||
learnerProfiles[learnerProfile.username] = camelCaseObject(learnerProfile);
|
||||
},
|
||||
);
|
||||
dispatch(fetchLearnersSuccess({ ...camelCaseObject(learnerStats), learnerProfiles }));
|
||||
dispatch(fetchLearnersSuccess({ ...camelCaseObject(learnerStats), learnerProfiles, page }));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchLearnersDenied());
|
||||
@@ -63,14 +65,16 @@ export function fetchLearners(courseId, {
|
||||
*
|
||||
* @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z
|
||||
* @param {string} username Username of the learner
|
||||
* @param {number} page
|
||||
* @returns a promise that will update the state with the learner's comments
|
||||
*/
|
||||
export function fetchUserComments(courseId, username) {
|
||||
export function fetchUserComments(courseId, username, { page = 1 } = {}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchUserCommentsRequest());
|
||||
const data = await getUserComments(courseId, username);
|
||||
const data = await getUserComments(courseId, username, { page });
|
||||
dispatch(fetchUserCommentsSuccess(camelCaseObject({
|
||||
page,
|
||||
username,
|
||||
comments: data.results,
|
||||
pagination: data.pagination,
|
||||
@@ -87,17 +91,21 @@ export function fetchUserComments(courseId, username) {
|
||||
* Fetch the posts of a user for the specified course and update the
|
||||
* redux state
|
||||
*
|
||||
* @param {sting} courseId Course ID of the course eg., course-v1:X+Y+Z
|
||||
* @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z
|
||||
* @param {string} username Username of the learner
|
||||
* @param page
|
||||
* @returns a promise that will update the state with the learner's posts
|
||||
*/
|
||||
export function fetchUserPosts(courseId, username) {
|
||||
export function fetchUserPosts(courseId, username, { page = 1 } = {}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchUserPostsRequest());
|
||||
const data = await getUserPosts(courseId, username, true);
|
||||
const data = await getUserPosts(courseId, username, { page });
|
||||
dispatch(fetchUserPostsSuccess(camelCaseObject({
|
||||
username, posts: data.results, pagination: data.pagination,
|
||||
page,
|
||||
username,
|
||||
posts: data.results,
|
||||
pagination: data.pagination,
|
||||
})));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
|
||||
@@ -1,33 +1,51 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import PropType from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { useDispatchWithState } from '../../../data/hooks';
|
||||
import Comment from '../../comments/comment/Comment';
|
||||
import messages from '../../comments/messages';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectUserComments } from '../data/selectors';
|
||||
import { selectLearnerCommentsNextPage, selectUserComments } from '../data/selectors';
|
||||
import { fetchUserComments } from '../data/thunks';
|
||||
|
||||
function CommentsTabContent({ tab }) {
|
||||
const dispatch = useDispatch();
|
||||
function CommentsTabContent({ tab, intl }) {
|
||||
const [loading, dispatch] = useDispatchWithState();
|
||||
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
||||
const comments = useSelector(selectUserComments(username, tab));
|
||||
const nextPage = useSelector(selectLearnerCommentsNextPage(username));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUserComments(courseId, username));
|
||||
}, [courseId, username]);
|
||||
|
||||
const handleLoadMoreComments = () => dispatch(fetchUserComments(courseId, username, { page: nextPage }));
|
||||
return (
|
||||
<div className="mx-3 my-3">
|
||||
{comments.map(
|
||||
(comment) => <Comment key={comment.id} comment={comment} showFullThread={false} postType="discussion" />,
|
||||
)}
|
||||
{nextPage && !loading && (
|
||||
<Button
|
||||
onClick={handleLoadMoreComments}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="card p-4"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreComments)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CommentsTabContent.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
tab: PropType.string.isRequired,
|
||||
};
|
||||
|
||||
export default CommentsTabContent;
|
||||
export default injectIntl(CommentsTabContent);
|
||||
|
||||
@@ -6,10 +6,6 @@ import { Link } from 'react-router-dom';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Icon, IconButton,
|
||||
} from '@edx/paragon';
|
||||
import { MoreVert } from '@edx/paragon/icons';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
@@ -63,12 +59,6 @@ function LearnerCard({
|
||||
</div>
|
||||
<LearnerFooter learner={learner} />
|
||||
</div>
|
||||
<IconButton
|
||||
src={MoreVert}
|
||||
iconAs={Icon}
|
||||
alt={learner.username}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ function LearnerFooter({
|
||||
&& (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<Tooltip id={`learner-${learner.username}`}>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
<span>
|
||||
{intl.formatMessage(messages.reported, { reported: activeFlags })}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { useDispatchWithState } from '../../../data/hooks';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { Post } from '../../posts';
|
||||
import { selectUserPosts } from '../data/selectors';
|
||||
import { selectLearnerPostsNextPage, selectUserPosts } from '../data/selectors';
|
||||
import { fetchUserPosts } from '../data/thunks';
|
||||
import messages from './messages';
|
||||
|
||||
function PostsTabContent() {
|
||||
const dispatch = useDispatch();
|
||||
function PostsTabContent({ intl }) {
|
||||
const [loading, dispatch] = useDispatchWithState();
|
||||
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
||||
const posts = useSelector(selectUserPosts(username));
|
||||
const nextPage = useSelector(selectLearnerPostsNextPage(username));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUserPosts(courseId, username));
|
||||
}, [courseId, username]);
|
||||
// console.log({ posts });
|
||||
const handleLoadMorePosts = () => dispatch(fetchUserPosts(courseId, username, { page: nextPage }));
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column my-3 mx-3 bg-white rounded">
|
||||
@@ -27,10 +35,22 @@ function PostsTabContent() {
|
||||
<Post post={post} />
|
||||
</div>
|
||||
))}
|
||||
{nextPage && !loading && (
|
||||
<Button
|
||||
onClick={handleLoadMorePosts}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="card p-4"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMorePosts)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PostsTabContent.propTypes = {};
|
||||
PostsTabContent.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default PostsTabContent;
|
||||
export default injectIntl(PostsTabContent);
|
||||
|
||||
@@ -13,6 +13,11 @@ const messages = defineMessages({
|
||||
id: 'discussions.learner.lastLogin',
|
||||
defaultMessage: 'Last active {lastActiveTime}',
|
||||
},
|
||||
loadMorePosts: {
|
||||
id: 'discussions.learner.loadMostPosts',
|
||||
defaultMessage: 'Load more posts',
|
||||
description: 'Text on button for loading more posts by a user',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import { matchPath, useParams } from 'react-router';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -27,6 +27,7 @@ function NavigationBar({ intl }) {
|
||||
},
|
||||
{
|
||||
route: Routes.TOPICS.ALL,
|
||||
isActive: (match, location) => Boolean(matchPath(location.pathname, { path: Routes.TOPICS.PATH })),
|
||||
labelMessage: messages.allTopics,
|
||||
},
|
||||
];
|
||||
@@ -41,7 +42,12 @@ function NavigationBar({ intl }) {
|
||||
<Nav variant="pills" className="py-2">
|
||||
{navLinks.map(link => (
|
||||
<Nav.Item key={link.route}>
|
||||
<Nav.Link as={NavLink} to={discussionsPath(link.route, { courseId })} className="border">
|
||||
<Nav.Link
|
||||
as={NavLink}
|
||||
to={discussionsPath(link.route, { courseId })}
|
||||
className="border"
|
||||
isActive={link.isActive}
|
||||
>
|
||||
{intl.formatMessage(link.labelMessage)}
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
|
||||
@@ -23,7 +23,32 @@ import PostFilterBar from './post-filter-bar/PostFilterBar';
|
||||
import NoResults from './NoResults';
|
||||
import { PostLink } from './post';
|
||||
|
||||
function PostsList({ posts }) {
|
||||
function PostsList({ posts, topics }) {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
courseId,
|
||||
page,
|
||||
} = useContext(DiscussionContext);
|
||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const orderBy = useSelector(selectThreadSorting());
|
||||
const filters = useSelector(selectThreadFilters());
|
||||
const nextPage = useSelector(selectThreadNextPage());
|
||||
const showOwnPosts = page === 'my-posts';
|
||||
|
||||
const loadThreads = (topicIds, pageNum = undefined) => dispatch(fetchThreads(courseId, {
|
||||
topicIds,
|
||||
orderBy,
|
||||
filters,
|
||||
page: pageNum,
|
||||
author: showOwnPosts ? authenticatedUser.username : null,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (topics !== undefined) {
|
||||
loadThreads(topics);
|
||||
}
|
||||
}, [courseId, orderBy, filters, page, JSON.stringify(topics)]);
|
||||
let lastPinnedIdx = null;
|
||||
const postInstances = posts && posts.map((post, idx) => {
|
||||
if (post.pinned && lastPinnedIdx !== false) {
|
||||
@@ -44,6 +69,18 @@ function PostsList({ posts }) {
|
||||
<>
|
||||
{postInstances}
|
||||
{posts && posts.length === 0 && <NoResults />}
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
nextPage && (
|
||||
<ScrollThreshold onScroll={() => {
|
||||
loadThreads(topics, nextPage);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -53,20 +90,22 @@ PostsList.propTypes = {
|
||||
pinned: PropTypes.bool.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
})),
|
||||
topics: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
PostsList.defaultProps = {
|
||||
posts: [],
|
||||
topics: undefined,
|
||||
};
|
||||
|
||||
function AllPostsList() {
|
||||
const posts = useSelector(selectAllThreads);
|
||||
return <PostsList posts={posts} />;
|
||||
return <PostsList posts={posts} topics={null} />;
|
||||
}
|
||||
|
||||
function TopicPostsList({ topicId }) {
|
||||
const posts = useSelector(selectTopicThreads([topicId]));
|
||||
return <PostsList posts={posts} />;
|
||||
return <PostsList posts={posts} topics={[topicId]} />;
|
||||
}
|
||||
|
||||
TopicPostsList.propTypes = {
|
||||
@@ -76,27 +115,22 @@ TopicPostsList.propTypes = {
|
||||
function CategoryPostsList({ category }) {
|
||||
const topicIds = useSelector(selectTopicsUnderCategory)(category);
|
||||
const posts = useSelector(selectTopicThreads(topicIds));
|
||||
return <PostsList posts={posts} />;
|
||||
return <PostsList posts={posts} topics={topicIds} />;
|
||||
}
|
||||
|
||||
CategoryPostsList.propTypes = {
|
||||
category: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function PostsView({ showOwnPosts }) {
|
||||
function PostsView() {
|
||||
const {
|
||||
courseId,
|
||||
topicId,
|
||||
category,
|
||||
page,
|
||||
} = useContext(DiscussionContext);
|
||||
const dispatch = useDispatch();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const orderBy = useSelector(selectThreadSorting());
|
||||
const filters = useSelector(selectThreadFilters());
|
||||
const nextPage = useSelector(selectThreadNextPage());
|
||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||
const topicIds = null;
|
||||
let postsListComponent = null;
|
||||
|
||||
let postsListComponent;
|
||||
const showOwnPosts = page === 'my-posts';
|
||||
|
||||
if (topicId) {
|
||||
postsListComponent = <TopicPostsList topicId={topicId} />;
|
||||
@@ -106,53 +140,17 @@ function PostsView({ showOwnPosts }) {
|
||||
postsListComponent = <AllPostsList />;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// The courseId from the URL is the course we WANT to load.
|
||||
dispatch(fetchThreads(courseId, {
|
||||
topicIds,
|
||||
orderBy,
|
||||
filters,
|
||||
author: showOwnPosts ? authenticatedUser.username : null,
|
||||
}));
|
||||
}, [courseId, orderBy, filters, showOwnPosts, topicId, category]);
|
||||
|
||||
const loadMorePosts = async () => {
|
||||
if (nextPage) {
|
||||
dispatch(fetchThreads(courseId, {
|
||||
topicIds,
|
||||
orderBy,
|
||||
filters,
|
||||
page: nextPage,
|
||||
author: showOwnPosts ? authenticatedUser.username : null,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="discussion-posts d-flex flex-column">
|
||||
<PostFilterBar filterSelfPosts={showOwnPosts} />
|
||||
<div className="list-group list-group-flush">
|
||||
{postsListComponent}
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
nextPage && (
|
||||
<ScrollThreshold onScroll={loadMorePosts} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PostsView.propTypes = {
|
||||
showOwnPosts: PropTypes.bool,
|
||||
};
|
||||
|
||||
PostsView.defaultProps = {
|
||||
showOwnPosts: false,
|
||||
};
|
||||
|
||||
export default PostsView;
|
||||
|
||||
@@ -29,16 +29,19 @@ async function renderComponent({
|
||||
postId, topicId, category, myPosts,
|
||||
} = { myPosts: false }) {
|
||||
let path = generatePath(Routes.POSTS.ALL_POSTS, { courseId });
|
||||
let showOwnPosts = false;
|
||||
let page;
|
||||
if (postId) {
|
||||
path = generatePath(Routes.POSTS.ALL_POSTS, { courseId, postId });
|
||||
page = 'posts';
|
||||
} else if (topicId) {
|
||||
path = generatePath(Routes.POSTS.PATH, { courseId, topicId });
|
||||
page = 'posts';
|
||||
} else if (category) {
|
||||
path = generatePath(Routes.TOPICS.CATEGORY, { courseId, category });
|
||||
page = 'category';
|
||||
} else if (myPosts) {
|
||||
path = generatePath(Routes.POSTS.MY_POSTS, { courseId });
|
||||
showOwnPosts = myPosts;
|
||||
page = 'my-posts';
|
||||
}
|
||||
await render(
|
||||
<IntlProvider locale="en">
|
||||
@@ -49,11 +52,12 @@ async function renderComponent({
|
||||
postId,
|
||||
topicId,
|
||||
category,
|
||||
page,
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Route path={Routes.POSTS.MY_POSTS}>
|
||||
<PostsView showOwnPosts={showOwnPosts} />
|
||||
<PostsView />
|
||||
</Route>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY]}
|
||||
|
||||
@@ -202,14 +202,15 @@ export async function uploadFile(blob, filename, courseId, threadKey) {
|
||||
*
|
||||
* @param {string} courseId Course ID of the course
|
||||
* @param {string} username Username of the user
|
||||
* @param {number} page
|
||||
* @returns API Response object in the format
|
||||
* {
|
||||
* results: [array of posts],
|
||||
* pagination: {count, num_pages, next, previous}
|
||||
* }
|
||||
*/
|
||||
export async function getUserPosts(courseId, username) {
|
||||
export async function getUserPosts(courseId, username, { page }) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(threadsApiUrl, { params: { course_id: courseId, author: username } });
|
||||
.get(threadsApiUrl, { params: { course_id: courseId, author: username, page } });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -20,6 +20,7 @@ function ClosePostReasonModal({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) {
|
||||
const scrollTo = useRef(null);
|
||||
const [reasonCode, setReasonCode] = useState(null);
|
||||
|
||||
const { postCloseReasons } = useSelector(selectModerationSettings);
|
||||
@@ -32,12 +33,25 @@ function ClosePostReasonModal({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
/* istanbul ignore if: This API is not available in the test environment. */
|
||||
if (scrollTo.current && scrollTo.current.scrollIntoView) {
|
||||
// Use a timeout since the component is first given focus, which scrolls
|
||||
// it into view but doesn't centrally align it. This should run after that.
|
||||
setTimeout(() => {
|
||||
scrollTo.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 0);
|
||||
}
|
||||
}, [scrollTo, isOpen]);
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={intl.formatMessage(messages.closePostModalTitle)}
|
||||
isOpen={isOpen}
|
||||
onClose={onCancel}
|
||||
hasCloseButton={false}
|
||||
isFullscreenOnMobile
|
||||
isFullscreenScroll
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
@@ -45,7 +59,7 @@ function ClosePostReasonModal({
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
<p>{intl.formatMessage(messages.closePostModalText)}</p>
|
||||
<p ref={scrollTo}>{intl.formatMessage(messages.closePostModalText)}</p>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
name="reasonCode"
|
||||
|
||||
@@ -24,7 +24,7 @@ function PostFooter({
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
<div className="d-flex align-items-center mt-2">
|
||||
<div className="d-flex align-items-center">
|
||||
<LikeButton
|
||||
count={post.voteCount}
|
||||
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
|
||||
|
||||
@@ -24,12 +24,14 @@ function PostLink({
|
||||
page,
|
||||
postId,
|
||||
inContext,
|
||||
category,
|
||||
} = useContext(DiscussionContext);
|
||||
const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], {
|
||||
0: inContext ? 'in-context' : undefined,
|
||||
courseId: post.courseId,
|
||||
topicId: post.topicId,
|
||||
postId: post.id,
|
||||
category,
|
||||
});
|
||||
const showAnsweredBadge = post.hasEndorsed && post.type === ThreadType.QUESTION;
|
||||
const authorLabelColor = AvatarBorderAndLabelColors[post.authorLabel];
|
||||
@@ -37,6 +39,7 @@ function PostLink({
|
||||
<Link
|
||||
className="discussion-post list-group-item list-group-item-action p-0 text-decoration-none text-gray-900 mw-100"
|
||||
to={linkUrl}
|
||||
style={{ lineHeight: '21px' }}
|
||||
>
|
||||
{post.pinned && (
|
||||
<div className="d-flex flex-fill justify-content-end mr-4 text-light-500 p-0">
|
||||
@@ -44,7 +47,7 @@ function PostLink({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames('d-flex flex-row flex-fill mw-100 p-3 border-primary-500', { 'bg-light-300': post.read })}
|
||||
className={classNames('d-flex flex-row flex-fill mw-100 p-1 border-primary-500', { 'bg-light-300': post.read })}
|
||||
style={post.id === postId ? {
|
||||
borderRightWidth: '4px',
|
||||
borderRightStyle: 'solid',
|
||||
|
||||
@@ -14,7 +14,6 @@ import appMessages from './i18n';
|
||||
import store from './store';
|
||||
|
||||
import './assets/favicon.ico';
|
||||
import './index.scss';
|
||||
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
@@ -38,6 +37,7 @@ initialize({
|
||||
config() {
|
||||
mergeConfig({
|
||||
POST_MARK_AS_READ_DELAY: process.env.POST_MARK_AS_READ_DELAY || 2000,
|
||||
THEME_LOADER_URL: process.env.THEME_LOADER_URL || 'http://localhost:8111/themes.js',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
94
src/theme-hooks.js
Normal file
94
src/theme-hooks.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export async function loadComponent(scope, module) {
|
||||
// Initializes the share scope. This fills it with known provided modules from this build and all remotes
|
||||
// Webpack module federation allows sharing common dependencies, like `react`, `react-dom` etc
|
||||
// between modules so that they are no loaded multiple times. The following line will initialise
|
||||
// the system for sharing common modules.
|
||||
// Since there are no shared module here, you can safely comment out the next three lines and this
|
||||
// will still work.
|
||||
// eslint-disable-next-line no-undef
|
||||
await __webpack_init_sharing__('default');
|
||||
const container = window[scope];
|
||||
// eslint-disable-next-line no-undef
|
||||
await container.init(__webpack_share_scopes__.default);
|
||||
const factory = await window[scope].get(module);
|
||||
return factory();
|
||||
} // The hook loads the supplied theme, and if the current theme changes it will
|
||||
// Given a script URL this hook will add the script to the body. When the url
|
||||
// changes it will unload the previous script.
|
||||
// For theming, if we know where the script will come from in advance we can just
|
||||
// include it in the HTML and not load it at runtime. However, that would
|
||||
// require supplying that as a build-time value. Keeping this dynamic allows us
|
||||
// to use it
|
||||
function useDynamicScript(url) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const element = document.createElement('script');
|
||||
|
||||
element.src = url;
|
||||
element.type = 'text/javascript';
|
||||
element.async = true;
|
||||
|
||||
setReady(false);
|
||||
setFailed(false);
|
||||
|
||||
element.onload = () => {
|
||||
console.log(`Dynamic Script Loaded: ${url}`);
|
||||
setReady(true);
|
||||
};
|
||||
|
||||
element.onerror = () => {
|
||||
console.error(`Dynamic Script Error: ${url}`);
|
||||
setReady(false);
|
||||
setFailed(true);
|
||||
};
|
||||
|
||||
document.head.appendChild(element);
|
||||
|
||||
return () => {
|
||||
console.log(`Dynamic Script Removed: ${url}`);
|
||||
document.head.removeChild(element);
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return {
|
||||
ready,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
// unload the previous theme and load the new one.
|
||||
export function useTheme(theme) {
|
||||
const { ready } = useDynamicScript(getConfig().THEME_LOADER_URL);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
return undefined;
|
||||
}
|
||||
let styles = null;
|
||||
(async () => {
|
||||
const themeComponent = await loadComponent(theme, './theme');
|
||||
styles = themeComponent.styles;
|
||||
|
||||
themeComponent.styles.use();
|
||||
setLoaded(true);
|
||||
})();
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
return () => {
|
||||
if (styles) {
|
||||
styles.unuse();
|
||||
}
|
||||
setLoaded(false);
|
||||
};
|
||||
}, [ready]);
|
||||
return loaded;
|
||||
}
|
||||
Reference in New Issue
Block a user