Skip to content

Commit 4ab6d78

Browse files
feat: added product tour for notification (#427)
* feat: added product tour for notification * feat: connect frontend with backend and added redux structure --------- Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
1 parent 9175344 commit 4ab6d78

13 files changed

Lines changed: 315 additions & 57 deletions

File tree

src/Notifications/index.jsx

Lines changed: 61 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { toggleTrayEvent } from './data/slice';
1616
import { useIsOnLargeScreen, useIsOnMediumScreen } from './data/hook';
1717
import NotificationTabs from './NotificationTabs';
1818
import messages from './messages';
19+
import NotificationTour from './tours/NotificationTour';
1920

2021
const Notifications = () => {
2122
const intl = useIntl();
@@ -61,60 +62,62 @@ const Notifications = () => {
6162
}
6263

6364
return (
64-
<OverlayTrigger
65-
trigger="click"
66-
key="bottom"
67-
placement="bottom"
68-
id="notificationTray"
69-
show={enableNotificationTray}
70-
overlay={(
71-
<Popover
72-
id="notificationTray"
73-
style={{ height: `${notificationBarHeight}px` }}
74-
data-testid="notification-tray"
75-
className={classNames('overflow-auto rounded-0 border-0', {
76-
'w-100': !isOnMediumScreen && !isOnLargeScreen,
77-
'medium-screen': isOnMediumScreen,
78-
'large-screen': isOnLargeScreen,
79-
})}
80-
>
81-
<div ref={popoverRef}>
82-
<Popover.Title as="h2" className="d-flex justify-content-between p-0 m-4 border-0 text-primary-500 font-size-18 line-height-24">
83-
{intl.formatMessage(messages.notificationTitle)}
84-
<Hyperlink
85-
destination={`${getConfig().ACCOUNT_SETTINGS_URL}/notifications`}
86-
target="_blank"
87-
rel="noopener noreferrer"
88-
showLaunchIcon={false}
89-
>
90-
<Icon
91-
src={Settings}
92-
className="icon-size-20 text-primary-500"
93-
data-testid="setting-icon"
94-
screenReaderText="preferences settings icon"
95-
/>
96-
</Hyperlink>
97-
</Popover.Title>
98-
<Popover.Content className="notification-content p-0">
99-
<NotificationTabs />
100-
</Popover.Content>
101-
</div>
102-
</Popover>
65+
<>
66+
<OverlayTrigger
67+
trigger="click"
68+
key="bottom"
69+
placement="bottom"
70+
id="notificationTray"
71+
show={enableNotificationTray}
72+
overlay={(
73+
<Popover
74+
id="notificationTray"
75+
style={{ height: `${notificationBarHeight}px` }}
76+
data-testid="notification-tray"
77+
className={classNames('overflow-auto rounded-0 border-0', {
78+
'w-100': !isOnMediumScreen && !isOnLargeScreen,
79+
'medium-screen': isOnMediumScreen,
80+
'large-screen': isOnLargeScreen,
81+
})}
82+
>
83+
<div ref={popoverRef}>
84+
<Popover.Title as="h2" className="d-flex justify-content-between p-0 m-4 border-0 text-primary-500 font-size-18 line-height-24">
85+
{intl.formatMessage(messages.notificationTitle)}
86+
<Hyperlink
87+
destination={`${getConfig().ACCOUNT_SETTINGS_URL}/notifications`}
88+
target="_blank"
89+
rel="noopener noreferrer"
90+
showLaunchIcon={false}
91+
>
92+
<Icon
93+
src={Settings}
94+
className="icon-size-20 text-primary-500"
95+
data-testid="setting-icon"
96+
screenReaderText="preferences settings icon"
97+
/>
98+
</Hyperlink>
99+
</Popover.Title>
100+
<Popover.Content className="notification-content p-0">
101+
<NotificationTabs />
102+
</Popover.Content>
103+
</div>
104+
</Popover>
103105
)}
104-
>
105-
<div ref={buttonRef}>
106-
<IconButton
107-
isActive={enableNotificationTray}
108-
alt="notification bell icon"
109-
onClick={toggleNotificationTray}
110-
src={NotificationsNone}
111-
iconAs={Icon}
112-
variant="light"
113-
iconClassNames="text-primary-500"
114-
className="ml-4 mr-1 notification-button"
115-
data-testid="notification-bell-icon"
116-
/>
117-
{notificationCounts?.count > 0 && (
106+
>
107+
<div ref={buttonRef}>
108+
<IconButton
109+
isActive={enableNotificationTray}
110+
alt="notification bell icon"
111+
onClick={toggleNotificationTray}
112+
src={NotificationsNone}
113+
iconAs={Icon}
114+
variant="light"
115+
id="bell-icon"
116+
iconClassNames="text-primary-500"
117+
className="ml-4 mr-1 notification-button"
118+
data-testid="notification-bell-icon"
119+
/>
120+
{notificationCounts?.count > 0 && (
118121
<Badge
119122
pill
120123
variant="danger"
@@ -125,9 +128,11 @@ const Notifications = () => {
125128
{notificationCounts.count >= 100 ? <div className="d-flex">99<p className="mb-0 plus-icon">+</p></div>
126129
: notificationCounts.count}
127130
</Badge>
128-
)}
129-
</div>
130-
</OverlayTrigger>
131+
)}
132+
</div>
133+
</OverlayTrigger>
134+
<NotificationTour />
135+
</>
131136
);
132137
};
133138

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React, { useEffect } from 'react';
2+
import { useDispatch } from 'react-redux';
3+
import isEmpty from 'lodash/isEmpty';
4+
import { ProductTour } from '@edx/paragon';
5+
import { useTourConfiguration } from './data/hooks';
6+
import { fetchNotificationTours } from './data/thunks';
7+
8+
const NotificationTour = () => {
9+
const dispatch = useDispatch();
10+
const config = useTourConfiguration();
11+
12+
useEffect(() => {
13+
dispatch(fetchNotificationTours());
14+
}, [dispatch]);
15+
16+
return (
17+
// eslint-disable-next-line react/jsx-no-useless-fragment
18+
<>
19+
{!isEmpty(config) && (
20+
<ProductTour
21+
tours={config}
22+
/>
23+
)}
24+
</>
25+
);
26+
};
27+
28+
export default NotificationTour;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/* eslint-disable react/jsx-filename-extension */
2+
import React from 'react';
3+
import { Icon, Hyperlink } from '@edx/paragon';
4+
import { Settings } from '@edx/paragon/icons';
5+
import { getConfig } from '@edx/frontend-platform';
6+
import messages from './messages';
7+
8+
export default function tourCheckpoints(intl) {
9+
return {
10+
NOTIFICATION_TOUR: [
11+
{
12+
body: (
13+
<>
14+
{intl.formatMessage(messages.notificationTourBody)}
15+
<Hyperlink
16+
destination={`${getConfig().ACCOUNT_SETTINGS_URL}/notifications`}
17+
target="_blank"
18+
rel="noopener noreferrer"
19+
showLaunchIcon={false}
20+
className="d-inline-block pl-1.5"
21+
>
22+
<Icon
23+
src={Settings}
24+
className="icon-size-20 text-primary-500"
25+
data-testid="tour-setting-icon"
26+
screenReaderText="preferences settings icon"
27+
/>
28+
</Hyperlink>
29+
</>
30+
),
31+
placement: 'left',
32+
target: '#bell-icon',
33+
title: intl.formatMessage(messages.notificationTourTitle),
34+
},
35+
],
36+
};
37+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { getConfig } from '@edx/frontend-platform';
2+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
3+
4+
// create constant for the API URL
5+
export const getDiscussionTourUrl = () => `${getConfig().LMS_BASE_URL}/api/user_tours/discussion_tours/`;
6+
7+
export async function getNotificationsTours() {
8+
const { data } = await getAuthenticatedHttpClient().get(getDiscussionTourUrl());
9+
return data;
10+
}
11+
12+
export async function updateNotificationsTour(tourId) {
13+
const { data } = await getAuthenticatedHttpClient().put(`${getDiscussionTourUrl()}${tourId}`, { show_tour: false });
14+
return data;
15+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useCallback, useMemo } from 'react';
2+
import { useIntl } from '@edx/frontend-platform/i18n';
3+
import { useDispatch, useSelector } from 'react-redux';
4+
import messages from '../messages';
5+
import tourCheckpoints from '../constants';
6+
import { selectTours } from './selectors';
7+
import { updateTourShowStatus } from './thunks';
8+
9+
export function camelToConstant(string) {
10+
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
11+
}
12+
13+
export const useTourConfiguration = () => {
14+
const intl = useIntl();
15+
const dispatch = useDispatch();
16+
const tours = useSelector(selectTours);
17+
18+
const handleOnOkay = useCallback((id) => {
19+
dispatch(updateTourShowStatus(id));
20+
}, [dispatch]);
21+
22+
const toursConfig = useMemo(() => (
23+
tours?.map((tour) => tour.tourName === intl.formatMessage(messages.notificationTourId) && (
24+
{
25+
tourId: tour.tourName,
26+
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
27+
endButtonText: intl.formatMessage(messages.endButtonText),
28+
enabled: tour && Boolean(tour.enabled && tour.showTour),
29+
onEnd: () => handleOnOkay(tour.id),
30+
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
31+
}
32+
))
33+
), [handleOnOkay, intl, tours]);
34+
35+
return toursConfig;
36+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './slices';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// eslint-disable-next-line import/prefer-default-export
2+
export const selectTours = (state) => state.tour?.tours;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* eslint-disable no-param-reassign */
2+
import { createSlice } from '@reduxjs/toolkit';
3+
4+
import { RequestStatus } from '../../data/slice';
5+
6+
const userNotificationsToursSlice = createSlice({
7+
name: 'userNotificationTours',
8+
initialState: {
9+
tours: [],
10+
loading: RequestStatus.SUCCESSFUL,
11+
error: null,
12+
},
13+
reducers: {
14+
notificationsTourRequest: (state) => {
15+
state.loading = RequestStatus.IN_PROGRESS;
16+
state.error = null;
17+
},
18+
fetchNotificationsToursSuccess: (state, action) => {
19+
state.tours = action.payload;
20+
state.loading = RequestStatus.SUCCESSFUL;
21+
state.error = null;
22+
},
23+
notificationsToursRequestError: (state, action) => {
24+
state.loading = RequestStatus.FAILED;
25+
state.error = action.payload;
26+
},
27+
updateNotificationsTourSuccess: (state, action) => {
28+
const tourIndex = state.tours.findIndex(tour => tour.id === action.payload.id);
29+
state.tours[tourIndex] = action.payload;
30+
state.loading = RequestStatus.SUCCESSFUL;
31+
state.error = null;
32+
},
33+
},
34+
});
35+
36+
export const {
37+
notificationsTourRequest,
38+
fetchNotificationsToursSuccess,
39+
notificationsToursRequestError,
40+
updateNotificationsTourSuccess,
41+
} = userNotificationsToursSlice.actions;
42+
43+
export const toursReducer = userNotificationsToursSlice.reducer;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { camelCaseObject } from '@edx/frontend-platform';
2+
import { logError } from '@edx/frontend-platform/logging';
3+
4+
import { getNotificationsTours, updateNotificationsTour } from './api';
5+
import {
6+
notificationsTourRequest,
7+
notificationsToursRequestError,
8+
fetchNotificationsToursSuccess,
9+
updateNotificationsTourSuccess,
10+
} from './slices';
11+
12+
function normaliseTourData(data) {
13+
return data.map(tour => ({ ...tour, enabled: true }));
14+
}
15+
16+
/**
17+
* Action thunk to fetch the list of notification tours for the current user.
18+
* @returns {function} - Thunk that dispatches the request, success, and error actions.
19+
*/
20+
export function fetchNotificationTours() {
21+
return async (dispatch) => {
22+
try {
23+
dispatch(notificationsTourRequest());
24+
const data = await getNotificationsTours();
25+
dispatch(fetchNotificationsToursSuccess(camelCaseObject(normaliseTourData(data))));
26+
} catch (error) {
27+
dispatch(notificationsToursRequestError());
28+
logError(error);
29+
}
30+
};
31+
}
32+
33+
/**
34+
* Action thunk to update the show_tour field for a specific notification tour for the current user.
35+
* @param {number} tourId - The ID of the tour to update.
36+
* @returns {function} - Thunk that dispatches the request, success, and error actions.
37+
*/
38+
39+
export function updateTourShowStatus(tourId) {
40+
return async (dispatch) => {
41+
try {
42+
dispatch(notificationsTourRequest());
43+
const data = await updateNotificationsTour(tourId);
44+
dispatch(updateNotificationsTourSuccess(camelCaseObject(data)));
45+
} catch (error) {
46+
dispatch(notificationsToursRequestError());
47+
logError(error);
48+
}
49+
};
50+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
const messages = defineMessages({
4+
notificationTourId: {
5+
id: 'notification.tour.id',
6+
defaultMessage: 'notification_tour',
7+
description: 'Notification Tour Id',
8+
},
9+
dismissButtonText: {
10+
id: 'tour.action.dismiss',
11+
defaultMessage: 'Dismiss',
12+
description: 'Action to dismiss current tour',
13+
},
14+
endButtonText: {
15+
id: 'tour.action.end',
16+
defaultMessage: 'Okay',
17+
description: 'Action to end current tour',
18+
},
19+
notificationTourBody: {
20+
id: 'notification.tour.body',
21+
defaultMessage: 'Click the bell icon to see Discussion notifications and customize your preferences by clicking on the gear icon',
22+
description: 'Body of the tour for the notification',
23+
},
24+
notificationTourTitle: {
25+
id: 'notification.tour.title',
26+
defaultMessage: 'Stay informed!',
27+
description: 'Title of the notification tour',
28+
},
29+
});
30+
31+
export default messages;

0 commit comments

Comments
 (0)