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:
Hamza Khchine
2022-03-28 05:53:26 +00:00
committed by GitHub
parent 4b17de8c13
commit 2c40685932
27 changed files with 728 additions and 2 deletions

View File

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

View File

@@ -6,5 +6,6 @@ export const DiscussionContext = React.createContext({
postId: null,
category: null,
commentId: null,
learnerUsername: null,
inContext: false,
});

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ const configSlice = createSlice({
allowAnonymousToPeers: false,
userRoles: [],
userIsPrivileged: false,
learnersTabEnabled: false,
settings: {
divisionScheme: 'none',
alwaysDivideInlineDiscussions: false,

View File

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

View File

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

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

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

View File

@@ -0,0 +1 @@
import './learners.factory';

View File

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

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

View File

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

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

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

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

View File

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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