Compare commits

...

7 Commits

Author SHA1 Message Date
Mashal Malik
a59f9a0acf Merge branch 'master' into mashal-m/remove-unused-url 2023-03-13 10:35:40 +05:00
Jenkins
04745d6429 chore(i18n): update translations 2023-03-12 16:26:50 -04:00
Muhammad Adeel Tajamul
aad6702339 feat: sort comments based on sort order dropdown (#468)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-03-10 21:38:05 +05:00
Ahtisham Shahid
d39a196cdf feat: added product tour for response sort (#462)
* feat: added product tour for response sort
2023-03-10 12:23:13 +05:00
SaadYousaf
7b000f1974 feat: send enableInContextSidebar param to backend to identify source of content for events 2023-03-09 09:02:10 +05:00
Mashal Malik
5298e12c0a Merge branch 'master' into mashal-m/remove-unused-url 2023-03-06 10:58:16 +05:00
mashal-m
0805e200c7 refactor: remove unused tranisfex v2 url 2023-03-03 13:13:58 +05:00
31 changed files with 360 additions and 213 deletions

View File

@@ -5,8 +5,6 @@ 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

@@ -27,7 +27,8 @@ const threadsApiUrl = getThreadsApiUrl();
const discussionPostId = 'thread-1';
const questionPostId = 'thread-2';
const courseId = 'course-v1:edX+TestX+Test_Course';
const reverseOrder = false;
const reverseOrder = true;
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 }, {

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.showTour && !enableInContextSidebar),
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
onDismiss: () => dispatch(updateTourShowStatus(tour.id)),
onEnd: () => dispatch(updateTourShowStatus(tour.id)),
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],

View File

