Compare commits

..

1 Commits

Author SHA1 Message Date
ayeshoali
07ec3486a0 fix: fixed post coment actions menu accessibilty for keyboard 2023-03-01 18:09:35 +05:00
48 changed files with 347 additions and 1060 deletions

View File

@@ -5,6 +5,8 @@ transifex_langs = "ar,fr,es_419,zh_CN,tr_TR,pl,fr_CA,fr_FR,de_DE,it_IT"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl

View File

@@ -72,7 +72,6 @@ function Search({ intl }) {
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
data-testid="search-icon"
/>
</span>
</SearchField.Advanced>

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
@@ -16,6 +16,7 @@ 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,
@@ -25,54 +26,41 @@ function ActionsDropdown({
iconSize,
dropDownIconSize,
}) {
const buttonRef = useRef();
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const actions = useActions(commentOrPost);
const handleActions = useCallback((action) => {
const { enableInContextSidebar } = useContext(DiscussionContext);
const handleActions = (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={onClickButton}
onClick={open}
alt={intl.formatMessage(messages.actionsAlt)}
src={MoreHoriz}
iconAs={Icon}
disabled={disabled}
size={iconSize}
ref={buttonRef}
ref={setTarget}
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
/>
<div className="actions-dropdown">
<ModalPopup
onClose={onCloseModal}
onClose={close}
positionRef={target}
isOpen={isOpen}
placement="bottom-end"
placement={enableInContextSidebar ? 'left' : 'auto-start'}
>
<div
className="bg-white p-1 shadow d-flex flex-column"

View File

@@ -27,8 +27,7 @@ const threadsApiUrl = getThreadsApiUrl();
const discussionPostId = 'thread-1';
const questionPostId = 'thread-2';
const courseId = 'course-v1:edX+TestX+Test_Course';
const reverseOrder = true;
const enableInContextSidebar = false;
const reverseOrder = false;
let store;
let axiosMock;
let container;
@@ -46,7 +45,6 @@ function mockAxiosReturnPagedComments() {
requested_fields: 'profile_image',
endorsed,
reverse_order: reverseOrder,
enable_in_context_sidebar: enableInContextSidebar,
},
})
.reply(200, Factory.build('commentsResult', { can_delete: true }, {

View File

@@ -8,13 +8,14 @@ ensureConfig([
], 'Posts API service');
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
export const getDiscussionsConfigUrl = (courseId) => `${getCourseConfigApiUrl()}${courseId}/`;
/**
* Get discussions course config
* @param {string} courseId
*/
export async function getDiscussionsConfig(courseId) {
const { data } = await getAuthenticatedHttpClient().get(getDiscussionsConfigUrl(courseId));
const url = `${getCourseConfigApiUrl()}${courseId}/`;
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
}
@@ -23,7 +24,7 @@ export async function getDiscussionsConfig(courseId) {
* @param {string} courseId
*/
export async function getDiscussionsSettings(courseId) {
const url = `${getDiscussionsConfigUrl(courseId)}settings`;
const url = `${getCourseConfigApiUrl()}${courseId}/settings`;
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
}

View File

@@ -217,7 +217,7 @@ export const useTourConfiguration = (intl) => {
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
endButtonText: intl.formatMessage(messages.endButtonText),
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
enabled: tour && Boolean(tour.showTour && !enableInContextSidebar),
onDismiss: () => dispatch(updateTourShowStatus(tour.id)),
onEnd: () => dispatch(updateTourShowStatus(tour.id)),
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],

View File

@@ -1,8 +1,6 @@
import React from 'react';
import {
fireEvent, render, screen, waitFor, within,
} from '@testing-library/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';
@@ -13,13 +11,11 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { PostActionsBar } from '../../components';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { getDiscussionsConfigUrl } from '../data/api';
import { getCourseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import { getUserProfileApiUrl, learnersApiUrl } from './data/api';
import { getCoursesApiUrl, getUserProfileApiUrl } from './data/api';
import { fetchLearners } from './data/thunks';
import LearnersView from './LearnersView';
@@ -27,31 +23,27 @@ import './data/__factories__';
let store;
let axiosMock;
const coursesApiUrl = getCoursesApiUrl();
const courseConfigApiUrl = getCourseConfigApiUrl();
const userProfileApiUrl = getUserProfileApiUrl();
const courseId = 'course-v1:edX+TestX+Test_Course';
let container;
function renderComponent() {
const wrapper = render(
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{
page: 'learners',
}}
>
<MemoryRouter initialEntries={[`/${courseId}/`]}>
<Route path="/:courseId/">
<PostActionsBar />
<LearnersView />
</Route>
</MemoryRouter>
</DiscussionContext.Provider>
<MemoryRouter initialEntries={[`/${courseId}/`]}>
<Route path="/:courseId/">
<LearnersView />
</Route>
</MemoryRouter>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
}
describe('LearnersView', () => {
const learnerCount = 3;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
@@ -61,190 +53,41 @@ describe('LearnersView', () => {
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
Factory.resetAll();
});
async function setUpLearnerMockResponse(
count = 3,
pageSize = 6,
page = 1,
username = ['learner-1', 'learner-2', 'learner-3'],
searchText,
) {
Factory.resetAll();
const learnersData = Factory.build('learnersResult', {}, {
count,
pageSize,
page,
count: learnerCount,
pageSize: 6,
});
axiosMock.onGet(learnersApiUrl(courseId))
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
.reply(() => [200, learnersData]);
axiosMock.onGet(`${getUserProfileApiUrl()}?username=${username.join()}`)
.reply(() => [200, Factory.build('learnersProfile', {}, {
username,
}).profiles]);
await executeThunk(fetchLearners(courseId, { usernameSearch: searchText }), store.dispatch, store.getState);
}
async function assignPrivilages() {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
learners_tab_enabled: true,
user_is_privileged: true,
const learnersProfile = Factory.build('learnersProfile', {}, {
username: ['leaner-1', 'leaner-2', 'leaner-3'],
});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
}
test('Learners tab is disabled by default', async () => {
await setUpLearnerMockResponse();
await renderComponent();
expect(screen.queryByText('learner-1')).not.toBeInTheDocument();
axiosMock.onGet(`${userProfileApiUrl}?username=leaner-1,leaner-2,leaner-3`)
.reply(() => [200, learnersProfile.profiles]);
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
});
test('Learners tab is enabled', async () => {
await setUpLearnerMockResponse();
await assignPrivilages();
await waitFor(() => {
renderComponent();
});
expect(screen.queryByText('learner-1')).toBeInTheDocument();
});
test('Most activity should be selected by default for the non-moderator role.', async () => {
await setUpLearnerMockResponse();
await renderComponent();
const filterBar = container.querySelector('.collapsible-trigger');
await act(async () => {
fireEvent.click(filterBar);
});
await waitFor(() => {
const mostActivity = screen.getByTestId('activity selected');
expect(mostActivity).toBeInTheDocument();
});
});
it.each([
{ searchBy: 'sort-recency', result: 0 },
{ searchBy: 'sort-activity', result: 3 },
])('successfully display learners by %s.', async ({ searchBy, result }) => {
await setUpLearnerMockResponse();
await assignPrivilages();
await renderComponent();
const filterBar = container.querySelector('.collapsible-trigger');
await act(async () => {
fireEvent.click(filterBar);
});
await waitFor(async () => {
const activity = container.querySelector(`#${searchBy}`);
describe('Basic', () => {
test('Learners tab is disabled by default', async () => {
await act(async () => {
fireEvent.click(activity);
await renderComponent();
});
await waitFor(() => {
const learners = container.querySelectorAll('.discussion-post');
expect(learners).toHaveLength(result);
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,
});
});
});
it('should display a learner\'s list.', async () => {
await setUpLearnerMockResponse();
await assignPrivilages();
await waitFor(() => {
renderComponent();
});
const learners = await container.querySelectorAll('.discussion-post');
const firstLearner = learners.item(0);
const learnerAvatar = firstLearner.querySelector('[alt=learner-1]');
const learnerTitle = within(firstLearner).queryByText('learner-1');
const stats = firstLearner.querySelectorAll('.icon-size');
expect(learners).toHaveLength(3);
expect(learnerAvatar).toBeInTheDocument();
expect(learnerTitle).toBeInTheDocument();
expect(stats).toHaveLength(2);
});
it.each([
{
searchText: 'hello world',
output: 'Showing 0 results for',
learnersCount: 0,
username: [],
},
{
searchText: 'learner',
output: 'Showing 2 results for',
learnersCount: 2,
username:
['learner-1', 'learner-2'],
},
])('should have a search bar with a clear button and \'$output\' results found text.',
async ({
searchText, output, learnersCount, username,
}) => {
await setUpLearnerMockResponse();
await assignPrivilages();
await renderComponent();
const searchField = within(container).getByPlaceholderText('Search learners');
const searchButton = within(container).getByTestId('search-icon');
await fireEvent.change(searchField, { target: { value: searchText } });
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await act(async () => {
fireEvent.click(searchButton);
setUpLearnerMockResponse(learnersCount, learnersCount, 1, username, searchText);
});
await waitFor(() => {
const clearButton = within(container).queryByText('Clear results');
const searchMessage = within(container).queryByText(`${output} "${searchText}"`);
const leaners = container.querySelectorAll('.discussion-post') ?? [];
expect(searchMessage).toBeInTheDocument();
expect(clearButton).toBeInTheDocument();
expect(leaners).toHaveLength(learnersCount);
await renderComponent();
});
});
test('When click on the clear button it should move to a list of all learners.', async () => {
await setUpLearnerMockResponse();
await assignPrivilages();
await renderComponent();
const searchField = within(container).getByPlaceholderText('Search learners');
const searchButton = within(container).getByTestId('search-icon');
let clearButton;
await fireEvent.change(searchField, { target: { value: 'learner' } });
await act(async () => {
fireEvent.click(searchButton);
setUpLearnerMockResponse(2, 2, 1, ['learner-1', 'learner-2'], 'learner');
});
await waitFor(() => {
clearButton = within(container).queryByText('Clear results');
});
await act(async () => fireEvent.click(clearButton));
await waitFor(() => {
setUpLearnerMockResponse();
});
const learners = container.querySelectorAll('.discussion-post');
expect(learners).toHaveLength(3);
});
});

View File

@@ -2,7 +2,7 @@ import { Factory } from 'rosie';
Factory.define('learner')
.sequence('id')
.attr('username', ['id'], (id) => `learner-${id}`)
.attr('username', ['id'], (id) => `leaner-${id}`)
.option('activeFlags', null, null)
.attr('active_flags', ['activeFlags'], (activeFlags) => activeFlags)
.attrs({
@@ -13,9 +13,9 @@ Factory.define('learner')
});
Factory.define('learnersResult')
.option('count', null)
.option('page', null)
.option('pageSize', null)
.option('count', null, 3)
.option('page', null, 1)
.option('pageSize', null, 5)
.option('courseId', null, 'course-v1:Test+TestX+Test_Course')
.option('activeFlags', null, 0)
.attr(
@@ -23,8 +23,14 @@ Factory.define('learnersResult')
['courseId', 'count', 'page', 'pageSize'],
(courseId, count, page, pageSize) => {
const numPages = Math.ceil(count / pageSize);
const next = page < numPages ? page + 1 : null;
const prev = page > 1 ? page - 1 : null;
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,
@@ -59,7 +65,7 @@ Factory.define('learnersResult')
);
Factory.define('learnersProfile')
.option('usernames', null, ['learner-1', 'learner-2', 'learner-3'])
.option('usernames', null, ['leaner-1', 'leaner-2', 'leaner-3'])
.attr('profiles', ['usernames'], (usernames) => {
const profiles = usernames.map((user) => ({
account_privacy: 'private',

View File

@@ -10,8 +10,6 @@ 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.
@@ -20,7 +18,8 @@ export const learnersApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/ac
* @returns {Promise<{}>}
*/
export async function getLearners(courseId, params) {
const { data } = await getAuthenticatedHttpClient().get(learnersApiUrl(courseId), { params });
const url = `${getCoursesApiUrl()}${courseId}/activity_stats/`;
const { data } = await getAuthenticatedHttpClient().get(url, { params });
return data;
}
@@ -66,6 +65,8 @@ export async function getUserPosts(courseId, {
countFlagged,
cohort,
} = {}) {
const learnerPostsApiUrl = `${getCoursesApiUrl()}${courseId}/learner/`;
const params = snakeCaseObject({
page,
pageSize,
@@ -80,6 +81,6 @@ export async function getUserPosts(courseId, {
});
const { data } = await getAuthenticatedHttpClient()
.get(learnerPostsApiUrl(courseId), { params });
.get(learnerPostsApiUrl, { params });
return data;
}

View File

@@ -1,79 +0,0 @@
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');
});
});

View File

@@ -1,120 +0,0 @@
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');
});
});

View File

@@ -1,81 +0,0 @@
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);
});
});

View File

@@ -24,7 +24,7 @@ const ActionItem = ({
<label
htmlFor={id}
className="focus border-bottom-0 d-flex align-items-center w-100 py-2 m-0 font-weight-500 filter-menu"
data-testid={value === selected ? `${value} selected` : null}
data-testid={value === selected ? 'selected' : null}
style={{ cursor: 'pointer' }}
aria-checked={value === selected}
>

View File

@@ -1,52 +0,0 @@
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;
}

View File

@@ -1,5 +1,6 @@
import React, { useContext, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { useHistory, useLocation } from 'react-router-dom';
@@ -9,7 +10,9 @@ import {
} from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import { EndorsementStatus, PostsPages, ThreadType } from '../../data/constants';
import {
EndorsementStatus, PostsPages, RequestStatus, ThreadType,
} from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks';
import { DiscussionContext } from '../common/context';
import { useIsOnDesktop } from '../data/hooks';
@@ -21,12 +24,14 @@ import { ResponseEditor } from './comments/comment';
import CommentsSort from './comments/CommentsSort';
import CommentsView from './comments/CommentsView';
import { useCommentsCount, usePost } from './data/hooks';
import { selectCommentsStatus } from './data/selectors';
import messages from './messages';
function PostCommentsView({ intl }) {
const [isLoading, submitDispatch] = useDispatchWithState();
const { postId } = useParams();
const thread = usePost(postId);
const commentsStatus = useSelector(selectCommentsStatus);
const commentsCount = useCommentsCount(postId);
const history = useHistory();
const location = useLocation();
@@ -35,6 +40,7 @@ function PostCommentsView({ intl }) {
const {
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
} = useContext(DiscussionContext);
const enableCommentsSort = false;
useEffect(() => {
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
@@ -93,7 +99,7 @@ function PostCommentsView({ intl }) {
)
)}
<div
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding on-focus"
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding"
>
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} />
{!thread.closed && (
@@ -104,7 +110,7 @@ function PostCommentsView({ intl }) {
/>
)}
</div>
{!!commentsCount && <CommentsSort />}
{!!commentsCount && commentsStatus === RequestStatus.SUCCESSFUL && enableCommentsSort && <CommentsSort />}
{thread.type === ThreadType.DISCUSSION && (
<CommentsView
postId={postId}

View File

@@ -10,7 +10,6 @@ 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';
@@ -18,18 +17,11 @@ import { getCourseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThread, fetchThreads } from '../posts/data/thunks';
import { fetchCourseTopics } from '../topics/data/thunks';
import { getDiscussionTourUrl } from '../tours/data/api';
import { selectTours } from '../tours/data/selectors';
import { fetchDiscussionTours } from '../tours/data/thunks';
import discussionTourFactory from '../tours/data/tours.factory';
import { fetchThreads } from '../posts/data/thunks';
import { getCommentsApiUrl } from './data/api';
import { removeComment } from './data/thunks';
import '../posts/data/__factories__';
import './data/__factories__';
import '../topics/data/__factories__';
const courseConfigApiUrl = getCourseConfigApiUrl();
const commentsApiUrl = getCommentsApiUrl();
@@ -38,14 +30,10 @@ 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 = true;
const enableInContextSidebar = false;
const reverseOrder = false;
let store;
let axiosMock;
let testLocation;
let container;
let unmount;
function mockAxiosReturnPagedComments() {
[null, false, true].forEach(endorsed => {
@@ -60,7 +48,6 @@ function mockAxiosReturnPagedComments() {
requested_fields: 'profile_image',
endorsed,
reverse_order: reverseOrder,
enable_in_context_sidebar: enableInContextSidebar,
},
})
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
@@ -82,7 +69,6 @@ function mockAxiosReturnPagedCommentsResponses() {
page: undefined,
page_size: undefined,
requested_fields: 'profile_image',
reverse_order: true,
};
for (let page = 1; page <= 2; page++) {
@@ -97,12 +83,6 @@ 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">
@@ -124,49 +104,9 @@ function renderComponent(postId) {
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
unmount = wrapper.unmount;
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({
@@ -741,87 +681,19 @@ describe('ThreadView', () => {
});
});
describe('For comments replies', () => {
describe('for comments replies', () => {
it('shows delete confirmation modal', async () => {
renderComponent(discussionPostId);
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
await act(async () => { fireEvent.click(within(reply).getByRole('button', { name: /actions menu/i })); });
await act(async () => { fireEvent.click(screen.queryByRole('button', { name: /Delete/i })); });
await act(async () => {
fireEvent.click(
within(reply).getByRole('button', { name: /actions menu/i }),
);
});
await act(async () => {
fireEvent.click(screen.queryByRole('button', { name: /Delete/i }));
});
expect(screen.queryByRole('dialog', { name: /Delete/i, exact: false })).toBeInTheDocument();
});
});
describe('for comments sort', () => {
const getCommentSortDropdown = async () => {
renderComponent(discussionPostId);
await waitFor(() => screen.findByTestId('comment-comment-1'));
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Newest first/i })); });
return waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup'));
};
it('should show sort dropdown if there are endorse or unendorsed comments', async () => {
renderComponent(discussionPostId);
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
const sortWrapper = container.querySelector('.comments-sort');
const sortDropDown = within(sortWrapper).getByRole('button', { name: /Newest first/i });
expect(comment).toBeInTheDocument();
expect(sortDropDown).toBeInTheDocument();
});
it('should not show sort dropdown if there is no response', async () => {
const commentId = 'comment-1';
renderComponent(discussionPostId);
await waitFor(() => screen.findByTestId('comment-comment-1'));
axiosMock.onDelete(`${commentsApiUrl}${commentId}/`).reply(201);
await executeThunk(removeComment(commentId, discussionPostId), store.dispatch, store.getState);
expect(await waitFor(() => screen.findByText('No responses', { exact: true }))).toBeInTheDocument();
expect(container.querySelector('.comments-sort')).not.toBeInTheDocument();
});
it('should have only two options', async () => {
const dropdown = await getCommentSortDropdown();
expect(dropdown).toBeInTheDocument();
expect(await within(dropdown).getAllByRole('button')).toHaveLength(2);
});
it('should be selected Newest first and auto focus', async () => {
const dropdown = await getCommentSortDropdown();
expect(within(dropdown).getByRole('button', { name: /Newest first/i })).toBeInTheDocument();
expect(within(dropdown).getByRole('button', { name: /Newest first/i })).toHaveFocus();
expect(within(dropdown).getByRole('button', { name: /Oldest first/i })).not.toHaveFocus();
});
test('successfully handles sort state update', async () => {
const dropdown = await getCommentSortDropdown();
expect(store.getState().comments.sortOrder).toBeTruthy();
await act(async () => { fireEvent.click(within(dropdown).getByRole('button', { name: /Oldest first/i })); });
expect(store.getState().comments.sortOrder).toBeFalsy();
});
test('successfully handles tour state update', async () => {
const tourName = 'response_sort';
await axiosMock.onGet(getDiscussionTourUrl(), {}).reply(200, [discussionTourFactory.build({ tourName })]);
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
renderComponent(discussionPostId);
await waitFor(() => screen.findByTestId('comment-comment-1'));
const responseSortTour = () => selectTours(store.getState()).find(item => item.tourName === 'response_sort');
expect(responseSortTour().enabled).toBeTruthy();
await unmount();
expect(responseSortTour().enabled).toBeFalsy();
});
});
});

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -6,43 +6,31 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Dropdown, ModalPopup, useToggle,
} from '@edx/paragon';
import { ExpandLess, ExpandMore } from '@edx/paragon/icons';
import {
ExpandLess, ExpandMore,
} from '@edx/paragon/icons';
import { updateUserDiscussionsTourByName } from '../../tours/data';
import { selectCommentSortOrder } from '../data/selectors';
import { setCommentSortOrder } from '../data/slices';
import messages from '../messages';
function CommentSortDropdown({ intl }) {
function CommentSortDropdown({
intl,
}) {
const dispatch = useDispatch();
const sortedOrder = useSelector(selectCommentSortOrder);
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const handleActions = (reverseOrder) => {
close();
dispatch(setCommentSortOrder(reverseOrder));
};
const enableCommentsSortTour = useCallback((enabled) => {
const data = {
enabled,
tourName: 'response_sort',
};
dispatch(updateUserDiscussionsTourByName(data));
}, []);
useEffect(() => {
enableCommentsSortTour(true);
return () => {
enableCommentsSortTour(false);
};
}, []);
return (
<>
<div className="comments-sort d-flex justify-content-end mx-4 mt-2">
<Button
id="comment-sort"
alt={intl.formatMessage(messages.actionsAlt)}
ref={setTarget}
variant="tertiary"

View File

@@ -1,7 +1,4 @@
import React, {
useCallback,
useContext, useEffect, useMemo, useState,
} from 'react';
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
@@ -19,12 +16,7 @@ import { useUserCanAddThreadInBlackoutDate } from '../../../data/hooks';
import { fetchThread } from '../../../posts/data/thunks';
import LikeButton from '../../../posts/post/LikeButton';
import { useActions } from '../../../utils';
import {
selectCommentCurrentPage,
selectCommentHasMorePages,
selectCommentResponses,
selectCommentSortOrder,
} from '../../data/selectors';
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../../data/selectors';
import { editComment, fetchCommentResponses, removeComment } from '../../data/thunks';
import messages from '../../messages';
import CommentEditor from './CommentEditor';
@@ -51,32 +43,28 @@ function Comment({
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const { courseId } = useContext(DiscussionContext);
const sortedOrder = useSelector(selectCommentSortOrder);
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 && showFullThread) {
dispatch(fetchCommentResponses(comment.id, {
page: 1,
reverseOrder: sortedOrder,
}));
if (hasChildren && !currentPage && showFullThread) {
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
}
}, [comment.id, sortedOrder]);
}, [comment.id]);
const actions = useActions({
...comment,
postType,
});
const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
const handleAbusedFlag = useCallback(() => {
const handleAbusedFlag = () => {
if (comment.abuseFlagged) {
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
} else {
showReportConfirmation();
}
}, [comment.abuseFlagged, comment.id, dispatch, showReportConfirmation]);
};
const handleDeleteConfirmation = () => {
dispatch(removeComment(comment.id));
@@ -88,7 +76,7 @@ function Comment({
hideReportConfirmation();
};
const actionHandlers = useMemo(() => ({
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: async () => {
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
@@ -96,13 +84,10 @@ 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,
reverseOrder: sortedOrder,
}))
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
);
return (

View File

@@ -13,7 +13,6 @@ 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,
@@ -33,7 +32,6 @@ 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);
@@ -73,7 +71,7 @@ function CommentEditor({
};
await dispatch(editComment(comment.id, payload));
} else {
await dispatch(addComment(values.comment, comment.threadId, comment.parentId, enableInContextSidebar));
await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
}
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
if (editorRef.current) {

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { 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 = useCallback(() => {
const handleAbusedFlag = () => {
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 = useMemo(() => ({
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: () => dispatch(editComment(
reply.id,
@@ -58,8 +58,7 @@ 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);

View File

@@ -16,8 +16,6 @@ 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(
@@ -26,7 +24,6 @@ export async function getThreadComments(
page,
pageSize,
reverseOrder,
enableInContextSidebar = false,
} = {},
) {
const params = snakeCaseObject({
@@ -36,7 +33,6 @@ export async function getThreadComments(
pageSize,
reverseOrder,
requestedFields: 'profile_image',
enableInContextSidebar,
});
const { data } = await getAuthenticatedHttpClient()
@@ -55,7 +51,6 @@ export async function getCommentResponses(
commentId, {
page,
pageSize,
reverseOrder,
} = {},
) {
const url = `${getCommentsApiUrl()}${commentId}/`;
@@ -63,7 +58,6 @@ export async function getCommentResponses(
page,
pageSize,
requestedFields: 'profile_image',
reverseOrder,
});
const { data } = await getAuthenticatedHttpClient()
.get(url, { params });
@@ -75,14 +69,11 @@ 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, enableInContextSidebar = false) {
export async function postComment(comment, threadId, parentId = null) {
const { data } = await getAuthenticatedHttpClient()
.post(getCommentsApiUrl(), snakeCaseObject({
threadId, raw_body: comment, parentId, enableInContextSidebar,
}));
.post(getCommentsApiUrl(), snakeCaseObject({ threadId, raw_body: comment, parentId }));
return data;
}

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect } from 'react';
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -6,7 +6,6 @@ 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 {
@@ -43,7 +42,6 @@ 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 = {
@@ -60,7 +58,6 @@ export function usePostComments(postId, endorsed = null) {
endorsed,
page: 1,
reverseOrder,
enableInContextSidebar,
}));
}, [postId, reverseOrder]);

View File

@@ -22,7 +22,7 @@ const commentsSlice = createSlice({
postStatus: RequestStatus.SUCCESSFUL,
pagination: {},
responsesPagination: {},
sortOrder: true,
sortOrder: false,
},
reducers: {
fetchCommentsRequest: (state) => {
@@ -75,16 +75,12 @@ const commentsSlice = createSlice({
},
fetchCommentResponsesSuccess: (state, { payload }) => {
state.status = RequestStatus.SUCCESSFUL;
if (payload.page === 1) {
state.commentsInComments[payload.commentId] = payload.commentsInComments[payload.commentId] || [];
} else {
state.commentsInComments[payload.commentId] = [
...new Set([
...(state.commentsInComments[payload.commentId] || []),
...(payload.commentsInComments[payload.commentId] || []),
]),
];
}
state.commentsInComments[payload.commentId] = [
...new Set([
...(state.commentsInComments[payload.commentId] || []),
...(payload.commentsInComments[payload.commentId] || []),
]),
];
state.commentsById = { ...state.commentsById, ...payload.commentsById };
state.responsesPagination[payload.commentId] = {
currentPage: payload.page,

View File

@@ -80,15 +80,12 @@ export function fetchThreadComments(
page = 1,
reverseOrder,
endorsed = EndorsementStatus.DISCUSSION,
enableInContextSidebar,
} = {},
) {
return async (dispatch) => {
try {
dispatch(fetchCommentsRequest());
const data = await getThreadComments(threadId, {
page, reverseOrder, endorsed, enableInContextSidebar,
});
const data = await getThreadComments(threadId, { page, reverseOrder, endorsed });
dispatch(fetchCommentsSuccess({
...normaliseComments(camelCaseObject(data)),
endorsed,
@@ -106,11 +103,11 @@ export function fetchThreadComments(
};
}
export function fetchCommentResponses(commentId, { page = 1, reverseOrder = true } = {}) {
export function fetchCommentResponses(commentId, { page = 1 } = {}) {
return async (dispatch) => {
try {
dispatch(fetchCommentResponsesRequest({ commentId }));
const data = await getCommentResponses(commentId, { page, reverseOrder });
const data = await getCommentResponses(commentId, { page });
dispatch(fetchCommentResponsesSuccess({
...normaliseComments(camelCaseObject(data)),
page,
@@ -147,7 +144,7 @@ export function editComment(commentId, comment, action = null) {
};
}
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
export function addComment(comment, threadId, parentId = null) {
return async (dispatch) => {
try {
dispatch(postCommentRequest({
@@ -155,7 +152,7 @@ export function addComment(comment, threadId, parentId = null, enableInContextSi
threadId,
parentId,
}));
const data = await postComment(comment, threadId, parentId, enableInContextSidebar);
const data = await postComment(comment, threadId, parentId);
dispatch(postCommentSuccess(camelCaseObject(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {

View File

@@ -160,9 +160,9 @@ describe('PostsView', () => {
test('displays a list of posts in a topic', async () => {
setupStore();
await act(async () => {
await renderComponent({ topicId: 'test-topic-1' });
await renderComponent({ topicId: 'some-topic-1' });
});
expect(screen.getAllByText(/this is thread-\d+ in topic test-topic-1/i)).toHaveLength(Math.ceil(threadCount / 3));
expect(screen.getAllByText(/this is thread-\d+ in topic some-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: ['test-topic-2', 'test-topic-0'],
topics: ['some-topic-2', 'some-topic-0'],
parent: 'test-seq-key',
},
'test-seq-key': { type: 'sequential', topics: ['test-topic-0', 'test-topic-1', 'test-topic-2'] },
'test-seq-key': { type: 'sequential', topics: ['some-topic-0', 'some-topic-1', 'some-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 test-topic-2/i))
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-2/i))
.toHaveLength(topicThreadCount);
expect(screen.queryAllByText(/this is thread-\d+ in topic test-topic-0/i))
expect(screen.queryAllByText(/this is thread-\d+ in topic some-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 test-topic-1/i))
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-1/i))
.toHaveLength(grouping ? topicThreadCount : 2);
},
);

View File

@@ -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 => `test-topic-${(idx % 3)}`)
.sequence('topic_id', idx => `some-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({

View File

@@ -87,7 +87,6 @@ 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(
@@ -102,7 +101,6 @@ export async function postThread(
anonymous,
anonymousToPeers,
} = {},
enableInContextSidebar = false,
) {
const postData = snakeCaseObject({
courseId,
@@ -114,8 +112,8 @@ export async function postThread(
anonymous,
anonymousToPeers,
groupId: cohort,
enableInContextSidebar,
});
const { data } = await getAuthenticatedHttpClient()
.post(getThreadsApiUrl(), postData);
return data;

View File

@@ -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('test-topic-1');
.toEqual('some-topic-1');
});
test('successfully handles thread creation', async () => {

View File

@@ -204,7 +204,6 @@ export function createNewThread({
anonymous,
anonymousToPeers,
cohort,
enableInContextSidebar,
}) {
return async (dispatch) => {
try {
@@ -224,7 +223,7 @@ export function createNewThread({
following,
anonymous,
anonymousToPeers,
}, enableInContextSidebar);
});
dispatch(postThreadSuccess(camelCaseObject(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {

View File

@@ -187,7 +187,6 @@ 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 */

View File

@@ -1,11 +1,10 @@
import React, { useCallback, useContext, useMemo } from 'react';
import React, { useContext } 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';
@@ -42,14 +41,13 @@ function Post({
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
const handleAbusedFlag = useCallback(() => {
const handleAbusedFlag = () => {
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));
@@ -65,7 +63,7 @@ function Post({
hideReportConfirmation();
};
const actionHandlers = useMemo(() => ({
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => history.push({
...location,
pathname: `${location.pathname}/edit`,
@@ -83,28 +81,12 @@ 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"
@@ -145,7 +127,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 })}
@@ -153,7 +135,7 @@ function Post({
>
<span className="text-gray-500" style={{ lineHeight: '20px' }}>{intl.formatMessage(messages.relatedTo)}{' '}</span>
<Hyperlink
destination={topicContext ? topicContext.unitLink : `${getConfig().BASE_URL}/${courseId}/topics/${post.topicId}`}
destination={topicContext.unitLink}
target="_top"
>
{(topicContext && !topic)
@@ -166,7 +148,7 @@ function Post({
<span className="w-auto">{topicContext.unitName}</span>
</>
)
: getTopicInfo(topic)}
: `${getTopicCategoryName(topic)} / ${topic.name}`}
</Hyperlink>
</div>
)}

View File

@@ -10,13 +10,5 @@ export default function tourCheckpoints(intl) {
title: intl.formatMessage(messages.notRespondedFilterTourTitle),
},
],
RESPONSE_SORT: [
{
body: intl.formatMessage(messages.responseSortTourBody),
placement: 'left',
target: '#comment-sort',
title: intl.formatMessage(messages.responseSortTourTitle),
},
],
};
}

View File

@@ -5,25 +5,29 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
import { RequestStatus } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { getDiscussionTourUrl } from './api';
import { selectTours } from './selectors';
import {
discussionsTourRequest,
discussionsToursRequestError,
fetchUserDiscussionsToursSuccess,
updateUserDiscussionsTourByName,
toursReducer,
updateUserDiscussionsTourSuccess,
} from './slices';
import { fetchDiscussionTours, updateTourShowStatus } from './thunks';
import discussionTourFactory from './tours.factory';
const url = getDiscussionTourUrl();
let actualActions;
let mockAxios;
// eslint-disable-next-line no-unused-vars
let store;
const url = getDiscussionTourUrl();
describe('DiscussionToursThunk', () => {
let actualActions;
const dispatch = (action) => {
actualActions.push(action);
};
describe('DiscussionTours data layer', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
@@ -42,147 +46,168 @@ describe('DiscussionTours data layer', () => {
mockAxios.reset();
});
describe('DiscussionToursThunk', () => {
const dispatch = (action) => {
actualActions.push(action);
};
it('dispatches get request, success actions', async () => {
const mockData = discussionTourFactory.buildList(2);
mockAxios.onGet(url)
.reply(200, mockData);
const getExpectedAction = (mockData) => ({
request: {
const expectedActions = [
{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
},
fetch: {
{
type: 'userDiscussionsTours/fetchUserDiscussionsToursSuccess',
payload: mockData,
},
update: {
];
await fetchDiscussionTours()(dispatch);
expect(actualActions)
.toEqual(expectedActions);
});
it('dispatches request, and error actions', async () => {
mockAxios.onGet('/api/discussion-tours/')
.reply(500);
const errorAction = [{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
}, {
payload: undefined,
type: 'userDiscussionsTours/discussionsToursRequestError',
}];
await fetchDiscussionTours()(dispatch);
expect(actualActions)
.toEqual(errorAction);
});
it('dispatches put request, success actions', async () => {
const mockData = discussionTourFactory.build();
mockAxios.onPut(`${url}${1}`)
.reply(200, mockData);
const expectedActions = [
{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
},
{
type: 'userDiscussionsTours/updateUserDiscussionsTourSuccess',
payload: mockData,
},
error: {
payload: undefined,
type: 'userDiscussionsTours/discussionsToursRequestError',
},
});
it('dispatches get request, success actions', async () => {
const mockData = discussionTourFactory.buildList(2);
mockAxios.onGet(url).reply(200, mockData);
const expectedActions = [getExpectedAction().request, getExpectedAction(mockData).fetch];
await fetchDiscussionTours()(dispatch);
expect(actualActions).toEqual(expectedActions);
});
it('dispatches request, and error actions', async () => {
mockAxios.onGet('/api/discussion-tours/').reply(500);
const expectedActions = [getExpectedAction().request, getExpectedAction().error];
await fetchDiscussionTours()(dispatch);
expect(actualActions).toEqual(expectedActions);
});
it('dispatches put request, success actions', async () => {
const mockData = discussionTourFactory.build();
mockAxios.onPut(`${url}${1}`).reply(200, mockData);
const expectedActions = [getExpectedAction().request, getExpectedAction(mockData).update];
await updateTourShowStatus(1)(dispatch);
expect(actualActions).toEqual(expectedActions);
});
it('dispatches update request, and error actions', async () => {
mockAxios.onPut(`${url}${1}`).reply(500);
const expectedActions = [getExpectedAction().request, getExpectedAction().error];
await updateTourShowStatus(1)(dispatch);
expect(actualActions).toEqual(expectedActions);
});
];
await updateTourShowStatus(1)(dispatch);
expect(actualActions)
.toEqual(expectedActions);
});
describe('toursReducer', () => {
it('handles the discussionsToursRequest action', async () => {
store.dispatch(discussionsTourRequest());
const { tours } = store.getState();
it('dispatches update request, and error actions', async () => {
mockAxios.onPut(`${url}${1}`)
.reply(500);
const errorAction = [{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
}, {
payload: undefined,
type: 'userDiscussionsTours/discussionsToursRequestError',
}];
expect(tours.tours).toEqual([]);
expect(tours.error).toBeNull();
expect(tours.loading).toEqual(RequestStatus.IN_PROGRESS);
});
await updateTourShowStatus(1)(dispatch);
expect(actualActions)
.toEqual(errorAction);
});
});
it('handles the fetchUserDiscussionsToursSuccess action', async () => {
const mockData = [{ id: 1 }, { id: 2 }];
await store.dispatch(fetchUserDiscussionsToursSuccess(mockData));
const { tours } = store.getState();
describe('toursReducer', () => {
it('handles the discussionsToursRequest action', () => {
const initialState = {
tours: [],
loading: false,
error: null,
};
const state = toursReducer(initialState, discussionsTourRequest());
expect(state)
.toEqual({
tours: [],
loading: RequestStatus.IN_PROGRESS,
error: null,
});
});
expect(tours).toEqual({
it('handles the fetchUserDiscussionsToursSuccess action', () => {
const initialState = {
tours: [],
loading: true,
error: null,
};
const mockData = [{ id: 1 }, { id: 2 }];
const state = toursReducer(initialState, fetchUserDiscussionsToursSuccess(mockData));
expect(state)
.toEqual({
tours: mockData,
loading: RequestStatus.SUCCESSFUL,
error: null,
});
});
});
it('handles the updateUserDiscussionsTourSuccess action', async () => {
const updatedTour = { id: 2, name: 'Updated Tour' };
await store.dispatch(fetchUserDiscussionsToursSuccess([{ id: 1 }, { id: 2 }]));
await store.dispatch(updateUserDiscussionsTourSuccess(updatedTour));
const { tours } = store.getState();
it('handles the updateUserDiscussionsTourSuccess action', () => {
const initialState = {
tours: [
{ id: 1 },
{ id: 2 },
],
};
const updatedTour = {
id: 2,
name: 'Updated Tour',
};
const state = toursReducer(initialState, updateUserDiscussionsTourSuccess(updatedTour));
expect(state.tours)
.toEqual([{ id: 1 }, updatedTour]);
});
expect(tours.tours).toEqual([{ id: 1 }, updatedTour]);
});
it('handles the discussionsToursRequestError action', async () => {
const errorMessage = 'Something went wrong';
await store.dispatch(discussionsToursRequestError(errorMessage));
const { tours } = store.getState();
expect(tours).toEqual({
it('handles the discussionsToursRequestError action', () => {
const initialState = {
tours: [],
loading: true,
error: null,
};
const mockError = new Error('Something went wrong');
const state = toursReducer(initialState, discussionsToursRequestError(mockError));
expect(state)
.toEqual({
tours: [],
loading: RequestStatus.FAILED,
error: errorMessage,
error: mockError,
});
});
it('handles the updateUserDiscussionsTourByName action', async () => {
const tourName = 'response_sort';
const updatedTour = {
tourName: 'response_sort',
enabled: false,
};
await mockAxios.onGet(getDiscussionTourUrl(), {}).reply(200, [discussionTourFactory.build({ tourName })]);
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
store.dispatch(updateUserDiscussionsTourByName(updatedTour));
expect(store.getState().tours.tours).toEqual([{
id: 4,
tourName: 'response_sort',
enabled: false,
description: 'This is the description for Discussion Tour 4.',
}]);
});
});
describe('tourSelector', () => {
it('returns the tours list from state', async () => {
await mockAxios.onGet(getDiscussionTourUrl(), {}).reply(200, [
discussionTourFactory.build({ tourName: 'other_filter' }),
]);
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
expect(selectTours(store.getState())).toEqual([{
id: 5,
tourName: 'other_filter',
description: 'This is the description for Discussion Tour 5.',
enabled: true,
}]);
});
it('returns an empty list if the tours state is not defined', async () => {
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
expect(selectTours(store.getState())).toEqual([]);
});
});
});
describe('tourSelector', () => {
it('returns the tours list from state', () => {
const state = {
tours: {
tours: [
{ id: 1, tourName: 'not_responded_filter' },
{ id: 2, tourName: 'other_filter' },
],
},
};
const expectedResult = [
{ id: 1, tourName: 'not_responded_filter' },
{ id: 2, tourName: 'other_filter' },
];
expect(selectTours(state)).toEqual(expectedResult);
});
it('returns an empty list if the tours state is not defined', () => {
const state = {
tours: {
tours: [],
},
};
expect(selectTours(state))
.toEqual([]);
});
});

View File

@@ -31,12 +31,6 @@ const userDiscussionsToursSlice = createSlice({
state.loading = RequestStatus.SUCCESSFUL;
state.error = null;
},
updateUserDiscussionsTourByName: (state, action) => {
const tourIndex = state.tours.findIndex(tour => tour.tourName === action.payload.tourName);
state.tours[tourIndex] = { ...state.tours[tourIndex], ...action.payload };
state.loading = RequestStatus.SUCCESSFUL;
state.error = null;
},
},
});
@@ -45,7 +39,6 @@ export const {
fetchUserDiscussionsToursSuccess,
discussionsToursRequestError,
updateUserDiscussionsTourSuccess,
updateUserDiscussionsTourByName,
} = userDiscussionsToursSlice.actions;
export const toursReducer = userDiscussionsToursSlice.reducer;

View File

@@ -9,10 +9,6 @@ import {
updateUserDiscussionsTourSuccess,
} from './slices';
function normaliseTourData(data) {
return data.map(tour => ({ ...tour, enabled: true }));
}
/**
* Action thunk to fetch the list of discussion tours for the current user.
* @returns {function} - Thunk that dispatches the request, success, and error actions.
@@ -22,7 +18,7 @@ export function fetchDiscussionTours() {
try {
dispatch(discussionsTourRequest());
const data = await getDiscssionTours();
dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(normaliseTourData(data))));
dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(data)));
} catch (error) {
dispatch(discussionsToursRequestError());
logError(error);

View File

@@ -2,8 +2,7 @@ import { Factory } from 'rosie';
const discussionTourFactory = new Factory()
.sequence('id')
.attr('tourName', ['id'], (id) => `Discussion Tour ${id}`)
.attr('description', ['id'], (id) => `This is the description for Discussion Tour ${id}.`)
.attr('enabled', ['id'], true);
.attr('name', ['id'], (id) => `Discussion Tour ${id}`)
.attr('description', ['id'], (id) => `This is the description for Discussion Tour ${id}.`);
export default discussionTourFactory;

View File

@@ -26,16 +26,6 @@ const messages = defineMessages({
defaultMessage: 'New filtering option!',
description: 'Title of the tour for the not responded filter',
},
responseSortTourBody: {
id: 'tour.body.responseSortTour',
defaultMessage: 'Responses and comments are now sorted by newest first. Please use this option to change the sort order',
description: 'Body of the tour for the response sort',
},
responseSortTourTitle: {
id: 'tour.title.responseSortTour',
defaultMessage: 'Sort Responses!',
description: 'Title of the tour for the response sort',
},
});
export default messages;

View File

@@ -208,7 +208,5 @@
"tour.action.dismiss": "Dismiss",
"tour.action.end": "Okay",
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
"tour.title.notRespondedFilter": "New filtering option!",
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
"tour.title.responseSortTour": "Sort Responses!"
"tour.title.notRespondedFilter": "New filtering option!"
}

View File

@@ -208,7 +208,5 @@
"tour.action.dismiss": "Abgewiesen",
"tour.action.end": "okay",
"tour.body.notRespondedFilter": "Jetzt können Sie Diskussionen filtern, um Beiträge ohne Antwort zu finden.",
"tour.title.notRespondedFilter": "Neue Filteroption!",
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
"tour.title.responseSortTour": "Sort Responses!"
"tour.title.notRespondedFilter": "Neue Filteroption!"
}

View File

@@ -208,7 +208,5 @@
"tour.action.dismiss": "Descartar",
"tour.action.end": "Okey",
"tour.body.notRespondedFilter": "Ahora puede filtrar debates para encontrar publicaciones sin respuesta.",
"tour.title.notRespondedFilter": "¡Nueva opción de filtrado!",
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
"tour.title.responseSortTour": "Sort Responses!"
"tour.title.notRespondedFilter": "¡Nueva opción de filtrado!"
}

View File

@@ -1,61 +1,61 @@
{
"navigation.course.tabs.label": "Matériel de cours",
"navigation.course.tabs.label": "Course Material",
"learn.course.tabs.navigation.overflow.menu": "Plus...",
"discussions.topics.backAlt": "Retour à la liste des sujets",
"discussions.topics.backAlt": "Back to topics list",
"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} 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.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.nothing": "Nothing here yet",
"discussions.topics.archived.label": "Archivé",
"discussions.topics.archived.label": "Archived",
"discussions.learner.reported": "{reported} signalé",
"discussions.learner.previouslyReported": "{previouslyReported} signalé précédemment",
"discussions.learner.lastLogin": "Dernier actif {lastActiveTime}",
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
"discussions.learner.loadMostLearners": "Charger plus",
"discussions.learner.back": "Retour",
"discussions.learner.activityForLearner": "Activité pour {username}",
"discussions.learner.mostActivity": "La plupart des activités",
"discussions.learner.reportedActivity": "Activité signalée",
"discussions.learner.recentActivity": "Activité récente",
"discussions.learner.mostActivity": "Most activity",
"discussions.learner.reportedActivity": "Reported activity",
"discussions.learner.recentActivity": "Recent activity",
"discussions.learner.sortFilterStatus": "All learners sorted by {sort, select,\n flagged {reported activity}\n activity {most activity}\n other {{sort}}\n }",
"discussion.learner.allActivity": "Toutes les activités",
"discussion.learner.allActivity": "All activity",
"discussion.learner.posts": "Posts",
"discussions.actions.button.alt": "Menu Actions",
"discussions.actions.copylink": "Copier le lien",
"discussions.actions.button.alt": "Actions menu",
"discussions.actions.copylink": "Copy link",
"discussions.actions.edit": "Modifier",
"discussions.actions.pin": "Épingler",
"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.unpin": "Unpin",
"discussions.actions.delete": "Delete",
"discussions.confirmation.button.confirm": "Confirm",
"discussions.actions.close": "Close",
"discussions.actions.reopen": "Reopen",
"discussions.actions.report": "Report",
"discussions.actions.unreport": "Unreport",
"discussions.actions.endorse": "Approuver",
"discussions.actions.unendorse": "Ne plus approuver",
"discussions.actions.markAnswered": "Marquer comme répondu",
"discussions.actions.endorse": "Endorse",
"discussions.actions.unendorse": "Unendorse",
"discussions.actions.markAnswered": "Mark as answered",
"discussions.actions.unMarkAnswered": "Unmark as answered",
"discussions.modal.confirmation.button.cancel": "Annuler",
"discussions.modal.confirmation.button.cancel": "Cancel",
"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": "Rien ici encore",
"discussions.empty.title": "Nothing here yet",
"discussions.empty.noPostSelected": "No post selected",
"discussions.empty.noTopicSelected": "Aucun sujet sélectionné",
"discussions.sidebar.noResultsFound": "Aucun résultat trouvé",
"discussions.empty.noTopicSelected": "No topic selected",
"discussions.sidebar.noResultsFound": "No results found",
"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": "Vide",
"discussions.authors.label.staff": "Équipe",
"discussions.empty.iconAlt": "Empty",
"discussions.authors.label.staff": "Staff",
"discussions.authors.label.ta": "TA",
"discussions.learner.loadMostPosts": "Load more posts",
"discussions.post.anonymous.author": "anonymous",
@@ -208,7 +208,5 @@
"tour.action.dismiss": "Dismiss",
"tour.action.end": "Okay",
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
"tour.title.notRespondedFilter": "New filtering option!",
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
"tour.title.responseSortTour": "Sort Responses!"
"tour.title.notRespondedFilter": "New filtering option!"
}

View File

@@ -208,7 +208,5 @@
"tour.action.dismiss": "Rejeter",
"tour.action.end": "Okay",
"tour.body.notRespondedFilter": "Vous pouvez maintenant filtrer les discussions pour trouver les messages sans réponse.",
"tour.title.notRespondedFilter": "Nouvelle option de filtrage!",
"tour.body.responseSortTour": "Les réponses et les commentaires sont désormais triés par les plus récents en premier. Veuillez utiliser cette option pour modifier l'ordre de tri",
"tour.title.responseSortTour": "Triez les réponses !"
"tour.title.notRespondedFilter": "Nouvelle option de filtrage!"
}

View File

@@ -208,7 +208,5 @@
"tour.action.dismiss": "Dismiss",
"tour.action.end": "Okay",
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
"tour.title.notRespondedFilter": "New filtering option!",
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
"tour.title.responseSortTour": "Sort Responses!"
"tour.title.notRespondedFilter": "New filtering option!"
}

View File

@@ -208,7 +208,5 @@
"tour.action.dismiss": "Dismiss",
"tour.action.end": "Okay",
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
"tour.title.notRespondedFilter": "New filtering option!",
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
"tour.title.responseSortTour": "Sort Responses!"
"tour.title.notRespondedFilter": "New filtering option!"
}

View File

@@ -208,7 +208,5 @@
"tour.action.dismiss": "Dismiss",
"tour.action.end": "Okay",
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
"tour.title.notRespondedFilter": "New filtering option!",
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
"tour.title.responseSortTour": "Sort Responses!"
"tour.title.notRespondedFilter": "New filtering option!"
}

View File

@@ -208,7 +208,5 @@
"tour.action.dismiss": "İptal",
"tour.action.end": "Tamam",
"tour.body.notRespondedFilter": "Artık yanıt vermeyen iletileri bulmak için tartışmaları filtreleyebilirsiniz.",
"tour.title.notRespondedFilter": "Yeni filtreleme seçeneği!",
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
"tour.title.responseSortTour": "Sort Responses!"
"tour.title.notRespondedFilter": "Yeni filtreleme seçeneği!"
}

View File

@@ -208,7 +208,5 @@
"tour.action.dismiss": "Dismiss",
"tour.action.end": "Okay",
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
"tour.title.notRespondedFilter": "New filtering option!",
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
"tour.title.responseSortTour": "Sort Responses!"
"tour.title.notRespondedFilter": "New filtering option!"
}

View File

@@ -1,5 +1,4 @@
/* eslint-disable import/prefer-default-export */
export const executeThunk = async (thunk, dispatch, getState) => {
await thunk(dispatch, getState);
await new Promise(setImmediate);