Compare commits

...

8 Commits

Author SHA1 Message Date
Ahtisham Shahid
0b3fc507bf Merge branch 'master' into ahtisham/INF-867 2023-01-17 20:47:25 +05:00
AhtishamShahid
a1a9e3b21e fix: update isEmpty logic 2023-01-17 20:40:17 +05:00
AhtishamShahid
6f96b0d6ef refactor: made tour component generic 2023-01-17 16:14:50 +05:00
AhtishamShahid
087adf6562 refactor: made tour component generic 2023-01-17 15:27:15 +05:00
AhtishamShahid
bb8f4b1c50 refactor: made tour component generic 2023-01-17 15:10:10 +05:00
AhtishamShahid
5c75651481 refactor: added translations , removed redundent code, fixed tests 2023-01-13 11:40:37 +05:00
AhtishamShahid
5f563ab702 fix: resolved linter errors 2023-01-10 13:53:41 +05:00
AhtishamShahid
e6d560733b feat: added tour for not responded filter 2023-01-10 11:23:35 +05:00
15 changed files with 482 additions and 11 deletions

View File

@@ -1,6 +1,9 @@
/* eslint-disable import/prefer-default-export */
import {
useContext, useEffect, useRef, useState,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -17,6 +20,10 @@ import { DiscussionContext } from '../common/context';
import { clearRedirect } from '../posts/data';
import { threadsLoadingStatus } from '../posts/data/selectors';
import { selectTopics } from '../topics/data/selectors';
import tourCheckpoints from '../tours/constants';
import { selectTours } from '../tours/data/selectors';
import { updateTourShowStatus } from '../tours/data/thunks';
import messages from '../tours/messages';
import { discussionsPath, inBlackoutDateRange } from '../utils';
import {
selectAreThreadsFiltered,
@@ -40,10 +47,11 @@ export function useTotalTopicThreadCount() {
return 0;
}
return Object.keys(topics).reduce((total, topicId) => {
const topic = topics[topicId];
return total + topic.threadCounts.discussion + topic.threadCounts.question;
}, 0);
return Object.keys(topics)
.reduce((total, topicId) => {
const topic = topics[topicId];
return total + topic.threadCounts.discussion + topic.threadCounts.question;
}, 0);
}
export const useSidebarVisible = () => {
@@ -192,3 +200,27 @@ export const useUserCanAddThreadInBlackoutDate = () => {
return (!(isInBlackoutDateRange)
|| (isUserAdmin || userHasModerationPrivilages || isUserGroupTA || isCourseAdmin || isCourseStaff));
};
function camelToConstant(string) {
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
}
export const useTourConfiguration = (intl) => {
const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const tours = useSelector(selectTours);
return tours.map((tour) => (
{
tourId: tour.tourName,
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
endButtonText: intl.formatMessage(messages.endButtonText),
enabled: tour && Boolean(tour.showTour && !enableInContextSidebar),
onDismiss: () => dispatch(updateTourShowStatus(tour.id)),
onEnd: () => dispatch(updateTourShowStatus(tour.id)),
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
}
));
};

View File

@@ -24,6 +24,7 @@ import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/compone
import messages from '../messages';
import { LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { selectPostEditorVisible } from '../posts/data/selectors';
import DiscussionsProductTour from '../tours/DiscussionsProductTour';
import { postMessageToParent } from '../utils';
import BlackoutInformationBanner from './BlackoutInformationBanner';
import DiscussionContent from './DiscussionContent';
@@ -101,6 +102,7 @@ export default function DiscussionsHome() {
component={LegacyBreadcrumbMenu}
/>
)}
<div className="d-flex flex-row">
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
{displayContentArea && <DiscussionContent />}
@@ -122,6 +124,7 @@ export default function DiscussionsHome() {
</Switch>
)}
</div>
<DiscussionsProductTour />
</main>
{!enableInContextSidebar && <Footer />}
</DiscussionContext.Provider>

View File

@@ -148,12 +148,15 @@ function PostFilterBar({
cohort: capitalize(selectedCohort?.name),
})}
</span>
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
<span id="icon-tune">
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
</span>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
<Form>

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import isEmpty from 'lodash/isEmpty';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ProductTour } from '@edx/paragon';
import { useTourConfiguration } from '../data/hooks';
import { fetchDiscussionTours } from './data/thunks';
function DiscussionsProductTour({ intl }) {
const dispatch = useDispatch();
const config = useTourConfiguration(intl);
useEffect(() => {
dispatch(fetchDiscussionTours());
}, []);
return (
<>
{!isEmpty(config) && (
<ProductTour
tours={config}
/>
)}
</>
);
}
DiscussionsProductTour.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DiscussionsProductTour);

