Compare commits

...

33 Commits

Author SHA1 Message Date
Kshitij Sobti
40ab53d793 feat: A prototype of dynamically loading themes 2022-04-22 11:26:22 +05:30
Awais Ansari
a77b947e8a fix: hide edit reason dropdown while adding a comment or response (#142)
* fix: hide edit reason dropdown while adding a comment or response
2022-04-21 22:05:21 +05:00
AsadAzam
34a0ae8939 Merge pull request #131 from openedx/kshitij/tnl-9841/tnl-9844
fix: improve UX to match mockups better [BD-38] [TNL-9841] [TNL-9844]
2022-04-21 11:31:45 +05:00
Kshitij Sobti
dfec88de20 fix: improve UX to match mockups better
- Move the learner header to the home page so it can use full width.
- Remove the menu icon from learner page till there is a menu in place
- Improve styling of header bar and sidebar
2022-04-20 18:04:01 +05:30
Kshitij Sobti
c57dfc1fc5 fix: Fix constant reloading on topics page and posts links on category page [BD-38] [TNL-9868] [TNL-9846] (#128)
This fixes two issues. The first is that the topics page can cause constant requests to threads, and the second, that clicking on a post link when browsing a category can cause the application to crash.
2022-04-20 12:26:03 +00:00
Muhammad Adeel Tajamul
d1dce4f2ea Merge pull request #141 from openedx/inf-41
fix: hyperlinks opening in current tab
2022-04-20 16:58:48 +05:00
Kshitij Sobti
e8a3e4eaa8 feat: add support for pagination on learner page [BD-38] [TNL-9844] (#129)
Adds support for pagination on the learners page, including the learners list, the post, comments and responses lists.
2022-04-20 11:10:27 +00:00
adeel.tajamul
7e5ae2a298 fix: hyperlinks opening in current tab 2022-04-20 15:15:52 +05:00
Kshitij Sobti
36ff2fad27 fix: scroll the post close dialog into view when it appears (#119) 2022-04-19 06:33:57 +00:00
Jawayria
7a864ed14e Merge pull request #140 from openedx/aht007/node-16
fix: Updated dependencies for Node 16 #139
2022-04-18 20:07:15 +05:00
Mohammad Ahtasham ul Hassan
dbade5dbd1 fix: update deps 2022-04-18 19:50:34 +05:00
Muhammad Adeel Tajamul
d9f085279e Merge pull request #130 from openedx/tnl-9851
fix: updated post summary design
2022-04-18 15:15:36 +05:00
Mehak Nasir
c0f675f41a revert: node 16 upgrade is reverted (#132)
* revert: node 16 upgrade is reverted

* revert: reverted frontend build version to 9.1.2

* revert: browsers list

Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2022-04-15 23:33:29 +05:00
Mehak Nasir
7f49543c37 Revert "fix: frontend build test fix for stage" (#138)
This reverts commit e426892bb2.
2022-04-15 22:52:20 +05:00
Awais Ansari
1d1b7eb94b Revert "revert: revert to node 12 to test build issue" (#136) 2022-04-15 21:48:45 +05:00
Awais Ansari
a059c50780 Revert "revert: downgraded node to 12 again to resolve build issue (#133)" (#137)
This reverts commit 45b3dd79f3.
2022-04-15 21:43:55 +05:00
Awais Ansari
cf83775052 Merge pull request #135 from openedx/revert-3
revert: revert to node 12 to test build issue
2022-04-15 21:22:43 +05:00
Mehak Nasir
6b98ef6506 revert: revert to node 12 to test build issue 2022-04-15 21:20:22 +05:00
Mehak Nasir
45b3dd79f3 revert: downgraded node to 12 again to resolve build issue (#133) 2022-04-15 21:15:07 +05:00
adeel.tajamul
59f97fff7d fix: updated post summary design 2022-04-15 14:57:42 +05:00
Mehak Nasir
86f7a07ded feat: allow anonymous post support is removed from add post section 2022-04-14 13:06:17 +05:00
Mehak Nasir
4be926d788 style: content sections background color changed 2022-04-14 12:49:25 +05:00
Mehak Nasir
e426892bb2 fix: frontend build test fix for stage 2022-04-13 15:25:07 +05:00
Mehak Nasir
80c3fee3da fix: version change test fix 2022-04-13 14:16:33 +05:00
Adam Stankiewicz
43c03ca2c0 build: use shared browserslist config 2022-04-13 14:16:33 +05:00
Awais Ansari
6bd1561bcb chore: upgrade frontend-build version to 9.1.4 (#124) 2022-04-12 19:03:34 +05:00
Awais Ansari
1f119c1bbb chore: create new package-lock file after node 16 upgrade 2022-04-12 12:14:15 +05:00
Awais Ansari
8fc067e64f chore: add @edx/frontend-build in project dependency (#122) 2022-04-12 02:54:09 +05:00
Mehak Nasir
428f2d2311 feat: upgrade node from 12 to 16 (#121) 2022-04-11 16:13:07 +05:00
Awais Ansari
a51f3ed7c6 fix: switching between subtopics are breaking the UI (#120) 2022-04-07 16:23:29 +05:00
Jawayria
7d6d2c4f79 Merge pull request #117 from openedx/jenkins/npm-8-14fe0d4
chore: Install dependencies using npm 8
2022-04-06 16:39:09 +05:00
edX requirements bot
038b8f8966 chore: Install dependencies using npm 8 2022-04-05 08:12:36 -04:00
Arunmozhi
14fe0d4ea5 feat: adds content area to browse learner contributions (#84)
When the learners tab is enabled and is accessible, the contributions of
a learner can be viewed by sorting them into Posts, Responses and
Comments, by selecting a specific learner. This implements the new
design of authored/reported tabs.

https://openedx.atlassian.net/browse/TNL-8844
2022-04-05 08:37:09 +00:00
44 changed files with 26814 additions and 620 deletions

1
.env
View File

@@ -20,3 +20,4 @@ REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
SITE_NAME=''
USER_INFO_COOKIE_NAME=''
THEME_LOADER_URL=''

View File

@@ -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'

View File

@@ -33,7 +33,5 @@ jobs:
run: npm run build
- name: i18n_extract
run: npm run i18n_extract
- name: is-es5
run: npm run is-es5
- name: Coverage
uses: codecov/codecov-action@v2

3
.nvmrc
View File

@@ -1,2 +1 @@
12.22.5
12

26347
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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,
}}

View File

@@ -134,6 +134,17 @@ export const LearnersOrdering = {
BY_LAST_ACTIVITY: 'activity',
};
/**
* Enum for Learner content tabs
* @readonly
* @enum {string}
*/
export const LearnerTabs = {
POSTS: 'posts',
COMMENTS: 'comments',
RESPONSES: 'responses',
};
/**
* Enum for discussion provider types supported by the MFE.
* @type {{OPEN_EDX: string, LEGACY: string}}
@@ -152,6 +163,11 @@ export const Routes = {
LEARNERS: {
PATH: `${BASE_PATH}/learners`,
LEARNER: `${BASE_PATH}/learners/:learnerUsername`,
TABS: {
posts: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.POSTS}`,
responses: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.RESPONSES}`,
comments: `${BASE_PATH}/learners/:learnerUsername/${LearnerTabs.COMMENTS}`,
},
},
POSTS: {
PATH: `${BASE_PATH}/topics/:topicId`,
@@ -163,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`,
@@ -170,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`,
@@ -184,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])

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -21,6 +22,7 @@ import Reply from './Reply';
function Comment({
postType,
comment,
showFullThread = true,
intl,
}) {
const dispatch = useDispatch();
@@ -34,7 +36,7 @@ function Comment({
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
useEffect(() => {
// If the comment has a parent comment, it won't have any children, so don't fetch them.
if (hasChildren && !currentPage) {
if (hasChildren && !currentPage && showFullThread) {
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
}
}, [comment.id]);
@@ -50,9 +52,10 @@ function Comment({
const handleLoadMoreComments = () => (
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
);
const commentClasses = classNames('d-flex flex-column card', { 'my-3': showFullThread });
return (
<div className="discussion-comment d-flex flex-column card my-3" data-testid={`comment-${comment.id}`}>
<div className={commentClasses} data-testid={`comment-${comment.id}`}>
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteResponseTitle)}
@@ -100,7 +103,7 @@ function Comment({
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{!isNested
{!isNested && showFullThread
&& (
isReplying
? (
@@ -109,6 +112,7 @@ function Comment({
threadId: comment.threadId,
parentId: comment.id,
}}
edit={false}
onCloseEditor={() => setReplying(false)}
/>
)
@@ -126,7 +130,12 @@ function Comment({
Comment.propTypes = {
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
comment: commentShape.isRequired,
showFullThread: PropTypes.bool,
intl: intlShape.isRequired,
};
Comment.defaultProps = {
showFullThread: true,
};
export default injectIntl(Comment);

View File

@@ -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);

View File

@@ -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)}>

View File

@@ -117,3 +117,29 @@ export async function deleteComment(commentId) {
await getAuthenticatedHttpClient()
.delete(url);
}
/**
* Get the comments by a specific user in a course's discussions
*
* comments = responses + comments in the UI
*
* @param {string} courseId Course ID for the course
* @param {string} username Username of the user
* @returns API response in the format
* {
* results: [array of comments],
* pagination: {count, num_pages, next, previous}
* }
*/
export async function getUserComments(courseId, username, { page }) {
const { data } = await getAuthenticatedHttpClient()
.get(commentsApiUrl, {
params: {
course_id: courseId,
username,
page,
},
});
return data;
}

View File

@@ -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,
});

View File

@@ -6,6 +6,8 @@ import { Route, Switch } from 'react-router';
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() {
@@ -14,7 +16,10 @@ export default function DiscussionContent() {
useContainerSizeForParent(refContainer);
return (
<div className="d-flex bg-light-300 flex-column w-75 w-xs-100 w-xl-75 align-items-center h-100 overflow-auto">
<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}>
@@ -28,6 +33,9 @@ export default function DiscussionContent() {
<Route path={Routes.COMMENTS.PATH}>
<CommentsView />
</Route>
<Route path={Routes.LEARNERS.LEARNER}>
<LearnersContentView />
</Route>
</Switch>
)}
</div>

View File

@@ -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} />

View File

@@ -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={{

View File

@@ -16,7 +16,7 @@ function EmptyPage({
}) {
const containerClasses = classNames(
'justify-content-center align-items-center d-flex w-100 flex-column pt-5',
{ 'bg-light-300': !fullWidth },
{ 'bg-light-400': !fullWidth },
);
return (

View 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);

View File

@@ -0,0 +1,53 @@
import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import {
generatePath, Redirect, Route, Switch,
} from 'react-router-dom';
import { Spinner } from '@edx/paragon';
import { LearnerTabs, RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { learnersLoadingStatus } from './data/selectors';
import CommentsTabContent from './learner/CommentsTabContent';
import PostsTabContent from './learner/PostsTabContent';
function LearnersContentView() {
const { courseId, learnerUsername } = useContext(DiscussionContext);
const params = { courseId, learnerUsername };
const apiStatus = useSelector(learnersLoadingStatus());
return (
<div className="learner-content d-flex flex-column">
<Switch>
<Route path={Routes.LEARNERS.LEARNER} exact>
<Redirect to={generatePath(Routes.LEARNERS.TABS.posts, params)} />
</Route>
<Route
path={Routes.LEARNERS.TABS.posts}
component={PostsTabContent}
/>
<Route path={Routes.LEARNERS.TABS.responses}>
<CommentsTabContent tab={LearnerTabs.RESPONSES} />
</Route>
<Route path={Routes.LEARNERS.TABS.comments}>
<CommentsTabContent tab={LearnerTabs.COMMENTS} />
</Route>
</Switch>
{
apiStatus === RequestStatus.IN_PROGRESS && (
<div className="my-3 text-center">
<Spinner animation="border" className="mie-3" />
</div>
)
}
</div>
);
}
LearnersContentView.propTypes = {
};
export default LearnersContentView;

View File

@@ -0,0 +1,176 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { LearnerTabs } from '../../data/constants';
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 } from './data/thunks';
import '../comments/data/__factories__';
import '../posts/data/__factories__';
import './data/__factories__';
let store;
let axiosMock;
const courseId = 'course-v1:edX+TestX+Test_Course';
const testUsername = 'leaner-1';
function renderComponent(username = testUsername) {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ learnerUsername: username, courseId }}>
<MemoryRouter initialEntries={[`/${courseId}/learners/${username}/${LearnerTabs.POSTS}`]}>
<Route path="/:courseId/learners/:learnerUsername">
<DiscussionContent />
</Route>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
describe('LearnersContentView', () => {
const learnerCount = 1;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore({});
Factory.resetAll();
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
.reply(
200,
Factory.build('learnersResult', {}, {
count: learnerCount,
pageSize: 5,
}),
);
axiosMock.onGet(`${userProfileApiUrl}?username=${testUsername}`)
.reply(
200,
Factory.build('learnersProfile', {}, {
username: [testUsername],
}).profiles,
);
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
axiosMock.onGet(threadsApiUrl)
.reply(200, Factory.build('threadsResult', {}, {
topicId: undefined,
count: 6,
pageSize: 5,
}));
axiosMock.onGet(commentsApiUrl)
.reply(200, Factory.build('commentsResult', {}, {
count: 9,
pageSize: 8,
}));
});
test('it loads the posts view by default', async () => {
await act(async () => {
await renderComponent();
});
expect(screen.queryAllByTestId('post')).toHaveLength(5);
expect(screen.queryAllByText('This is Thread', { exact: false })).toHaveLength(5);
});
test('it renders all the comments WITHOUT parent id in responses tab', async () => {
await act(async () => {
await renderComponent();
});
await act(async () => {
fireEvent.click(screen.getByText('Responses', { exact: false }));
});
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
});
test('it renders all the comments with parent id in comments tab', async () => {
axiosMock.onGet(commentsApiUrl)
.reply(200, Factory.build('commentsResult', {}, {
count: 4,
parentId: 'test_parent_id',
}));
await act(async () => {
await renderComponent();
});
await act(async () => {
fireEvent.click(screen.getByRole('link', { name: /Comments \d+/i }));
});
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(4);
});
test('it can switch back to the posts tab', async () => {
await act(async () => {
await renderComponent();
});
await act(async () => {
fireEvent.click(screen.getByRole('link', { name: /Responses \d+/i }));
});
expect(screen.queryAllByText('comment number', { exact: false })).toHaveLength(8);
await act(async () => {
fireEvent.click(screen.getByRole('link', { name: /Posts \d+/i }));
});
expect(screen.queryAllByTestId('post')).toHaveLength(5);
});
describe('Posts Tab Button', () => {
it('does not show Report Icon when the learner has NO active flags', async () => {
await act(async () => {
await renderComponent('leaner-2');
});
const button = screen.getByRole('link', { name: /Posts/i });
expect(button.innerHTML).not.toContain('svg');
});
it('shows the Report Icon when the learner has active Flags', async () => {
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
.reply(() => [200, Factory.build('learnersResult', {}, {
count: 1,
pageSize: 5,
activeFlags: 1,
})]);
axiosMock.onGet(`${userProfileApiUrl}?username=leaner-2`)
.reply(() => [200, Factory.build('learnersProfile', {}, {
username: ['leaner-2'],
}).profiles]);
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
await act(async () => {
await renderComponent('leaner-2');
});
const button = screen.getByRole('link', { name: /Posts/i });
expect(button.innerHTML).toContain('svg');
});
});
});

View File

@@ -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={{

View File

@@ -3,11 +3,12 @@ import { Factory } from 'rosie';
Factory.define('learner')
.sequence('id')
.attr('username', ['id'], (id) => `leaner-${id}`)
.option('activeFlags', null, null)
.attr('active_flags', ['activeFlags'], (activeFlags) => activeFlags)
.attrs({
threads: 1,
replies: 0,
responses: 3,
active_flags: null,
inactive_flags: null,
});
@@ -16,19 +17,18 @@ Factory.define('learnersResult')
.option('page', null, 1)
.option('pageSize', null, 5)
.option('courseId', null, 'course-v1:Test+TestX+Test_Course')
.option('activeFlags', null, 0)
.attr(
'pagination',
['courseId', 'count', 'page', 'pageSize'],
(courseId, count, page, pageSize) => {
const numPages = Math.ceil(count / pageSize);
const next = page < numPages
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${
page + 1
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${page + 1
}`
: null;
const prev = page > 1
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${
page - 1
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${page - 1
}`
: null;
return {
@@ -41,12 +41,26 @@ Factory.define('learnersResult')
)
.attr(
'results',
['count', 'pageSize', 'page', 'courseId'],
(count, pageSize, page, courseId) => {
['count', 'pageSize', 'page', 'courseId', 'activeFlags'],
(count, pageSize, page, courseId, activeFlags) => {
const attrs = { course_id: courseId };
Object.keys(attrs).forEach((key) => (attrs[key] === undefined ? delete attrs[key] : {}));
const len = pageSize * page <= count ? pageSize : count % pageSize;
return Factory.buildList('learner', len, attrs);
let learners = [];
if (activeFlags && activeFlags <= len) {
learners = Factory.buildList('learner', len - activeFlags, attrs);
learners = learners.concat(
Factory.buildList(
'learner',
activeFlags,
{ ...attrs, active_flags: Math.floor(Math.random() * 10) + 1 },
),
);
} else {
learners = Factory.buildList('learner', len, attrs);
}
return learners;
},
);
@@ -68,6 +82,7 @@ Factory.define('learnersProfile')
},
last_login: new Date(Date.now() - 1000 * 60).toISOString(),
username: user,
name: 'Test User',
}));
return profiles;
});

View File

@@ -14,13 +14,16 @@ export const userProfileApiUrl = `${apiBaseUrl}/api/user/v1/accounts`;
/**
* 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;
}

View File

@@ -2,9 +2,11 @@
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;
@@ -15,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
);
@@ -22,3 +32,26 @@ export const selectLearnerAvatar = author => state => (
export const selectLearnerLastLogin = author => state => (
state.learners.learnerProfiles[author]?.lastLogin
);
export const selectLearner = (username) => createSelector(
[selectAllLearners],
learners => learners.find(l => l.username === username) || {},
);
export const selectLearnerProfile = (username) => state => state.learners.learnerProfiles[username] || {};
export const selectUserPosts = username => state => (state.learners.postsByUser[username] || []).flat();
/**
* Get the comments of a post.
* @param {string} username Username of the learner to get the comments of
* @param {LearnerTabs} commentType Type of comments to get
* @returns {Array} Array of comments
*/
export const selectUserComments = (username, commentType) => state => (
commentType === LearnerTabs.COMMENTS
? (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;

View File

@@ -11,23 +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
},
},
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;
},
@@ -42,8 +53,37 @@ 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] ??= [];
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;
},
fetchUserPostsRequest: (state) => {
state.status = RequestStatus.IN_PROGRESS;
},
fetchUserPostsSuccess: (state, { payload }) => {
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) => {
state.status = RequestStatus.DENIED;
},
},
});
@@ -53,6 +93,13 @@ export const {
fetchLearnersSuccess,
fetchLearnersDenied,
setSortedBy,
fetchUserCommentsRequest,
fetchUserCommentsDenied,
fetchUserCommentsSuccess,
fetchUserPostsRequest,
fetchUserPostsDenied,
fetchUserPostsSuccess,
} = learnersSlice.actions;
export const learnersReducer = learnersSlice.reducer;

View File

@@ -2,6 +2,8 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { getUserComments } from '../../comments/data/api';
import { getUserPosts } from '../../posts/data/api';
import { getHttpErrorStatus } from '../../utils';
import {
getLearners, getUserProfiles,
@@ -11,22 +13,30 @@ import {
fetchLearnersFailed,
fetchLearnersRequest,
fetchLearnersSuccess,
fetchUserCommentsDenied,
fetchUserCommentsRequest,
fetchUserCommentsSuccess,
fetchUserPostsDenied,
fetchUserPostsRequest,
fetchUserPostsSuccess,
} from './slices';
/**
* 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);
@@ -37,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());
@@ -48,3 +58,59 @@ export function fetchLearners(courseId, {
}
};
}
/**
* Fetch the comments of a user for the specified course and update the
* redux state
*
* @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, { page = 1 } = {}) {
return async (dispatch) => {
try {
dispatch(fetchUserCommentsRequest());
const data = await getUserComments(courseId, username, { page });
dispatch(fetchUserCommentsSuccess(camelCaseObject({
page,
username,
comments: data.results,
pagination: data.pagination,
})));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchUserCommentsDenied());
}
}
};
}
/**
* Fetch the posts of a user for the specified course and update the
* redux state
*
* @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, { page = 1 } = {}) {
return async (dispatch) => {
try {
dispatch(fetchUserPostsRequest());
const data = await getUserPosts(courseId, username, { page });
dispatch(fetchUserPostsSuccess(camelCaseObject({
page,
username,
posts: data.results,
pagination: data.pagination,
})));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchUserPostsDenied());
}
}
};
}

View File

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

View File

@@ -0,0 +1,51 @@
import React, { useContext, useEffect } from 'react';
import PropType from 'prop-types';
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 { selectLearnerCommentsNextPage, selectUserComments } from '../data/selectors';
import { fetchUserComments } from '../data/thunks';
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 injectIntl(CommentsTabContent);

View File

@@ -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>
);

View File

@@ -4,10 +4,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Icon, OverlayTrigger, Tooltip,
} from '@edx/paragon';
import {
Edit, Error,
QuestionAnswer,
} from '@edx/paragon/icons';
import { Edit, QuestionAnswer, Report } from '@edx/paragon/icons';
import messages from './messages';
import { learnerShape } from './proptypes';
@@ -32,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 })}
@@ -48,7 +45,7 @@ function LearnerFooter({
)}
>
<div className="d-flex">
<Icon src={Error} className="mx-2 my-0 text-danger" />
<Icon src={Report} className="mx-2 my-0 text-danger" />
<span style={{ minWidth: '2rem' }}>
{activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`}
</span>

View File

@@ -0,0 +1,56 @@
import React, { useContext, useEffect } from 'react';
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 { selectLearnerPostsNextPage, selectUserPosts } from '../data/selectors';
import { fetchUserPosts } from '../data/thunks';
import messages from './messages';
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">
{posts.map((post) => (
<div
data-testid="post"
key={post.id}
className="px-3 pb-3 border-bottom border-light-500"
>
<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 = {
intl: intlShape.isRequired,
};
export default injectIntl(PostsTabContent);

View File

@@ -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;

View File

@@ -0,0 +1,21 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
postsTab: {
id: 'discussions.learner.tab.posts',
defaultMessage: 'Posts',
description: "Label for the learner's posts tab",
},
responsesTab: {
id: 'discussions.learner.tab.responses',
defaultMessage: 'Responses',
description: "Label for the learner's responses tab",
},
commentsTab: {
id: 'discussions.learner.tab.comments',
defaultMessage: 'Comments',
description: "Label for the learner's comments tab",
},
});
export default messages;

View File

@@ -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>

View File

@@ -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,84 +90,67 @@ PostsList.propTypes = {
pinned: PropTypes.bool.isRequired,
id: PropTypes.string.isRequired,
})),
topics: PropTypes.arrayOf(PropTypes.string),
};
PostsList.defaultProps = {
posts: [],
topics: undefined,
};
function PostsView({ showOwnPosts }) {
function AllPostsList() {
const posts = useSelector(selectAllThreads);
return <PostsList posts={posts} topics={null} />;
}
function TopicPostsList({ topicId }) {
const posts = useSelector(selectTopicThreads([topicId]));
return <PostsList posts={posts} topics={[topicId]} />;
}
TopicPostsList.propTypes = {
topicId: PropTypes.string.isRequired,
};
function CategoryPostsList({ category }) {
const topicIds = useSelector(selectTopicsUnderCategory)(category);
const posts = useSelector(selectTopicThreads(topicIds));
return <PostsList posts={posts} topics={topicIds} />;
}
CategoryPostsList.propTypes = {
category: PropTypes.string.isRequired,
};
function PostsView() {
const {
courseId,
topicId,
category,
page,
} = useContext(DiscussionContext);
const dispatch = useDispatch();
const { authenticatedUser } = useContext(AppContext);
let topicIds = null;
const orderBy = useSelector(selectThreadSorting());
const filters = useSelector(selectThreadFilters());
const nextPage = useSelector(selectThreadNextPage());
const loadingStatus = useSelector(threadsLoadingStatus());
let postsListComponent = null;
let posts = [];
let postsListComponent;
const showOwnPosts = page === 'my-posts';
if (topicId) {
posts = useSelector(selectTopicThreads([topicId]));
postsListComponent = <TopicPostsList topicId={topicId} />;
} else if (category) {
topicIds = useSelector(selectTopicsUnderCategory)(category);
posts = useSelector(selectTopicThreads(topicIds));
postsListComponent = <CategoryPostsList category={category} />;
} else {
posts = useSelector(selectAllThreads);
postsListComponent = <AllPostsList />;
}
postsListComponent = <PostsList posts={posts} />;
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;

View File

@@ -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]}

View File

@@ -196,3 +196,21 @@ export async function uploadFile(blob, filename, courseId, threadKey) {
}
return data;
}
/**
* Get the posts by a specific user in a course's discussions
*
* @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, { page }) {
const { data } = await getAuthenticatedHttpClient()
.get(threadsApiUrl, { params: { course_id: courseId, author: username, page } });
return data;
}

View File

@@ -374,19 +374,6 @@ function PostEditor({
{intl.formatMessage(messages.followPost)}
</Form.Checkbox>
</Form.Group>
{allowAnonymous && (
<Form.Group>
<Form.Checkbox
name="anonymous"
checked={values.anonymous}
onChange={handleChange}
onBlur={handleBlur}
className="mr-4"
>
{intl.formatMessage(messages.anonymousPost)}
</Form.Checkbox>
</Form.Group>
)}
{allowAnonymousToPeers
&& (
<Form.Group>

View File

@@ -117,15 +117,13 @@ describe('PostEditor', () => {
.toHaveLength(3);
expect(screen.queryByText('cohort', { exact: false }))
.not
.toBeInTheDocument();
.not.toBeInTheDocument();
if (allowAnonymous) {
expect(screen.queryByText('Post anonymously'))
.toBeInTheDocument();
.not.toBeInTheDocument();
} else {
expect(screen.queryByText('Post anonymously'))
.not
.toBeInTheDocument();
.not.toBeInTheDocument();
}
if (allowAnonymousToPeers) {
expect(screen.queryByText('Post anonymously to peers'))

View File

@@ -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"

View File

@@ -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 }))}

View File

@@ -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',

View File

@@ -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
View 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;
}