Compare commits
7 Commits
inf-799
...
saad/INF-7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3226c729b | ||
|
|
627390c4e3 | ||
|
|
1db94718c8 | ||
|
|
24d02350a8 | ||
|
|
f66cdda1b6 | ||
|
|
07b56e6070 | ||
|
|
be1a2ccaab |
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -16,7 +16,6 @@ import messages from '../messages';
|
||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
||||
import { postShape } from '../posts/post/proptypes';
|
||||
import { inBlackoutDateRange, useActions } from '../utils';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
function ActionsDropdown({
|
||||
intl,
|
||||
@@ -26,41 +25,54 @@ function ActionsDropdown({
|
||||
iconSize,
|
||||
dropDownIconSize,
|
||||
}) {
|
||||
const buttonRef = useRef();
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
const actions = useActions(commentOrPost);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const handleActions = (action) => {
|
||||
|
||||
const handleActions = useCallback((action) => {
|
||||
const actionFunction = actionHandlers[action];
|
||||
if (actionFunction) {
|
||||
actionFunction();
|
||||
} else {
|
||||
logError(`Unknown or unimplemented action ${action}`);
|
||||
}
|
||||
};
|
||||
}, [actionHandlers]);
|
||||
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
// Find and remove edit action if in blackout date range.
|
||||
if (inBlackoutDateRange(blackoutDateRange)) {
|
||||
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
|
||||
}
|
||||
|
||||
const onClickButton = useCallback(() => {
|
||||
setTarget(buttonRef.current);
|
||||
open();
|
||||
}, [open]);
|
||||
|
||||
const onCloseModal = useCallback(() => {
|
||||
close();
|
||||
setTarget(null);
|
||||
}, [close]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={open}
|
||||
onClick={onClickButton}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
disabled={disabled}
|
||||
size={iconSize}
|
||||
ref={setTarget}
|
||||
ref={buttonRef}
|
||||
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
|
||||
/>
|
||||
<div className="actions-dropdown">
|
||||
<ModalPopup
|
||||
onClose={close}
|
||||
onClose={onCloseModal}
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
placement={enableInContextSidebar ? 'left' : 'auto-start'}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div
|
||||
className="bg-white p-1 shadow d-flex flex-column"
|
||||
|
||||
@@ -28,6 +28,7 @@ const discussionPostId = 'thread-1';
|
||||
const questionPostId = 'thread-2';
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const reverseOrder = false;
|
||||
const enableInContextSidebar = false;
|
||||
let store;
|
||||
let axiosMock;
|
||||
let container;
|
||||
@@ -45,6 +46,7 @@ function mockAxiosReturnPagedComments() {
|
||||
requested_fields: 'profile_image',
|
||||
endorsed,
|
||||
reverse_order: reverseOrder,
|
||||
enable_in_context_sidebar: enableInContextSidebar,
|
||||
},
|
||||
})
|
||||
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||
|
||||
@@ -64,9 +64,9 @@ describe('LearnersView', () => {
|
||||
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
|
||||
.reply(() => [200, learnersData]);
|
||||
const learnersProfile = Factory.build('learnersProfile', {}, {
|
||||
username: ['leaner-1', 'leaner-2', 'leaner-3'],
|
||||
username: ['learner-1', 'learner-2', 'learner-3'],
|
||||
});
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=leaner-1,leaner-2,leaner-3`)
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=learner-1,learner-2,learner-3`)
|
||||
.reply(() => [200, learnersProfile.profiles]);
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Factory } from 'rosie';
|
||||
|
||||
Factory.define('learner')
|
||||
.sequence('id')
|
||||
.attr('username', ['id'], (id) => `leaner-${id}`)
|
||||
.attr('username', ['id'], (id) => `learner-${id}`)
|
||||
.option('activeFlags', null, null)
|
||||
.attr('active_flags', ['activeFlags'], (activeFlags) => activeFlags)
|
||||
.attrs({
|
||||
@@ -23,14 +23,8 @@ Factory.define('learnersResult')
|
||||
['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;
|
||||
const next = page < numPages ? page + 1 : null;
|
||||
const prev = page > 1 ? page - 1 : null;
|
||||
return {
|
||||
next,
|
||||
prev,
|
||||
@@ -65,7 +59,7 @@ Factory.define('learnersResult')
|
||||
);
|
||||
|
||||
Factory.define('learnersProfile')
|
||||
.option('usernames', null, ['leaner-1', 'leaner-2', 'leaner-3'])
|
||||
.option('usernames', null, ['learner-1', 'learner-2', 'learner-3'])
|
||||
.attr('profiles', ['usernames'], (usernames) => {
|
||||
const profiles = usernames.map((user) => ({
|
||||
account_privacy: 'private',
|
||||
|
||||
@@ -10,6 +10,8 @@ ensureConfig([
|
||||
|
||||
export const getCoursesApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
|
||||
export const getUserProfileApiUrl = () => `${getConfig().LMS_BASE_URL}/api/user/v1/accounts`;
|
||||
export const learnerPostsApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/learner/`;
|
||||
export const learnersApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/activity_stats/`;
|
||||
|
||||
/**
|
||||
* Fetches all the learners in the given course.
|
||||
@@ -18,8 +20,7 @@ export const getUserProfileApiUrl = () => `${getConfig().LMS_BASE_URL}/api/user/
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getLearners(courseId, params) {
|
||||
const url = `${getCoursesApiUrl()}${courseId}/activity_stats/`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url, { params });
|
||||
const { data } = await getAuthenticatedHttpClient().get(learnersApiUrl(courseId), { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -65,8 +66,6 @@ export async function getUserPosts(courseId, {
|
||||
countFlagged,
|
||||
cohort,
|
||||
} = {}) {
|
||||
const learnerPostsApiUrl = `${getCoursesApiUrl()}${courseId}/learner/`;
|
||||
|
||||
const params = snakeCaseObject({
|
||||
page,
|
||||
pageSize,
|
||||
@@ -81,6 +80,6 @@ export async function getUserPosts(courseId, {
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(learnerPostsApiUrl, { params });
|
||||
.get(learnerPostsApiUrl(courseId), { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
79
src/discussions/learners/data/api.test.jsx
Normal file
79
src/discussions/learners/data/api.test.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { setupLearnerMockResponse, setupPostsMockResponse } from '../test-utils';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId2 = 'course-v1:edX+TestX+Test_Course2';
|
||||
let axiosMock;
|
||||
|
||||
describe('Learner api test cases', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
it('Successfully get and store API response for the learner\'s list and learners posts in redux',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
const threads = await setupPostsMockResponse();
|
||||
|
||||
expect(learners.status).toEqual('successful');
|
||||
expect(Object.values(learners.learnerProfiles)).toHaveLength(3);
|
||||
expect(threads.status).toEqual('successful');
|
||||
expect(Object.values(threads.threadsById)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: 'statusUnread', search: 'Title', cohort: 'post' },
|
||||
{ status: 'statusUnanswered', search: 'Title', cohort: 'post' },
|
||||
{ status: 'statusReported', search: 'Title', cohort: 'post' },
|
||||
{ status: 'statusUnresponded', search: 'Title', cohort: 'post' },
|
||||
])('Successfully fetch user posts based on %s filters',
|
||||
async ({ status, search, cohort }) => {
|
||||
const threads = await setupPostsMockResponse({ filters: { status, search, cohort } });
|
||||
|
||||
expect(threads.status).toEqual('successful');
|
||||
expect(Object.values(threads.threadsById)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Failed to fetch learners', async () => {
|
||||
const learners = await setupLearnerMockResponse({ learnerCourseId: courseId2 });
|
||||
|
||||
expect(learners.status).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Denied to fetch learners', async () => {
|
||||
const learners = await setupLearnerMockResponse({ statusCode: 403 });
|
||||
|
||||
expect(learners.status).toEqual('denied');
|
||||
});
|
||||
|
||||
it('Failed to fetch learnerPosts', async () => {
|
||||
const threads = await setupPostsMockResponse({ learnerCourseId: courseId2 });
|
||||
|
||||
expect(threads.status).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Denied to fetch learnerPosts', async () => {
|
||||
const threads = await setupPostsMockResponse({ statusCode: 403 });
|
||||
|
||||
expect(threads.status).toEqual('denied');
|
||||
});
|
||||
});
|
||||
120
src/discussions/learners/data/redux.test.jsx
Normal file
120
src/discussions/learners/data/redux.test.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { setupLearnerMockResponse } from '../test-utils';
|
||||
import { setPostFilter, setSortedBy, setUsernameSearch } from './slices';
|
||||
import { fetchLearners } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
describe('Learner redux test cases', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('Successfully load initial states in redux', async () => {
|
||||
executeThunk(
|
||||
fetchLearners('course-v1:edX+DemoX+Demo_Course', { usernameSearch: 'learner-1' }),
|
||||
store.dispatch, store.getState,
|
||||
);
|
||||
const { learners } = store.getState();
|
||||
|
||||
expect(learners.status).toEqual('in-progress');
|
||||
expect(learners.learnerProfiles).toEqual({});
|
||||
expect(learners.pages).toHaveLength(0);
|
||||
expect(learners.nextPage).toBeNull();
|
||||
expect(learners.totalPages).toBeNull();
|
||||
expect(learners.totalLearners).toBeNull();
|
||||
expect(learners.sortedBy).toEqual('activity');
|
||||
expect(learners.usernameSearch).toBeNull();
|
||||
expect(learners.postFilter.postType).toEqual('all');
|
||||
expect(learners.postFilter.status).toEqual('statusAll');
|
||||
expect(learners.postFilter.orderBy).toEqual('lastActivityAt');
|
||||
expect(learners.postFilter.cohort).toEqual('');
|
||||
});
|
||||
|
||||
test('Successfully store a learner posts stats data as pages object in redux',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
const page = learners.pages[0];
|
||||
const statsObject = page[0];
|
||||
|
||||
expect(page).toHaveLength(3);
|
||||
expect(statsObject.responses).toEqual(3);
|
||||
expect(statsObject.threads).toEqual(1);
|
||||
expect(statsObject.replies).toEqual(0);
|
||||
});
|
||||
|
||||
test('Successfully store the nextPage, totalPages, totalLearners, and sortedBy data in redux',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
|
||||
expect(learners.nextPage).toEqual(2);
|
||||
expect(learners.totalPages).toEqual(2);
|
||||
expect(learners.totalLearners).toEqual(6);
|
||||
expect(learners.sortedBy).toEqual('activity');
|
||||
});
|
||||
|
||||
test('Successfully updated the learner\'s sort data in redux', async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
|
||||
expect(learners.sortedBy).toEqual('activity');
|
||||
expect(learners.pages[0]).toHaveLength(3);
|
||||
|
||||
await store.dispatch(setSortedBy('recency'));
|
||||
const updatedLearners = store.getState().learners;
|
||||
|
||||
expect(updatedLearners.sortedBy).toEqual('recency');
|
||||
expect(updatedLearners.pages).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Successfully updated the post-filter data in redux', async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
const filter = {
|
||||
...learners.postFilter,
|
||||
postType: 'discussion',
|
||||
};
|
||||
|
||||
expect(learners.postFilter.postType).toEqual('all');
|
||||
|
||||
await store.dispatch(setPostFilter(filter));
|
||||
const updatedLearners = store.getState().learners;
|
||||
|
||||
expect(updatedLearners.postFilter.postType).toEqual('discussion');
|
||||
expect(updatedLearners.pages).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Successfully update the learner\'s search query in redux when searching for a learner',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
|
||||
expect(learners.usernameSearch).toBeNull();
|
||||
|
||||
await store.dispatch(setUsernameSearch('learner-2'));
|
||||
const updatedLearners = store.getState().learners;
|
||||
|
||||
expect(updatedLearners.usernameSearch).toEqual('learner-2');
|
||||
});
|
||||
});
|
||||
81
src/discussions/learners/data/selector.test.jsx
Normal file
81
src/discussions/learners/data/selector.test.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getUserProfileApiUrl, learnersApiUrl } from './api';
|
||||
import {
|
||||
learnersLoadingStatus,
|
||||
selectLearnerNextPage,
|
||||
selectLearnerSorting,
|
||||
selectUsernameSearch,
|
||||
} from './selectors';
|
||||
import { fetchLearners } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const userProfileApiUrl = getUserProfileApiUrl();
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const username = 'abc123';
|
||||
const learnerCount = 6;
|
||||
let state;
|
||||
|
||||
describe('Learner selectors test cases', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username,
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(learnersApiUrl(courseId))
|
||||
.reply(() => [200, Factory.build('learnersResult', {}, {
|
||||
count: learnerCount,
|
||||
pageSize: 3,
|
||||
})]);
|
||||
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=learner-1,learner-2,learner-3`)
|
||||
.reply(() => [200, Factory.build('learnersProfile', {}, {
|
||||
username: ['learner-1', 'learner-2', 'learner-3'],
|
||||
}).profiles]);
|
||||
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
state = store.getState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('learnersLoadingStatus should return learners list loading status.', async () => {
|
||||
const status = learnersLoadingStatus()(state);
|
||||
expect(status).toEqual('successful');
|
||||
});
|
||||
|
||||
test('selectUsernameSearch should return a learner search query.', async () => {
|
||||
const userNameSearch = selectUsernameSearch()(state);
|
||||
expect(userNameSearch).toBeNull();
|
||||
});
|
||||
|
||||
test('selectLearnerSorting should return learner sortedBy.', async () => {
|
||||
const learnerSorting = selectLearnerSorting()(state);
|
||||
expect(learnerSorting).toEqual('activity');
|
||||
});
|
||||
|
||||
test('selectLearnerNextPage should return learners next page.', async () => {
|
||||
const learnerNextPage = selectLearnerNextPage()(state);
|
||||
expect(learnerNextPage).toEqual(2);
|
||||
});
|
||||
});
|
||||
52
src/discussions/learners/test-utils.js
Normal file
52
src/discussions/learners/test-utils.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { getUserProfileApiUrl, learnerPostsApiUrl, learnersApiUrl } from './data/api';
|
||||
import { fetchLearners, fetchUserPosts } from './data/thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
export async function setupLearnerMockResponse({
|
||||
learnerCourseId = courseId,
|
||||
statusCode = 200,
|
||||
learnerCount = 6,
|
||||
} = {}) {
|
||||
const store = initializeStore();
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(learnersApiUrl(learnerCourseId))
|
||||
.reply(() => [statusCode, Factory.build('learnersResult', {}, {
|
||||
count: learnerCount,
|
||||
pageSize: 3,
|
||||
})]);
|
||||
|
||||
axiosMock.onGet(`${getUserProfileApiUrl()}?username=learner-1,learner-2,learner-3`)
|
||||
.reply(() => [statusCode, Factory.build('learnersProfile', {}, {
|
||||
username: ['learner-1', 'learner-2', 'learner-3'],
|
||||
}).profiles]);
|
||||
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
return store.getState().learners;
|
||||
}
|
||||
|
||||
export async function setupPostsMockResponse({
|
||||
learnerCourseId = courseId,
|
||||
statusCode = 200,
|
||||
username = 'abc123',
|
||||
filters = { status: 'all' },
|
||||
} = {}) {
|
||||
const store = initializeStore();
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(learnerPostsApiUrl(learnerCourseId), { username, count_flagged: true })
|
||||
.reply(() => [statusCode, Factory.build('learnerPosts', {}, {
|
||||
abuseFlaggedCount: 1,
|
||||
})]);
|
||||
|
||||
await executeThunk(fetchUserPosts(courseId, { filters }), store.dispatch, store.getState);
|
||||
return store.getState().threads;
|
||||
}
|
||||
@@ -99,7 +99,7 @@ function PostCommentsView({ intl }) {
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding"
|
||||
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding on-focus"
|
||||
>
|
||||
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} />
|
||||
{!thread.closed && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { getApiBaseUrl } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
@@ -17,11 +18,13 @@ import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { fetchThread, fetchThreads } from '../posts/data/thunks';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { getCommentsApiUrl } from './data/api';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
import './data/__factories__';
|
||||
import '../topics/data/__factories__';
|
||||
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
const commentsApiUrl = getCommentsApiUrl();
|
||||
@@ -30,7 +33,9 @@ const discussionPostId = 'thread-1';
|
||||
const questionPostId = 'thread-2';
|
||||
const closedPostId = 'thread-2';
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
|
||||
const reverseOrder = false;
|
||||
const enableInContextSidebar = false;
|
||||
let store;
|
||||
let axiosMock;
|
||||
let testLocation;
|
||||
@@ -48,6 +53,7 @@ function mockAxiosReturnPagedComments() {
|
||||
requested_fields: 'profile_image',
|
||||
endorsed,
|
||||
reverse_order: reverseOrder,
|
||||
enable_in_context_sidebar: enableInContextSidebar,
|
||||
},
|
||||
})
|
||||
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||
@@ -83,6 +89,12 @@ function mockAxiosReturnPagedCommentsResponses() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getThreadAPIResponse(threadId, topicId) {
|
||||
axiosMock.onGet(`${threadsApiUrl}${discussionPostId}/`)
|
||||
.reply(200, Factory.build('thread', { id: threadId, topic_id: topicId }));
|
||||
await executeThunk(fetchThread(discussionPostId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
function renderComponent(postId) {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
@@ -107,6 +119,45 @@ function renderComponent(postId) {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('PostView', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(topicsApiUrl)
|
||||
.reply(200, {
|
||||
non_courseware_topics: Factory.buildList('topic', 1, {}, { topicPrefix: 'non-courseware-' }),
|
||||
courseware_topics: Factory.buildList('category', 1, {}, { name: 'courseware' }),
|
||||
});
|
||||
executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
it('should show Topic Info for non-courseware topics', async () => {
|
||||
await getThreadAPIResponse('thread-1', 'non-courseware-topic-1');
|
||||
renderComponent(discussionPostId);
|
||||
expect(await screen.findByText('Related to')).toBeInTheDocument();
|
||||
expect(await screen.findByText('non-courseware-topic 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Topic Info for courseware topics with category', async () => {
|
||||
await getThreadAPIResponse('thread-2', 'courseware-topic-2');
|
||||
|
||||
renderComponent('thread-2');
|
||||
expect(await screen.findByText('Related to')).toBeInTheDocument();
|
||||
expect(await screen.findByText('category-1 / courseware-topic 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadView', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@@ -43,28 +46,28 @@ function Comment({
|
||||
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
const {
|
||||
courseId,
|
||||
} = useContext(DiscussionContext);
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
|
||||
useEffect(() => {
|
||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||
if (hasChildren && !currentPage && showFullThread) {
|
||||
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
|
||||
}
|
||||
}, [comment.id]);
|
||||
|
||||
const actions = useActions({
|
||||
...comment,
|
||||
postType,
|
||||
});
|
||||
const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
const handleAbusedFlag = useCallback(() => {
|
||||
if (comment.abuseFlagged) {
|
||||
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
}, [comment.abuseFlagged, comment.id, dispatch, showReportConfirmation]);
|
||||
|
||||
const handleDeleteConfirmation = () => {
|
||||
dispatch(removeComment(comment.id));
|
||||
@@ -76,7 +79,7 @@ function Comment({
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
const actionHandlers = useMemo(() => ({
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: async () => {
|
||||
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
|
||||
@@ -84,7 +87,7 @@ function Comment({
|
||||
},
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
}), [showDeleteConfirmation, dispatch, comment.id, comment.endorsed, comment.threadId, courseId, handleAbusedFlag]);
|
||||
|
||||
const handleLoadMoreComments = () => (
|
||||
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
|
||||
@@ -119,7 +122,7 @@ function Comment({
|
||||
/>
|
||||
)}
|
||||
<EndorsedAlertBanner postType={postType} content={comment} />
|
||||
<div className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px" aria-level={5}>
|
||||
<div className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px" tabIndex="0">
|
||||
<HoverCard
|
||||
commentOrPost={comment}
|
||||
actionHandlers={actionHandlers}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TinyMCEEditor } from '../../../../components';
|
||||
import FormikErrorFeedback from '../../../../components/FormikErrorFeedback';
|
||||
import PostPreviewPane from '../../../../components/PostPreviewPane';
|
||||
import { useDispatchWithState } from '../../../../data/hooks';
|
||||
import { DiscussionContext } from '../../../common/context';
|
||||
import {
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
@@ -32,6 +33,7 @@ function CommentEditor({
|
||||
}) {
|
||||
const editorRef = useRef(null);
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
@@ -71,7 +73,7 @@ function CommentEditor({
|
||||
};
|
||||
await dispatch(editComment(comment.id, payload));
|
||||
} else {
|
||||
await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
|
||||
await dispatch(addComment(values.comment, comment.threadId, comment.parentId, enableInContextSidebar));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
if (editorRef.current) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -31,13 +31,13 @@ function Reply({
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
const handleAbusedFlag = useCallback(() => {
|
||||
if (reply.abuseFlagged) {
|
||||
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
}, [dispatch, reply.abuseFlagged, reply.id, showReportConfirmation]);
|
||||
|
||||
const handleDeleteConfirmation = () => {
|
||||
dispatch(removeComment(reply.id));
|
||||
@@ -49,7 +49,7 @@ function Reply({
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
const actionHandlers = useMemo(() => ({
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: () => dispatch(editComment(
|
||||
reply.id,
|
||||
@@ -58,7 +58,8 @@ function Reply({
|
||||
)),
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
}), [dispatch, handleAbusedFlag, reply.endorsed, reply.id, showDeleteConfirmation]);
|
||||
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
|
||||
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
|
||||
const hasAnyAlert = useAlertBannerVisible(reply);
|
||||
|
||||
@@ -16,6 +16,8 @@ export const getCommentsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussi
|
||||
* @param {EndorsementStatus} endorsed
|
||||
* @param {number=} page
|
||||
* @param {number=} pageSize
|
||||
* @param reverseOrder
|
||||
* @param enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getThreadComments(
|
||||
@@ -24,6 +26,7 @@ export async function getThreadComments(
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
enableInContextSidebar = false,
|
||||
} = {},
|
||||
) {
|
||||
const params = snakeCaseObject({
|
||||
@@ -33,6 +36,7 @@ export async function getThreadComments(
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
requestedFields: 'profile_image',
|
||||
enableInContextSidebar,
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
@@ -69,11 +73,14 @@ export async function getCommentResponses(
|
||||
* @param {string} comment Raw comment data to post.
|
||||
* @param {string} threadId Thread ID for thread in which to post comment.
|
||||
* @param {string=} parentId ID for a comments parent.
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function postComment(comment, threadId, parentId = null) {
|
||||
export async function postComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCommentsApiUrl(), snakeCaseObject({ threadId, raw_body: comment, parentId }));
|
||||
.post(getCommentsApiUrl(), snakeCaseObject({
|
||||
threadId, raw_body: comment, parentId, enableInContextSidebar,
|
||||
}));
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import { EndorsementStatus } from '../../../data/constants';
|
||||
import { useDispatchWithState } from '../../../data/hooks';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectThread } from '../../posts/data/selectors';
|
||||
import { markThreadAsRead } from '../../posts/data/thunks';
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ export function usePostComments(postId, endorsed = null) {
|
||||
const reverseOrder = useSelector(selectCommentSortOrder);
|
||||
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
|
||||
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
|
||||
const handleLoadMoreResponses = async () => {
|
||||
const params = {
|
||||
@@ -58,6 +60,7 @@ export function usePostComments(postId, endorsed = null) {
|
||||
endorsed,
|
||||
page: 1,
|
||||
reverseOrder,
|
||||
enableInContextSidebar,
|
||||
}));
|
||||
}, [postId, reverseOrder]);
|
||||
|
||||
|
||||
@@ -80,12 +80,15 @@ export function fetchThreadComments(
|
||||
page = 1,
|
||||
reverseOrder,
|
||||
endorsed = EndorsementStatus.DISCUSSION,
|
||||
enableInContextSidebar,
|
||||
} = {},
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchCommentsRequest());
|
||||
const data = await getThreadComments(threadId, { page, reverseOrder, endorsed });
|
||||
const data = await getThreadComments(threadId, {
|
||||
page, reverseOrder, endorsed, enableInContextSidebar,
|
||||
});
|
||||
dispatch(fetchCommentsSuccess({
|
||||
...normaliseComments(camelCaseObject(data)),
|
||||
endorsed,
|
||||
@@ -144,7 +147,7 @@ export function editComment(commentId, comment, action = null) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addComment(comment, threadId, parentId = null) {
|
||||
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(postCommentRequest({
|
||||
@@ -152,7 +155,7 @@ export function addComment(comment, threadId, parentId = null) {
|
||||
threadId,
|
||||
parentId,
|
||||
}));
|
||||
const data = await postComment(comment, threadId, parentId);
|
||||
const data = await postComment(comment, threadId, parentId, enableInContextSidebar);
|
||||
dispatch(postCommentSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
|
||||
@@ -160,9 +160,9 @@ describe('PostsView', () => {
|
||||
test('displays a list of posts in a topic', async () => {
|
||||
setupStore();
|
||||
await act(async () => {
|
||||
await renderComponent({ topicId: 'some-topic-1' });
|
||||
await renderComponent({ topicId: 'test-topic-1' });
|
||||
});
|
||||
expect(screen.getAllByText(/this is thread-\d+ in topic some-topic-1/i)).toHaveLength(Math.ceil(threadCount / 3));
|
||||
expect(screen.getAllByText(/this is thread-\d+ in topic test-topic-1/i)).toHaveLength(Math.ceil(threadCount / 3));
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
@@ -173,10 +173,10 @@ describe('PostsView', () => {
|
||||
blocks: {
|
||||
'test-usage-key': {
|
||||
type: 'vertical',
|
||||
topics: ['some-topic-2', 'some-topic-0'],
|
||||
topics: ['test-topic-2', 'test-topic-0'],
|
||||
parent: 'test-seq-key',
|
||||
},
|
||||
'test-seq-key': { type: 'sequential', topics: ['some-topic-0', 'some-topic-1', 'some-topic-2'] },
|
||||
'test-seq-key': { type: 'sequential', topics: ['test-topic-0', 'test-topic-1', 'test-topic-2'] },
|
||||
},
|
||||
},
|
||||
config: { groupAtSubsection: grouping, hasModerationPrivileges: true, provider: 'openedx' },
|
||||
@@ -185,12 +185,12 @@ describe('PostsView', () => {
|
||||
await renderComponent({ category: 'test-usage-key', enableInContextSidebar: true, p: true });
|
||||
});
|
||||
const topicThreadCount = Math.ceil(threadCount / 3);
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-2/i))
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic test-topic-2/i))
|
||||
.toHaveLength(topicThreadCount);
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-0/i))
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic test-topic-0/i))
|
||||
.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))
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic test-topic-1/i))
|
||||
.toHaveLength(grouping ? topicThreadCount : 2);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ Factory.define('thread')
|
||||
.sequence('rendered_body', (idx) => `Some contents for <b>thread number ${idx}</b>.`)
|
||||
.sequence('type', (idx) => (idx % 2 === 1 ? 'discussion' : 'question'))
|
||||
.sequence('pinned', idx => (idx < 3))
|
||||
.sequence('topic_id', idx => `some-topic-${(idx % 3)}`)
|
||||
.sequence('topic_id', idx => `test-topic-${(idx % 3)}`)
|
||||
.sequence('closed', idx => Boolean(idx % 3 === 2)) // Mark every 3rd post closed
|
||||
.attr('comment_list_url', ['id'], (threadId) => `http://test.site/api/discussion/v1/comments/?thread_id=${threadId}`)
|
||||
.attrs({
|
||||
|
||||
@@ -87,6 +87,7 @@ export async function getThread(threadId, courseId) {
|
||||
* @param {boolean} following Follow the thread after creating
|
||||
* @param {boolean} anonymous Should the thread be anonymous to all users
|
||||
* @param {boolean} anonymousToPeers Should the thread be anonymous to peers
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function postThread(
|
||||
@@ -101,6 +102,7 @@ export async function postThread(
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
} = {},
|
||||
enableInContextSidebar = false,
|
||||
) {
|
||||
const postData = snakeCaseObject({
|
||||
courseId,
|
||||
@@ -112,8 +114,8 @@ export async function postThread(
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
groupId: cohort,
|
||||
enableInContextSidebar,
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getThreadsApiUrl(), postData);
|
||||
return data;
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('Threads/Posts data layer tests', () => {
|
||||
expect(store.getState().threads.threadsById['thread-1'])
|
||||
.toHaveProperty('topicId');
|
||||
expect(store.getState().threads.threadsById['thread-1'].topicId)
|
||||
.toEqual('some-topic-1');
|
||||
.toEqual('test-topic-1');
|
||||
});
|
||||
|
||||
test('successfully handles thread creation', async () => {
|
||||
|
||||
@@ -204,6 +204,7 @@ export function createNewThread({
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
cohort,
|
||||
enableInContextSidebar,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
@@ -223,7 +224,7 @@ export function createNewThread({
|
||||
following,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
});
|
||||
}, enableInContextSidebar);
|
||||
dispatch(postThreadSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
|
||||
@@ -187,6 +187,7 @@ function PostEditor({
|
||||
anonymous: allowAnonymous ? values.anonymous : undefined,
|
||||
anonymousToPeers: allowAnonymousToPeers ? values.anonymousToPeers : undefined,
|
||||
cohort,
|
||||
enableInContextSidebar,
|
||||
}));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { useCallback, useContext, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, useToggle } from '@edx/paragon';
|
||||
|
||||
@@ -41,13 +42,14 @@ function Post({
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
||||
const handleAbusedFlag = () => {
|
||||
|
||||
const handleAbusedFlag = useCallback(() => {
|
||||
if (post.abuseFlagged) {
|
||||
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
}, [dispatch, post.abuseFlagged, post.id, showReportConfirmation]);
|
||||
|
||||
const handleDeleteConfirmation = async () => {
|
||||
await dispatch(removeThread(post.id));
|
||||
@@ -63,7 +65,7 @@ function Post({
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
const actionHandlers = useMemo(() => ({
|
||||
[ContentActions.EDIT_CONTENT]: () => history.push({
|
||||
...location,
|
||||
pathname: `${location.pathname}/edit`,
|
||||
@@ -81,17 +83,34 @@ function Post({
|
||||
[ContentActions.COPY_LINK]: () => { navigator.clipboard.writeText(`${window.location.origin}/${courseId}/posts/${post.id}`); },
|
||||
[ContentActions.PIN]: () => dispatch(updateExistingThread(post.id, { pinned: !post.pinned })),
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
}), [
|
||||
showDeleteConfirmation,
|
||||
history,
|
||||
location,
|
||||
post.closed,
|
||||
post.id,
|
||||
post.pinned,
|
||||
reasonCodesEnabled,
|
||||
dispatch,
|
||||
showClosePostModal,
|
||||
courseId,
|
||||
handleAbusedFlag,
|
||||
]);
|
||||
|
||||
const getTopicCategoryName = topicData => (
|
||||
topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId
|
||||
);
|
||||
|
||||
const getTopicInfo = topicData => (
|
||||
getTopicCategoryName(topicData) ? `${getTopicCategoryName(topicData)} / ${topicData.name}` : `${topicData.name}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="d-flex flex-column w-100 mw-100 post-card-comment"
|
||||
aria-level={5}
|
||||
data-testid={`post-${post.id}`}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex="0"
|
||||
>
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
@@ -126,7 +145,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 && (
|
||||
{(topicContext || topic) && (
|
||||
<div
|
||||
className={classNames('mt-14px mb-1 font-style font-size-12',
|
||||
{ 'w-100': enableInContextSidebar })}
|
||||
@@ -134,7 +153,7 @@ function Post({
|
||||
>
|
||||
<span className="text-gray-500" style={{ lineHeight: '20px' }}>{intl.formatMessage(messages.relatedTo)}{' '}</span>
|
||||
<Hyperlink
|
||||
destination={topicContext.unitLink}
|
||||
destination={topicContext ? topicContext.unitLink : `${getConfig().BASE_URL}/${courseId}/topics/${post.topicId}`}
|
||||
target="_top"
|
||||
>
|
||||
{(topicContext && !topic)
|
||||
@@ -147,7 +166,7 @@ function Post({
|
||||
<span className="w-auto">{topicContext.unitName}</span>
|
||||
</>
|
||||
)
|
||||
: `${getTopicCategoryName(topic)} / ${topic.name}`}
|
||||
: getTopicInfo(topic)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
{
|
||||
"navigation.course.tabs.label": "Course Material",
|
||||
"navigation.course.tabs.label": "Matériel de cours",
|
||||
"learn.course.tabs.navigation.overflow.menu": "Plus...",
|
||||
"discussions.topics.backAlt": "Back to topics list",
|
||||
"discussions.topics.backAlt": "Retour à la liste des sujets",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.unnamed.section.label": "Unnamed Section",
|
||||
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
|
||||
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
|
||||
"discussions.topics.title": "No topic exists",
|
||||
"discussions.topics.createTopic": "Please contact you admin to create a topic",
|
||||
"discussions.topics.reported": "{reported} signalé",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} signalé précédemment",
|
||||
"discussions.topics.find.label": "Rechercher des sujets",
|
||||
"discussions.topics.unnamed.section.label": "Section sans nom",
|
||||
"discussions.topics.unnamed.subsection.label": "Sous-section sans nom",
|
||||
"discussions.subtopics.unnamed.topic.label": "Sujet sans nom",
|
||||
"discussions.topics.title": "Aucun sujet n'existe",
|
||||
"discussions.topics.createTopic": "Veuillez contacter votre administrateur pour créer un sujet",
|
||||
"discussions.topics.nothing": "Nothing here yet",
|
||||
"discussions.topics.archived.label": "Archived",
|
||||
"discussions.topics.archived.label": "Archivé",
|
||||
"discussions.learner.reported": "{reported} signalé",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} signalé précédemment",
|
||||
"discussions.learner.lastLogin": "Dernier actif {lastActiveTime}",
|
||||
"discussions.learner.loadMostLearners": "Charger plus",
|
||||
"discussions.learner.back": "Retour",
|
||||
"discussions.learner.activityForLearner": "Activité pour {username}",
|
||||
"discussions.learner.mostActivity": "Most activity",
|
||||
"discussions.learner.reportedActivity": "Reported activity",
|
||||
"discussions.learner.recentActivity": "Recent activity",
|
||||
"discussions.learner.mostActivity": "La plupart des activités",
|
||||
"discussions.learner.reportedActivity": "Activité signalée",
|
||||
"discussions.learner.recentActivity": "Activité récente",
|
||||
"discussions.learner.sortFilterStatus": "All learners sorted by {sort, select,\n flagged {reported activity}\n activity {most activity}\n other {{sort}}\n }",
|
||||
"discussion.learner.allActivity": "All activity",
|
||||
"discussion.learner.allActivity": "Toutes les activités",
|
||||
"discussion.learner.posts": "Posts",
|
||||
"discussions.actions.button.alt": "Actions menu",
|
||||
"discussions.actions.copylink": "Copy link",
|
||||
"discussions.actions.button.alt": "Menu Actions",
|
||||
"discussions.actions.copylink": "Copier le lien",
|
||||
"discussions.actions.edit": "Modifier",
|
||||
"discussions.actions.pin": "Épingler",
|
||||
"discussions.actions.unpin": "Unpin",
|
||||
"discussions.actions.delete": "Delete",
|
||||
"discussions.confirmation.button.confirm": "Confirm",
|
||||
"discussions.actions.close": "Close",
|
||||
"discussions.actions.reopen": "Reopen",
|
||||
"discussions.actions.unpin": "Ne plus épingler",
|
||||
"discussions.actions.delete": "Supprimer",
|
||||
"discussions.confirmation.button.confirm": "Confirmer",
|
||||
"discussions.actions.close": "Fermer",
|
||||
"discussions.actions.reopen": "Réouvrir",
|
||||
"discussions.actions.report": "Report",
|
||||
"discussions.actions.unreport": "Unreport",
|
||||
"discussions.actions.endorse": "Endorse",
|
||||
"discussions.actions.unendorse": "Unendorse",
|
||||
"discussions.actions.markAnswered": "Mark as answered",
|
||||
"discussions.actions.endorse": "Approuver",
|
||||
"discussions.actions.unendorse": "Ne plus approuver",
|
||||
"discussions.actions.markAnswered": "Marquer comme répondu",
|
||||
"discussions.actions.unMarkAnswered": "Unmark as answered",
|
||||
"discussions.modal.confirmation.button.cancel": "Cancel",
|
||||
"discussions.modal.confirmation.button.cancel": "Annuler",
|
||||
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
|
||||
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
|
||||
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
|
||||
"discussions.empty.topic": "All discussion activity for this topic will show up here.",
|
||||
"discussions.empty.title": "Nothing here yet",
|
||||
"discussions.empty.title": "Rien ici encore",
|
||||
"discussions.empty.noPostSelected": "No post selected",
|
||||
"discussions.empty.noTopicSelected": "No topic selected",
|
||||
"discussions.sidebar.noResultsFound": "No results found",
|
||||
"discussions.empty.noTopicSelected": "Aucun sujet sélectionné",
|
||||
"discussions.sidebar.noResultsFound": "Aucun résultat trouvé",
|
||||
"discussions.sidebar.differentKeywords": "Try searching different keywords",
|
||||
"discussions.sidebar.removeKeywords": "Try searching different keywords or removing some filters",
|
||||
"discussions.sidebar.removeKeywordsOnly": "Try searching different keywords",
|
||||
"discussions.sidebar.removeFilters": "Try removing some filters",
|
||||
"discussions.empty.iconAlt": "Empty",
|
||||
"discussions.authors.label.staff": "Staff",
|
||||
"discussions.empty.iconAlt": "Vide",
|
||||
"discussions.authors.label.staff": "Équipe",
|
||||
"discussions.authors.label.ta": "TA",
|
||||
"discussions.learner.loadMostPosts": "Load more posts",
|
||||
"discussions.post.anonymous.author": "anonymous",
|
||||
|
||||
@@ -433,7 +433,7 @@ header {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.on-focus:focus-visible {
|
||||
.on-focus:focus-within {
|
||||
outline: 2px solid black;
|
||||
}
|
||||
|
||||
@@ -442,6 +442,8 @@ header {
|
||||
}
|
||||
|
||||
.post-card-comment {
|
||||
outline: none;
|
||||
|
||||
&:not(:hover),
|
||||
&:not(:focus) {
|
||||
.hover-card {
|
||||
@@ -450,7 +452,7 @@ header {
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
&:focus-within {
|
||||
.hover-card {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export const executeThunk = async (thunk, dispatch, getState) => {
|
||||
await thunk(dispatch, getState);
|
||||
await new Promise(setImmediate);
|
||||
|
||||
Reference in New Issue
Block a user