Compare commits
7 Commits
sundas/INF
...
inf-763
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8cefa7269 | ||
|
|
1c2da56e3b | ||
|
|
e99c30f213 | ||
|
|
eacc16b7f1 | ||
|
|
f8800de766 | ||
|
|
f7740de54a | ||
|
|
4ca25c14d9 |
@@ -5,6 +5,8 @@ import DOMPurify from 'dompurify';
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { useDebounce } from '../discussions/data/hooks';
|
||||
|
||||
const defaultSanitizeOptions = {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_ATTR: ['columnalign'],
|
||||
@@ -16,19 +18,21 @@ function HTMLLoader({
|
||||
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
|
||||
const previewRef = useRef();
|
||||
|
||||
const debouncedPostContent = useDebounce(htmlNode, 500);
|
||||
|
||||
useEffect(() => {
|
||||
let promise = Promise.resolve(); // Used to hold chain of typesetting calls
|
||||
|
||||
function typeset(code) {
|
||||
promise = promise.then(() => window.MathJax?.typesetPromise(code()))
|
||||
.catch((err) => logError(`Typeset failed: ${err.message}`));
|
||||
return promise;
|
||||
}
|
||||
|
||||
typeset(() => {
|
||||
previewRef.current.innerHTML = sanitizedMath;
|
||||
});
|
||||
}, [htmlNode]);
|
||||
if (debouncedPostContent) {
|
||||
typeset(() => {
|
||||
previewRef.current.innerHTML = sanitizedMath;
|
||||
});
|
||||
}
|
||||
}, [debouncedPostContent]);
|
||||
|
||||
return (
|
||||
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
|
||||
|
||||
@@ -119,6 +119,7 @@ export default function TinyMCEEditor(props) {
|
||||
content_css: false,
|
||||
content_style: contentStyle,
|
||||
body_class: 'm-2 text-editor',
|
||||
convert_urls: false,
|
||||
relative_urls: false,
|
||||
default_link_target: '_blank',
|
||||
target_list: false,
|
||||
|
||||
108
src/components/TopicStats.jsx
Normal file
108
src/components/TopicStats.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/* eslint react/prop-types: 0 */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
} from '../discussions/data/selectors';
|
||||
import messages from '../discussions/in-context-topics/messages';
|
||||
|
||||
function TopicStats({
|
||||
threadCounts,
|
||||
activeFlags,
|
||||
inactiveFlags,
|
||||
intl,
|
||||
}) {
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
|
||||
return (
|
||||
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{intl.formatMessage(messages.discussions, {
|
||||
count: threadCounts?.discussion || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={PostOutline} className="icon-size mr-2" />
|
||||
{threadCounts?.discussion || 0}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{intl.formatMessage(messages.questions, {
|
||||
count: threadCounts?.question || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={HelpOutline} className="icon-size mr-2" />
|
||||
{threadCounts?.question || 0}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
{Boolean(canSeeReportedStats) && (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{Boolean(activeFlags) && (
|
||||
<span>
|
||||
{intl.formatMessage(messages.reported, { reported: activeFlags })}
|
||||
</span>
|
||||
)}
|
||||
{Boolean(inactiveFlags) && (
|
||||
<span>
|
||||
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<Icon src={Report} className="icon-size mr-2 text-danger" />
|
||||
{activeFlags}{Boolean(inactiveFlags) && `/${inactiveFlags}`}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TopicStats.propTypes = {
|
||||
threadCounts: PropTypes.shape({
|
||||
discussions: PropTypes.number,
|
||||
questions: PropTypes.number,
|
||||
}),
|
||||
activeFlags: PropTypes.number,
|
||||
inactiveFlags: PropTypes.number,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
TopicStats.defaultProps = {
|
||||
threadCounts: {
|
||||
discussions: 0,
|
||||
questions: 0,
|
||||
},
|
||||
activeFlags: null,
|
||||
inactiveFlags: null,
|
||||
};
|
||||
|
||||
export default injectIntl(TopicStats);
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
|
||||
export { default as Search } from './Search';
|
||||
export { default as TinyMCEEditor } from './TinyMCEEditor';
|
||||
export { default as TopicStats } from './TopicStats';
|
||||
|
||||
@@ -63,6 +63,7 @@ export const ContentActions = {
|
||||
* @enum {string}
|
||||
*/
|
||||
export const RequestStatus = {
|
||||
IDLE: 'idle',
|
||||
IN_PROGRESS: 'in-progress',
|
||||
SUCCESSFUL: 'successful',
|
||||
FAILED: 'failed',
|
||||
|
||||
@@ -224,3 +224,24 @@ export const useTourConfiguration = (intl) => {
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
export const useDebounce = (value, delay) => {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(
|
||||
() => {
|
||||
// Update debounced value after delay
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
// Cancel the timeout if value changes (also on delay change or unmount)
|
||||
// This is how we prevent debounced value from updating if value is changed ...
|
||||
// .. within the delay period. Timeout gets cleared and restarted.
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
},
|
||||
[value, delay], // Only re-call effect if value or delay changes
|
||||
);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -8,6 +8,7 @@ import { Spinner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { selectDiscussionProvider } from '../data/selectors';
|
||||
import { selectTopicThreads } from '../posts/data/selectors';
|
||||
import PostsList from '../posts/PostsList';
|
||||
import { discussionsPath, handleKeyDown } from '../utils';
|
||||
@@ -15,14 +16,18 @@ import {
|
||||
selectArchivedTopic, selectLoadingStatus, selectNonCoursewareTopics,
|
||||
selectSubsection, selectSubsectionUnits, selectUnits,
|
||||
} from './data/selectors';
|
||||
import { fetchCourseTopicsV3 } from './data/thunks';
|
||||
import { BackButton, NoResults } from './components';
|
||||
import messages from './messages';
|
||||
import { Topic } from './topic';
|
||||
|
||||
function TopicPostsView({ intl }) {
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const { courseId, topicId, category } = useContext(DiscussionContext);
|
||||
const topicsLoadingStatus = useSelector(selectLoadingStatus);
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
const topicsStatus = useSelector(selectLoadingStatus);
|
||||
const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS;
|
||||
const posts = useSelector(selectTopicThreads([topicId]));
|
||||
const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category));
|
||||
const selectedSubsection = useSelector(selectSubsection(category));
|
||||
@@ -30,6 +35,12 @@ function TopicPostsView({ intl }) {
|
||||
const selectedNonCoursewareTopic = useSelector(selectNonCoursewareTopics)?.find(topic => topic.id === topicId);
|
||||
const selectedArchivedTopic = useSelector(selectArchivedTopic(topicId));
|
||||
|
||||
useEffect(() => {
|
||||
if (provider && topicsStatus === RequestStatus.IDLE) {
|
||||
dispatch(fetchCourseTopicsV3(courseId));
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
const backButtonPath = () => {
|
||||
const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL;
|
||||
const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId };
|
||||
@@ -40,12 +51,14 @@ function TopicPostsView({ intl }) {
|
||||
<div className="discussion-posts d-flex flex-column h-100">
|
||||
{topicId ? (
|
||||
<BackButton
|
||||
loading={topicsInProgress}
|
||||
path={backButtonPath()}
|
||||
title={selectedUnit?.name || selectedNonCoursewareTopic?.name || selectedArchivedTopic?.name
|
||||
|| intl.formatMessage(messages.unnamedTopic)}
|
||||
/>
|
||||
) : (
|
||||
<BackButton
|
||||
loading={topicsInProgress}
|
||||
path={discussionsPath(Routes.TOPICS.ALL, { courseId })(location)}
|
||||
title={selectedSubsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
|
||||
/>
|
||||
@@ -56,6 +69,7 @@ function TopicPostsView({ intl }) {
|
||||
<PostsList
|
||||
posts={posts}
|
||||
topics={[topicId]}
|
||||
parentIsLoading={topicsInProgress}
|
||||
/>
|
||||
) : (
|
||||
selectedSubsectionUnits?.map((unit) => (
|
||||
@@ -65,10 +79,10 @@ function TopicPostsView({ intl }) {
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{(category && selectedSubsectionUnits.length === 0 && topicsLoadingStatus === RequestStatus.SUCCESSFUL) && (
|
||||
{(category && selectedSubsectionUnits.length === 0 && topicsStatus === RequestStatus.SUCCESSFUL) && (
|
||||
<NoResults />
|
||||
)}
|
||||
{(category && topicsLoadingStatus === RequestStatus.IN_PROGRESS) && (
|
||||
{(category && topicsInProgress) && (
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import {
|
||||
fireEvent, render, screen, waitFor,
|
||||
within,
|
||||
} 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 { DiscussionContext } from '../common/context';
|
||||
import { getCourseTopicsApiUrl } from './data/api';
|
||||
import { selectCoursewareTopics, selectNonCoursewareTopics } from './data/selectors';
|
||||
import { fetchCourseTopicsV3 } from './data/thunks';
|
||||
import TopicsView from './TopicsView';
|
||||
|
||||
import './data/__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
const topicsApiUrl = `${getCourseTopicsApiUrl()}`;
|
||||
let store;
|
||||
let axiosMock;
|
||||
let lastLocation;
|
||||
let component;
|
||||
let debug;
|
||||
|
||||
function renderComponent() {
|
||||
component = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider value={{ courseId }}>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/topics/`]}>
|
||||
<Route path="/:courseId/topics/">
|
||||
<TopicsView />
|
||||
</Route>
|
||||
<Route path="/:courseId/category/:category">
|
||||
<TopicsView />
|
||||
</Route>
|
||||
<Route
|
||||
render={({ location }) => {
|
||||
lastLocation = location;
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
debug = component.debug;
|
||||
}
|
||||
|
||||
describe('Legacy Topics View', () => {
|
||||
let nonCoursewareTopics;
|
||||
let coursewareTopics;
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
config: { enableInContext: true, provider: 'openedx', hasModerationPrivileges: true },
|
||||
});
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
lastLocation = undefined;
|
||||
});
|
||||
|
||||
async function setupMockResponse() {
|
||||
axiosMock.onGet(`${topicsApiUrl}${courseId}`)
|
||||
.reply(200, (Factory.buildList('topic', 1, null, {
|
||||
topicPrefix: 'noncourseware-topic',
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: 'general-topic',
|
||||
usageKey: '',
|
||||
courseware: false,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
}).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
|
||||
const state = store.getState();
|
||||
nonCoursewareTopics = selectNonCoursewareTopics(state);
|
||||
coursewareTopics = selectCoursewareTopics(state);
|
||||
}
|
||||
|
||||
it('A non-courseware topic should be clickable and should have a title', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
nonCoursewareTopics.forEach(async topic => {
|
||||
expect(screen.queryByText(topic.name)).toBeInTheDocument();
|
||||
const noncoursewareTopic = await screen.findByText(topic.name);
|
||||
fireEvent.click(noncoursewareTopic);
|
||||
});
|
||||
});
|
||||
|
||||
it('A non-courseware topic should be on the top of the list', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const topicsList = await screen.getByRole('list');
|
||||
const { firstChild } = topicsList;
|
||||
expect(within(firstChild).queryByText(nonCoursewareTopics[0].name)).toBeInTheDocument();
|
||||
expect(topicsList.children[1]).toBe(topicsList.querySelector('.divider'));
|
||||
});
|
||||
|
||||
it('A non-Courseware topic should have 3 stats and should be hoverable', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const topicsList = await screen.getByRole('list');
|
||||
const { firstChild } = topicsList;
|
||||
const statsList = await firstChild.getElementsByClassName('pgn__icon');
|
||||
|
||||
fireEvent.mouseOver(statsList[0]);
|
||||
|
||||
expect(statsList.length).toBe(3);
|
||||
expect(screen.queryByText('1 Discussion')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Section groups should be listed in the middle of the topics list.', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const topicsList = await screen.getByRole('list');
|
||||
const sectionGroups = await screen.getAllByTestId('section-group');
|
||||
|
||||
expect(topicsList.children[1]).toStrictEqual(topicsList.querySelector('.divider'));
|
||||
expect(sectionGroups.length).toBe(2);
|
||||
expect(topicsList.children[5]).toStrictEqual(topicsList.querySelector('.divider'));
|
||||
});
|
||||
|
||||
it('A section group should have only a title and required subsections.', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const sectionGroups = await screen.getAllByTestId('section-group');
|
||||
coursewareTopics.forEach(async (topic, index) => {
|
||||
const statsList = await sectionGroups[index].getElementsByClassName('pgn__icon');
|
||||
const subsectionGroups = await within(sectionGroups[index]).getAllByTestId('subsection-group');
|
||||
|
||||
expect(within(sectionGroups[index]).queryByText(topic.displayName)).toBeInTheDocument();
|
||||
expect(statsList).toHaveLength(0);
|
||||
expect(subsectionGroups).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('The subsection should have a title name, be clickable, and not have the stats', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const sectionGroups = await screen.getAllByTestId('section-group');
|
||||
coursewareTopics.forEach(async (topic, index) => {
|
||||
const subsectionGroups = await within(sectionGroups[index]).getAllByTestId('subsection-group');
|
||||
topic.children.forEach(async (subsection, i) => {
|
||||
const statsList = await subsectionGroups[i].getElementsByClassName('pgn__icon');
|
||||
|
||||
expect(await within(subsectionGroups[i]).queryByText(subsection.displayName)).toBeInTheDocument();
|
||||
const subsectionTopic = await screen.findByText(subsection.displayName);
|
||||
fireEvent.click(subsectionTopic);
|
||||
expect(statsList).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Subsection names should be clickable and redirected to the units lists', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const subsectionGroup = await screen.getAllByTestId('subsection-group');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(subsectionGroup[0]);
|
||||
// debug();
|
||||
});
|
||||
await waitFor(async () => {
|
||||
debug();
|
||||
// expect(screen.queryByText(coursewareTopics[0].children[0].name)).toBeInTheDocument();
|
||||
// const backButton = screen.getByRole('button', { type: 'button' });
|
||||
// expect(backButton).toBeInTheDocument();
|
||||
// const ele = await screen.getByLabelText('Back to topics list');
|
||||
// expect(ele).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,14 @@ import PropTypes from 'prop-types';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, IconButton } from '@edx/paragon';
|
||||
import { Icon, IconButton, Spinner } from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function BackButton({ intl, path, title }) {
|
||||
function BackButton({
|
||||
intl, path, title, loading,
|
||||
}) {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
@@ -24,7 +26,7 @@ function BackButton({ intl, path, title }) {
|
||||
alt={intl.formatMessage(messages.backAlt)}
|
||||
/>
|
||||
<div className="d-flex flex-fill justify-content-center align-items-center mr-4.5">
|
||||
{title}
|
||||
{loading ? <Spinner animation="border" variant="primary" size="sm" /> : title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-bottom border-light-400" />
|
||||
@@ -36,6 +38,11 @@ BackButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
path: PropTypes.shape({}).isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
};
|
||||
|
||||
BackButton.defaultProps = {
|
||||
loading: false,
|
||||
};
|
||||
|
||||
export default injectIntl(BackButton);
|
||||
|
||||
@@ -8,7 +8,7 @@ Factory.define('topic')
|
||||
.sequence('name', ['topicNamePrefix'], (idx, topicNamePrefix) => `${topicNamePrefix}-${idx}`)
|
||||
.sequence('usage-key', ['usageKey'], (idx, usageKey) => usageKey)
|
||||
.sequence('courseware', ['courseware'], (idx, courseware) => courseware)
|
||||
.attr('activeFlags', null, true)
|
||||
|
||||
.attr('thread_counts', ['discussionCount', 'questionCount'], (discCount, questCount) => {
|
||||
Factory.reset('thread-counts');
|
||||
return Factory.build('thread-counts', null, { discussionCount: discCount, questionCount: questCount });
|
||||
|
||||
@@ -6,7 +6,7 @@ import { RequestStatus } from '../../../data/constants';
|
||||
const topicsSlice = createSlice({
|
||||
name: 'inContextTopics',
|
||||
initialState: {
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
status: RequestStatus.IDLE,
|
||||
topics: [],
|
||||
coursewareTopics: [],
|
||||
nonCoursewareTopics: [],
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import TopicStats from '../../../components/TopicStats';
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { discussionsPath } from '../../utils';
|
||||
import messages from '../messages';
|
||||
@@ -55,6 +56,7 @@ function SectionBaseGroup({
|
||||
<div className="topic-name text-truncate">
|
||||
{subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
|
||||
</div>
|
||||
<TopicStats threadCounts={subsection?.threadCounts} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
||||
|
||||
import TopicStats from '../../../components/TopicStats';
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
|
||||
import { discussionsPath } from '../../utils';
|
||||
@@ -53,65 +54,11 @@ function Topic({
|
||||
{topic?.name || topic?.displayName || intl.formatMessage(messages.unnamedTopicSubCategories)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{intl.formatMessage(messages.discussions, {
|
||||
count: topic.threadCounts?.discussion || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={PostOutline} className="icon-size mr-2" />
|
||||
{topic.threadCounts?.discussion || 0}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{intl.formatMessage(messages.questions, {
|
||||
count: topic.threadCounts?.question || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={HelpOutline} className="icon-size mr-2" />
|
||||
{topic.threadCounts?.question || 0}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
{Boolean(canSeeReportedStats) && (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{Boolean(activeFlags) && (
|
||||
<span>
|
||||
{intl.formatMessage(messages.reported, { reported: activeFlags })}
|
||||
</span>
|
||||
)}
|
||||
{Boolean(inactiveFlags) && (
|
||||
<span>
|
||||
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<Icon src={Report} className="icon-size mr-2 text-danger" />
|
||||
{activeFlags}{Boolean(inactiveFlags) && `/${inactiveFlags}`}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
<TopicStats
|
||||
threadCounts={topic?.threadCounts}
|
||||
activeFlags={topic?.activeFlags}
|
||||
inactiveFlags={topic?.inactiveFlags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -23,7 +23,7 @@ import NoResults from './NoResults';
|
||||
import { PostLink } from './post';
|
||||
|
||||
function PostsList({
|
||||
posts, topics, intl, isTopicTab,
|
||||
posts, topics, intl, isTopicTab, parentIsLoading,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
@@ -85,10 +85,10 @@ function PostsList({
|
||||
|
||||
return (
|
||||
<>
|
||||
{postInstances(pinnedPosts)}
|
||||
{postInstances(unpinnedPosts)}
|
||||
{!parentIsLoading && postInstances(pinnedPosts)}
|
||||
{!parentIsLoading && postInstances(unpinnedPosts)}
|
||||
{posts?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? (
|
||||
<div className="d-flex justify-content-center p-4 mx-auto my-auto">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
@@ -110,6 +110,7 @@ PostsList.propTypes = {
|
||||
})),
|
||||
topics: PropTypes.arrayOf(PropTypes.string),
|
||||
isTopicTab: PropTypes.bool,
|
||||
parentIsLoading: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
@@ -117,6 +118,7 @@ PostsList.defaultProps = {
|
||||
posts: [],
|
||||
topics: undefined,
|
||||
isTopicTab: false,
|
||||
parentIsLoading: undefined,
|
||||
};
|
||||
|
||||
export default injectIntl(PostsList);
|
||||
|
||||
@@ -37,7 +37,7 @@ function CategoryPostsList({ category }) {
|
||||
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
|
||||
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
|
||||
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
|
||||
const posts = useSelector(selectTopicThreads(topicIds));
|
||||
const posts = useSelector(enableInContextSidebar ? selectAllThreads : selectTopicThreads(topicIds));
|
||||
return <PostsList posts={posts} topics={topicIds} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('PostsView', () => {
|
||||
.toHaveLength(topicThreadCount);
|
||||
// When grouping is enabled, topic 1 will be shown, but not otherwise.
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-1/i))
|
||||
.toHaveLength(grouping ? topicThreadCount : 0);
|
||||
.toHaveLength(grouping ? topicThreadCount : 2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ function Post({
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const { courseId } = useSelector((state) => state.courseTabs);
|
||||
const courseId = useSelector((state) => state.config.id);
|
||||
const topic = useSelector(selectTopic(post.topicId));
|
||||
const getTopicSubsection = useSelector(selectorForUnitSubsection);
|
||||
const topicContext = useSelector(selectTopicContext(post.topicId));
|
||||
@@ -126,7 +126,7 @@ function Post({
|
||||
<div className="d-flex mt-14px text-break font-style text-primary-500">
|
||||
<HTMLLoader htmlNode={post.renderedBody} componentId="post" cssClassName="html-loader" testId={post.id} />
|
||||
</div>
|
||||
{topicContext && topic && (
|
||||
{topicContext && (
|
||||
<div
|
||||
className={classNames('mt-14px mb-1 font-style font-size-12',
|
||||
{ 'w-100': enableInContextSidebar })}
|
||||
@@ -137,7 +137,7 @@ function Post({
|
||||
destination={topicContext.unitLink}
|
||||
target="_top"
|
||||
>
|
||||
{enableInContextSidebar
|
||||
{(topicContext && !topic)
|
||||
? (
|
||||
<>
|
||||
<span className="w-auto">{topicContext.chapterName}</span>
|
||||
|
||||
Reference in New Issue
Block a user