View File

@@ -0,0 +1,14 @@
import messages from './messages';
export default function tourCheckpoints(intl) {
return {
NOT_RESPONDED_FILTER: [
{
body: intl.formatMessage(messages.notRespondedFilterTourBody),
placement: 'right',
target: '#icon-tune',
title: intl.formatMessage(messages.notRespondedFilterTourTitle),
},
],
};
}

View File

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

View File

@@ -0,0 +1 @@
export * from './slices';

View File

@@ -0,0 +1,218 @@
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import { RequestStatus } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { getDiscussionTourUrl } from './api';
import { selectTours } from './selectors';
import {
discussionsTourRequest,
discussionsToursRequestError,
fetchUserDiscussionsToursSuccess,
toursReducer,
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);
};
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/discussionsTourRequest',
},
{
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',
},
{
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/discussionsTourRequest',
}, {
payload: undefined,
type: 'userDiscussionsTours/discussionsToursRequestError',
}];
await updateTourShowStatus(1)(dispatch);
expect(actualActions)
.toEqual(errorAction);
});
});
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', () => {
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', () => {
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: RequestStatus.SUCCESSFUL,
error: null,
});
});
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: mockError,
});
});
});
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

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const selectTours = (state) => state.tours.tours;

View File

@@ -0,0 +1,44 @@
/* eslint-disable no-param-reassign,import/prefer-default-export */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../../data/constants';
const userDiscussionsToursSlice = createSlice({
name: 'userDiscussionsTours',
initialState: {
tours: [],
loading: RequestStatus.SUCCESSFUL,
error: null,
},
reducers: {
discussionsTourRequest: (state) => {
state.loading = RequestStatus.IN_PROGRESS;
state.error = null;
},
fetchUserDiscussionsToursSuccess: (state, action) => {
state.tours = action.payload;
state.loading = RequestStatus.SUCCESSFUL;
state.error = null;
},
discussionsToursRequestError: (state, action) => {
state.loading = RequestStatus.FAILED;
state.error = action.payload;
},
updateUserDiscussionsTourSuccess: (state, action) => {
const tourIndex = state.tours.tours.findIndex(tour => tour.id === action.payload.id);
state.tours.tours[tourIndex] = action.payload;
state.tours.loading = RequestStatus.SUCCESSFUL;
state.tours.error = null;
},
},
});
export const {
discussionsTourRequest,
fetchUserDiscussionsToursSuccess,
discussionsToursRequestError,
updateUserDiscussionsTourSuccess,
} = userDiscussionsToursSlice.actions;
export const toursReducer = userDiscussionsToursSlice.reducer;

View File

@@ -0,0 +1,46 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { getDiscssionTours, updateDiscussionTour } from './api';
import {
discussionsTourRequest,
discussionsToursRequestError,
fetchUserDiscussionsToursSuccess,
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(discussionsTourRequest());
const data = await getDiscssionTours();
dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(data)));
} catch (error) {
dispatch(discussionsToursRequestError());
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(discussionsTourRequest());
const data = await updateDiscussionTour(tourId);
dispatch(updateUserDiscussionsTourSuccess(camelCaseObject(data)));
} catch (error) {
dispatch(discussionsToursRequestError());
logError(error);
}
};
}

View File

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

View File

@@ -0,0 +1,31 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
advanceButtonText: {
id: 'tour.action.advance',
defaultMessage: 'Next',
description: 'Action to go to next step of tour',
},
dismissButtonText: {
id: 'tour.action.dismiss',
defaultMessage: 'Dismiss',
description: 'Action to dismiss current tour',
},
endButtonText: {
id: 'tour.action.end',
defaultMessage: 'Okay',
description: 'Action to end current tour',
},
notRespondedFilterTourBody: {
id: 'tour.body.notRespondedFilter',
defaultMessage: 'Now you can filter discussions to find posts with no response.',
description: 'Body of the tour for the not responded filter',
},
notRespondedFilterTourTitle: {
id: 'tour.title.notRespondedFilter',
defaultMessage: 'New filtering option!',
description: 'Title of the tour for the not responded filter',
},
});
export default messages;

View File

@@ -283,3 +283,6 @@ header {
padding: 1px 5px !important;
}
.pgn__checkpoint {
max-width: 340px !important;
}

View File

@@ -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';
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,
});