feat: Add Learners list [BD-38] [TNL-8841] (#83)
Adds a new tab and listing view for learners in a course that have interacted with the discussions forums. It reports stats about the learner's created posts, comments, and reported content. Co-authored-by: Kshitij Sobti <kshitij@sobti.in>
This commit is contained in:
@@ -119,6 +119,11 @@ export const TopicOrdering = {
|
||||
BY_COMMENT_COUNT: 'sortByCommentCount',
|
||||
};
|
||||
|
||||
export const LearnersOrdering = {
|
||||
BY_FLAG: 'flagged',
|
||||
BY_LAST_ACTIVITY: 'activity',
|
||||
};
|
||||
|
||||
/**
|
||||
* Enum for discussion provider types supported by the MFE.
|
||||
* @type {{OPEN_EDX: string, LEGACY: string}}
|
||||
@@ -134,6 +139,10 @@ export const Routes = {
|
||||
DISCUSSIONS: {
|
||||
PATH: BASE_PATH,
|
||||
},
|
||||
LEARNERS: {
|
||||
PATH: `${BASE_PATH}/learners`,
|
||||
LEARNER: `${BASE_PATH}/learners/:learnerUsername`,
|
||||
},
|
||||
POSTS: {
|
||||
PATH: `${BASE_PATH}/topics/:topicId`,
|
||||
MY_POSTS: `${BASE_PATH}/my-posts(/:postId)?`,
|
||||
@@ -177,4 +186,5 @@ export const ALL_ROUTES = []
|
||||
.concat(Routes.COMMENTS.PATH)
|
||||
.concat(Routes.TOPICS.PATH)
|
||||
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS])
|
||||
.concat([Routes.LEARNERS.LEARNER, Routes.LEARNERS.PATH])
|
||||
.concat([Routes.DISCUSSIONS.PATH]);
|
||||
|
||||
@@ -6,5 +6,6 @@ export const DiscussionContext = React.createContext({
|
||||
postId: null,
|
||||
category: null,
|
||||
commentId: null,
|
||||
learnerUsername: null,
|
||||
inContext: false,
|
||||
});
|
||||
|
||||
@@ -33,12 +33,13 @@ export const useSidebarVisible = () => {
|
||||
const isFiltered = useSelector(selectAreThreadsFiltered);
|
||||
const totalThreads = useSelector(selectPostThreadCount);
|
||||
const isViewingTopics = useRouteMatch(Routes.TOPICS.PATH);
|
||||
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
|
||||
|
||||
if (isFiltered) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isViewingTopics) {
|
||||
if (isViewingTopics || isViewingLearners) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ export const selectAnonymousPostingConfig = state => ({
|
||||
|
||||
export const selectUserIsPrivileged = state => state.config.userIsPrivileged;
|
||||
|
||||
export const selectconfigLoadingStatus = state => state.config.status;
|
||||
|
||||
export const selectLearnersTabEnabled = state => state.config.learnersTabEnabled;
|
||||
|
||||
export const selectDivisionSettings = state => state.config.settings;
|
||||
|
||||
export const selectModerationSettings = state => ({
|
||||
|
||||
@@ -12,6 +12,7 @@ const configSlice = createSlice({
|
||||
allowAnonymousToPeers: false,
|
||||
userRoles: [],
|
||||
userIsPrivileged: false,
|
||||
learnersTabEnabled: false,
|
||||
settings: {
|
||||
divisionScheme: 'none',
|
||||
alwaysDivideInlineDiscussions: false,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { LearnersView } from '../learners';
|
||||
import { PostsView } from '../posts';
|
||||
import {
|
||||
selectAllThreads, threadsLoadingStatus,
|
||||
@@ -48,6 +49,7 @@ export default function DiscussionSidebar({ displaySidebar }) {
|
||||
component={PostsView}
|
||||
/>
|
||||
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
|
||||
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
|
||||
{RequestStatus.SUCCESSFUL === loadingStatus && (
|
||||
<Redirect
|
||||
from={Routes.DISCUSSIONS.PATH}
|
||||
|
||||
@@ -35,11 +35,12 @@ export default function DiscussionsHome() {
|
||||
postId,
|
||||
topicId,
|
||||
category,
|
||||
learnerUsername,
|
||||
} = params;
|
||||
const inContext = new URLSearchParams(location.search).get('inContext') !== null;
|
||||
|
||||
// Display the content area if we are currently viewing/editing a post or creating one.
|
||||
const displayContentArea = postId || postEditorVisible;
|
||||
const displayContentArea = postId || postEditorVisible || learnerUsername;
|
||||
|
||||
const isSidebarVisible = useSidebarVisible();
|
||||
let displaySidebar = isSidebarVisible;
|
||||
@@ -64,6 +65,7 @@ export default function DiscussionsHome() {
|
||||
topicId,
|
||||
inContext,
|
||||
category,
|
||||
learnerUsername,
|
||||
}}
|
||||
>
|
||||
<main className="container-fluid d-flex flex-column p-0 h-100 w-100 overflow-hidden">
|
||||
|
||||
76
src/discussions/learners/LearnersView.jsx
Normal file
76
src/discussions/learners/LearnersView.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Redirect, useLocation, useParams,
|
||||
} from 'react-router';
|
||||
|
||||
import { Spinner } from '@edx/paragon';
|
||||
|
||||
import ScrollThreshold from '../../components/ScrollThreshold';
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { selectconfigLoadingStatus, selectLearnersTabEnabled } from '../data/selectors';
|
||||
import {
|
||||
learnersLoadingStatus,
|
||||
selectAllLearners,
|
||||
selectLearnerNextPage,
|
||||
selectLearnerSorting,
|
||||
} from './data/selectors';
|
||||
import { fetchLearners } from './data/thunks';
|
||||
import { LearnerCard } from './learner';
|
||||
|
||||
function LearnersView() {
|
||||
const {
|
||||
courseId,
|
||||
} = useParams();
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const orderBy = useSelector(selectLearnerSorting());
|
||||
const nextPage = useSelector(selectLearnerNextPage());
|
||||
const loadingStatus = useSelector(learnersLoadingStatus());
|
||||
const courseConfigLoadingStatus = useSelector(selectconfigLoadingStatus);
|
||||
const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
|
||||
const learners = useSelector(selectAllLearners);
|
||||
useEffect(() => {
|
||||
if (learnersTabEnabled) {
|
||||
dispatch(fetchLearners(courseId, { orderBy }));
|
||||
}
|
||||
}, [courseId, orderBy, learnersTabEnabled]);
|
||||
|
||||
const loadPage = async () => {
|
||||
if (nextPage) {
|
||||
dispatch(fetchLearners(courseId, {
|
||||
orderBy,
|
||||
page: nextPage,
|
||||
}));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="d-flex flex-column">
|
||||
<div className="list-group list-group-flush">
|
||||
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && (
|
||||
<Redirect
|
||||
to={{
|
||||
...location,
|
||||
pathname: Routes.DISCUSSIONS.PATH,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => (
|
||||
<LearnerCard learner={learner} key={learner.username} courseId={courseId} />
|
||||
))}
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
nextPage && (
|
||||
<ScrollThreshold onScroll={loadPage} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LearnersView;
|
||||
91
src/discussions/learners/LearnersView.test.jsx
Normal file
91
src/discussions/learners/LearnersView.test.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
|
||||
import { 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 { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { courseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import { coursesApiUrl, userProfileApiUrl } from './data/api';
|
||||
import { fetchLearners } from './data/thunks';
|
||||
import LearnersView from './LearnersView';
|
||||
|
||||
import './data/__factories__';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
|
||||
function renderComponent() {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/`]}>
|
||||
<Route path="/:courseId/">
|
||||
<LearnersView />
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('LearnersView', () => {
|
||||
const learnerCount = 3;
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
Factory.resetAll();
|
||||
const learnersData = Factory.build('learnersResult', {}, {
|
||||
count: learnerCount,
|
||||
pageSize: 6,
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
|
||||
.reply(() => [200, learnersData]);
|
||||
const learnersProfile = Factory.build('learnersProfile', {}, {
|
||||
username: ['leaner-1', 'leaner-2', 'leaner-3'],
|
||||
});
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=leaner-1,leaner-2,leaner-3`)
|
||||
.reply(() => [200, learnersProfile.profiles]);
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
describe('Basic', () => {
|
||||
test('Learners tab is disabled by default', async () => {
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
});
|
||||
expect(screen.queryByText(/Last active/i)).toBeFalsy();
|
||||
});
|
||||
test('Learners tab is enabled', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
learners_tab_enabled: true,
|
||||
user_is_privileged: true,
|
||||
});
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
});
|
||||
expect(screen.queryAllByText(/Last active/i, { exact: false }).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/discussions/learners/data/__factories__/index.js
Normal file
1
src/discussions/learners/data/__factories__/index.js
Normal file
@@ -0,0 +1 @@
|
||||
import './learners.factory';
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
Factory.define('learner')
|
||||
.sequence('id')
|
||||
.attr('username', ['id'], (id) => `leaner-${id}`)
|
||||
.attrs({
|
||||
threads: 1,
|
||||
replies: 0,
|
||||
responses: 3,
|
||||
active_flags: null,
|
||||
inactive_flags: null,
|
||||
});
|
||||
|
||||
Factory.define('learnersResult')
|
||||
.option('count', null, 3)
|
||||
.option('page', null, 1)
|
||||
.option('pageSize', null, 5)
|
||||
.option('courseId', null, 'course-v1:Test+TestX+Test_Course')
|
||||
.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
|
||||
}`
|
||||
: null;
|
||||
const prev = page > 1
|
||||
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${
|
||||
page - 1
|
||||
}`
|
||||
: null;
|
||||
return {
|
||||
next,
|
||||
prev,
|
||||
count,
|
||||
num_pages: numPages,
|
||||
};
|
||||
},
|
||||
)
|
||||
.attr(
|
||||
'results',
|
||||
['count', 'pageSize', 'page', 'courseId'],
|
||||
(count, pageSize, page, courseId) => {
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
Factory.define('learnersProfile')
|
||||
.option('usernames', null, ['leaner-1', 'leaner-2', 'leaner-3'])
|
||||
.attr('profiles', ['usernames'], (usernames) => {
|
||||
const profiles = usernames.map((user) => ({
|
||||
account_privacy: 'private',
|
||||
profile_image: {
|
||||
has_image: false,
|
||||
image_url_full:
|
||||
'http://localhost:18000/static/images/profiles/default_500.png',
|
||||
image_url_large:
|
||||
'http://localhost:18000/static/images/profiles/default_120.png',
|
||||
image_url_medium:
|
||||
'http://localhost:18000/static/images/profiles/default_50.png',
|
||||
image_url_small:
|
||||
'http://localhost:18000/static/images/profiles/default_30.png',
|
||||
},
|
||||
last_login: new Date(Date.now() - 1000 * 60).toISOString(),
|
||||
username: user,
|
||||
}));
|
||||
return profiles;
|
||||
});
|
||||
35
src/discussions/learners/data/api.js
Normal file
35
src/discussions/learners/data/api.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
ensureConfig([
|
||||
'LMS_BASE_URL',
|
||||
], 'Posts API service');
|
||||
|
||||
const apiBaseUrl = getConfig().LMS_BASE_URL;
|
||||
|
||||
export const coursesApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`;
|
||||
export const userProfileApiUrl = `${apiBaseUrl}/api/user/v1/accounts`;
|
||||
|
||||
/**
|
||||
* Fetches all the learners in the given course.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getLearners(
|
||||
courseId,
|
||||
) {
|
||||
const url = `${coursesApiUrl}${courseId}/activity_stats/`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile
|
||||
* @param {string} usernames
|
||||
*/
|
||||
export async function getUserProfiles(usernames) {
|
||||
const url = `${userProfileApiUrl}?username=${usernames.join()}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
}
|
||||
1
src/discussions/learners/data/index.js
Normal file
1
src/discussions/learners/data/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './slices';
|
||||
24
src/discussions/learners/data/selectors.js
Normal file
24
src/discussions/learners/data/selectors.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
export const selectAllLearners = createSelector(
|
||||
state => state.learners,
|
||||
learners => learners.learners,
|
||||
);
|
||||
|
||||
export const learnersLoadingStatus = () => state => state.learners.status;
|
||||
|
||||
export const selectLearnerSorting = () => state => state.learners.sortedBy;
|
||||
|
||||
export const selectLearnerFilters = () => state => state.learners.filters;
|
||||
|
||||
export const selectLearnerNextPage = () => state => state.learners.nextPage;
|
||||
|
||||
export const selectLearnerAvatar = author => state => (
|
||||
state.learners.learnerProfiles[author]?.profileImage?.imageUrlSmall
|
||||
);
|
||||
|
||||
export const selectLearnerLastLogin = author => state => (
|
||||
state.learners.learnerProfiles[author]?.lastLogin
|
||||
);
|
||||
58
src/discussions/learners/data/slices.js
Normal file
58
src/discussions/learners/data/slices.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable no-param-reassign,import/prefer-default-export */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
LearnersOrdering,
|
||||
RequestStatus,
|
||||
} from '../../../data/constants';
|
||||
|
||||
const learnersSlice = createSlice({
|
||||
name: 'learner',
|
||||
initialState: {
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
avatars: {},
|
||||
learners: [],
|
||||
learnerProfiles: {},
|
||||
pages: [],
|
||||
nextPage: null,
|
||||
totalPages: null,
|
||||
totalLearners: null,
|
||||
sortedBy: LearnersOrdering.BY_LAST_ACTIVITY,
|
||||
},
|
||||
reducers: {
|
||||
fetchLearnersSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
state.learners = payload.results;
|
||||
state.learnerProfiles = {
|
||||
...state.learnerProfiles,
|
||||
...(payload.learnerProfiles || {}),
|
||||
};
|
||||
state.nextPage = payload.pagination.next;
|
||||
state.totalPages = payload.pagination.numPages;
|
||||
state.totalLearners = payload.pagination.count;
|
||||
},
|
||||
fetchLearnersFailed: (state) => {
|
||||
state.status = RequestStatus.FAILED;
|
||||
},
|
||||
fetchLearnersDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
fetchLearnersRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
setSortedBy: (state, { payload }) => {
|
||||
state.sortedBy = payload;
|
||||
state.pages = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchLearnersFailed,
|
||||
fetchLearnersRequest,
|
||||
fetchLearnersSuccess,
|
||||
fetchLearnersDenied,
|
||||
setSortedBy,
|
||||
} = learnersSlice.actions;
|
||||
|
||||
export const learnersReducer = learnersSlice.reducer;
|
||||
50
src/discussions/learners/data/thunks.js
Normal file
50
src/discussions/learners/data/thunks.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import {
|
||||
getLearners, getUserProfiles,
|
||||
} from './api';
|
||||
import {
|
||||
fetchLearnersDenied,
|
||||
fetchLearnersFailed,
|
||||
fetchLearnersRequest,
|
||||
fetchLearnersSuccess,
|
||||
} from './slices';
|
||||
|
||||
/**
|
||||
* Fetches the learners for the course courseId.
|
||||
* @param {string} courseId The course ID for the course to fetch data for.
|
||||
* @returns {(function(*): Promise<void>)|*}
|
||||
*/
|
||||
export function fetchLearners(courseId, {
|
||||
orderBy,
|
||||
page = 1,
|
||||
} = {}) {
|
||||
const options = {
|
||||
orderBy,
|
||||
page,
|
||||
};
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchLearnersRequest({ courseId }));
|
||||
const learnerStats = await getLearners(courseId, options);
|
||||
const learnerProfilesData = await getUserProfiles(learnerStats.results.map((l) => l.username));
|
||||
const learnerProfiles = {};
|
||||
learnerProfilesData.forEach(
|
||||
learnerProfile => {
|
||||
learnerProfiles[learnerProfile.username] = camelCaseObject(learnerProfile);
|
||||
},
|
||||
);
|
||||
dispatch(fetchLearnersSuccess({ ...camelCaseObject(learnerStats), learnerProfiles }));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchLearnersDenied());
|
||||
} else {
|
||||
dispatch(fetchLearnersFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
2
src/discussions/learners/index.js
Normal file
2
src/discussions/learners/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as LearnersView } from './LearnersView';
|
||||
28
src/discussions/learners/learner/LearnerAvatar.jsx
Normal file
28
src/discussions/learners/learner/LearnerAvatar.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { Avatar } from '@edx/paragon';
|
||||
|
||||
import { selectLearnerAvatar } from '../data/selectors';
|
||||
import { learnerShape } from './proptypes';
|
||||
|
||||
function LearnerAvatar({ learner }) {
|
||||
const learnerAvatar = useSelector(selectLearnerAvatar(learner.username));
|
||||
return (
|
||||
<div className="mr-2">
|
||||
<Avatar
|
||||
size="md"
|
||||
className="mt-2.5 ml-2.5"
|
||||
alt={learner.username}
|
||||
src={learnerAvatar}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LearnerAvatar.propTypes = {
|
||||
learner: learnerShape.isRequired,
|
||||
};
|
||||
|
||||
export default LearnerAvatar;
|
||||
83
src/discussions/learners/learner/LearnerCard.jsx
Normal file
83
src/discussions/learners/learner/LearnerCard.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
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';
|
||||
import { discussionsPath } from '../../utils';
|
||||
import { selectLearnerLastLogin } from '../data/selectors';
|
||||
import LearnerAvatar from './LearnerAvatar';
|
||||
import LearnerFooter from './LearnerFooter';
|
||||
import messages from './messages';
|
||||
import { learnerShape } from './proptypes';
|
||||
|
||||
function LearnerCard({
|
||||
learner,
|
||||
intl,
|
||||
courseId,
|
||||
}) {
|
||||
const {
|
||||
inContext,
|
||||
learnerUsername,
|
||||
} = useContext(DiscussionContext);
|
||||
const linkUrl = discussionsPath(Routes.LEARNERS.LEARNER, {
|
||||
0: inContext ? 'in-context' : undefined,
|
||||
learnerUsername: learner.username,
|
||||
courseId,
|
||||
});
|
||||
const learnerLastLogin = useSelector(selectLearnerLastLogin(learner.username));
|
||||
const lastActiveTime = timeago.format(new Date(learnerLastLogin), intl.locale);
|
||||
return (
|
||||
<Link
|
||||
className="list-group-item list-group-item-action p-0 text-decoration-none text-gray-900 mw-100"
|
||||
to={linkUrl}
|
||||
>
|
||||
<div
|
||||
className="d-flex flex-row flex-fill mw-100 p-3.5 border-primary-500"
|
||||
style={learner.username === learnerUsername ? {
|
||||
borderRightWidth: '4px',
|
||||
borderRightStyle: 'solid',
|
||||
} : null}
|
||||
>
|
||||
<LearnerAvatar learner={learner} />
|
||||
<div className="d-flex flex-column" style={{ width: 'calc(100% - 4rem)' }}>
|
||||
<div className="align-items-center d-flex flex-row flex-fill mb-3">
|
||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
||||
<div className="h4 d-flex align-items-center pb-0 mb-0 flex-fill">
|
||||
<div className="flex-fill text-truncate">
|
||||
{learner.username}
|
||||
</div>
|
||||
</div>
|
||||
{learnerLastLogin
|
||||
&& <span> {intl.formatMessage(messages.lastActive, { lastActiveTime })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<LearnerFooter learner={learner} />
|
||||
</div>
|
||||
<IconButton
|
||||
src={MoreVert}
|
||||
iconAs={Icon}
|
||||
alt={learner.username}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
LearnerCard.propTypes = {
|
||||
learner: learnerShape.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearnerCard);
|
||||
67
src/discussions/learners/learner/LearnerFooter.jsx
Normal file
67
src/discussions/learners/learner/LearnerFooter.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Icon, OverlayTrigger, Tooltip,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
Edit, Error,
|
||||
QuestionAnswer,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
import { learnerShape } from './proptypes';
|
||||
|
||||
function LearnerFooter({
|
||||
learner,
|
||||
intl,
|
||||
}) {
|
||||
const { inactiveFlags } = learner;
|
||||
const { activeFlags } = learner;
|
||||
return (
|
||||
<div className="d-flex align-items-center">
|
||||
<Icon src={QuestionAnswer} className="mx-2 my-0" />
|
||||
<span style={{ minWidth: '2rem' }}>
|
||||
{learner.threads}
|
||||
</span>
|
||||
<Icon src={Edit} className="mx-2 my-0" />
|
||||
<span style={{ minWidth: '2rem' }}>
|
||||
{learner.replies + learner.responses}
|
||||
</span>
|
||||
{Boolean(activeFlags || inactiveFlags)
|
||||
&& (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
<span>
|
||||
{intl.formatMessage(messages.reported, { reported: activeFlags })}
|
||||
</span>
|
||||
{Boolean(inactiveFlags)
|
||||
&& (
|
||||
<span>
|
||||
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex">
|
||||
<Icon src={Error} className="mx-2 my-0 text-danger" />
|
||||
<span style={{ minWidth: '2rem' }}>
|
||||
{activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`}
|
||||
</span>
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LearnerFooter.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
learner: learnerShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearnerFooter);
|
||||
67
src/discussions/learners/learner/LearnerFooter.test.jsx
Normal file
67
src/discussions/learners/learner/LearnerFooter.test.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import LearnerFooter from './LearnerFooter';
|
||||
import messages from './messages';
|
||||
|
||||
let store;
|
||||
|
||||
function renderComponent(learner) {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<LearnerFooter learner={learner} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const mockLearner = {
|
||||
threads: 5,
|
||||
replies: 1,
|
||||
responses: 3,
|
||||
activeFlags: null,
|
||||
inactiveFlags: null,
|
||||
username: 'username',
|
||||
};
|
||||
|
||||
const mockLearnerWithFlags = {
|
||||
threads: 5,
|
||||
replies: 1,
|
||||
responses: 3,
|
||||
activeFlags: 1,
|
||||
inactiveFlags: 2,
|
||||
username: 'username',
|
||||
};
|
||||
|
||||
describe('LearnerFooter', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('Always shows threads and replies icons', () => {
|
||||
renderComponent(mockLearner);
|
||||
expect(screen.getByText(mockLearner.threads)).toBeTruthy();
|
||||
expect(screen.getByText(mockLearner.replies + mockLearner.responses)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows flags when the learner have ones', () => {
|
||||
renderComponent(mockLearnerWithFlags);
|
||||
expect(screen.queryByText(messages.reported)).toBeFalsy();
|
||||
expect(screen.queryByText(messages.previouslyReported)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
3
src/discussions/learners/learner/index.js
Normal file
3
src/discussions/learners/learner/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as LearnerCard } from './LearnerCard';
|
||||
export { default as LearnerFooter } from './LearnerFooter';
|
||||
18
src/discussions/learners/learner/messages.js
Normal file
18
src/discussions/learners/learner/messages.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
reported: {
|
||||
id: 'discussions.learner.reported',
|
||||
defaultMessage: '{reported} reported',
|
||||
},
|
||||
previouslyReported: {
|
||||
id: 'discussions.learner.previouslyReported',
|
||||
defaultMessage: '{previouslyReported} previously reported',
|
||||
},
|
||||
lastActive: {
|
||||
id: 'discussions.learner.lastLogin',
|
||||
defaultMessage: 'Last active {lastActiveTime}',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
11
src/discussions/learners/learner/proptypes.js
Normal file
11
src/discussions/learners/learner/proptypes.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const learnerShape = PropTypes.shape({
|
||||
activeFlags: PropTypes.number,
|
||||
inactiveFlags: PropTypes.number,
|
||||
username: PropTypes.string,
|
||||
replies: PropTypes.number,
|
||||
responses: PropTypes.number,
|
||||
threads: PropTypes.number,
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
@@ -7,11 +8,13 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Nav } from '@edx/paragon';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { selectLearnersTabEnabled } from '../../data/selectors';
|
||||
import { discussionsPath } from '../../utils';
|
||||
import messages from './messages';
|
||||
|
||||
function NavigationBar({ intl }) {
|
||||
const { courseId } = useParams();
|
||||
const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
|
||||
|
||||
const navLinks = [
|
||||
{
|
||||
@@ -27,6 +30,13 @@ function NavigationBar({ intl }) {
|
||||
labelMessage: messages.allTopics,
|
||||
},
|
||||
];
|
||||
if (learnersTabEnabled) {
|
||||
navLinks.push({
|
||||
route: Routes.LEARNERS.PATH,
|
||||
labelMessage: messages.learners,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Nav variant="pills" className="py-2">
|
||||
{navLinks.map(link => (
|
||||
|
||||
@@ -16,6 +16,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'My posts',
|
||||
description: 'Option in navbar to show a user\'s posts',
|
||||
},
|
||||
learners: {
|
||||
id: 'discussions.navigation.navigationBar.learners',
|
||||
defaultMessage: 'Learners',
|
||||
description: 'Option in navbar to show learners',
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { blocksReducer } from './data/slices';
|
||||
import { cohortsReducer } from './discussions/cohorts/data';
|
||||
import { commentsReducer } from './discussions/comments/data';
|
||||
import { configReducer } from './discussions/data/slices';
|
||||
import { learnersReducer } from './discussions/learners/data';
|
||||
import { threadsReducer } from './discussions/posts/data';
|
||||
import { topicsReducer } from './discussions/topics/data';
|
||||
|
||||
@@ -16,6 +17,7 @@ export function initializeStore(preloadedState = undefined) {
|
||||
cohorts: cohortsReducer,
|
||||
config: configReducer,
|
||||
blocks: blocksReducer,
|
||||
learners: learnersReducer,
|
||||
},
|
||||
preloadedState,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user