From e6d560733bde9124c8d21da60bb76908c49bc5fb Mon Sep 17 00:00:00 2001 From: AhtishamShahid Date: Tue, 10 Jan 2023 11:23:35 +0500 Subject: [PATCH] feat: added tour for not responded filter --- .../discussions-home/DiscussionsHome.jsx | 3 + src/discussions/tours/NotRespondedFilter.jsx | 42 +++ src/discussions/tours/data/api.js | 30 +++ src/discussions/tours/data/redux.test.js | 252 ++++++++++++++++++ src/discussions/tours/data/selectors.js | 10 + src/discussions/tours/data/slices.js | 52 ++++ src/discussions/tours/data/thunks.js | 48 ++++ src/discussions/tours/data/tours.factory.js | 8 + src/store.js | 2 + 9 files changed, 447 insertions(+) create mode 100644 src/discussions/tours/NotRespondedFilter.jsx create mode 100644 src/discussions/tours/data/api.js create mode 100644 src/discussions/tours/data/redux.test.js create mode 100644 src/discussions/tours/data/selectors.js create mode 100644 src/discussions/tours/data/slices.js create mode 100644 src/discussions/tours/data/thunks.js create mode 100644 src/discussions/tours/data/tours.factory.js diff --git a/src/discussions/discussions-home/DiscussionsHome.jsx b/src/discussions/discussions-home/DiscussionsHome.jsx index 61a21a5c..f454ce86 100644 --- a/src/discussions/discussions-home/DiscussionsHome.jsx +++ b/src/discussions/discussions-home/DiscussionsHome.jsx @@ -29,6 +29,7 @@ import BlackoutInformationBanner from './BlackoutInformationBanner'; import DiscussionContent from './DiscussionContent'; import DiscussionSidebar from './DiscussionSidebar'; import InformationBanner from './InformationBanner'; +import NotRespondedFilter from '../tours/NotRespondedFilter'; export default function DiscussionsHome() { const location = useLocation(); @@ -101,6 +102,8 @@ export default function DiscussionsHome() { component={LegacyBreadcrumbMenu} /> )} + +
{displayContentArea && } diff --git a/src/discussions/tours/NotRespondedFilter.jsx b/src/discussions/tours/NotRespondedFilter.jsx new file mode 100644 index 00000000..1c03a25a --- /dev/null +++ b/src/discussions/tours/NotRespondedFilter.jsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; + +import { useDispatch, useSelector } from 'react-redux'; + +import { ProductTour } from '@edx/paragon'; + +import { fetchDiscussionTours, updateTourShowStatus } from './data/thunks'; +import { notRespondedFilterTour } from './data/selectors'; + +export default () => { + const dispatch = useDispatch(); + useEffect(() => { + dispatch(fetchDiscussionTours()); + }, []); + const tourData = useSelector(notRespondedFilterTour); + const config = { + tourId: 'notRespondedTour', + advanceButtonText: 'Next', + dismissButtonText: 'Dismiss', + endButtonText: 'Okay', + enabled: tourData ? tourData.showTour : false, + onDismiss: () => dispatch(updateTourShowStatus(tourData.id)), + onEnd: () => dispatch(updateTourShowStatus(tourData.id)), + checkpoints: [ + { + body: 'Now you can filter discussions .', + placement: 'right', + target: '#icon-tune', + title: 'New filtering option!', + }, + + ], + }; + + return ( + <> + + + ); +}; diff --git a/src/discussions/tours/data/api.js b/src/discussions/tours/data/api.js new file mode 100644 index 00000000..e01bafe5 --- /dev/null +++ b/src/discussions/tours/data/api.js @@ -0,0 +1,30 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +// create constant for the API URL +export const getDiscussionTourUrl = () => `${getConfig().LMS_BASE_URL}/api/user_tours/discussion_tours/`; + +/** + * getDiscussionTours + * This function makes an HTTP GET request to the API to retrieve a list of tours for the authenticated user. + * @returns {Promise} - A promise that resolves to the API response data. + */ +export async function getDiscssionTours() { + const { data } = await getAuthenticatedHttpClient() + .get(getDiscussionTourUrl()); + return data; +} + +/** + * updateDiscussionTour + * This function makes an HTTP PUT request to the API to update a specific tour for the authenticated user. + * @param {number} tourId - The ID of the tour to be updated. + * @returns {Promise} - A promise that resolves to the API response data. + */ +export async function updateDiscussionTour(tourId) { + const { data } = await getAuthenticatedHttpClient() + .put(`${getDiscussionTourUrl()}${tourId}`, { + show_tour: false, + }); + return data; +} diff --git a/src/discussions/tours/data/redux.test.js b/src/discussions/tours/data/redux.test.js new file mode 100644 index 00000000..d05de496 --- /dev/null +++ b/src/discussions/tours/data/redux.test.js @@ -0,0 +1,252 @@ +import MockAdapter from 'axios-mock-adapter'; + +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; + +import { initializeStore } from '../../../store'; +import { getDiscussionTourUrl } from './api'; +import { + fetchUserDiscussionsToursError, + fetchUserDiscussionsToursRequest, + fetchUserDiscussionsToursSuccess, + toursReducer, updateUserDiscussionsTourError, updateUserDiscussionsTourRequest, updateUserDiscussionsTourSuccess +} from './slices'; +import { fetchDiscussionTours, updateTourShowStatus } from './thunks'; +import discussionTourFactory from './tours.factory'; + +let mockAxios; +let store; +const url = getDiscussionTourUrl(); +describe('DiscussionToursThunk', () => { + let actualActions; + + const dispatch = (action) => { + actualActions.push(action); + }; + + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + mockAxios = new MockAdapter(getAuthenticatedHttpClient()); + store = initializeStore(); + actualActions = []; + }); + + afterEach(() => { + mockAxios.reset(); + }); + + it('dispatches get request, success actions', async () => { + const mockData = discussionTourFactory.buildList(2); + mockAxios.onGet(url) + .reply(200, mockData); + + const expectedActions = [ + { + payload: undefined, + type: 'userDiscussionsTours/fetchUserDiscussionsToursRequest', + }, + { + 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/fetchUserDiscussionsToursRequest', + }, { + payload: undefined, + type: 'userDiscussionsTours/fetchUserDiscussionsToursError', + }]; + + 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/updateUserDiscussionsTourRequest', + }, + { + type: 'userDiscussionsTours/updateUserDiscussionsTourSuccess', + payload: mockData, + }, + ]; + 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/updateUserDiscussionsTourRequest', + }, { + payload: undefined, + type: 'userDiscussionsTours/updateUserDiscussionsTourError', + }]; + + await updateTourShowStatus(1)(dispatch); + expect(actualActions) + .toEqual(errorAction); + }); +}); + +describe('toursReducer', () => { + it('handles the fetchUserDiscussionsToursRequest action', () => { + const initialState = { + tours: [], + loading: false, + error: null, + }; + const state = toursReducer(initialState, fetchUserDiscussionsToursRequest()); + expect(state) + .toEqual({ + tours: [], + loading: true, + error: null, + }); + }); + + 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: false, + error: null, + }); + }); + + it('handles the fetchUserDiscussionsToursError action', () => { + const initialState = { + tours: [], + loading: true, + error: null, + }; + const mockError = new Error('Something went wrong'); + const state = toursReducer(initialState, fetchUserDiscussionsToursError(mockError)); + expect(state) + .toEqual({ + tours: [], + loading: false, + error: mockError, + }); + }); + + it('handles the updateUserDiscussionsTourRequest action', () => { + const initialState = { + tours: [], + loading: false, + error: null, + }; + const state = toursReducer(initialState, updateUserDiscussionsTourRequest()); + expect(state) + .toEqual({ + tours: [], + loading: true, + error: null, + }); + }); + + it('handles the updateUserDiscussionsTourSuccess action', () => { + const initialState = { + tours: { + tours: [{ id: 1 }, { id: 2 }], + loading: true, + error: null, + }, + }; + const updatedTour = { + id: 2, + name: 'Updated Tour', + }; + const state = toursReducer(initialState, updateUserDiscussionsTourSuccess(updatedTour)); + expect(state.tours) + .toEqual({ + tours: [{ id: 1 }, updatedTour], + loading: false, + error: null, + }); + }); + + it('handles the updateUserDiscussionsTourError action', () => { + const initialState = { + tours: [], + loading: true, + error: null, + }; + const mockError = new Error('Something went wrong'); + const state = toursReducer(initialState, updateUserDiscussionsTourError(mockError)); + expect(state) + .toEqual({ + tours: [], + loading: false, + error: mockError, + }); + }); +}); + +import { notRespondedFilterTour } from './selectors'; + +describe('notRespondedFilterTour', () => { + it('filters the tours list by the "not_responded_filter" tour name', () => { + const state = { + tours: { + tours: [ + { id: 1, tourName: 'not_responded_filter' }, + { id: 2, tourName: 'other_filter' }, + ], + }, + }; + const expectedResult = { id: 1, tourName: 'not_responded_filter' } + ; + expect(notRespondedFilterTour(state)).toEqual(expectedResult); + }); + + it('returns an empty object if the tours state is not defined', () => { + const state = {}; + expect(notRespondedFilterTour(state)).toEqual({}); + }); + + it('returns an empty object if the tours state does not contain not_responded_filter', () => { + const state = { + tours: { + tours: [ + { id: 1, tourName: 'other_data' }, + { id: 2, tourName: 'other_data_1' }, + ], + }, + }; + expect(notRespondedFilterTour(state)).toEqual({}); + }); +}); diff --git a/src/discussions/tours/data/selectors.js b/src/discussions/tours/data/selectors.js new file mode 100644 index 00000000..794ee8c0 --- /dev/null +++ b/src/discussions/tours/data/selectors.js @@ -0,0 +1,10 @@ +export const notRespondedFilterTour = ({ tours }) => { + // This function filters the tours list in the state by the tour_name 'not_responded_filter' + // and returns the filtered list. This can be useful for displaying only the 'not_responded_filter' + // tours to the user, for example in a list or table. + if (!tours) { + return {}; + } + const response = tours.tours.find(tour => tour.tourName === 'not_responded_filter'); + return response || {}; +}; diff --git a/src/discussions/tours/data/slices.js b/src/discussions/tours/data/slices.js new file mode 100644 index 00000000..33119a31 --- /dev/null +++ b/src/discussions/tours/data/slices.js @@ -0,0 +1,52 @@ +/* eslint-disable no-param-reassign,import/prefer-default-export */ + +import { createSlice } from '@reduxjs/toolkit'; + +const userDiscussionsToursSlice = createSlice({ + name: 'userDiscussionsTours', + initialState: { + tours: [], + loading: false, + error: null, + }, + reducers: { + fetchUserDiscussionsToursRequest: (state) => { + state.loading = true; + state.error = null; + }, + fetchUserDiscussionsToursSuccess: (state, action) => { + state.tours = action.payload; + state.loading = false; + state.error = null; + }, + fetchUserDiscussionsToursError: (state, action) => { + state.loading = false; + state.error = action.payload; + }, + updateUserDiscussionsTourRequest: (state) => { + state.loading = true; + state.error = null; + }, + updateUserDiscussionsTourSuccess: (state, action) => { + const tourIndex = state.tours.tours.findIndex(tour => tour.id === action.payload.id); + state.tours.tours[tourIndex] = action.payload; + state.tours.loading = false; + state.tours.error = null; + }, + updateUserDiscussionsTourError: (state, action) => { + state.loading = false; + state.error = action.payload; + }, + }, +}); + +export const { + fetchUserDiscussionsToursRequest, + fetchUserDiscussionsToursSuccess, + fetchUserDiscussionsToursError, + updateUserDiscussionsTourRequest, + updateUserDiscussionsTourSuccess, + updateUserDiscussionsTourError, +} = userDiscussionsToursSlice.actions; + +export const toursReducer = userDiscussionsToursSlice.reducer; diff --git a/src/discussions/tours/data/thunks.js b/src/discussions/tours/data/thunks.js new file mode 100644 index 00000000..733e2337 --- /dev/null +++ b/src/discussions/tours/data/thunks.js @@ -0,0 +1,48 @@ +import { camelCaseObject } from '@edx/frontend-platform'; +import { logError } from '@edx/frontend-platform/logging'; + +import { getDiscssionTours, updateDiscussionTour } from './api'; +import { + fetchUserDiscussionsToursError, + fetchUserDiscussionsToursRequest, + fetchUserDiscussionsToursSuccess, + updateUserDiscussionsTourError, + updateUserDiscussionsTourRequest, + updateUserDiscussionsTourSuccess, +} from './slices'; + +/** + * Action thunk to fetch the list of discussion tours for the current user. + * @returns {function} - Thunk that dispatches the request, success, and error actions. + */ +export function fetchDiscussionTours() { + return async (dispatch) => { + try { + dispatch(fetchUserDiscussionsToursRequest()); + const data = await getDiscssionTours(); + dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(data))); + } catch (error) { + dispatch(fetchUserDiscussionsToursError()); + logError(error); + } + }; +} + +/** + * Action thunk to update the show_tour field for a specific discussion tour for the current user. + * @param {number} tourId - The ID of the tour to update. + * @returns {function} - Thunk that dispatches the request, success, and error actions. + */ + +export function updateTourShowStatus(tourId) { + return async (dispatch) => { + try { + dispatch(updateUserDiscussionsTourRequest()); + const data = await updateDiscussionTour(tourId); + dispatch(updateUserDiscussionsTourSuccess(camelCaseObject(data))); + } catch (error) { + dispatch(updateUserDiscussionsTourError()); + logError(error); + } + }; +} diff --git a/src/discussions/tours/data/tours.factory.js b/src/discussions/tours/data/tours.factory.js new file mode 100644 index 00000000..b48a840d --- /dev/null +++ b/src/discussions/tours/data/tours.factory.js @@ -0,0 +1,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}.`); + +export default discussionTourFactory; diff --git a/src/store.js b/src/store.js index 3159f288..1ace5ff8 100644 --- a/src/store.js +++ b/src/store.js @@ -9,6 +9,7 @@ import { inContextTopicsReducer } from './discussions/in-context-topics/data'; import { learnersReducer } from './discussions/learners/data'; import { threadsReducer } from './discussions/posts/data'; import { topicsReducer } from './discussions/topics/data'; +import { toursReducer } from './discussions/tours/data/slices'; export function initializeStore(preloadedState = undefined) { return configureStore({ @@ -22,6 +23,7 @@ export function initializeStore(preloadedState = undefined) { blocks: blocksReducer, learners: learnersReducer, courseTabs: courseTabsReducer, + tours: toursReducer, }, preloadedState, });