Compare commits
4 Commits
manwar/VAN
...
split-full
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3467534bc7 | ||
|
|
5f43f945bb | ||
|
|
67feee5e0b | ||
|
|
837ac4e635 |
@@ -5,7 +5,7 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@openedx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
@import "@edx/frontend-component-header/dist/index";
|
||||
@import "~@edx/frontend-component-header/dist/index";
|
||||
@import "~@edx/frontend-component-footer/dist/footer";
|
||||
|
||||
@import "./account-settings/style";
|
||||
|
||||
@@ -2,18 +2,21 @@ import React, { useCallback, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible } from '@openedx/paragon';
|
||||
import { Collapsible, NavItem } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import messages from './messages';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
import {
|
||||
selectPreferenceAppToggleValue,
|
||||
selectNonEditablePreferences,
|
||||
selectPreferencesOfApp,
|
||||
selectSelectedCourseId,
|
||||
selectUpdatePreferencesStatus,
|
||||
} from './data/selectors';
|
||||
import NotificationPreferenceRow from './NotificationPreferenceRow';
|
||||
import { updateAppPreferenceToggle } from './data/thunks';
|
||||
import { updateAppPreferenceToggle, updateChannelPreferenceToggle } from './data/thunks';
|
||||
import { LOADING_STATUS } from '../constants';
|
||||
import NOTIFICATION_CHANNELS from './data/constants';
|
||||
|
||||
const NotificationPreferenceApp = ({ appId }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -22,6 +25,18 @@ const NotificationPreferenceApp = ({ appId }) => {
|
||||
const appPreferences = useSelector(selectPreferencesOfApp(appId));
|
||||
const appToggle = useSelector(selectPreferenceAppToggleValue(appId));
|
||||
const updatePreferencesStatus = useSelector(selectUpdatePreferencesStatus());
|
||||
const nonEditable = useSelector(selectNonEditablePreferences(appId));
|
||||
|
||||
const onChannelToggle = useCallback((event) => {
|
||||
const { id: notificationChannel } = event.target;
|
||||
const isPreferenceNonEditable = (preference) => nonEditable?.[preference.id]?.includes(notificationChannel);
|
||||
|
||||
const hasActivePreferences = appPreferences.some(
|
||||
(preference) => preference[notificationChannel] && !isPreferenceNonEditable(preference),
|
||||
);
|
||||
|
||||
dispatch(updateChannelPreferenceToggle(courseId, appId, notificationChannel, !hasActivePreferences));
|
||||
}, [appId, appPreferences, courseId, dispatch, nonEditable]);
|
||||
|
||||
const preferences = useMemo(() => (
|
||||
appPreferences.map(preference => (
|
||||
@@ -36,12 +51,12 @@ const NotificationPreferenceApp = ({ appId }) => {
|
||||
dispatch(updateAppPreferenceToggle(courseId, appId, event.target.checked));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appId]);
|
||||
|
||||
if (!courseId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible.Advanced open={appToggle} data-testid="notification-app" className="mb-5">
|
||||
<Collapsible.Advanced open={appToggle} data-testid={`${appId}-app`} className="mb-5">
|
||||
<Collapsible.Trigger>
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="mr-auto">
|
||||
@@ -62,7 +77,22 @@ const NotificationPreferenceApp = ({ appId }) => {
|
||||
<div className="d-flex flex-row header-label">
|
||||
<span className="col-8 px-0">{intl.formatMessage(messages.typeLabel)}</span>
|
||||
<span className="d-flex col-4 px-0">
|
||||
<span className="ml-auto">{intl.formatMessage(messages.webLabel)}</span>
|
||||
{NOTIFICATION_CHANNELS.map((channel) => (
|
||||
<NavItem
|
||||
id={channel}
|
||||
key={channel}
|
||||
className={classNames(
|
||||
'd-flex',
|
||||
{ 'ml-auto': channel === 'web' },
|
||||
{ 'mx-auto': channel === 'email' },
|
||||
{ 'ml-auto mr-0': channel === 'push' },
|
||||
)}
|
||||
role="button"
|
||||
onClick={onChannelToggle}
|
||||
>
|
||||
{intl.formatMessage(messages.notificationChannel, { text: channel })}
|
||||
</NavItem>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-3">
|
||||
|
||||
@@ -78,6 +78,7 @@ const NotificationPreferenceRow = ({ appId, preferenceName }) => {
|
||||
value={preference[channel]}
|
||||
onChange={onToggle}
|
||||
disabled={nonEditable.includes(channel) || updatePreferencesStatus === LOADING_STATUS}
|
||||
id={`${preferenceName}-${channel}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
fireEvent, render, screen, waitFor, act, within,
|
||||
} from '@testing-library/react';
|
||||
import * as auth from '@edx/frontend-platform/auth';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import NotificationPreferences from './NotificationPreferences';
|
||||
@@ -29,37 +31,51 @@ const defaultPreferences = {
|
||||
],
|
||||
preferences: [
|
||||
{
|
||||
id: 'newPost',
|
||||
id: 'core',
|
||||
appId: 'discussion',
|
||||
web: false,
|
||||
push: false,
|
||||
mobile: false,
|
||||
web: true,
|
||||
push: true,
|
||||
email: true,
|
||||
},
|
||||
{
|
||||
id: 'newComment',
|
||||
appId: 'discussion',
|
||||
web: false,
|
||||
push: false,
|
||||
mobile: false,
|
||||
email: false,
|
||||
},
|
||||
{
|
||||
id: 'newAssignment',
|
||||
appId: 'coursework',
|
||||
web: false,
|
||||
push: false,
|
||||
mobile: false,
|
||||
email: false,
|
||||
},
|
||||
{
|
||||
id: 'newGrade',
|
||||
appId: 'coursework',
|
||||
web: false,
|
||||
push: false,
|
||||
mobile: false,
|
||||
email: false,
|
||||
},
|
||||
],
|
||||
nonEditable: {},
|
||||
nonEditable: {
|
||||
discussion: {
|
||||
core: [
|
||||
'web',
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updateChannelPreferences = (toggleVal = false) => ({
|
||||
preferences: [
|
||||
{ id: 'core', appId: 'discussion', web: true },
|
||||
{ id: 'newComment', appId: 'discussion', web: toggleVal },
|
||||
{ id: 'newAssignment', appId: 'coursework', web: toggleVal },
|
||||
],
|
||||
});
|
||||
|
||||
const setupStore = (override = {}) => {
|
||||
const storeState = defaultState;
|
||||
storeState.courses = {
|
||||
@@ -78,17 +94,19 @@ const setupStore = (override = {}) => {
|
||||
return store;
|
||||
};
|
||||
|
||||
const renderComponent = (store = {}) => render(
|
||||
const notificationPreferences = (store = {}) => (
|
||||
<Router>
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={store}>
|
||||
<NotificationPreferences />
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
</Router>,
|
||||
</Router>
|
||||
);
|
||||
|
||||
describe('Notification Preferences', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = setupStore({
|
||||
...defaultPreferences,
|
||||
@@ -108,30 +126,32 @@ describe('Notification Preferences', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('tests if all notification apps are listed', async () => {
|
||||
await renderComponent(store);
|
||||
expect(screen.queryAllByTestId('notification-app')).toHaveLength(2);
|
||||
await render(notificationPreferences(store));
|
||||
expect(screen.queryByTestId('discussion-app')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('coursework-app')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('show spinner if api call is in progress', async () => {
|
||||
store = setupStore({ status: LOADING_STATUS });
|
||||
await renderComponent(store);
|
||||
await render(notificationPreferences(store));
|
||||
expect(screen.queryByTestId('loading-spinner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tests if all notification preferences are listed', async () => {
|
||||
await renderComponent(store);
|
||||
await render(notificationPreferences(store));
|
||||
expect(screen.queryAllByTestId('notification-preference')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('update group on click', async () => {
|
||||
const wrapper = await renderComponent(store);
|
||||
const wrapper = await render(notificationPreferences(store));
|
||||
const element = wrapper.container.querySelector('#discussion-app-toggle');
|
||||
await fireEvent.click(element);
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update preference on click', async () => {
|
||||
const wrapper = await renderComponent(store);
|
||||
const element = wrapper.container.querySelector('#newPost-web');
|
||||
const wrapper = await render(notificationPreferences(store));
|
||||
const element = wrapper.container.querySelector('#core-web');
|
||||
expect(element).not.toBeChecked();
|
||||
await fireEvent.click(element);
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
@@ -139,7 +159,43 @@ describe('Notification Preferences', () => {
|
||||
|
||||
it('show not found page if invalid course id is entered in url', async () => {
|
||||
store = setupStore({ status: FAILURE_STATUS, selectedCourse: 'invalid-course-id' });
|
||||
await renderComponent(store);
|
||||
await render(notificationPreferences(store));
|
||||
expect(screen.queryByTestId('not-found-page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates all preferences in the column on web channel click', async () => {
|
||||
store = setupStore(updateChannelPreferences(true));
|
||||
const wrapper = render(notificationPreferences(store));
|
||||
|
||||
const getChannelSwitch = (id) => screen.queryByTestId(`${id}-web`);
|
||||
const notificationTypes = ['newComment', 'newAssignment'];
|
||||
|
||||
const verifyState = (toggleState) => {
|
||||
notificationTypes.forEach((notificationType) => {
|
||||
if (toggleState) {
|
||||
expect(getChannelSwitch(notificationType)).toBeChecked();
|
||||
} else {
|
||||
expect(getChannelSwitch(notificationType)).not.toBeChecked();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
verifyState(true);
|
||||
expect(getChannelSwitch('core')).toBeChecked();
|
||||
|
||||
const discussionApp = screen.queryByTestId('discussion-app');
|
||||
const webChannel = within(discussionApp).queryByText('Web');
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.click(webChannel);
|
||||
});
|
||||
|
||||
store = setupStore(updateChannelPreferences(false));
|
||||
wrapper.rerender(notificationPreferences(store));
|
||||
|
||||
await waitFor(() => {
|
||||
verifyState(false);
|
||||
expect(getChannelSwitch('core')).toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,12 +7,14 @@ const ToggleSwitch = ({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
id,
|
||||
}) => (
|
||||
<Form.Switch
|
||||
name={name}
|
||||
checked={value}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
data-testid={id}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -21,11 +23,13 @@ ToggleSwitch.propTypes = {
|
||||
value: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
id: PropTypes.string,
|
||||
};
|
||||
|
||||
ToggleSwitch.defaultProps = {
|
||||
onChange: () => null,
|
||||
disabled: false,
|
||||
id: '',
|
||||
};
|
||||
|
||||
export default React.memo(ToggleSwitch);
|
||||
|
||||
@@ -54,6 +54,10 @@ export const selectPreferenceNonEditableChannels = (appId, name) => state => (
|
||||
state?.notificationPreferences.preferences.nonEditable[appId]?.[name] || []
|
||||
);
|
||||
|
||||
export const selectNonEditablePreferences = appId => state => (
|
||||
state?.notificationPreferences.preferences.nonEditable[appId] || []
|
||||
);
|
||||
|
||||
export const selectSelectedCourseId = () => state => (
|
||||
state.notificationPreferences.preferences.selectedCourse
|
||||
);
|
||||
|
||||
@@ -42,3 +42,10 @@ export const patchPreferenceToggle = async (
|
||||
const { data } = await getAuthenticatedHttpClient().patch(url, patchData);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const patchChannelPreferenceToggle = async (courseId, notificationApp, notificationChannel, value) => {
|
||||
const patchData = snakeCaseObject({ notificationApp, notificationChannel, value });
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/notifications/channel/configurations/${courseId}`;
|
||||
const { data } = await getAuthenticatedHttpClient().patch(url, patchData);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getCourseList,
|
||||
getCourseNotificationPreferences,
|
||||
patchAppPreferenceToggle,
|
||||
patchChannelPreferenceToggle,
|
||||
patchPreferenceToggle,
|
||||
} from './service';
|
||||
|
||||
@@ -148,3 +149,15 @@ export const updatePreferenceToggle = (
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateChannelPreferenceToggle = (courseId, notificationApp, notificationChannel, value) => (
|
||||
async (dispatch) => {
|
||||
try {
|
||||
const data = await patchChannelPreferenceToggle(courseId, notificationApp, notificationChannel, value);
|
||||
const normalizedData = normalizePreferences(camelCaseObject(data));
|
||||
dispatch(fetchNotificationPreferenceSuccess(courseId, normalizedData));
|
||||
} catch (errors) {
|
||||
dispatch(fetchNotificationPreferenceFailed());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -28,6 +28,17 @@ const messages = defineMessages({
|
||||
}`,
|
||||
description: 'Display text for Notification Types',
|
||||
},
|
||||
notificationChannel: {
|
||||
id: 'notification.preference.channel',
|
||||
defaultMessage: `{
|
||||
text, select,
|
||||
web {Web}
|
||||
email {Email}
|
||||
push {Push}
|
||||
other {{text}}
|
||||
}`,
|
||||
description: 'Display text for Notification Channel',
|
||||
},
|
||||
typeLabel: {
|
||||
id: 'notification.preference.type.label',
|
||||
defaultMessage: 'Type',
|
||||
|
||||
Reference in New Issue
Block a user