@@ -1,6 +1,5 @@
import React, { useContext, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { useHistory, useLocation } from 'react-router-dom';
@@ -10,9 +9,7 @@ import {
} from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import {
EndorsementStatus, PostsPages, RequestStatus, ThreadType,
} from '../../data/constants';
import { EndorsementStatus, PostsPages, ThreadType } from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks';
import { DiscussionContext } from '../common/context';
import { useIsOnDesktop } from '../data/hooks';
@@ -24,14 +21,12 @@ 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();
@@ -40,7 +35,6 @@ function PostCommentsView({ intl }) {
const {
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
} = useContext(DiscussionContext);
const enableCommentsSort = false;
useEffect(() => {
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
@@ -110,7 +104,7 @@ function PostCommentsView({ intl }) {
/>
)}
</div>
{!!commentsCount && commentsStatus === RequestStatus.SUCCESSFUL && enableCommentsSort && <CommentsSort />}
{!!commentsCount && <CommentsSort />}
{thread.type === ThreadType.DISCUSSION && (
<CommentsView
postId={postId}

View File

@@ -20,7 +20,12 @@ 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 { getCommentsApiUrl } from './data/api';
import { removeComment } from './data/thunks';
import '../posts/data/__factories__';
import './data/__factories__';
@@ -34,10 +39,13 @@ 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 reverseOrder = true;
const enableInContextSidebar = false;
let store;
let axiosMock;
let testLocation;
let container;
let unmount;
function mockAxiosReturnPagedComments() {
[null, false, true].forEach(endorsed => {
@@ -52,6 +60,7 @@ function mockAxiosReturnPagedComments() {
requested_fields: 'profile_image',
endorsed,
reverse_order: reverseOrder,
enable_in_context_sidebar: enableInContextSidebar,
},
})
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
@@ -73,6 +82,7 @@ function mockAxiosReturnPagedCommentsResponses() {
page: undefined,
page_size: undefined,
requested_fields: 'profile_image',
reverse_order: true,
};
for (let page = 1; page <= 2; page++) {
@@ -114,7 +124,8 @@ function renderComponent(postId) {
</AppProvider>
</IntlProvider>,
);
return wrapper;
container = wrapper.container;
unmount = wrapper.unmount;
}
describe('PostView', () => {
@@ -730,19 +741,87 @@ 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, { useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -6,31 +6,43 @@ 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

@@ -19,7 +19,12 @@ 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 } from '../../data/selectors';
import {
selectCommentCurrentPage,
selectCommentHasMorePages,
selectCommentResponses,
selectCommentSortOrder,
} from '../../data/selectors';
import { editComment, fetchCommentResponses, removeComment } from '../../data/thunks';
import messages from '../../messages';
import CommentEditor from './CommentEditor';
@@ -47,13 +52,17 @@ function Comment({
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const { courseId } = useContext(DiscussionContext);
const sortedOrder = useSelector(selectCommentSortOrder);
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 }));
if (hasChildren && showFullThread) {
dispatch(fetchCommentResponses(comment.id, {
page: 1,
reverseOrder: sortedOrder,
}));
}
}, [comment.id]);
}, [comment.id, sortedOrder]);
const actions = useActions({
...comment,
@@ -90,7 +99,10 @@ function Comment({
}), [showDeleteConfirmation, dispatch, comment.id, comment.endorsed, comment.threadId, courseId, handleAbusedFlag]);
const handleLoadMoreComments = () => (
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
dispatch(fetchCommentResponses(comment.id, {
page: currentPage + 1,
reverseOrder: sortedOrder,
}))
);
return (

View File

@@ -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) {

View File

@@ -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()
@@ -51,6 +55,7 @@ export async function getCommentResponses(
commentId, {
page,
pageSize,
reverseOrder,
} = {},
) {
const url = `${getCommentsApiUrl()}${commentId}/`;
@@ -58,6 +63,7 @@ export async function getCommentResponses(
page,
pageSize,
requestedFields: 'profile_image',
reverseOrder,
});
const { data } = await getAuthenticatedHttpClient()
.get(url, { params });
@@ -69,11 +75,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;
}

View File

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

View File

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

View File

@@ -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,
@@ -103,11 +106,11 @@ export function fetchThreadComments(
};
}
export function fetchCommentResponses(commentId, { page = 1 } = {}) {
export function fetchCommentResponses(commentId, { page = 1, reverseOrder = true } = {}) {
return async (dispatch) => {
try {
dispatch(fetchCommentResponsesRequest({ commentId }));
const data = await getCommentResponses(commentId, { page });
const data = await getCommentResponses(commentId, { page, reverseOrder });
dispatch(fetchCommentResponsesSuccess({
...normaliseComments(camelCaseObject(data)),
page,
@@ -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) {

View File

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

View File

@@ -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) {

View File

@@ -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 */

View File

@@ -10,5 +10,13 @@ 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,29 +5,25 @@ 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,
toursReducer,
updateUserDiscussionsTourByName,
updateUserDiscussionsTourSuccess,
} from './slices';
import { fetchDiscussionTours, updateTourShowStatus } from './thunks';
import discussionTourFactory from './tours.factory';
let mockAxios;
// eslint-disable-next-line no-unused-vars
let store;
const url = getDiscussionTourUrl();
describe('DiscussionToursThunk', () => {
let actualActions;
const dispatch = (action) => {
actualActions.push(action);
};
let actualActions;
let mockAxios;
let store;
describe('DiscussionTours data layer', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
@@ -46,168 +42,147 @@ describe('DiscussionToursThunk', () => {
mockAxios.reset();
});
it('dispatches get request, success actions', async () => {
const mockData = discussionTourFactory.buildList(2);
mockAxios.onGet(url)
.reply(200, mockData);
describe('DiscussionToursThunk', () => {
const dispatch = (action) => {
actualActions.push(action);
};
const expectedActions = [
{
const getExpectedAction = (mockData) => ({
request: {
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
},
{
fetch: {
type: 'userDiscussionsTours/fetchUserDiscussionsToursSuccess',
payload: mockData,
},
];
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',
},
{
update: {
type: 'userDiscussionsTours/updateUserDiscussionsTourSuccess',
payload: mockData,
},
];
await updateTourShowStatus(1)(dispatch);
expect(actualActions)
.toEqual(expectedActions);
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);
});
});
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',
}];
describe('toursReducer', () => {
it('handles the discussionsToursRequest action', async () => {
store.dispatch(discussionsTourRequest());
const { tours } = store.getState();
await updateTourShowStatus(1)(dispatch);
expect(actualActions)
.toEqual(errorAction);
});
});
expect(tours.tours).toEqual([]);
expect(tours.error).toBeNull();
expect(tours.loading).toEqual(RequestStatus.IN_PROGRESS);
});
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,
});
});
it('handles the fetchUserDiscussionsToursSuccess action', async () => {
const mockData = [{ id: 1 }, { id: 2 }];
await store.dispatch(fetchUserDiscussionsToursSuccess(mockData));
const { tours } = store.getState();
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({
expect(tours).toEqual({
tours: mockData,
loading: RequestStatus.SUCCESSFUL,
error: null,
});
});
});
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]);
});
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 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({
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({
tours: [],
loading: RequestStatus.FAILED,
error: mockError,
error: errorMessage,
});
});
});
});
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('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.',
}]);
});
});
it('returns an empty list if the tours state is not defined', () => {
const state = {
tours: {
tours: [],
},
};
expect(selectTours(state))
.toEqual([]);
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([]);
});
});
});

View File

@@ -31,6 +31,12 @@ 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;
},
},
});
@@ -39,6 +45,7 @@ export const {
fetchUserDiscussionsToursSuccess,
discussionsToursRequestError,
updateUserDiscussionsTourSuccess,
updateUserDiscussionsTourByName,
} = userDiscussionsToursSlice.actions;
export const toursReducer = userDiscussionsToursSlice.reducer;

View File

@@ -9,6 +9,10 @@ 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.
@@ -18,7 +22,7 @@ export function fetchDiscussionTours() {
try {
dispatch(discussionsTourRequest());
const data = await getDiscssionTours();
dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(data)));
dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(normaliseTourData(data))));
} catch (error) {
dispatch(discussionsToursRequestError());
logError(error);

View File

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

View File

@@ -26,6 +26,16 @@ 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,5 +208,7 @@
"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.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!"
}

View File

@@ -208,5 +208,7 @@
"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.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!"
}

View File

@@ -208,5 +208,7 @@
"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.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!"
}

View File

@@ -208,5 +208,7 @@
"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.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!"
}

View File

@@ -208,5 +208,7 @@
"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.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 !"
}

View File

@@ -208,5 +208,7 @@
"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.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!"
}

View File

@@ -208,5 +208,7 @@
"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.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!"
}

View File

@@ -208,5 +208,7 @@
"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.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!"
}

View File

@@ -208,5 +208,7 @@
"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.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!"
}

View File

@@ -208,5 +208,7 @@
"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.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!"
}