diff --git a/src/data/constants.js b/src/data/constants.js index ce779366..016c27d7 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -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]); diff --git a/src/discussions/common/context.js b/src/discussions/common/context.js index 0e24e62e..19003713 100644 --- a/src/discussions/common/context.js +++ b/src/discussions/common/context.js @@ -6,5 +6,6 @@ export const DiscussionContext = React.createContext({ postId: null, category: null, commentId: null, + learnerUsername: null, inContext: false, }); diff --git a/src/discussions/data/hooks.js b/src/discussions/data/hooks.js index 3236526f..f3e729e7 100644 --- a/src/discussions/data/hooks.js +++ b/src/discussions/data/hooks.js @@ -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; } diff --git a/src/discussions/data/selectors.js b/src/discussions/data/selectors.js index 883253c1..d615c1b6 100644 --- a/src/discussions/data/selectors.js +++ b/src/discussions/data/selectors.js @@ -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 => ({ diff --git a/src/discussions/data/slices.js b/src/discussions/data/slices.js index 944466d5..35346240 100644 --- a/src/discussions/data/slices.js +++ b/src/discussions/data/slices.js @@ -12,6 +12,7 @@ const configSlice = createSlice({ allowAnonymousToPeers: false, userRoles: [], userIsPrivileged: false, + learnersTabEnabled: false, settings: { divisionScheme: 'none', alwaysDivideInlineDiscussions: false, diff --git a/src/discussions/discussions-home/DiscussionSidebar.jsx b/src/discussions/discussions-home/DiscussionSidebar.jsx index c98990f1..f7773498 100644 --- a/src/discussions/discussions-home/DiscussionSidebar.jsx +++ b/src/discussions/discussions-home/DiscussionSidebar.jsx @@ -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} /> + {RequestStatus.SUCCESSFUL === loadingStatus && (
diff --git a/src/discussions/learners/LearnersView.jsx b/src/discussions/learners/LearnersView.jsx new file mode 100644 index 00000000..5c1b94a1 --- /dev/null +++ b/src/discussions/learners/LearnersView.jsx @@ -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 ( +
+
+ {courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && ( + + )} + {courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => ( + + ))} + {loadingStatus === RequestStatus.IN_PROGRESS ? ( +
+ +
+ ) : ( + nextPage && ( + + ) + )} +
+
+ ); +} + +export default LearnersView; diff --git a/src/discussions/learners/LearnersView.test.jsx b/src/discussions/learners/LearnersView.test.jsx new file mode 100644 index 00000000..da8ebd0a --- /dev/null +++ b/src/discussions/learners/LearnersView.test.jsx @@ -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( + + + + + + + + + , + ); +} + +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); + }); + }); +}); diff --git a/src/discussions/learners/data/__factories__/index.js b/src/discussions/learners/data/__factories__/index.js new file mode 100644 index 00000000..6f99a80f --- /dev/null +++ b/src/discussions/learners/data/__factories__/index.js @@ -0,0 +1 @@ +import './learners.factory'; diff --git a/src/discussions/learners/data/__factories__/learners.factory.js b/src/discussions/learners/data/__factories__/learners.factory.js new file mode 100644 index 00000000..91995f84 --- /dev/null +++ b/src/discussions/learners/data/__factories__/learners.factory.js @@ -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; + }); diff --git a/src/discussions/learners/data/api.js b/src/discussions/learners/data/api.js new file mode 100644 index 00000000..96a06cc9 --- /dev/null +++ b/src/discussions/learners/data/api.js @@ -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; +} diff --git a/src/discussions/learners/data/index.js b/src/discussions/learners/data/index.js new file mode 100644 index 00000000..716c85cf --- /dev/null +++ b/src/discussions/learners/data/index.js @@ -0,0 +1 @@ +export * from './slices'; diff --git a/src/discussions/learners/data/selectors.js b/src/discussions/learners/data/selectors.js new file mode 100644 index 00000000..7b2f28ce --- /dev/null +++ b/src/discussions/learners/data/selectors.js @@ -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 +); diff --git a/src/discussions/learners/data/slices.js b/src/discussions/learners/data/slices.js new file mode 100644 index 00000000..faec3d4c --- /dev/null +++ b/src/discussions/learners/data/slices.js @@ -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; diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js new file mode 100644 index 00000000..71cdbec1 --- /dev/null +++ b/src/discussions/learners/data/thunks.js @@ -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)|*} + */ +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); + } + }; +} diff --git a/src/discussions/learners/index.js b/src/discussions/learners/index.js new file mode 100644 index 00000000..28bf3d5d --- /dev/null +++ b/src/discussions/learners/index.js @@ -0,0 +1,2 @@ +/* eslint-disable import/prefer-default-export */ +export { default as LearnersView } from './LearnersView'; diff --git a/src/discussions/learners/learner/LearnerAvatar.jsx b/src/discussions/learners/learner/LearnerAvatar.jsx new file mode 100644 index 00000000..d3f966f7 --- /dev/null +++ b/src/discussions/learners/learner/LearnerAvatar.jsx @@ -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 ( +
+ +
+ ); +} + +LearnerAvatar.propTypes = { + learner: learnerShape.isRequired, +}; + +export default LearnerAvatar; diff --git a/src/discussions/learners/learner/LearnerCard.jsx b/src/discussions/learners/learner/LearnerCard.jsx new file mode 100644 index 00000000..cfc4686d --- /dev/null +++ b/src/discussions/learners/learner/LearnerCard.jsx @@ -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 ( + +
+ +
+
+
+
+
+ {learner.username} +
+
+ {learnerLastLogin + && {intl.formatMessage(messages.lastActive, { lastActiveTime })}} +
+
+ +
+ +
+ + ); +} + +LearnerCard.propTypes = { + learner: learnerShape.isRequired, + intl: intlShape.isRequired, + courseId: PropTypes.string.isRequired, +}; + +export default injectIntl(LearnerCard); diff --git a/src/discussions/learners/learner/LearnerFooter.jsx b/src/discussions/learners/learner/LearnerFooter.jsx new file mode 100644 index 00000000..209b6946 --- /dev/null +++ b/src/discussions/learners/learner/LearnerFooter.jsx @@ -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 ( +
+ + + {learner.threads} + + + + {learner.replies + learner.responses} + + {Boolean(activeFlags || inactiveFlags) + && ( + +
+ + {intl.formatMessage(messages.reported, { reported: activeFlags })} + + {Boolean(inactiveFlags) + && ( + + {intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })} + + )} +
+ + )} + > +
+ + + {activeFlags} {Boolean(inactiveFlags) && `/ ${inactiveFlags}`} + +
+
+ )} +
+ ); +} + +LearnerFooter.propTypes = { + intl: intlShape.isRequired, + learner: learnerShape.isRequired, +}; + +export default injectIntl(LearnerFooter); diff --git a/src/discussions/learners/learner/LearnerFooter.test.jsx b/src/discussions/learners/learner/LearnerFooter.test.jsx new file mode 100644 index 00000000..f991bfc9 --- /dev/null +++ b/src/discussions/learners/learner/LearnerFooter.test.jsx @@ -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( + + + + + , + ); +} + +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(); + }); +}); diff --git a/src/discussions/learners/learner/index.js b/src/discussions/learners/learner/index.js new file mode 100644 index 00000000..20ce0187 --- /dev/null +++ b/src/discussions/learners/learner/index.js @@ -0,0 +1,3 @@ +/* eslint-disable import/prefer-default-export */ +export { default as LearnerCard } from './LearnerCard'; +export { default as LearnerFooter } from './LearnerFooter'; diff --git a/src/discussions/learners/learner/messages.js b/src/discussions/learners/learner/messages.js new file mode 100644 index 00000000..3cc887b8 --- /dev/null +++ b/src/discussions/learners/learner/messages.js @@ -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; diff --git a/src/discussions/learners/learner/proptypes.js b/src/discussions/learners/learner/proptypes.js new file mode 100644 index 00000000..35fcd7d0 --- /dev/null +++ b/src/discussions/learners/learner/proptypes.js @@ -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, +}); diff --git a/src/discussions/navigation/navigation-bar/NavigationBar.jsx b/src/discussions/navigation/navigation-bar/NavigationBar.jsx index 72e50d81..c45effc3 100644 --- a/src/discussions/navigation/navigation-bar/NavigationBar.jsx +++ b/src/discussions/navigation/navigation-bar/NavigationBar.jsx @@ -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 (