Compare commits
35 Commits
release/ul
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7199b78358 | ||
|
|
a580d245c0 | ||
|
|
ca954e139d | ||
|
|
0d2eb96c86 | ||
|
|
f1180bffde | ||
|
|
f93598a9e4 | ||
|
|
35680b8b05 | ||
|
|
5d1000530d | ||
|
|
eef5d3f671 | ||
|
|
8fc839dc3d | ||
|
|
b21e6e553d | ||
|
|
cfc82975d8 | ||
|
|
50ea19d6af | ||
|
|
9e61ae677e | ||
|
|
247794b21d | ||
|
|
0a50937b4c | ||
|
|
22a1c658f1 | ||
|
|
75396f1dab | ||
|
|
62099a50eb | ||
|
|
19ccb8ab87 | ||
|
|
a3e2c80537 | ||
|
|
324cb525c6 | ||
|
|
f14ab8851d | ||
|
|
c277150716 | ||
|
|
2a0ed5714f | ||
|
|
1f0b758705 | ||
|
|
85a5a6e94e | ||
|
|
f59b5013c8 | ||
|
|
86b6574c60 | ||
|
|
c38e80505c | ||
|
|
b926e13c01 | ||
|
|
0a4285aad3 | ||
|
|
e70fa29261 | ||
|
|
b6ba8fb366 | ||
|
|
2221655950 |
3
.env
3
.env
@@ -1,4 +1,5 @@
|
||||
NODE_ENV='production'
|
||||
APP_ID='learner-dashboard'
|
||||
NODE_PATH=./src
|
||||
BASE_URL=''
|
||||
LMS_BASE_URL=''
|
||||
@@ -37,10 +38,10 @@ HOTJAR_VERSION='6'
|
||||
HOTJAR_DEBUG=''
|
||||
ACCOUNT_SETTINGS_URL=''
|
||||
ACCOUNT_PROFILE_URL=''
|
||||
ENABLE_NOTICES=''
|
||||
CAREER_LINK_URL=''
|
||||
ENABLE_EDX_PERSONAL_DASHBOARD=false
|
||||
ENABLE_PROGRAMS=false
|
||||
NON_BROWSABLE_COURSES=false
|
||||
SHOW_UNENROLL_SURVEY=true
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
NODE_ENV='development'
|
||||
APP_ID='learner-dashboard'
|
||||
PORT=1996
|
||||
BASE_URL='localhost:1996'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
@@ -43,10 +44,10 @@ HOTJAR_VERSION='6'
|
||||
HOTJAR_DEBUG=''
|
||||
ACCOUNT_SETTINGS_URL='http://localhost:1997'
|
||||
ACCOUNT_PROFILE_URL='http://localhost:1995'
|
||||
ENABLE_NOTICES=''
|
||||
CAREER_LINK_URL=''
|
||||
ENABLE_EDX_PERSONAL_DASHBOARD=false
|
||||
ENABLE_PROGRAMS=false
|
||||
NON_BROWSABLE_COURSES=false
|
||||
SHOW_UNENROLL_SURVEY=true
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
NODE_ENV='test'
|
||||
APP_ID='learner-dashboard'
|
||||
PORT=1996
|
||||
BASE_URL='localhost:1996'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
@@ -42,9 +43,9 @@ HOTJAR_VERSION='6'
|
||||
HOTJAR_DEBUG=''
|
||||
ACCOUNT_SETTINGS_URL='http://account-settings-url.test'
|
||||
ACCOUNT_PROFILE_URL='http://account-profile-url.test'
|
||||
ENABLE_NOTICES=''
|
||||
CAREER_LINK_URL=''
|
||||
ENABLE_EDX_PERSONAL_DASHBOARD=true
|
||||
ENABLE_PROGRAMS=false
|
||||
NON_BROWSABLE_COURSES=false
|
||||
SHOW_UNENROLL_SURVEY=true
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
11
Makefile
11
Makefile
@@ -12,6 +12,11 @@ transifex_temp = ./temp/babel-plugin-formatjs
|
||||
|
||||
NPM_TESTS=build i18n_extract lint test
|
||||
|
||||
# Variables for additional translation sources and imports (define in edx-internal if needed)
|
||||
ATLAS_EXTRA_SOURCES ?=
|
||||
ATLAS_EXTRA_INTL_IMPORTS ?=
|
||||
ATLAS_OPTIONS ?=
|
||||
|
||||
.PHONY: test
|
||||
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
|
||||
|
||||
@@ -47,10 +52,12 @@ pull_translations:
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard
|
||||
translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard \
|
||||
$(ATLAS_EXTRA_SOURCES)
|
||||
|
||||
$(intl_imports) frontend-platform paragon frontend-component-footer frontend-app-learner-dashboard
|
||||
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-learner-dashboard $(ATLAS_EXTRA_INTL_IMPORTS)
|
||||
|
||||
# This target is used by CI.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
|
||||
@@ -19,6 +19,7 @@ frontend-platform's getConfig loads configuration in the following sequence:
|
||||
|
||||
module.exports = {
|
||||
NODE_ENV: 'development',
|
||||
APP_ID: 'learner-dashboard',
|
||||
NODE_PATH: './src',
|
||||
PORT: 1996,
|
||||
BASE_URL: 'localhost:1996',
|
||||
@@ -67,7 +68,7 @@ module.exports = {
|
||||
NEW_RELIC_LICENSE_KEY: '',
|
||||
ACCOUNT_SETTINGS_URL: 'http://localhost:1997',
|
||||
ACCOUNT_PROFILE_URL: 'http://localhost:1995',
|
||||
ENABLE_NOTICES: '',
|
||||
CAREER_LINK_URL: '',
|
||||
EXPERIMENT_08_23_VAN_PAINTED_DOOR: true,
|
||||
SHOW_UNENROLL_SURVEY: true
|
||||
};
|
||||
|
||||
6247
package-lock.json
generated
6247
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -31,7 +31,7 @@
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^6.6.0",
|
||||
"@edx/frontend-component-header": "^8.0.0",
|
||||
"@edx/frontend-enterprise-hotjar": "7.2.0",
|
||||
"@edx/frontend-platform": "^8.3.1",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
@@ -41,11 +41,9 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@redux-devtools/extension": "3.3.0",
|
||||
"@reduxjs/toolkit": "^2.0.0",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"classnames": "^2.3.1",
|
||||
"core-js": "3.46.0",
|
||||
"filesize": "^10.0.0",
|
||||
"core-js": "3.48.0",
|
||||
"font-awesome": "4.7.0",
|
||||
"history": "5.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -55,15 +53,9 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-intl": "6.8.9",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-router-dom": "6.30.1",
|
||||
"react-share": "^4.4.0",
|
||||
"redux": "4.2.1",
|
||||
"redux-logger": "3.0.6",
|
||||
"redux-thunk": "2.4.2",
|
||||
"react-router-dom": "6.30.3",
|
||||
"react-share": "^5.2.2",
|
||||
"regenerator-runtime": "^0.14.0",
|
||||
"reselect": "^4.0.0",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"util": "^0.12.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -75,12 +67,11 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"copy-webpack-plugin": "^13.0.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest": "^30.0.0",
|
||||
"jest-environment-jsdom": "^30.0.0",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"jest-when": "^3.6.0",
|
||||
"react-dev-utils": "^12.0.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"redux-mock-store": "^1.5.4"
|
||||
"react-test-renderer": "^18.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
48
src/App.jsx
48
src/App.jsx
@@ -5,60 +5,30 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
|
||||
|
||||
import { ErrorPage, AppContext } from '@edx/frontend-platform/react';
|
||||
import { ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { FooterSlot } from '@edx/frontend-component-footer';
|
||||
import { Alert } from '@openedx/paragon';
|
||||
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
import store from 'data/store';
|
||||
import {
|
||||
selectors,
|
||||
actions,
|
||||
} from 'data/redux';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import Dashboard from 'containers/Dashboard';
|
||||
|
||||
import track from 'tracking';
|
||||
|
||||
import fakeData from 'data/services/lms/fakeData/courses';
|
||||
|
||||
import AppWrapper from 'containers/AppWrapper';
|
||||
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import { useMasquerade } from 'data/context';
|
||||
import messages from './messages';
|
||||
import './App.scss';
|
||||
|
||||
export const App = () => {
|
||||
const { authenticatedUser } = React.useContext(AppContext);
|
||||
const { formatMessage } = useIntl();
|
||||
const isFailed = {
|
||||
initialize: reduxHooks.useRequestIsFailed(RequestKeys.initialize),
|
||||
refreshList: reduxHooks.useRequestIsFailed(RequestKeys.refreshList),
|
||||
};
|
||||
const hasNetworkFailure = isFailed.initialize || isFailed.refreshList;
|
||||
const { supportEmail } = reduxHooks.usePlatformSettingsData();
|
||||
const loadData = reduxHooks.useLoadData();
|
||||
const { masqueradeUser } = useMasquerade();
|
||||
const { data, isError } = useInitializeLearnerHome();
|
||||
const hasNetworkFailure = !masqueradeUser && isError;
|
||||
const supportEmail = data?.platformSettings?.supportEmail || undefined;
|
||||
|
||||
/* istanbul ignore next */
|
||||
React.useEffect(() => {
|
||||
if (authenticatedUser?.administrator || getConfig().NODE_ENV === 'development') {
|
||||
window.loadEmptyData = () => {
|
||||
loadData({ ...fakeData.globalData, courses: [] });
|
||||
};
|
||||
window.loadMockData = () => {
|
||||
loadData({
|
||||
...fakeData.globalData,
|
||||
courses: [
|
||||
...fakeData.courseRunData,
|
||||
...fakeData.entitlementData,
|
||||
],
|
||||
});
|
||||
};
|
||||
window.store = store;
|
||||
window.selectors = selectors;
|
||||
window.actions = actions;
|
||||
window.track = track;
|
||||
}
|
||||
if (getConfig().HOTJAR_APP_ID) {
|
||||
try {
|
||||
initializeHotjar({
|
||||
@@ -70,7 +40,7 @@ export const App = () => {
|
||||
logError(error);
|
||||
}
|
||||
}
|
||||
}, [authenticatedUser, loadData]);
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
|
||||
@@ -3,30 +3,24 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import { App } from './App';
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('data/context', () => ({
|
||||
useMasquerade: jest.fn(() => ({ masqueradeUser: null })),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-component-footer', () => ({
|
||||
FooterSlot: jest.fn(() => <div>FooterSlot</div>),
|
||||
}));
|
||||
jest.mock('containers/Dashboard', () => jest.fn(() => <div>Dashboard</div>));
|
||||
jest.mock('containers/LearnerDashboardHeader', () => jest.fn(() => <div>LearnerDashboardHeader</div>));
|
||||
jest.mock('containers/AppWrapper', () => jest.fn(({ children }) => <div className="AppWrapper">{children}</div>));
|
||||
jest.mock('data/redux', () => ({
|
||||
selectors: 'redux.selectors',
|
||||
actions: 'redux.actions',
|
||||
thunkActions: 'redux.thunkActions',
|
||||
}));
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useRequestIsFailed: jest.fn(),
|
||||
usePlatformSettingsData: jest.fn(),
|
||||
useLoadData: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('data/store', () => 'data/store');
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: jest.fn(() => ({})),
|
||||
@@ -37,11 +31,15 @@ jest.mock('@edx/frontend-platform/react', () => ({
|
||||
ErrorPage: () => 'ErrorPage',
|
||||
}));
|
||||
|
||||
const loadData = jest.fn();
|
||||
reduxHooks.useLoadData.mockReturnValue(loadData);
|
||||
|
||||
const supportEmail = 'test@support.com';
|
||||
reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail });
|
||||
useInitializeLearnerHome.mockReturnValue({
|
||||
data: {
|
||||
platformSettings: {
|
||||
supportEmail,
|
||||
},
|
||||
},
|
||||
isError: false,
|
||||
});
|
||||
|
||||
describe('App router component', () => {
|
||||
describe('component', () => {
|
||||
@@ -66,7 +64,6 @@ describe('App router component', () => {
|
||||
describe('no network failure', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
reduxHooks.useRequestIsFailed.mockReturnValue(false);
|
||||
getConfig.mockReturnValue({});
|
||||
render(<IntlProvider locale="en"><App /></IntlProvider>);
|
||||
});
|
||||
@@ -79,7 +76,6 @@ describe('App router component', () => {
|
||||
describe('no network failure with optimizely url', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
reduxHooks.useRequestIsFailed.mockReturnValue(false);
|
||||
getConfig.mockReturnValue({ OPTIMIZELY_URL: 'fake.url' });
|
||||
render(<IntlProvider locale="en"><App /></IntlProvider>);
|
||||
});
|
||||
@@ -92,7 +88,6 @@ describe('App router component', () => {
|
||||
describe('no network failure with optimizely project id', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
reduxHooks.useRequestIsFailed.mockReturnValue(false);
|
||||
getConfig.mockReturnValue({ OPTIMIZELY_PROJECT_ID: 'fakeId' });
|
||||
render(<IntlProvider locale="en"><App /></IntlProvider>);
|
||||
});
|
||||
@@ -105,7 +100,10 @@ describe('App router component', () => {
|
||||
describe('initialize failure', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.initialize);
|
||||
useInitializeLearnerHome.mockReturnValue({
|
||||
data: null,
|
||||
isError: true,
|
||||
});
|
||||
getConfig.mockReturnValue({});
|
||||
render(<IntlProvider locale="en" messages={messages}><App /></IntlProvider>);
|
||||
});
|
||||
@@ -119,7 +117,6 @@ describe('App router component', () => {
|
||||
});
|
||||
describe('refresh failure', () => {
|
||||
beforeEach(() => {
|
||||
reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.refreshList);
|
||||
getConfig.mockReturnValue({});
|
||||
render(<IntlProvider locale="en"><App /></IntlProvider>);
|
||||
});
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { logError, logInfo } from '@edx/frontend-platform/logging';
|
||||
|
||||
export const noticesUrl = `${getConfig().LMS_BASE_URL}/notices/api/v1/unacknowledged`;
|
||||
|
||||
export const getNotices = ({ onLoad, notFoundMessage }) => {
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
|
||||
const handleError = async (e) => {
|
||||
// Error probably means that notices is not installed, which is fine.
|
||||
const { customAttributes: { httpErrorStatus } } = e;
|
||||
if (httpErrorStatus === 404) {
|
||||
logInfo(`${e}. ${notFoundMessage}`);
|
||||
} else {
|
||||
logError(e);
|
||||
}
|
||||
};
|
||||
if (authenticatedUser) {
|
||||
return getAuthenticatedHttpClient().get(noticesUrl, {}).then(onLoad).catch(handleError);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default { getNotices };
|
||||
@@ -1,65 +0,0 @@
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { logError, logInfo } from '@edx/frontend-platform/logging';
|
||||
|
||||
import * as api from './api';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: jest.fn(() => ({
|
||||
LMS_BASE_URL: 'test-lms-url',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedHttpClient: jest.fn(),
|
||||
getAuthenticatedUser: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
logError: jest.fn(),
|
||||
logInfo: jest.fn(),
|
||||
}));
|
||||
|
||||
const testData = 'test-data';
|
||||
const successfulGet = () => Promise.resolve(testData);
|
||||
const error404 = { customAttributes: { httpErrorStatus: 404 }, test: 'error' };
|
||||
const error404Get = () => Promise.reject(error404);
|
||||
const error500 = { customAttributes: { httpErrorStatus: 500 }, test: 'error' };
|
||||
const error500Get = () => Promise.reject(error500);
|
||||
|
||||
const get = jest.fn().mockImplementation(successfulGet);
|
||||
getAuthenticatedHttpClient.mockReturnValue({ get });
|
||||
const authenticatedUser = { fake: 'user' };
|
||||
getAuthenticatedUser.mockReturnValue(authenticatedUser);
|
||||
|
||||
const onLoad = jest.fn();
|
||||
describe('getNotices api method', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
describe('not authenticated', () => {
|
||||
it('does not fetch anything', () => {
|
||||
getAuthenticatedUser.mockReturnValueOnce(null);
|
||||
api.getNotices({ onLoad });
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe('authenticated', () => {
|
||||
it('fetches noticesUrl with onLoad behavior', async () => {
|
||||
await api.getNotices({ onLoad });
|
||||
expect(get).toHaveBeenCalledWith(api.noticesUrl, {});
|
||||
expect(onLoad).toHaveBeenCalledWith(testData);
|
||||
});
|
||||
it('calls logInfo if fetch fails with 404', async () => {
|
||||
get.mockImplementation(error404Get);
|
||||
await api.getNotices({ onLoad });
|
||||
expect(logInfo).toHaveBeenCalledWith(`${error404}. ${api.error404Message}`);
|
||||
});
|
||||
it('calls logError if fetch fails with non-404 error', async () => {
|
||||
get.mockImplementation(error500Get);
|
||||
await api.getNotices({ onLoad });
|
||||
expect(logError).toHaveBeenCalledWith(error500);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { StrictDict } from 'utils';
|
||||
import { getNotices } from './api';
|
||||
import * as module from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* This component uses the platform-plugin-notices plugin to function.
|
||||
* If the user has an unacknowledged notice, they will be rerouted off
|
||||
* course home and onto a full-screen notice page. If the plugin is not
|
||||
* installed, or there are no notices, we just passthrough this component.
|
||||
*/
|
||||
export const state = StrictDict({
|
||||
isRedirected: (val) => React.useState(val), // eslint-disable-line
|
||||
});
|
||||
|
||||
export const useNoticesWrapperData = () => {
|
||||
const [isRedirected, setIsRedirected] = module.state.isRedirected();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (getConfig().ENABLE_NOTICES) {
|
||||
getNotices({
|
||||
onLoad: (data) => {
|
||||
if (data?.data?.results?.length > 0) {
|
||||
setIsRedirected(true);
|
||||
window.location.replace(`${data.data.results[0]}?next=${window.location.href}`);
|
||||
}
|
||||
},
|
||||
notFoundMessage: formatMessage(messages.error404Message),
|
||||
});
|
||||
}
|
||||
}, [setIsRedirected, formatMessage]);
|
||||
return { isRedirected };
|
||||
};
|
||||
|
||||
export default useNoticesWrapperData;
|
||||
@@ -1,99 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { MockUseState, formatMessage } from 'testUtils';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getNotices } from './api';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
|
||||
jest.mock('./api', () => ({ getNotices: jest.fn() }));
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
|
||||
useContext: jest.fn(context => context),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
const { formatMessage: fn } = jest.requireActual('testUtils');
|
||||
return {
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
useIntl: () => ({
|
||||
formatMessage: fn,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
getConfig.mockReturnValue({ ENABLE_NOTICES: true });
|
||||
const state = new MockUseState(hooks);
|
||||
|
||||
let hook;
|
||||
describe('NoticesWrapper hooks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('state hooks', () => {
|
||||
state.testGetter(state.keys.isRedirected);
|
||||
});
|
||||
describe('useNoticesWrapperData', () => {
|
||||
beforeEach(() => {
|
||||
state.mock();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes state hooks', () => {
|
||||
hooks.useNoticesWrapperData();
|
||||
expect(hooks.state.isRedirected).toHaveBeenCalledWith();
|
||||
});
|
||||
describe('effects', () => {
|
||||
it('does not call notices if not enabled', () => {
|
||||
getConfig.mockReturnValueOnce({ ENABLE_NOTICES: false });
|
||||
hooks.useNoticesWrapperData();
|
||||
const [cb, prereqs] = React.useEffect.mock.calls[0];
|
||||
expect(prereqs).toEqual([state.setState.isRedirected, formatMessage]);
|
||||
cb();
|
||||
expect(getNotices).not.toHaveBeenCalled();
|
||||
});
|
||||
describe('getNotices call (if enabled) onLoad behavior', () => {
|
||||
it('does not redirect if there are no results', () => {
|
||||
hooks.useNoticesWrapperData();
|
||||
expect(React.useEffect).toHaveBeenCalled();
|
||||
const [cb, prereqs] = React.useEffect.mock.calls[0];
|
||||
expect(prereqs).toEqual([state.setState.isRedirected, formatMessage]);
|
||||
cb();
|
||||
expect(getNotices).toHaveBeenCalled();
|
||||
const { onLoad } = getNotices.mock.calls[0][0];
|
||||
onLoad({});
|
||||
expect(state.setState.isRedirected).not.toHaveBeenCalled();
|
||||
onLoad({ data: {} });
|
||||
expect(state.setState.isRedirected).not.toHaveBeenCalled();
|
||||
onLoad({ data: { results: [] } });
|
||||
expect(state.setState.isRedirected).not.toHaveBeenCalled();
|
||||
});
|
||||
it('redirects and set isRedirected if results are returned', () => {
|
||||
delete window.location;
|
||||
window.location = { replace: jest.fn(), href: 'test-old-href' };
|
||||
hooks.useNoticesWrapperData();
|
||||
const [cb, prereqs] = React.useEffect.mock.calls[0];
|
||||
expect(prereqs).toEqual([state.setState.isRedirected, formatMessage]);
|
||||
cb();
|
||||
expect(getNotices).toHaveBeenCalled();
|
||||
const { onLoad } = getNotices.mock.calls[0][0];
|
||||
const target = 'url-target';
|
||||
onLoad({ data: { results: [target] } });
|
||||
expect(state.setState.isRedirected).toHaveBeenCalledWith(true);
|
||||
expect(window.location.replace).toHaveBeenCalledWith(
|
||||
`${target}?next=${window.location.href}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
it('forwards isRedirected from state call', () => {
|
||||
hook = hooks.useNoticesWrapperData();
|
||||
expect(hook.isRedirected).toEqual(state.stateVals.isRedirected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import useNoticesWrapperData from './hooks';
|
||||
|
||||
/**
|
||||
* This component uses the platform-plugin-notices plugin to function.
|
||||
* If the user has an unacknowledged notice, they will be rerouted off
|
||||
* course home and onto a full-screen notice page. If the plugin is not
|
||||
* installed, or there are no notices, we just passthrough this component.
|
||||
*/
|
||||
const NoticesWrapper = ({ children }) => {
|
||||
const { isRedirected } = useNoticesWrapperData();
|
||||
return (
|
||||
<div>
|
||||
{isRedirected === true ? null : children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
NoticesWrapper.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default NoticesWrapper;
|
||||
@@ -1,36 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import useNoticesWrapperData from './hooks';
|
||||
import NoticesWrapper from '.';
|
||||
|
||||
jest.mock('./hooks', () => jest.fn());
|
||||
|
||||
const hookProps = { isRedirected: false };
|
||||
|
||||
const children = [<b key={1}>some</b>, <i key={2}>children</i>];
|
||||
describe('NoticesWrapper component', () => {
|
||||
beforeEach(() => {
|
||||
useNoticesWrapperData.mockClear();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes hooks', () => {
|
||||
useNoticesWrapperData.mockReturnValue(hookProps);
|
||||
render(<NoticesWrapper>{children}</NoticesWrapper>);
|
||||
expect(useNoticesWrapperData).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
it('does not show children if redirected', () => {
|
||||
useNoticesWrapperData.mockReturnValueOnce({ isRedirected: true });
|
||||
render(<NoticesWrapper>{children}</NoticesWrapper>);
|
||||
expect(screen.queryByText('some')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('children')).not.toBeInTheDocument();
|
||||
});
|
||||
it('shows children if not redirected', () => {
|
||||
useNoticesWrapperData.mockReturnValue(hookProps);
|
||||
render(<NoticesWrapper>{children}</NoticesWrapper>);
|
||||
expect(screen.getByText('some')).toBeInTheDocument();
|
||||
expect(screen.getByText('children')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
error404Message: {
|
||||
id: 'learner-dash.notices.error404Message',
|
||||
defaultMessage: 'This probably happened because the notices plugin is not installed on platform.',
|
||||
description: 'Error message when notices API returns 404',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,5 +1,6 @@
|
||||
const configuration = {
|
||||
// BASE_URL: process.env.BASE_URL,
|
||||
APP_ID: process.env.APP_ID,
|
||||
LMS_BASE_URL: process.env.LMS_BASE_URL,
|
||||
ECOMMERCE_BASE_URL: process.env.ECOMMERCE_BASE_URL,
|
||||
CREDIT_PURCHASE_URL: process.env.CREDIT_PURCHASE_URL,
|
||||
@@ -14,13 +15,13 @@ const configuration = {
|
||||
LEARNING_BASE_URL: process.env.LEARNING_BASE_URL,
|
||||
SESSION_COOKIE_DOMAIN: process.env.SESSION_COOKIE_DOMAIN || '',
|
||||
SUPPORT_URL: process.env.SUPPORT_URL || null,
|
||||
ENABLE_NOTICES: process.env.ENABLE_NOTICES || null,
|
||||
CAREER_LINK_URL: process.env.CAREER_LINK_URL || null,
|
||||
LOGO_URL: process.env.LOGO_URL,
|
||||
ENABLE_EDX_PERSONAL_DASHBOARD: process.env.ENABLE_EDX_PERSONAL_DASHBOARD === 'true',
|
||||
SEARCH_CATALOG_URL: process.env.SEARCH_CATALOG_URL || null,
|
||||
ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS === 'true',
|
||||
NON_BROWSABLE_COURSES: process.env.NON_BROWSABLE_COURSES === 'true',
|
||||
SHOW_UNENROLL_SURVEY: process.env.SHOW_UNENROLL_SURVEY === 'true',
|
||||
};
|
||||
|
||||
const features = {};
|
||||
|
||||
15
src/containers/AppWrapper/index.test.tsx
Normal file
15
src/containers/AppWrapper/index.test.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import AppWrapper from './index';
|
||||
|
||||
describe('AppWrapper', () => {
|
||||
it('should render children without modification', () => {
|
||||
render(
|
||||
<AppWrapper>
|
||||
<div>Test Child</div>
|
||||
</AppWrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Child')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,29 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';
|
||||
|
||||
import track from 'tracking';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useCourseTrackingEvent } from 'hooks';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import useActionDisabledState from '../hooks';
|
||||
import ActionButton from './ActionButton';
|
||||
import messages from './messages';
|
||||
|
||||
export const BeginCourseButton = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
|
||||
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId);
|
||||
const { data: learnerData } = useInitializeLearnerHome();
|
||||
const courseData = useCourseData(cardId);
|
||||
const homeUrl = courseData?.courseRun?.homeUrl;
|
||||
const execEdTrackingParam = useMemo(() => {
|
||||
const isExecEd2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(courseData.enrollment.mode);
|
||||
const { authOrgId } = learnerData.enterpriseDashboard || {};
|
||||
return isExecEd2UCourse ? `?org_id=${authOrgId}` : '';
|
||||
}, [courseData.enrollment.mode, learnerData.enterpriseDashboard]);
|
||||
const { disableBeginCourse } = useActionDisabledState(cardId);
|
||||
|
||||
const handleClick = reduxHooks.useTrackCourseEvent(
|
||||
const handleClick = useCourseTrackingEvent(
|
||||
track.course.enterCourseClicked,
|
||||
cardId,
|
||||
homeUrl + execEdTrackingParam,
|
||||
|
||||
@@ -1,36 +1,42 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import track from 'tracking';
|
||||
import { useCourseData, useCourseTrackingEvent } from 'hooks';
|
||||
import useActionDisabledState from '../hooks';
|
||||
import BeginCourseButton from './BeginCourseButton';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
useCourseData: jest.fn().mockReturnValue({
|
||||
enrollment: { mode: 'executive-education' },
|
||||
courseRun: { homeUrl: 'home-url' },
|
||||
}),
|
||||
useCourseTrackingEvent: jest.fn().mockReturnValue({
|
||||
trackCourseEvent: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn().mockReturnValue({
|
||||
data: {
|
||||
enterpriseDashboard: {
|
||||
authOrgId: 'test-org-id',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('tracking', () => ({
|
||||
course: {
|
||||
enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCourseRunData: jest.fn(),
|
||||
useCardExecEdTrackingParam: jest.fn(),
|
||||
useTrackCourseEvent: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../hooks', () => jest.fn(() => ({ disableBeginCourse: false })));
|
||||
|
||||
jest.mock('./ActionButton/hooks', () => jest.fn(() => false));
|
||||
|
||||
const homeUrl = 'home-url';
|
||||
reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl });
|
||||
const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`;
|
||||
reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath);
|
||||
reduxHooks.useTrackCourseEvent.mockImplementation(
|
||||
(eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }),
|
||||
);
|
||||
|
||||
const props = {
|
||||
cardId: 'cardId',
|
||||
@@ -45,11 +51,7 @@ describe('BeginCourseButton', () => {
|
||||
describe('initiliaze hooks', () => {
|
||||
it('initializes course run data with cardId', () => {
|
||||
renderComponent();
|
||||
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
|
||||
});
|
||||
it('loads exec education path param', () => {
|
||||
renderComponent();
|
||||
expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
|
||||
expect(useCourseData).toHaveBeenCalledWith(props.cardId);
|
||||
});
|
||||
it('loads disabled states for begin action from action hooks', () => {
|
||||
renderComponent();
|
||||
@@ -73,15 +75,15 @@ describe('BeginCourseButton', () => {
|
||||
expect(button).not.toHaveClass('disabled');
|
||||
expect(button).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
it('should track enter course clicked event on click, with exec ed param', async () => {
|
||||
it('should track enter course clicked event on click, with exec ed param', () => {
|
||||
renderComponent();
|
||||
const user = userEvent.setup();
|
||||
const button = screen.getByRole('button', { name: 'Begin Course' });
|
||||
user.click(button);
|
||||
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
|
||||
expect(useCourseTrackingEvent).toHaveBeenCalledWith(
|
||||
track.course.enterCourseClicked,
|
||||
props.cardId,
|
||||
homeUrl + execEdPath(props.cardId),
|
||||
`${homeUrl}?org_id=test-org-id`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';
|
||||
import track from 'tracking';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseTrackingEvent, useCourseData } from 'hooks';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import useActionDisabledState from '../hooks';
|
||||
import ActionButton from './ActionButton';
|
||||
import messages from './messages';
|
||||
|
||||
export const ResumeButton = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { resumeUrl } = reduxHooks.useCardCourseRunData(cardId);
|
||||
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId);
|
||||
const { data: learnerData } = useInitializeLearnerHome();
|
||||
const courseData = useCourseData(cardId);
|
||||
const resumeUrl = courseData?.courseRun?.resumeUrl;
|
||||
const execEdTrackingParam = useMemo(() => {
|
||||
const isExecEd2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(courseData.enrollment.mode);
|
||||
const { authOrgId } = learnerData.enterpriseDashboard || {};
|
||||
return isExecEd2UCourse ? `?org_id=${authOrgId}` : '';
|
||||
}, [courseData.enrollment.mode, learnerData.enterpriseDashboard]);
|
||||
const { disableResumeCourse } = useActionDisabledState(cardId);
|
||||
|
||||
const handleClick = reduxHooks.useTrackCourseEvent(
|
||||
const handleClick = useCourseTrackingEvent(
|
||||
track.course.enterCourseClicked,
|
||||
cardId,
|
||||
resumeUrl + execEdTrackingParam,
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useCourseTrackingEvent, useCourseData } from 'hooks';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import track from 'tracking';
|
||||
import useActionDisabledState from '../hooks';
|
||||
import ResumeButton from './ResumeButton';
|
||||
|
||||
const authOrgId = 'auth-org-id';
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn().mockReturnValue({
|
||||
data: {
|
||||
enterpriseDashboard: {
|
||||
authOrgId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
useCourseData: jest.fn().mockReturnValue({
|
||||
enrollment: { mode: 'executive-education' },
|
||||
courseRun: { homeUrl: 'home-url' },
|
||||
}),
|
||||
useCourseTrackingEvent: jest.fn().mockReturnValue({
|
||||
trackCourseEvent: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('tracking', () => ({
|
||||
course: {
|
||||
enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCourseRunData: jest.fn(),
|
||||
useCardExecEdTrackingParam: jest.fn(),
|
||||
useTrackCourseEvent: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../hooks', () => jest.fn(() => ({ disableResumeCourse: false })));
|
||||
|
||||
jest.mock('./ActionButton/hooks', () => jest.fn(() => false));
|
||||
|
||||
const resumeUrl = 'resume-url';
|
||||
reduxHooks.useCardCourseRunData.mockReturnValue({ resumeUrl });
|
||||
const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`;
|
||||
reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath);
|
||||
reduxHooks.useTrackCourseEvent.mockImplementation(
|
||||
(eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }),
|
||||
);
|
||||
useCourseData.mockReturnValue({
|
||||
enrollment: { mode: 'executive-education' },
|
||||
courseRun: { resumeUrl: 'home-url' },
|
||||
});
|
||||
|
||||
describe('ResumeButton', () => {
|
||||
const props = {
|
||||
@@ -39,10 +50,7 @@ describe('ResumeButton', () => {
|
||||
describe('initialize hooks', () => {
|
||||
beforeEach(() => render(<IntlProvider locale="en"><ResumeButton {...props} /></IntlProvider>));
|
||||
it('initializes course run data with cardId', () => {
|
||||
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
|
||||
});
|
||||
it('loads exec education path param', () => {
|
||||
expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
|
||||
expect(useCourseData).toHaveBeenCalledWith(props.cardId);
|
||||
});
|
||||
it('loads disabled states for resume action from action hooks', () => {
|
||||
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
|
||||
@@ -73,10 +81,10 @@ describe('ResumeButton', () => {
|
||||
const user = userEvent.setup();
|
||||
const button = screen.getByRole('button', { name: 'Resume' });
|
||||
user.click(button);
|
||||
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
|
||||
expect(useCourseTrackingEvent).toHaveBeenCalledWith(
|
||||
track.course.enterCourseClicked,
|
||||
props.cardId,
|
||||
resumeUrl + execEdPath(props.cardId),
|
||||
`home-url?org_id=${authOrgId}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useSelectSessionModal } from 'data/context';
|
||||
import useActionDisabledState from '../hooks';
|
||||
import ActionButton from './ActionButton';
|
||||
import messages from './messages';
|
||||
@@ -11,11 +11,11 @@ import messages from './messages';
|
||||
export const SelectSessionButton = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { disableSelectSession } = useActionDisabledState(cardId);
|
||||
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId);
|
||||
const { updateSelectSessionModal } = useSelectSessionModal();
|
||||
return (
|
||||
<ActionButton
|
||||
disabled={disableSelectSession}
|
||||
onClick={openSessionModal}
|
||||
onClick={() => updateSelectSessionModal(cardId)}
|
||||
>
|
||||
{formatMessage(messages.selectSession)}
|
||||
</ActionButton>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useSelectSessionModal } from 'data/context';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import useActionDisabledState from '../hooks';
|
||||
|
||||
import SelectSessionButton from './SelectSessionButton';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useUpdateSelectSessionModalCallback: jest.fn(),
|
||||
},
|
||||
jest.mock('data/context', () => ({
|
||||
useSelectSessionModal: jest.fn().mockReturnValue({
|
||||
updateSelectSessionModal: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('../hooks', () => jest.fn(() => ({ disableSelectSession: false })));
|
||||
|
||||
@@ -33,11 +33,15 @@ describe('SelectSessionButton', () => {
|
||||
});
|
||||
describe('on click', () => {
|
||||
it('should call openSessionModal', async () => {
|
||||
const mockedUpdateSelectSessionModal = jest.fn();
|
||||
useSelectSessionModal.mockReturnValue({
|
||||
updateSelectSessionModal: mockedUpdateSelectSessionModal,
|
||||
});
|
||||
render(<IntlProvider locale="en"><SelectSessionButton {...props} /></IntlProvider>);
|
||||
const user = userEvent.setup();
|
||||
const button = screen.getByRole('button', { name: 'Select Session' });
|
||||
await user.click(button);
|
||||
expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(props.cardId);
|
||||
expect(mockedUpdateSelectSessionModal).toHaveBeenCalledWith(props.cardId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,17 +4,18 @@ import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import track from 'tracking';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseTrackingEvent, useCourseData } from 'hooks';
|
||||
import useActionDisabledState from '../hooks';
|
||||
import ActionButton from './ActionButton';
|
||||
import messages from './messages';
|
||||
|
||||
export const ViewCourseButton = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
|
||||
const courseData = useCourseData(cardId);
|
||||
const homeUrl = courseData?.courseRun?.homeUrl;
|
||||
const { disableViewCourse } = useActionDisabledState(cardId);
|
||||
|
||||
const handleClick = reduxHooks.useTrackCourseEvent(
|
||||
const handleClick = useCourseTrackingEvent(
|
||||
track.course.enterCourseClicked,
|
||||
cardId,
|
||||
homeUrl,
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useCourseTrackingEvent } from 'hooks';
|
||||
|
||||
import track from 'tracking';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import useActionDisabledState from '../hooks';
|
||||
import ViewCourseButton from './ViewCourseButton';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
useCourseData: jest.fn().mockReturnValue({
|
||||
courseRun: { homeUrl: 'homeUrl' },
|
||||
}),
|
||||
useCourseTrackingEvent: jest.fn().mockReturnValue({
|
||||
trackCourseEvent: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('tracking', () => ({
|
||||
course: {
|
||||
enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCourseRunData: jest.fn(() => ({ homeUrl: 'homeUrl' })),
|
||||
useTrackCourseEvent: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../hooks', () => jest.fn(() => ({ disableViewCourse: false })));
|
||||
|
||||
jest.mock('./ActionButton/hooks', () => jest.fn(() => false));
|
||||
@@ -35,15 +38,18 @@ describe('ViewCourseButton', () => {
|
||||
expect(button).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
it('calls trackCourseEvent on click', async () => {
|
||||
const mockedTrackCourseEvent = jest.fn();
|
||||
useCourseTrackingEvent.mockReturnValue(mockedTrackCourseEvent);
|
||||
render(<IntlProvider locale="en"><ViewCourseButton {...defaultProps} /></IntlProvider>);
|
||||
const user = userEvent.setup();
|
||||
const button = screen.getByRole('button', { name: 'View Course' });
|
||||
await user.click(button);
|
||||
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
|
||||
expect(useCourseTrackingEvent).toHaveBeenCalledWith(
|
||||
track.course.enterCourseClicked,
|
||||
defaultProps.cardId,
|
||||
homeUrl,
|
||||
);
|
||||
expect(mockedTrackCourseEvent).toHaveBeenCalled();
|
||||
});
|
||||
it('learner cannot view course', () => {
|
||||
useActionDisabledState.mockReturnValueOnce({ disableViewCourse: true });
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { ActionRow } from '@openedx/paragon';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useEntitlementInfo } from 'hooks';
|
||||
|
||||
import CourseCardActionSlot from 'plugin-slots/CourseCardActionSlot';
|
||||
import SelectSessionButton from './SelectSessionButton';
|
||||
@@ -12,11 +12,10 @@ import ResumeButton from './ResumeButton';
|
||||
import ViewCourseButton from './ViewCourseButton';
|
||||
|
||||
export const CourseCardActions = ({ cardId }) => {
|
||||
const { isEntitlement, isFulfilled } = reduxHooks.useCardEntitlementData(cardId);
|
||||
const {
|
||||
hasStarted,
|
||||
} = reduxHooks.useCardEnrollmentData(cardId);
|
||||
const { isArchived } = reduxHooks.useCardCourseRunData(cardId);
|
||||
const cardData = useCourseData(cardId);
|
||||
const hasStarted = cardData.enrollment.hasStarted || false;
|
||||
const { isEntitlement, isFulfilled } = useEntitlementInfo(cardData);
|
||||
const isArchived = cardData.courseRun.isArchived || false;
|
||||
|
||||
return (
|
||||
<ActionRow data-test-id="CourseCardActions">
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { reduxHooks } from 'hooks';
|
||||
|
||||
import { useCourseData } from 'hooks';
|
||||
import CourseCardActions from '.';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCourseRunData: jest.fn(),
|
||||
useCardEnrollmentData: jest.fn(),
|
||||
useCardEntitlementData: jest.fn(),
|
||||
useMasqueradeData: jest.fn(),
|
||||
},
|
||||
...jest.requireActual('hooks'),
|
||||
useCourseData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('plugin-slots/CourseCardActionSlot', () => jest.fn(() => <div>CourseCardActionSlot</div>));
|
||||
@@ -24,26 +19,22 @@ const props = { cardId };
|
||||
describe('CourseCardActions', () => {
|
||||
const mockHooks = ({
|
||||
isEntitlement = false,
|
||||
isExecEd2UCourse = false,
|
||||
isFulfilled = false,
|
||||
isArchived = false,
|
||||
isVerified = false,
|
||||
hasStarted = false,
|
||||
isMasquerading = false,
|
||||
} = {}) => {
|
||||
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isFulfilled });
|
||||
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ isArchived });
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isExecEd2UCourse, isVerified, hasStarted });
|
||||
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading });
|
||||
useCourseData.mockReturnValueOnce({
|
||||
enrollment: { hasStarted },
|
||||
courseRun: { isArchived },
|
||||
entitlement: isEntitlement !== null ? { isEntitlement, isFulfilled } : null,
|
||||
});
|
||||
};
|
||||
const renderComponent = () => render(<CourseCardActions {...props} />);
|
||||
describe('hooks', () => {
|
||||
it('initializes redux hooks', () => {
|
||||
it('initializes hooks', () => {
|
||||
mockHooks();
|
||||
renderComponent();
|
||||
expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId);
|
||||
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
|
||||
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
|
||||
expect(useCourseData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
@@ -63,7 +54,7 @@ describe('CourseCardActions', () => {
|
||||
});
|
||||
describe('not entitlement, verified, or exec ed', () => {
|
||||
it('renders CourseCardActionSlot and ViewCourseButton for archived courses', () => {
|
||||
mockHooks({ isArchived: true });
|
||||
mockHooks({ isArchived: true, isEntitlement: null });
|
||||
renderComponent();
|
||||
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
|
||||
expect(CourseCardActionSlot).toBeInTheDocument();
|
||||
@@ -72,7 +63,7 @@ describe('CourseCardActions', () => {
|
||||
});
|
||||
describe('unstarted courses', () => {
|
||||
it('renders CourseCardActionSlot and BeginCourseButton', () => {
|
||||
mockHooks();
|
||||
mockHooks({ isEntitlement: null });
|
||||
renderComponent();
|
||||
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
|
||||
expect(CourseCardActionSlot).toBeInTheDocument();
|
||||
@@ -82,7 +73,7 @@ describe('CourseCardActions', () => {
|
||||
});
|
||||
describe('active courses (started, and not archived)', () => {
|
||||
it('renders CourseCardActionSlot and ResumeButton', () => {
|
||||
mockHooks({ hasStarted: true });
|
||||
mockHooks({ hasStarted: true, isEntitlement: null });
|
||||
renderComponent();
|
||||
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
|
||||
expect(CourseCardActionSlot).toBeInTheDocument();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/* eslint-disable max-len */
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { MailtoLink, Hyperlink } from '@openedx/paragon';
|
||||
import { CheckCircle } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { baseAppUrl } from 'data/services/lms/urls';
|
||||
|
||||
import { utilHooks, reduxHooks } from 'hooks';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import { utilHooks, useCourseData } from 'hooks';
|
||||
import Banner from 'components/Banner';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -14,15 +16,32 @@ import messages from './messages';
|
||||
const { useFormatDate } = utilHooks;
|
||||
|
||||
export const CertificateBanner = ({ cardId }) => {
|
||||
const certificate = reduxHooks.useCardCertificateData(cardId);
|
||||
const { data: learnerHomeData } = useInitializeLearnerHome();
|
||||
const courseData = useCourseData(cardId);
|
||||
const {
|
||||
isAudit,
|
||||
isVerified,
|
||||
} = reduxHooks.useCardEnrollmentData(cardId);
|
||||
const { isPassing } = reduxHooks.useCardGradeData(cardId);
|
||||
const { isArchived } = reduxHooks.useCardCourseRunData(cardId);
|
||||
const { minPassingGrade, progressUrl } = reduxHooks.useCardCourseRunData(cardId);
|
||||
const { supportEmail, billingEmail } = reduxHooks.usePlatformSettingsData();
|
||||
certificate = {},
|
||||
isVerified = false,
|
||||
isAudit = false,
|
||||
isPassing = false,
|
||||
isArchived = false,
|
||||
minPassingGrade = 0,
|
||||
progressUrl = '',
|
||||
} = useMemo(() => ({
|
||||
isVerified: courseData?.enrollment?.isVerified,
|
||||
isAudit: courseData?.enrollment?.isAudit,
|
||||
certificate: courseData?.certificate || {},
|
||||
isPassing: courseData?.gradeData?.isPassing,
|
||||
isArchived: courseData?.courseRun?.isArchived,
|
||||
minPassingGrade: Math.floor((courseData?.courseRun?.minPassingGrade ?? 0) * 100),
|
||||
progressUrl: baseAppUrl(courseData?.courseRun?.progressUrl || ''),
|
||||
}), [courseData]);
|
||||
const { supportEmail, billingEmail } = useMemo(
|
||||
() => ({
|
||||
supportEmail: learnerHomeData?.platformSettings?.supportEmail,
|
||||
billingEmail: learnerHomeData?.platformSettings?.billingEmail,
|
||||
}),
|
||||
[learnerHomeData],
|
||||
);
|
||||
const { formatMessage } = useIntl();
|
||||
const formatDate = useFormatDate();
|
||||
|
||||
@@ -75,7 +94,7 @@ export const CertificateBanner = ({ cardId }) => {
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (certificate.isEarnedButUnavailable) {
|
||||
if (certificate.isEarned && new Date(certificate.availableDate) > new Date()) {
|
||||
return (
|
||||
<Banner>
|
||||
{formatMessage(
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import CertificateBanner from './CertificateBanner';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
utilHooks: {
|
||||
useFormatDate: jest.fn(() => date => date),
|
||||
},
|
||||
reduxHooks: {
|
||||
useCardCertificateData: jest.fn(),
|
||||
useCardCourseRunData: jest.fn(),
|
||||
useCardEnrollmentData: jest.fn(),
|
||||
useCardGradeData: jest.fn(),
|
||||
usePlatformSettingsData: jest.fn(),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn(),
|
||||
}));
|
||||
|
||||
const defaultCertificate = {
|
||||
@@ -35,9 +35,14 @@ const supportEmail = 'suport@email.com';
|
||||
const billingEmail = 'billing@email.com';
|
||||
|
||||
describe('CertificateBanner', () => {
|
||||
reduxHooks.useCardCourseRunData.mockReturnValue({
|
||||
minPassingGrade: 0.8,
|
||||
progressUrl: 'progressUrl',
|
||||
useCourseData.mockReturnValue({
|
||||
enrollment: {},
|
||||
certificate: {},
|
||||
gradeData: {},
|
||||
courseRun: {
|
||||
minPassingGrade: 0.8,
|
||||
progressUrl: 'progressUrl',
|
||||
},
|
||||
});
|
||||
const createWrapper = ({
|
||||
certificate = {},
|
||||
@@ -46,11 +51,17 @@ describe('CertificateBanner', () => {
|
||||
courseRun = {},
|
||||
platformSettings = {},
|
||||
}) => {
|
||||
reduxHooks.useCardGradeData.mockReturnValueOnce({ ...defaultGrade, ...grade });
|
||||
reduxHooks.useCardCertificateData.mockReturnValueOnce({ ...defaultCertificate, ...certificate });
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ ...defaultEnrollment, ...enrollment });
|
||||
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ ...defaultCourseRun, ...courseRun });
|
||||
reduxHooks.usePlatformSettingsData.mockReturnValueOnce({ ...defaultPlatformSettings, ...platformSettings });
|
||||
useCourseData.mockReturnValue({
|
||||
enrollment: { ...defaultEnrollment, ...enrollment },
|
||||
certificate: { ...defaultCertificate, ...certificate },
|
||||
gradeData: { ...defaultGrade, ...grade },
|
||||
courseRun: {
|
||||
...defaultCourseRun,
|
||||
...courseRun,
|
||||
},
|
||||
});
|
||||
const lernearData = { data: { platformSettings: { ...defaultPlatformSettings, ...platformSettings } } };
|
||||
useInitializeLearnerHome.mockReturnValue(lernearData);
|
||||
return render(<IntlProvider locale="en"><CertificateBanner {...props} /></IntlProvider>);
|
||||
};
|
||||
beforeEach(() => {
|
||||
@@ -222,7 +233,8 @@ describe('CertificateBanner', () => {
|
||||
isPassing: true,
|
||||
},
|
||||
certificate: {
|
||||
isEarnedButUnavailable: true,
|
||||
isEarned: true,
|
||||
availableDate: '10/20/3030',
|
||||
},
|
||||
});
|
||||
const banner = screen.getByRole('alert');
|
||||
@@ -239,4 +251,27 @@ describe('CertificateBanner', () => {
|
||||
const banner = screen.queryByRole('alert');
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
it('should use default values when courseData is empty or undefined', () => {
|
||||
useCourseData.mockReturnValue({});
|
||||
const lernearData = { data: { platformSettings: { supportEmail } } };
|
||||
useInitializeLearnerHome.mockReturnValue(lernearData);
|
||||
render(<IntlProvider locale="en"><CertificateBanner cardId="test-card" /></IntlProvider>);
|
||||
|
||||
const mockedUseMemo = jest.spyOn(React, 'useMemo');
|
||||
const useMemoCall = mockedUseMemo.mock.calls.find(call => call[1].some(dep => dep === undefined || dep === null));
|
||||
|
||||
if (useMemoCall) {
|
||||
const result = useMemoCall[0]();
|
||||
|
||||
expect(result.certificate).toEqual({});
|
||||
expect(result.isVerified).toBe(false);
|
||||
expect(result.isAudit).toBe(false);
|
||||
expect(result.isPassing).toBe(false);
|
||||
expect(result.isArchived).toBe(false);
|
||||
expect(result.minPassingGrade).toBe(0);
|
||||
expect(result.progressUrl).toBeDefined();
|
||||
}
|
||||
|
||||
mockedUseMemo.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
/* eslint-disable max-len */
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { utilHooks, reduxHooks } from 'hooks';
|
||||
import { utilHooks, useCourseData } from 'hooks';
|
||||
import Banner from 'components/Banner';
|
||||
import messages from './messages';
|
||||
|
||||
export const CourseBanner = ({ cardId }) => {
|
||||
const {
|
||||
isVerified,
|
||||
isAuditAccessExpired,
|
||||
coursewareAccess = {},
|
||||
} = reduxHooks.useCardEnrollmentData(cardId);
|
||||
const courseRun = reduxHooks.useCardCourseRunData(cardId);
|
||||
const { formatMessage } = useIntl();
|
||||
const courseData = useCourseData(cardId);
|
||||
const {
|
||||
isVerified = false,
|
||||
isAuditAccessExpired = false,
|
||||
coursewareAccess = {},
|
||||
} = useMemo(() => ({
|
||||
isVerified: courseData.enrollment?.isVerified,
|
||||
isAuditAccessExpired: courseData.enrollment?.isAuditAccessExpired,
|
||||
coursewareAccess: courseData.enrollment?.coursewareAccess || {},
|
||||
}), [courseData]);
|
||||
const courseRun = courseData?.courseRun || {};
|
||||
const formatDate = utilHooks.useFormatDate();
|
||||
|
||||
const { hasUnmetPrerequisites, isStaff, isTooEarly } = coursewareAccess;
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import { formatMessage } from 'testUtils';
|
||||
import { CourseBanner } from './CourseBanner';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
useCourseData: jest.fn(),
|
||||
utilHooks: {
|
||||
useFormatDate: () => date => date,
|
||||
},
|
||||
reduxHooks: {
|
||||
useCardCourseRunData: jest.fn(),
|
||||
useCardEnrollmentData: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const cardId = 'test-card-id';
|
||||
@@ -39,13 +36,15 @@ const renderCourseBanner = (overrides = {}) => {
|
||||
courseRun = {},
|
||||
enrollment = {},
|
||||
} = overrides;
|
||||
reduxHooks.useCardCourseRunData.mockReturnValueOnce({
|
||||
...courseRunData,
|
||||
...courseRun,
|
||||
});
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
|
||||
...enrollmentData,
|
||||
...enrollment,
|
||||
useCourseData.mockReturnValue({
|
||||
courseRun: {
|
||||
...courseRunData,
|
||||
...courseRun,
|
||||
},
|
||||
enrollment: {
|
||||
...enrollmentData,
|
||||
...enrollment,
|
||||
},
|
||||
});
|
||||
return render(<IntlProvider locale="en"><CourseBanner cardId={cardId} /></IntlProvider>);
|
||||
};
|
||||
@@ -53,13 +52,20 @@ const renderCourseBanner = (overrides = {}) => {
|
||||
describe('CourseBanner', () => {
|
||||
it('initializes data with course number from enrollment, course and course run data', () => {
|
||||
renderCourseBanner();
|
||||
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
|
||||
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
|
||||
expect(useCourseData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
it('no display if learner is verified', () => {
|
||||
renderCourseBanner({ enrollment: { isVerified: true } });
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
it('should use default values when enrollment data is undefined', () => {
|
||||
renderCourseBanner({
|
||||
enrollment: undefined,
|
||||
courseRun: {},
|
||||
});
|
||||
|
||||
expect(useCourseData).toHaveBeenCalledWith('test-card-id');
|
||||
});
|
||||
describe('audit access expired', () => {
|
||||
it('should display correct message and link', () => {
|
||||
renderCourseBanner({ enrollment: { isAuditAccessExpired: true } });
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import { StrictDict } from 'utils';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
|
||||
import ApprovedContent from './views/ApprovedContent';
|
||||
import EligibleContent from './views/EligibleContent';
|
||||
@@ -15,9 +17,29 @@ export const statusComponents = StrictDict({
|
||||
});
|
||||
|
||||
export const useCreditBannerData = (cardId) => {
|
||||
const credit = reduxHooks.useCardCreditData(cardId);
|
||||
const { supportEmail } = reduxHooks.usePlatformSettingsData();
|
||||
if (!credit.isEligible) { return null; }
|
||||
const courseData = useCourseData(cardId);
|
||||
const { data: learnerHomeData } = useInitializeLearnerHome();
|
||||
const supportEmail = useMemo(
|
||||
() => (learnerHomeData?.platformSettings?.supportEmail),
|
||||
[learnerHomeData],
|
||||
);
|
||||
|
||||
const credit = useMemo(() => {
|
||||
const creditData = courseData?.credit;
|
||||
if (!creditData || Object.keys(creditData).length === 0) {
|
||||
return { isEligible: false };
|
||||
}
|
||||
return {
|
||||
isEligible: true,
|
||||
providerStatusUrl: creditData.providerStatusUrl,
|
||||
providerName: creditData.providerName,
|
||||
providerId: creditData.providerId,
|
||||
error: creditData.error,
|
||||
purchased: creditData.purchased,
|
||||
requestStatus: creditData.requestStatus,
|
||||
};
|
||||
}, [courseData]);
|
||||
if (!credit.isEligible || !courseData?.credit?.isEligible) { return null; }
|
||||
|
||||
const { error, purchased, requestStatus } = credit;
|
||||
let ContentComponent = EligibleContent;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { keyStore } from 'utils';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
|
||||
import ApprovedContent from './views/ApprovedContent';
|
||||
import EligibleContent from './views/EligibleContent';
|
||||
@@ -9,12 +10,19 @@ import RejectedContent from './views/RejectedContent';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCreditData: jest.fn(),
|
||||
usePlatformSettingsData: jest.fn(),
|
||||
},
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useMemo: (fn) => fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
useCourseData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./views/ApprovedContent', () => 'ApprovedContent');
|
||||
jest.mock('./views/EligibleContent', () => 'EligibleContent');
|
||||
jest.mock('./views/MustRequestContent', () => 'MustRequestContent');
|
||||
@@ -34,18 +42,18 @@ const defaultProps = {
|
||||
};
|
||||
|
||||
const loadHook = (creditData = {}) => {
|
||||
reduxHooks.useCardCreditData.mockReturnValue({ ...defaultProps, ...creditData });
|
||||
useCourseData.mockReturnValue({ credit: { ...defaultProps, ...creditData } });
|
||||
out = hooks.useCreditBannerData(cardId);
|
||||
};
|
||||
|
||||
describe('useCreditBannerData hook', () => {
|
||||
beforeEach(() => {
|
||||
reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail });
|
||||
useInitializeLearnerHome.mockReturnValue({ data: { platformSettings: { supportEmail } } });
|
||||
});
|
||||
it('loads card credit data with cardID and loads platform settings data', () => {
|
||||
loadHook({ isEligible: false });
|
||||
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
|
||||
expect(reduxHooks.usePlatformSettingsData).toHaveBeenCalledWith();
|
||||
expect(useCourseData).toHaveBeenCalledWith(cardId);
|
||||
expect(useInitializeLearnerHome).toHaveBeenCalledWith();
|
||||
});
|
||||
describe('non-credit-eligible learner', () => {
|
||||
it('returns null if the learner is not credit eligible', () => {
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useIsMasquerading } from 'hooks';
|
||||
import CreditContent from './components/CreditContent';
|
||||
import ProviderLink from './components/ProviderLink';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
export const ApprovedContent = ({ cardId }) => {
|
||||
const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId);
|
||||
const { isMasquerading } = reduxHooks.useMasqueradeData();
|
||||
const courseData = useCourseData(cardId);
|
||||
const { providerStatusUrl: href, providerName } = useMemo(() => {
|
||||
const creditData = courseData?.credit;
|
||||
return {
|
||||
providerStatusUrl: creditData.providerStatusUrl,
|
||||
providerName: creditData.providerName,
|
||||
};
|
||||
}, [courseData]);
|
||||
const isMasquerading = useIsMasquerading();
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<CreditContent
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { formatMessage } from 'testUtils';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useIsMasquerading } from 'hooks';
|
||||
import messages from './messages';
|
||||
import ApprovedContent from './ApprovedContent';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCreditData: jest.fn(),
|
||||
useMasqueradeData: jest.fn(),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
useIsMasquerading: jest.fn(),
|
||||
}));
|
||||
|
||||
const cardId = 'test-card-id';
|
||||
@@ -17,14 +15,14 @@ const credit = {
|
||||
providerStatusUrl: 'test-credit-provider-status-url',
|
||||
providerName: 'test-credit-provider-name',
|
||||
};
|
||||
reduxHooks.useCardCreditData.mockReturnValue(credit);
|
||||
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
|
||||
useCourseData.mockReturnValue({ credit });
|
||||
useIsMasquerading.mockReturnValue(false);
|
||||
|
||||
describe('ApprovedContent component', () => {
|
||||
describe('hooks', () => {
|
||||
it('initializes credit data with cardId', () => {
|
||||
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
|
||||
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
|
||||
expect(useCourseData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
@@ -56,7 +54,7 @@ describe('ApprovedContent component', () => {
|
||||
});
|
||||
describe('when masquerading', () => {
|
||||
beforeEach(() => {
|
||||
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
|
||||
useIsMasquerading.mockReturnValue(true);
|
||||
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import track from 'tracking';
|
||||
|
||||
import CreditContent from './components/CreditContent';
|
||||
@@ -11,8 +11,9 @@ import messages from './messages';
|
||||
|
||||
export const EligibleContent = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { providerName } = reduxHooks.useCardCreditData(cardId);
|
||||
const { courseId } = reduxHooks.useCardCourseRunData(cardId);
|
||||
const courseData = useCourseData(cardId);
|
||||
const providerName = courseData?.credit?.providerName;
|
||||
const courseId = courseData?.courseRun?.courseId;
|
||||
|
||||
const onClick = track.credit.purchase(courseId);
|
||||
const getCredit = formatMessage(messages.getCredit);
|
||||
|
||||
@@ -2,17 +2,14 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import track from 'tracking';
|
||||
|
||||
import messages from './messages';
|
||||
import EligibleContent from './EligibleContent';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCreditData: jest.fn(),
|
||||
useCardCourseRunData: jest.fn(),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('tracking', () => ({
|
||||
@@ -26,8 +23,7 @@ const courseId = 'test-course-id';
|
||||
const credit = {
|
||||
providerName: 'test-credit-provider-name',
|
||||
};
|
||||
reduxHooks.useCardCreditData.mockReturnValue(credit);
|
||||
reduxHooks.useCardCourseRunData.mockReturnValue({ courseId });
|
||||
useCourseData.mockReturnValue({ credit, courseRun: { courseId } });
|
||||
|
||||
const renderEligibleContent = () => render(<IntlProvider locale="en" messages={{}}><EligibleContent cardId={cardId} /></IntlProvider>);
|
||||
|
||||
@@ -35,11 +31,7 @@ describe('EligibleContent component', () => {
|
||||
describe('hooks', () => {
|
||||
it('initializes credit data with cardId', () => {
|
||||
renderEligibleContent();
|
||||
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
it('initializes course run data with cardId', () => {
|
||||
renderEligibleContent();
|
||||
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
|
||||
expect(useCourseData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
});
|
||||
describe('behavior', () => {
|
||||
@@ -63,7 +55,7 @@ describe('EligibleContent component', () => {
|
||||
expect(eligibleMessage).toHaveTextContent(credit.providerName);
|
||||
});
|
||||
it('message is formatted eligible message if no provider', () => {
|
||||
reduxHooks.useCardCreditData.mockReturnValue({});
|
||||
useCourseData.mockReturnValue({ credit: {}, courseRun: { courseId } });
|
||||
renderEligibleContent();
|
||||
const eligibleMessage = screen.getByTestId('credit-msg');
|
||||
expect(eligibleMessage).toBeInTheDocument();
|
||||
|
||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useIsMasquerading } from 'hooks';
|
||||
import CreditContent from './components/CreditContent';
|
||||
import ProviderLink from './components/ProviderLink';
|
||||
import hooks from './hooks';
|
||||
@@ -13,7 +13,7 @@ import messages from './messages';
|
||||
export const MustRequestContent = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { requestData, createCreditRequest } = hooks.useCreditRequestData(cardId);
|
||||
const { isMasquerading } = reduxHooks.useMasqueradeData();
|
||||
const isMasquerading = useIsMasquerading();
|
||||
return (
|
||||
<CreditContent
|
||||
action={{
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useIsMasquerading } from 'hooks';
|
||||
import messages from './messages';
|
||||
import hooks from './hooks';
|
||||
import MustRequestContent from './MustRequestContent';
|
||||
@@ -12,10 +11,8 @@ jest.mock('./hooks', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useMasqueradeData: jest.fn(),
|
||||
useCardCreditData: jest.fn(),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
useIsMasquerading: jest.fn(),
|
||||
}));
|
||||
|
||||
const cardId = 'test-card-id';
|
||||
@@ -44,10 +41,12 @@ describe('MustRequestContent component', () => {
|
||||
requestData,
|
||||
createCreditRequest,
|
||||
});
|
||||
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
|
||||
reduxHooks.useCardCreditData.mockReturnValue({
|
||||
providerName,
|
||||
providerStatusUrl,
|
||||
useIsMasquerading.mockReturnValue(false);
|
||||
useCourseData.mockReturnValue({
|
||||
credit: {
|
||||
providerName,
|
||||
providerStatusUrl,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +89,7 @@ describe('MustRequestContent component', () => {
|
||||
|
||||
describe('when masquerading', () => {
|
||||
beforeEach(() => {
|
||||
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
|
||||
useIsMasquerading.mockReturnValue(true);
|
||||
renderMustRequestContent();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useIsMasquerading } from 'hooks';
|
||||
import CreditContent from './components/CreditContent';
|
||||
import messages from './messages';
|
||||
|
||||
export const PendingContent = ({ cardId }) => {
|
||||
const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId);
|
||||
const { isMasquerading } = reduxHooks.useMasqueradeData();
|
||||
const courseData = useCourseData(cardId);
|
||||
const { providerStatusUrl: href, providerName } = courseData?.credit || {};
|
||||
const isMasquerading = useIsMasquerading();
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<CreditContent
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useIsMasquerading } from 'hooks';
|
||||
|
||||
import messages from './messages';
|
||||
import PendingContent from './PendingContent';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: { useCardCreditData: jest.fn(), useMasqueradeData: jest.fn() },
|
||||
useCourseData: jest.fn(),
|
||||
useIsMasquerading: jest.fn(),
|
||||
}));
|
||||
|
||||
const cardId = 'test-card-id';
|
||||
const providerName = 'test-credit-provider-name';
|
||||
const providerStatusUrl = 'test-credit-provider-status-url';
|
||||
reduxHooks.useCardCreditData.mockReturnValue({
|
||||
providerName,
|
||||
providerStatusUrl,
|
||||
useIsMasquerading.mockReturnValue(false);
|
||||
useCourseData.mockReturnValue({
|
||||
credit: {
|
||||
providerName,
|
||||
providerStatusUrl,
|
||||
},
|
||||
});
|
||||
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
|
||||
|
||||
const renderPendingContent = () => render(
|
||||
<IntlProvider messages={{}} locale="en">
|
||||
@@ -28,7 +30,7 @@ describe('PendingContent component', () => {
|
||||
describe('hooks', () => {
|
||||
it('initializes card credit data with cardId', () => {
|
||||
renderPendingContent();
|
||||
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
|
||||
expect(useCourseData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
});
|
||||
describe('behavior', () => {
|
||||
@@ -56,7 +58,7 @@ describe('PendingContent component', () => {
|
||||
});
|
||||
describe('when masqueradeData is true', () => {
|
||||
it('disables the view details button', () => {
|
||||
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
|
||||
useIsMasquerading.mockReturnValue(true);
|
||||
renderPendingContent();
|
||||
const button = screen.getByRole('link', { name: messages.viewDetails.defaultMessage });
|
||||
expect(button).toHaveClass('disabled');
|
||||
|
||||
@@ -3,18 +3,19 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import CreditContent from './components/CreditContent';
|
||||
import ProviderLink from './components/ProviderLink';
|
||||
import messages from './messages';
|
||||
|
||||
export const RejectedContent = ({ cardId }) => {
|
||||
const credit = reduxHooks.useCardCreditData(cardId);
|
||||
const courseData = useCourseData(cardId);
|
||||
const credit = courseData?.credit;
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<CreditContent
|
||||
message={formatMessage(messages.rejected, {
|
||||
providerName: credit.providerName,
|
||||
providerName: credit?.providerName,
|
||||
linkToProviderSite: (<ProviderLink cardId={cardId} />),
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import RejectedContent from './RejectedContent';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCreditData: jest.fn(),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
}));
|
||||
|
||||
const cardId = 'test-card-id';
|
||||
@@ -15,7 +13,9 @@ const credit = {
|
||||
providerStatusUrl: 'test-credit-provider-status-url',
|
||||
providerName: 'test-credit-provider-name',
|
||||
};
|
||||
reduxHooks.useCardCreditData.mockReturnValue(credit);
|
||||
useCourseData.mockReturnValue({
|
||||
credit,
|
||||
});
|
||||
|
||||
const renderRejectedContent = () => render(<IntlProvider><RejectedContent cardId={cardId} /></IntlProvider>);
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('RejectedContent component', () => {
|
||||
describe('hooks', () => {
|
||||
it('initializes credit data with cardId', () => {
|
||||
renderRejectedContent();
|
||||
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
|
||||
expect(useCourseData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
|
||||
export const ProviderLink = ({ cardId }) => {
|
||||
const credit = reduxHooks.useCardCreditData(cardId);
|
||||
const courseData = useCourseData(cardId);
|
||||
const credit = courseData?.credit || {};
|
||||
return (
|
||||
<Hyperlink
|
||||
href={credit.providerStatusUrl}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useCourseData } from 'hooks';
|
||||
|
||||
import ProviderLink from './ProviderLink';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCreditData: jest.fn(),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
}));
|
||||
|
||||
const cardId = 'test-card-id';
|
||||
@@ -23,12 +21,12 @@ const renderProviderLink = () => render(
|
||||
describe('ProviderLink component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
reduxHooks.useCardCreditData.mockReturnValue(credit);
|
||||
useCourseData.mockReturnValue({ credit });
|
||||
renderProviderLink();
|
||||
});
|
||||
describe('hooks', () => {
|
||||
it('initializes credit hook with cardId', () => {
|
||||
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
|
||||
expect(useCourseData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { StrictDict } from 'utils';
|
||||
import { apiHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import { useCreateCreditRequest } from 'data/hooks';
|
||||
|
||||
import * as module from './hooks';
|
||||
|
||||
@@ -11,13 +12,19 @@ export const state = StrictDict({
|
||||
|
||||
export const useCreditRequestData = (cardId) => {
|
||||
const [requestData, setRequestData] = module.state.creditRequestData(null);
|
||||
const createCreditApiRequest = apiHooks.useCreateCreditRequest(cardId);
|
||||
const courseData = useCourseData(cardId);
|
||||
const providerId = courseData?.credit?.providerId;
|
||||
const { authenticatedUser: { username } } = React.useContext(AppContext);
|
||||
const courseId = courseData?.courseRun?.courseId;
|
||||
const { mutate: createCreditMutation } = useCreateCreditRequest();
|
||||
|
||||
const createCreditRequest = (e) => {
|
||||
e.preventDefault();
|
||||
createCreditApiRequest()
|
||||
.then((request) => {
|
||||
setRequestData(request.data);
|
||||
});
|
||||
createCreditMutation({ providerId, courseId, username }, {
|
||||
onSuccess: (response) => {
|
||||
setRequestData(response.data);
|
||||
},
|
||||
});
|
||||
};
|
||||
return { requestData, createCreditRequest };
|
||||
};
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { MockUseState } from 'testUtils';
|
||||
import { apiHooks } from 'hooks';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
apiHooks: {
|
||||
useCreateCreditRequest: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const state = new MockUseState(hooks);
|
||||
|
||||
const cardId = 'test-card-id';
|
||||
const requestData = { data: 'request data' };
|
||||
const creditRequest = jest.fn().mockReturnValue(Promise.resolve(requestData));
|
||||
apiHooks.useCreateCreditRequest.mockReturnValue(creditRequest);
|
||||
const event = { preventDefault: jest.fn() };
|
||||
|
||||
let out;
|
||||
describe('Credit Banner view hooks', () => {
|
||||
describe('state', () => {
|
||||
state.testGetter(state.keys.creditRequestData);
|
||||
});
|
||||
describe('useCreditRequestData', () => {
|
||||
beforeEach(() => {
|
||||
state.mock();
|
||||
out = hooks.useCreditRequestData(cardId);
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes creditRequestData state field with null value', () => {
|
||||
state.expectInitializedWith(state.keys.creditRequestData, null);
|
||||
});
|
||||
it('calls useCreateCreditRequest with passed cardID', () => {
|
||||
expect(apiHooks.useCreateCreditRequest).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
it('returns requestData state value', () => {
|
||||
state.mockVal(state.keys.creditRequestData, requestData);
|
||||
out = hooks.useCreditRequestData(cardId);
|
||||
expect(out.requestData).toEqual(requestData);
|
||||
});
|
||||
describe('createCreditRequest', () => {
|
||||
it('returns an event handler that prevents default click behavior', () => {
|
||||
out.createCreditRequest(event);
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
it('calls api.createCreditRequest and sets requestData with the response', async () => {
|
||||
await out.createCreditRequest(event);
|
||||
expect(creditRequest).toHaveBeenCalledWith();
|
||||
expect(state.setState.creditRequestData).toHaveBeenCalledWith(requestData.data);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import * as api from 'data/services/lms/api';
|
||||
import { useCourseData } from 'hooks';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('data/services/lms/api', () => ({
|
||||
createCreditRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
useCourseData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/logging'),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContext.Provider value={{
|
||||
authenticatedUser: { username: 'test-user' },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe('useCreditRequestData', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper();
|
||||
(useCourseData as jest.Mock).mockReturnValue({
|
||||
credit: { providerId: 'provider-123' },
|
||||
courseRun: { courseId: 'course-456' },
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes requestData as null', () => {
|
||||
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
|
||||
|
||||
expect(result.current.requestData).toBeNull();
|
||||
});
|
||||
|
||||
it('returns createCreditRequest function', () => {
|
||||
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
|
||||
|
||||
expect(typeof result.current.createCreditRequest).toBe('function');
|
||||
});
|
||||
|
||||
it('prevents default event behavior', async () => {
|
||||
const event = { preventDefault: jest.fn() };
|
||||
(api.createCreditRequest as jest.Mock).mockResolvedValue({ data: 'success' });
|
||||
|
||||
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.createCreditRequest(event);
|
||||
});
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls API with correct parameters', async () => {
|
||||
const event = { preventDefault: jest.fn() };
|
||||
(api.createCreditRequest as jest.Mock).mockResolvedValue({ data: 'success' });
|
||||
|
||||
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.createCreditRequest(event);
|
||||
});
|
||||
|
||||
expect(api.createCreditRequest).toHaveBeenCalledWith({
|
||||
providerId: 'provider-123',
|
||||
courseId: 'course-456',
|
||||
username: 'test-user',
|
||||
});
|
||||
});
|
||||
|
||||
it('sets requestData with response data on success', async () => {
|
||||
const event = { preventDefault: jest.fn() };
|
||||
const responseData = { data: { id: 'credit-123', status: 'pending' } };
|
||||
(api.createCreditRequest as jest.Mock).mockResolvedValue(responseData);
|
||||
|
||||
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.createCreditRequest(event);
|
||||
});
|
||||
|
||||
expect(api.createCreditRequest).toHaveBeenCalledWith({
|
||||
providerId: 'provider-123',
|
||||
courseId: 'course-456',
|
||||
username: 'test-user',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.requestData).toEqual(responseData.data);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing providerId gracefully', async () => {
|
||||
const event = { preventDefault: jest.fn() };
|
||||
(useCourseData as jest.Mock).mockReturnValue({
|
||||
credit: null,
|
||||
courseRun: { courseId: 'course-456' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.createCreditRequest(event);
|
||||
});
|
||||
|
||||
expect(api.createCreditRequest).toHaveBeenCalledWith({
|
||||
providerId: undefined,
|
||||
courseId: 'course-456',
|
||||
username: 'test-user',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing courseId gracefully', async () => {
|
||||
const event = { preventDefault: jest.fn() };
|
||||
(useCourseData as jest.Mock).mockReturnValue({
|
||||
credit: { providerId: 'provider-123' },
|
||||
courseRun: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.createCreditRequest(event);
|
||||
});
|
||||
|
||||
expect(api.createCreditRequest).toHaveBeenCalledWith({
|
||||
providerId: 'provider-123',
|
||||
courseId: undefined,
|
||||
username: 'test-user',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles API errors without crashing', async () => {
|
||||
const event = { preventDefault: jest.fn() };
|
||||
(api.createCreditRequest as jest.Mock).mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.createCreditRequest(event);
|
||||
});
|
||||
|
||||
expect(result.current.requestData).toBeNull();
|
||||
});
|
||||
|
||||
it('uses cardId to fetch course data', () => {
|
||||
renderHook(() => hooks.useCreditRequestData('different-card'), { wrapper });
|
||||
|
||||
expect(useCourseData).toHaveBeenCalledWith('different-card');
|
||||
});
|
||||
|
||||
it('handles undefined response data', async () => {
|
||||
const event = { preventDefault: jest.fn() };
|
||||
(api.createCreditRequest as jest.Mock).mockResolvedValue({ status: 200 });
|
||||
|
||||
const { result } = renderHook(() => hooks.useCreditRequestData('card-123'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
result.current.createCreditRequest(event);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.requestData).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,21 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, MailtoLink } from '@openedx/paragon';
|
||||
|
||||
import { utilHooks, reduxHooks } from 'hooks';
|
||||
|
||||
import { utilHooks, useCourseData, useEntitlementInfo } from 'hooks';
|
||||
import { useSelectSessionModal } from 'data/context';
|
||||
import Banner from 'components/Banner';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
export const EntitlementBanner = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data: learnerHomeData } = useInitializeLearnerHome();
|
||||
const courseData = useCourseData(cardId);
|
||||
|
||||
const {
|
||||
isEntitlement,
|
||||
hasSessions,
|
||||
@@ -18,9 +23,12 @@ export const EntitlementBanner = ({ cardId }) => {
|
||||
changeDeadline,
|
||||
showExpirationWarning,
|
||||
isExpired,
|
||||
} = reduxHooks.useCardEntitlementData(cardId);
|
||||
const { supportEmail } = reduxHooks.usePlatformSettingsData();
|
||||
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId);
|
||||
} = useEntitlementInfo(courseData);
|
||||
const supportEmail = useMemo(
|
||||
() => learnerHomeData?.platformSettings?.supportEmail,
|
||||
[learnerHomeData],
|
||||
);
|
||||
const { updateSelectSessionModal } = useSelectSessionModal();
|
||||
const formatDate = utilHooks.useFormatDate();
|
||||
|
||||
if (!isEntitlement) {
|
||||
@@ -42,7 +50,7 @@ export const EntitlementBanner = ({ cardId }) => {
|
||||
{formatMessage(messages.entitlementExpiringSoon, {
|
||||
changeDeadline: formatDate(changeDeadline),
|
||||
selectSessionButton: (
|
||||
<Button variant="link" size="inline" className="m-0 p-0" onClick={openSessionModal}>
|
||||
<Button variant="link" size="inline" className="m-0 p-0" onClick={() => updateSelectSessionModal(cardId)}>
|
||||
{formatMessage(messages.selectSession)}
|
||||
</Button>
|
||||
),
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { formatMessage } from 'testUtils';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import EntitlementBanner from './EntitlementBanner';
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useMemo: (fn) => fn(),
|
||||
}));
|
||||
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn().mockReturnValue({
|
||||
data: {
|
||||
platformSettings: {
|
||||
supportEmail: 'test-support-email',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const mockUpdateSelectSessionModal = jest.fn().mockName('updateSelectSessionModal');
|
||||
jest.mock('data/context/SelectSessionProvider', () => ({
|
||||
useSelectSessionModal: () => ({
|
||||
updateSelectSessionModal: mockUpdateSelectSessionModal,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
...jest.requireActual('hooks'),
|
||||
useCourseData: jest.fn(),
|
||||
utilHooks: {
|
||||
useFormatDate: () => date => date,
|
||||
},
|
||||
reduxHooks: {
|
||||
usePlatformSettingsData: jest.fn(),
|
||||
useCardEntitlementData: jest.fn(),
|
||||
useUpdateSelectSessionModalCallback: jest.fn(
|
||||
(cardId) => jest.fn().mockName(`updateSelectSessionModalCallback(${cardId})`),
|
||||
),
|
||||
useFormatDate: () => date => date?.toDateString(),
|
||||
},
|
||||
|
||||
}));
|
||||
|
||||
const cardId = 'test-card-id';
|
||||
@@ -32,16 +50,20 @@ const platformData = { supportEmail: 'test-support-email' };
|
||||
|
||||
const renderComponent = (overrides = {}) => {
|
||||
const { entitlement = {} } = overrides;
|
||||
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ ...entitlementData, ...entitlement });
|
||||
reduxHooks.usePlatformSettingsData.mockReturnValueOnce(platformData);
|
||||
useCourseData.mockReturnValue({
|
||||
entitlement: { ...entitlementData, ...entitlement },
|
||||
platformSettings: platformData,
|
||||
});
|
||||
return render(<IntlProvider locale="en"><EntitlementBanner cardId={cardId} /></IntlProvider>);
|
||||
};
|
||||
|
||||
describe('EntitlementBanner', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('initializes data with course number from entitlement', () => {
|
||||
renderComponent();
|
||||
expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId);
|
||||
expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(cardId);
|
||||
expect(useCourseData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
it('no display if not an entitlement', () => {
|
||||
renderComponent({ entitlement: { isEntitlement: false } });
|
||||
@@ -56,7 +78,10 @@ describe('EntitlementBanner', () => {
|
||||
expect(banner.innerHTML).toContain(platformData.supportEmail);
|
||||
});
|
||||
it('renders when expiration warning', () => {
|
||||
renderComponent({ entitlement: { showExpirationWarning: true } });
|
||||
const deadline = new Date();
|
||||
deadline.setDate(deadline.getDate() + 4);
|
||||
const deadlineStr = `${deadline.getMonth() + 1}/${deadline.getDate()}/${deadline.getFullYear()}`;
|
||||
renderComponent({ entitlement: { changeDeadline: deadlineStr, isFulfilled: false, availableSessions: [1, 2, 3] } });
|
||||
const banner = screen.getByRole('alert');
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(banner).toHaveClass('alert-info');
|
||||
@@ -64,9 +89,37 @@ describe('EntitlementBanner', () => {
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
it('renders expired banner', () => {
|
||||
renderComponent({ entitlement: { isExpired: true } });
|
||||
renderComponent({ entitlement: { isExpired: true, availableSessions: [1, 2, 3] } });
|
||||
const banner = screen.getByRole('alert');
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(banner.innerHTML).toContain(formatMessage(messages.entitlementExpired));
|
||||
});
|
||||
it('should call updateSelectSessionModal with cardId when select session button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const deadline = new Date();
|
||||
deadline.setDate(deadline.getDate() + 4);
|
||||
const deadlineStr = `${deadline.getMonth() + 1}/${deadline.getDate()}/${deadline.getFullYear()}`;
|
||||
renderComponent({ entitlement: { changeDeadline: deadlineStr, isFulfilled: false, availableSessions: [1, 2, 3] } });
|
||||
const banner = screen.getByRole('alert');
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(banner).toHaveClass('alert-info');
|
||||
const button = screen.getByRole('button', { name: formatMessage(messages.selectSession) });
|
||||
expect(button).toBeInTheDocument();
|
||||
await user.click(button);
|
||||
|
||||
expect(mockUpdateSelectSessionModal).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
it('should return null when isExpired is false and showExpirationWarning is false', () => {
|
||||
renderComponent({
|
||||
entitlement: {
|
||||
isEntitlement: true,
|
||||
hasSessions: true,
|
||||
isFulfilled: true,
|
||||
showExpirationWarning: false,
|
||||
isExpired: false,
|
||||
},
|
||||
});
|
||||
const banner = screen.queryByRole('alert');
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
||||
import { Program } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import Banner from 'components/Banner';
|
||||
|
||||
import ProgramList from './ProgramsList';
|
||||
@@ -12,10 +12,10 @@ import messages from './messages';
|
||||
|
||||
export const RelatedProgramsBanner = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const courseData = useCourseData(cardId);
|
||||
const programData = courseData?.programs;
|
||||
|
||||
const programData = reduxHooks.useCardRelatedProgramsData(cardId);
|
||||
|
||||
if (!programData?.length) {
|
||||
if (!courseData || !programData?.relatedPrograms.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const RelatedProgramsBanner = ({ cardId }) => {
|
||||
<span className="font-weight-bolder">
|
||||
{formatMessage(messages.relatedPrograms)}
|
||||
</span>
|
||||
<ProgramList programs={programData.list} />
|
||||
<ProgramList programs={programData.relatedPrograms} />
|
||||
</Banner>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import RelatedProgramsBanner from '.';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardRelatedProgramsData: jest.fn(),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
}));
|
||||
|
||||
const cardId = 'test-card-id';
|
||||
@@ -27,21 +25,21 @@ const programData = {
|
||||
|
||||
describe('RelatedProgramsBanner', () => {
|
||||
it('render empty', () => {
|
||||
reduxHooks.useCardRelatedProgramsData.mockReturnValue({});
|
||||
useCourseData.mockReturnValue(null);
|
||||
render(<IntlProvider locale="en"><RelatedProgramsBanner cardId={cardId} /></IntlProvider>);
|
||||
const banner = screen.queryByRole('alert');
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
|
||||
it('render with programs', () => {
|
||||
reduxHooks.useCardRelatedProgramsData.mockReturnValue(programData);
|
||||
useCourseData.mockReturnValue({ programs: { relatedPrograms: programData.list } });
|
||||
render(<IntlProvider locale="en"><RelatedProgramsBanner cardId={cardId} /></IntlProvider>);
|
||||
const list = screen.getByRole('list');
|
||||
expect(list.childElementCount).toBe(programData.list.length);
|
||||
});
|
||||
|
||||
it('render related programs title', () => {
|
||||
reduxHooks.useCardRelatedProgramsData.mockReturnValue(programData);
|
||||
useCourseData.mockReturnValue({ programs: { relatedPrograms: programData.list } });
|
||||
render(<IntlProvider locale="en"><RelatedProgramsBanner cardId={cardId} /></IntlProvider>);
|
||||
const title = screen.getByText('Related Programs:');
|
||||
expect(title).toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
|
||||
import CourseBannerSlot from 'plugin-slots/CourseBannerSlot';
|
||||
import CertificateBanner from './CertificateBanner';
|
||||
@@ -10,7 +10,11 @@ import EntitlementBanner from './EntitlementBanner';
|
||||
import RelatedProgramsBanner from './RelatedProgramsBanner';
|
||||
|
||||
export const CourseCardBanners = ({ cardId }) => {
|
||||
const { isEnrolled } = reduxHooks.useCardEnrollmentData(cardId);
|
||||
const courseData = useCourseData(cardId);
|
||||
if (!courseData) {
|
||||
return null;
|
||||
}
|
||||
const { isEnrolled = false } = courseData.enrollment;
|
||||
return (
|
||||
<div className="course-card-banners" data-testid="CourseCardBanners">
|
||||
<RelatedProgramsBanner cardId={cardId} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
|
||||
import CourseCardBanners from '.';
|
||||
|
||||
@@ -20,9 +20,11 @@ const mockedComponents = [
|
||||
];
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardEnrollmentData: jest.fn(() => ({ isEnrolled: true })),
|
||||
},
|
||||
useCourseData: jest.fn(() => ({
|
||||
enrollment: {
|
||||
isEnrolled: true,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('CourseCardBanners', () => {
|
||||
@@ -36,8 +38,13 @@ describe('CourseCardBanners', () => {
|
||||
return expect(mockedComponent).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('render null with no courseData', () => {
|
||||
useCourseData.mockReturnValue(null);
|
||||
const { container } = render(<IntlProvider locale="en"><CourseCardBanners {...props} /></IntlProvider>);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
it('render with isEnrolled false', () => {
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: false });
|
||||
useCourseData.mockReturnValue({ enrollment: { isEnrolled: false } });
|
||||
render(<IntlProvider locale="en"><CourseCardBanners {...props} /></IntlProvider>);
|
||||
const mockedComponentsIfNotEnrolled = mockedComponents.slice(-2);
|
||||
mockedComponentsIfNotEnrolled.map((componentName) => {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { utilHooks, reduxHooks } from 'hooks';
|
||||
import { utilHooks, useCourseData, useEntitlementInfo } from 'hooks';
|
||||
import { useSelectSessionModal } from 'data/context';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
export const useAccessMessage = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const enrollment = reduxHooks.useCardEnrollmentData(cardId);
|
||||
const courseRun = reduxHooks.useCardCourseRunData(cardId);
|
||||
const courseData = useCourseData(cardId);
|
||||
const { courseRun, enrollment } = courseData || {};
|
||||
const formatDate = utilHooks.useFormatDate();
|
||||
if (!courseRun.isStarted) {
|
||||
if (!courseRun.startDate && !courseRun.advertisedStart) { return null; }
|
||||
const startDate = courseRun.advertisedStart ? courseRun.advertisedStart : formatDate(courseRun.startDate);
|
||||
return formatMessage(messages.courseStarts, { startDate });
|
||||
}
|
||||
if (enrollment.isEnrolled) {
|
||||
if (enrollment?.isEnrolled) {
|
||||
const { isArchived, endDate } = courseRun;
|
||||
const {
|
||||
accessExpirationDate,
|
||||
@@ -38,15 +39,15 @@ export const useAccessMessage = ({ cardId }) => {
|
||||
|
||||
export const useCardDetailsData = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const providerName = reduxHooks.useCardProviderData(cardId).name;
|
||||
const { courseNumber } = reduxHooks.useCardCourseData(cardId);
|
||||
const courseData = useCourseData(cardId);
|
||||
const providerName = courseData?.courseProvider?.name;
|
||||
const courseNumber = courseData?.course?.courseNumber;
|
||||
const {
|
||||
isEntitlement,
|
||||
isFulfilled,
|
||||
canChange,
|
||||
} = reduxHooks.useCardEntitlementData(cardId);
|
||||
|
||||
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId);
|
||||
} = useEntitlementInfo(courseData);
|
||||
const { updateSelectSessionModal } = useSelectSessionModal();
|
||||
|
||||
return {
|
||||
providerName: providerName || formatMessage(messages.unknownProviderName),
|
||||
@@ -54,7 +55,7 @@ export const useCardDetailsData = ({ cardId }) => {
|
||||
isEntitlement,
|
||||
isFulfilled,
|
||||
canChange,
|
||||
openSessionModal,
|
||||
openSessionModal: () => updateSelectSessionModal(cardId),
|
||||
courseNumber,
|
||||
changeOrLeaveSessionMessage: formatMessage(messages.changeOrLeaveSessionButton),
|
||||
};
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { keyStore } from 'utils';
|
||||
import { utilHooks, reduxHooks } from 'hooks';
|
||||
|
||||
import { utilHooks, useCourseData } from 'hooks';
|
||||
import { useSelectSessionModal } from 'data/context';
|
||||
import * as hooks from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useMemo: (fn) => fn(),
|
||||
}));
|
||||
|
||||
const updateSelectSessionModalMock = jest.fn().mockName('updateSelectSessionModal');
|
||||
jest.mock('data/context', () => ({
|
||||
useSelectSessionModal: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks', () => ({
|
||||
...jest.requireActual('hooks'),
|
||||
useCourseData: jest.fn(),
|
||||
utilHooks: {
|
||||
useFormatDate: jest.fn(),
|
||||
},
|
||||
reduxHooks: {
|
||||
useCardCourseData: jest.fn(),
|
||||
useCardCourseRunData: jest.fn(),
|
||||
useCardEnrollmentData: jest.fn(),
|
||||
useCardEntitlementData: jest.fn(),
|
||||
useCardProviderData: jest.fn(),
|
||||
useUpdateSelectSessionModalCallback: (...args) => ({ updateSelectSessionModalCallback: args }),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
@@ -60,15 +63,13 @@ describe('CourseCardDetails hooks', () => {
|
||||
const runHook = ({ provider = {}, entitlement = {} }) => {
|
||||
jest.spyOn(hooks, hookKeys.useAccessMessage)
|
||||
.mockImplementationOnce(mockAccessMessage);
|
||||
reduxHooks.useCardProviderData.mockReturnValueOnce({
|
||||
...providerData,
|
||||
...provider,
|
||||
useCourseData.mockReturnValue({
|
||||
courseProvider: { ...providerData, ...provider },
|
||||
course: { courseNumber },
|
||||
courseRun: {},
|
||||
entitlement: { ...entitlementData, ...entitlement },
|
||||
});
|
||||
reduxHooks.useCardEntitlementData.mockReturnValueOnce({
|
||||
...entitlementData,
|
||||
...entitlement,
|
||||
});
|
||||
reduxHooks.useCardCourseData.mockReturnValueOnce({ courseNumber });
|
||||
useSelectSessionModal.mockReturnValue({ updateSelectSessionModal: updateSelectSessionModalMock });
|
||||
out = hooks.useCardDetailsData({ cardId });
|
||||
};
|
||||
beforeEach(() => {
|
||||
@@ -85,6 +86,10 @@ describe('CourseCardDetails hooks', () => {
|
||||
it('forward changeOrLeaveSessionMessage', () => {
|
||||
expect(out.changeOrLeaveSessionMessage).toEqual(formatMessage(messages.changeOrLeaveSessionButton));
|
||||
});
|
||||
it('calls updateSelectSessionModal when openSessionModal is called', () => {
|
||||
out.openSessionModal();
|
||||
expect(updateSelectSessionModalMock).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAccessMessage', () => {
|
||||
@@ -101,21 +106,16 @@ describe('CourseCardDetails hooks', () => {
|
||||
endDate: '10/20/2000',
|
||||
};
|
||||
const runHook = ({ enrollment = {}, courseRun = {} }) => {
|
||||
reduxHooks.useCardCourseRunData.mockReturnValueOnce({
|
||||
...courseRunData,
|
||||
...courseRun,
|
||||
});
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
|
||||
...enrollmentData,
|
||||
...enrollment,
|
||||
useCourseData.mockReturnValue({
|
||||
courseRun: { ...courseRunData, ...courseRun },
|
||||
enrollment: { ...enrollmentData, ...enrollment },
|
||||
});
|
||||
out = hooks.useAccessMessage({ cardId });
|
||||
};
|
||||
|
||||
it('loads data from enrollment and course run data based on course number', () => {
|
||||
runHook({});
|
||||
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
|
||||
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
|
||||
expect(useCourseData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
|
||||
describe('if not started yet', () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { baseAppUrl } from 'data/services/lms/urls';
|
||||
|
||||
import { Badge } from '@openedx/paragon';
|
||||
|
||||
import track from 'tracking';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useCourseTrackingEvent } from 'hooks';
|
||||
import verifiedRibbon from 'assets/verified-ribbon.png';
|
||||
import useActionDisabledState from './hooks';
|
||||
|
||||
@@ -15,11 +16,10 @@ const { courseImageClicked } = track.course;
|
||||
|
||||
export const CourseCardImage = ({ cardId, orientation }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { bannerImgSrc } = reduxHooks.useCardCourseData(cardId);
|
||||
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
|
||||
const { isVerified } = reduxHooks.useCardEnrollmentData(cardId);
|
||||
const courseData = useCourseData(cardId);
|
||||
const { homeUrl } = courseData?.courseRun || {};
|
||||
const { disableCourseTitle } = useActionDisabledState(cardId);
|
||||
const handleImageClicked = reduxHooks.useTrackCourseEvent(courseImageClicked, cardId, homeUrl);
|
||||
const handleImageClicked = useCourseTrackingEvent(courseImageClicked, cardId, homeUrl);
|
||||
const wrapperClassName = `pgn__card-wrapper-image-cap d-inline-block overflow-visible ${orientation}`;
|
||||
const image = (
|
||||
<>
|
||||
@@ -27,11 +27,11 @@ export const CourseCardImage = ({ cardId, orientation }) => {
|
||||
// w-100 is necessary for images on Safari, otherwise stretches full height of the image
|
||||
// https://stackoverflow.com/a/44250830
|
||||
className="pgn__card-image-cap w-100 show"
|
||||
src={bannerImgSrc}
|
||||
src={courseData?.course?.bannerImgSrc && baseAppUrl(courseData.course.bannerImgSrc)}
|
||||
alt={formatMessage(messages.bannerAlt)}
|
||||
/>
|
||||
{
|
||||
isVerified && (
|
||||
courseData?.enrollment?.isVerified && (
|
||||
<span
|
||||
className="course-card-verify-ribbon-container"
|
||||
title={formatMessage(messages.verifiedHoverDescription)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { formatMessage } from 'testUtils';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
import useActionDisabledState from './hooks';
|
||||
import { CourseCardImage } from './CourseCardImage';
|
||||
import messages from '../messages';
|
||||
@@ -10,14 +10,14 @@ const homeUrl = 'https://example.com';
|
||||
const bannerImgSrc = 'banner-img-src.jpg';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCourseData: jest.fn(() => ({ bannerImgSrc })),
|
||||
useCardCourseRunData: jest.fn(() => ({ homeUrl })),
|
||||
useCardEnrollmentData: jest.fn(),
|
||||
useTrackCourseEvent: jest.fn((eventName, cardId, url) => ({
|
||||
trackCourseEvent: { eventName, cardId, url },
|
||||
})),
|
||||
},
|
||||
useCourseData: jest.fn(() => ({
|
||||
course: { bannerImgSrc },
|
||||
courseRun: { homeUrl },
|
||||
enrollment: {},
|
||||
})),
|
||||
useCourseTrackingEvent: jest.fn((eventName, cardId, url) => ({
|
||||
trackCourseEvent: { eventName, cardId, url },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('./hooks', () => jest.fn());
|
||||
@@ -30,7 +30,13 @@ describe('CourseCardImage', () => {
|
||||
|
||||
it('renders course image with correct attributes', () => {
|
||||
useActionDisabledState.mockReturnValue({ disableCourseTitle: true });
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true });
|
||||
useCourseData.mockReturnValue(
|
||||
{
|
||||
course: { bannerImgSrc },
|
||||
courseRun: { homeUrl },
|
||||
enrollment: { isVerified: true },
|
||||
},
|
||||
);
|
||||
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
|
||||
|
||||
const image = screen.getByRole('img', { name: formatMessage(messages.bannerAlt) });
|
||||
@@ -41,7 +47,13 @@ describe('CourseCardImage', () => {
|
||||
|
||||
it('isVerified, should render badge', () => {
|
||||
useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true });
|
||||
useCourseData.mockReturnValue(
|
||||
{
|
||||
course: { bannerImgSrc },
|
||||
courseRun: { homeUrl },
|
||||
enrollment: { isVerified: true },
|
||||
},
|
||||
);
|
||||
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
|
||||
|
||||
const badge = screen.getByText(formatMessage(messages.verifiedBanner));
|
||||
@@ -52,7 +64,13 @@ describe('CourseCardImage', () => {
|
||||
|
||||
it('renders link with correct href if disableCourseTitle is false', () => {
|
||||
useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: false });
|
||||
useCourseData.mockReturnValue(
|
||||
{
|
||||
course: { bannerImgSrc },
|
||||
courseRun: { homeUrl },
|
||||
enrollment: { isVerified: false },
|
||||
},
|
||||
);
|
||||
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
@@ -61,12 +79,15 @@ describe('CourseCardImage', () => {
|
||||
describe('hooks', () => {
|
||||
it('initializes', () => {
|
||||
useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true });
|
||||
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
|
||||
expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId);
|
||||
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(
|
||||
props.cardId,
|
||||
useCourseData.mockReturnValue(
|
||||
{
|
||||
course: { bannerImgSrc },
|
||||
courseRun: { homeUrl },
|
||||
enrollment: { isVerified: true },
|
||||
},
|
||||
);
|
||||
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
|
||||
expect(useCourseData).toHaveBeenCalledWith(props.cardId);
|
||||
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as ReactShare from 'react-share';
|
||||
|
||||
import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@openedx/paragon';
|
||||
|
||||
import track from 'tracking';
|
||||
import { reduxHooks } from 'hooks';
|
||||
|
||||
import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from 'hooks';
|
||||
import { useCardSocialSettingsData } from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
export const testIds = {
|
||||
@@ -16,14 +16,15 @@ export const testIds = {
|
||||
|
||||
export const SocialShareMenu = ({ cardId, emailSettings }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const courseData = useCourseData(cardId);
|
||||
const courseName = courseData?.course?.courseName;
|
||||
const isExecEd2UCourse = EXECUTIVE_EDUCATION_COURSE_MODES.includes(courseData.enrollment.mode);
|
||||
const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false;
|
||||
const { twitter, facebook } = useCardSocialSettingsData(cardId);
|
||||
const isMasquerading = useIsMasquerading();
|
||||
|
||||
const { courseName } = reduxHooks.useCardCourseData(cardId);
|
||||
const { isEmailEnabled, isExecEd2UCourse } = reduxHooks.useCardEnrollmentData(cardId);
|
||||
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
|
||||
const { isMasquerading } = reduxHooks.useMasqueradeData();
|
||||
|
||||
const handleTwitterShare = reduxHooks.useTrackCourseEvent(track.socialShare, cardId, 'twitter');
|
||||
const handleFacebookShare = reduxHooks.useTrackCourseEvent(track.socialShare, cardId, 'facebook');
|
||||
const handleTwitterShare = useCourseTrackingEvent(track.socialShare, cardId, 'twitter');
|
||||
const handleFacebookShare = useCourseTrackingEvent(track.socialShare, cardId, 'facebook');
|
||||
|
||||
if (isExecEd2UCourse) {
|
||||
return null;
|
||||
@@ -50,6 +51,7 @@ export const SocialShareMenu = ({ cardId, emailSettings }) => {
|
||||
})}
|
||||
resetButtonStyle={false}
|
||||
className="pgn__dropdown-item dropdown-item"
|
||||
aria-label="facebook"
|
||||
>
|
||||
{formatMessage(messages.shareToFacebook)}
|
||||
</ReactShare.FacebookShareButton>
|
||||
@@ -64,6 +66,7 @@ export const SocialShareMenu = ({ cardId, emailSettings }) => {
|
||||
})}
|
||||
resetButtonStyle={false}
|
||||
className="pgn__dropdown-item dropdown-item"
|
||||
aria-label="twitter"
|
||||
>
|
||||
{formatMessage(messages.shareToTwitter)}
|
||||
</ReactShare.TwitterShareButton>
|
||||
|
||||
@@ -4,9 +4,9 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import track from 'tracking';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from 'hooks';
|
||||
|
||||
import { useEmailSettings } from './hooks';
|
||||
import { useEmailSettings, useCardSocialSettingsData } from './hooks';
|
||||
import SocialShareMenu from './SocialShareMenu';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -15,16 +15,13 @@ jest.mock('tracking', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useMasqueradeData: jest.fn(),
|
||||
useCardCourseData: jest.fn(),
|
||||
useCardEnrollmentData: jest.fn(),
|
||||
useCardSocialSettingsData: jest.fn(),
|
||||
useTrackCourseEvent: jest.fn((...args) => ({ trackCourseEvent: args })),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
useCourseTrackingEvent: jest.fn((...args) => ({ trackCourseEvent: args })),
|
||||
useIsMasquerading: jest.fn(),
|
||||
}));
|
||||
jest.mock('./hooks', () => ({
|
||||
useEmailSettings: jest.fn(),
|
||||
useCardSocialSettingsData: jest.fn(),
|
||||
}));
|
||||
|
||||
const props = {
|
||||
@@ -57,23 +54,25 @@ const socialShare = {
|
||||
|
||||
const mockHooks = (returnVals = {}) => {
|
||||
mockHook(
|
||||
reduxHooks.useCardEnrollmentData,
|
||||
useCourseData,
|
||||
{
|
||||
isEmailEnabled: !!returnVals.isEmailEnabled,
|
||||
isExecEd2UCourse: !!returnVals.isExecEd2UCourse,
|
||||
enrollment: {
|
||||
isEmailEnabled: !!returnVals.isEmailEnabled,
|
||||
mode: returnVals.isExecEd2UCourse ? 'exec-ed-2u' : 'standard',
|
||||
},
|
||||
course: { courseName },
|
||||
},
|
||||
{ isCardHook: true },
|
||||
);
|
||||
mockHook(reduxHooks.useCardCourseData, { courseName }, { isCardHook: true });
|
||||
mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading });
|
||||
mockHook(
|
||||
reduxHooks.useCardSocialSettingsData,
|
||||
useCardSocialSettingsData,
|
||||
{
|
||||
facebook: { ...socialShare.facebook, isEnabled: !!returnVals.facebook?.isEnabled },
|
||||
twitter: { ...socialShare.twitter, isEnabled: !!returnVals.twitter?.isEnabled },
|
||||
},
|
||||
{ isCardHook: true },
|
||||
);
|
||||
mockHook(useIsMasquerading, !!returnVals.isMasquerading);
|
||||
};
|
||||
|
||||
const renderComponent = () => render(<IntlProvider locale="en"><SocialShareMenu {...props} /></IntlProvider>);
|
||||
@@ -87,13 +86,12 @@ describe('SocialShareMenu', () => {
|
||||
it('initializes local hooks', () => {
|
||||
when(useEmailSettings).expectCalledWith();
|
||||
});
|
||||
it('initializes redux hook data ', () => {
|
||||
when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId);
|
||||
when(reduxHooks.useCardCourseData).expectCalledWith(props.cardId);
|
||||
when(reduxHooks.useCardSocialSettingsData).expectCalledWith(props.cardId);
|
||||
when(reduxHooks.useMasqueradeData).expectCalledWith();
|
||||
when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter');
|
||||
when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook');
|
||||
it('initializes hook data ', () => {
|
||||
when(useCourseData).expectCalledWith(props.cardId);
|
||||
when(useCardSocialSettingsData).expectCalledWith(props.cardId);
|
||||
when(useIsMasquerading).expectCalledWith();
|
||||
when(useCourseTrackingEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter');
|
||||
when(useCourseTrackingEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook');
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import track from 'tracking';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useCourseTrackingEvent } from 'hooks';
|
||||
import { useState } from 'react';
|
||||
import { StrictDict } from 'utils';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
|
||||
export const state = StrictDict({
|
||||
isUnenrollConfirmVisible: (val) => useState(val), // eslint-disable-line
|
||||
@@ -27,7 +28,7 @@ export const useEmailSettings = () => {
|
||||
};
|
||||
|
||||
export const useHandleToggleDropdown = (cardId) => {
|
||||
const trackCourseEvent = reduxHooks.useTrackCourseEvent(
|
||||
const trackCourseEvent = useCourseTrackingEvent(
|
||||
track.course.courseOptionsDropdownClicked,
|
||||
cardId,
|
||||
);
|
||||
@@ -36,10 +37,30 @@ export const useHandleToggleDropdown = (cardId) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const useCardSocialSettingsData = (cardId) => {
|
||||
const { data: learnerHomeData } = useInitializeLearnerHome();
|
||||
const courseData = useCourseData(cardId);
|
||||
const socialShareSettings = learnerHomeData?.socialShareSettings;
|
||||
const { socialShareUrl } = courseData?.course || {};
|
||||
const defaultSettings = { isEnabled: false, shareUrl: '' };
|
||||
|
||||
if (!socialShareSettings) {
|
||||
return { facebook: defaultSettings, twitter: defaultSettings };
|
||||
}
|
||||
const { facebook, twitter } = socialShareSettings;
|
||||
const loadSettings = (target) => ({
|
||||
isEnabled: target.isEnabled,
|
||||
shareUrl: `${socialShareUrl}?${target.utmParams}`,
|
||||
});
|
||||
return { facebook: loadSettings(facebook), twitter: loadSettings(twitter) };
|
||||
};
|
||||
|
||||
export const useOptionVisibility = (cardId) => {
|
||||
const { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
|
||||
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
|
||||
const { isEarned } = reduxHooks.useCardCertificateData(cardId);
|
||||
const courseData = useCourseData(cardId);
|
||||
const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false;
|
||||
const isEnrolled = courseData?.enrollment?.isEnrolled ?? false;
|
||||
const { twitter, facebook } = useCardSocialSettingsData(cardId);
|
||||
const isEarned = courseData?.certificate?.isEarned ?? false;
|
||||
|
||||
const shouldShowUnenrollItem = isEnrolled && !isEarned;
|
||||
const shouldShowDropdown = (
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useCourseTrackingEvent } from 'hooks';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import track from 'tracking';
|
||||
import { MockUseState } from 'testUtils';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCertificateData: jest.fn(),
|
||||
useCardEnrollmentData: jest.fn(),
|
||||
useCardSocialSettingsData: jest.fn(),
|
||||
useTrackCourseEvent: jest.fn(),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
useCourseTrackingEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
const trackCourseEvent = jest.fn();
|
||||
reduxHooks.useTrackCourseEvent.mockReturnValue(trackCourseEvent);
|
||||
useCourseTrackingEvent.mockReturnValue(trackCourseEvent);
|
||||
const cardId = 'test-card-id';
|
||||
let out;
|
||||
|
||||
@@ -71,7 +72,7 @@ describe('CourseCardMenu hooks', () => {
|
||||
beforeEach(() => { out = hooks.useHandleToggleDropdown(cardId); });
|
||||
describe('behavior', () => {
|
||||
it('initializes course event tracker with event name and card ID', () => {
|
||||
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
|
||||
expect(useCourseTrackingEvent).toHaveBeenCalledWith(
|
||||
track.course.courseOptionsDropdownClicked,
|
||||
cardId,
|
||||
);
|
||||
@@ -88,55 +89,61 @@ describe('CourseCardMenu hooks', () => {
|
||||
});
|
||||
|
||||
describe('useOptionVisibility', () => {
|
||||
const mockReduxHooks = (returnVals = {}) => {
|
||||
reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({
|
||||
facebook: { isEnabled: !!returnVals.facebook?.isEnabled },
|
||||
twitter: { isEnabled: !!returnVals.twitter?.isEnabled },
|
||||
const mockHooks = (returnVals = {}) => {
|
||||
useInitializeLearnerHome.mockReturnValue({
|
||||
data: {
|
||||
socialShareSettings: {
|
||||
facebook: { isEnabled: !!returnVals.facebook?.isEnabled },
|
||||
twitter: { isEnabled: !!returnVals.twitter?.isEnabled },
|
||||
},
|
||||
},
|
||||
});
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
|
||||
isEnrolled: !!returnVals.isEnrolled,
|
||||
isEmailEnabled: !!returnVals.isEmailEnabled,
|
||||
});
|
||||
reduxHooks.useCardCertificateData.mockReturnValueOnce({
|
||||
isEarned: !!returnVals.isEarned,
|
||||
useCourseData.mockReturnValue({
|
||||
enrollment: {
|
||||
isEnrolled: !!returnVals.isEnrolled,
|
||||
isEmailEnabled: !!returnVals.isEmailEnabled,
|
||||
},
|
||||
certificate: {
|
||||
isEarned: !!returnVals.isEarned,
|
||||
},
|
||||
});
|
||||
};
|
||||
describe('shouldShowUnenrollItem', () => {
|
||||
it('returns true if enrolled and not earned', () => {
|
||||
mockReduxHooks({ isEnrolled: true });
|
||||
mockHooks({ isEnrolled: true });
|
||||
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(true);
|
||||
});
|
||||
it('returns false if not enrolled', () => {
|
||||
mockReduxHooks();
|
||||
mockHooks();
|
||||
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false);
|
||||
});
|
||||
it('returns false if enrolled but also earned', () => {
|
||||
mockReduxHooks({ isEarned: true });
|
||||
mockHooks({ isEarned: true });
|
||||
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowDropdown', () => {
|
||||
it('returns false if not enrolled and both email and socials are disabled', () => {
|
||||
mockReduxHooks();
|
||||
mockHooks();
|
||||
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false);
|
||||
});
|
||||
it('returns false if enrolled but already earned, and both email and socials are disabled', () => {
|
||||
mockReduxHooks({ isEnrolled: true, isEarned: true });
|
||||
mockHooks({ isEnrolled: true, isEarned: true });
|
||||
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false);
|
||||
});
|
||||
it('returns true if either social is enabled', () => {
|
||||
mockReduxHooks({ facebook: { isEnabled: true } });
|
||||
mockHooks({ facebook: { isEnabled: true } });
|
||||
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
|
||||
mockReduxHooks({ twitter: { isEnabled: true } });
|
||||
mockHooks({ twitter: { isEnabled: true } });
|
||||
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
|
||||
});
|
||||
it('returns true if email is enabled', () => {
|
||||
mockReduxHooks({ isEmailEnabled: true });
|
||||
mockHooks({ isEmailEnabled: true });
|
||||
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
|
||||
});
|
||||
it('returns true if enrolled and not earned', () => {
|
||||
mockReduxHooks({ isEnrolled: true });
|
||||
mockHooks({ isEnrolled: true });
|
||||
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MoreVert } from '@openedx/paragon/icons';
|
||||
|
||||
import EmailSettingsModal from 'containers/EmailSettingsModal';
|
||||
import UnenrollConfirmModal from 'containers/UnenrollConfirmModal';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useIsMasquerading } from 'hooks';
|
||||
import SocialShareMenu from './SocialShareMenu';
|
||||
import {
|
||||
useEmailSettings,
|
||||
@@ -23,13 +23,15 @@ export const testIds = {
|
||||
|
||||
export const CourseCardMenu = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const courseData = useCourseData(cardId);
|
||||
|
||||
const isEmailEnabled = courseData?.enrollment?.isEmailEnabled ?? false;
|
||||
|
||||
const emailSettings = useEmailSettings();
|
||||
const unenrollModal = useUnenrollData();
|
||||
const handleToggleDropdown = useHandleToggleDropdown(cardId);
|
||||
const { shouldShowUnenrollItem, shouldShowDropdown } = useOptionVisibility(cardId);
|
||||
const { isMasquerading } = reduxHooks.useMasqueradeData();
|
||||
const { isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
|
||||
const isMasquerading = useIsMasquerading();
|
||||
|
||||
if (!shouldShowDropdown) {
|
||||
return null;
|
||||
|
||||
@@ -4,16 +4,14 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useIsMasquerading } from 'hooks';
|
||||
import * as hooks from './hooks';
|
||||
import CourseCardMenu from '.';
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useMasqueradeData: jest.fn(),
|
||||
useCardEnrollmentData: jest.fn(),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
useIsMasquerading: jest.fn(),
|
||||
}));
|
||||
jest.mock('./SocialShareMenu', () => jest.fn(() => <div>SocialShareMenu</div>));
|
||||
jest.mock('containers/EmailSettingsModal', () => jest.fn(() => <div>EmailSettingsModal</div>));
|
||||
@@ -69,10 +67,14 @@ const mockHooks = (returnVals = {}) => {
|
||||
},
|
||||
{ isCardHook: true },
|
||||
);
|
||||
mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading });
|
||||
mockHook(useIsMasquerading, !!returnVals.isMasquerading);
|
||||
mockHook(
|
||||
reduxHooks.useCardEnrollmentData,
|
||||
{ isEmailEnabled: !!returnVals.isEmailEnabled },
|
||||
useCourseData,
|
||||
{
|
||||
enrollment: {
|
||||
isEmailEnabled: !!returnVals.isEmailEnabled,
|
||||
},
|
||||
},
|
||||
{ isCardHook: true },
|
||||
);
|
||||
};
|
||||
@@ -87,13 +89,10 @@ describe('CourseCardMenu', () => {
|
||||
});
|
||||
it('initializes local hooks', () => {
|
||||
when(hooks.useEmailSettings).expectCalledWith();
|
||||
when(hooks.useUnenrollData).expectCalledWith();
|
||||
when(hooks.useHandleToggleDropdown).expectCalledWith(props.cardId);
|
||||
when(hooks.useOptionVisibility).expectCalledWith(props.cardId);
|
||||
});
|
||||
it('initializes redux hook data ', () => {
|
||||
when(reduxHooks.useMasqueradeData).expectCalledWith();
|
||||
when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId);
|
||||
it('initializes hook data ', () => {
|
||||
when(useIsMasquerading).expectCalledWith();
|
||||
when(useCourseData).expectCalledWith(props.cardId);
|
||||
});
|
||||
});
|
||||
describe('render', () => {
|
||||
|
||||
@@ -2,15 +2,16 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import track from 'tracking';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useCourseTrackingEvent } from 'hooks';
|
||||
import useActionDisabledState from './hooks';
|
||||
|
||||
const { courseTitleClicked } = track.course;
|
||||
|
||||
export const CourseCardTitle = ({ cardId }) => {
|
||||
const { courseName } = reduxHooks.useCardCourseData(cardId);
|
||||
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
|
||||
const handleTitleClicked = reduxHooks.useTrackCourseEvent(
|
||||
const courseData = useCourseData(cardId);
|
||||
const courseName = courseData?.course?.courseName;
|
||||
const homeUrl = courseData?.courseRun?.homeUrl;
|
||||
const handleTitleClicked = useCourseTrackingEvent(
|
||||
courseTitleClicked,
|
||||
cardId,
|
||||
homeUrl,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useCourseTrackingEvent } from 'hooks';
|
||||
import track from 'tracking';
|
||||
import useActionDisabledState from './hooks';
|
||||
import CourseCardTitle from './CourseCardTitle';
|
||||
@@ -12,11 +12,8 @@ jest.mock('tracking', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCourseData: jest.fn(),
|
||||
useCardCourseRunData: jest.fn(),
|
||||
useTrackCourseEvent: jest.fn(),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
useCourseTrackingEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./hooks', () => jest.fn(() => ({ disableCourseTitle: false })));
|
||||
@@ -32,9 +29,11 @@ describe('CourseCardTitle', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
reduxHooks.useCardCourseData.mockReturnValue({ courseName });
|
||||
reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl });
|
||||
reduxHooks.useTrackCourseEvent.mockReturnValue(handleTitleClick);
|
||||
useCourseData.mockReturnValue({
|
||||
course: { courseName },
|
||||
courseRun: { homeUrl },
|
||||
});
|
||||
useCourseTrackingEvent.mockReturnValue(handleTitleClick);
|
||||
});
|
||||
|
||||
it('renders course name as link when not disabled', async () => {
|
||||
@@ -62,9 +61,8 @@ describe('CourseCardTitle', () => {
|
||||
useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
|
||||
render(<CourseCardTitle {...props} />);
|
||||
|
||||
expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId);
|
||||
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
|
||||
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
|
||||
expect(useCourseData).toHaveBeenCalledWith(props.cardId);
|
||||
expect(useCourseTrackingEvent).toHaveBeenCalledWith(
|
||||
track.course.courseTitleClicked,
|
||||
props.cardId,
|
||||
homeUrl,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { StrictDict } from 'utils';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
|
||||
import messages from './messages';
|
||||
import * as module from './hooks';
|
||||
@@ -14,7 +14,8 @@ export const state = StrictDict({
|
||||
export const useRelatedProgramsBadgeData = ({ cardId }) => {
|
||||
const [isOpen, setIsOpen] = module.state.isOpen(false);
|
||||
const { formatMessage } = useIntl();
|
||||
const numPrograms = reduxHooks.useCardRelatedProgramsData(cardId).length;
|
||||
const courseData = useCourseData(cardId);
|
||||
const numPrograms = courseData?.programs?.relatedPrograms?.length || 0;
|
||||
let programsMessage = '';
|
||||
if (numPrograms) {
|
||||
programsMessage = formatMessage(
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { MockUseState } from 'testUtils';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardRelatedProgramsData: jest.fn(),
|
||||
},
|
||||
useCourseData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
@@ -39,8 +37,10 @@ describe('RelatedProgramsBadge hooks', () => {
|
||||
describe('useRelatedProgramsBadgeData', () => {
|
||||
beforeEach(() => {
|
||||
state.mock();
|
||||
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({
|
||||
length: numPrograms,
|
||||
useCourseData.mockReturnValue({
|
||||
programs: {
|
||||
relatedPrograms: new Array(numPrograms).fill({}),
|
||||
},
|
||||
});
|
||||
out = hooks.useRelatedProgramsBadgeData({ cardId });
|
||||
});
|
||||
@@ -64,12 +64,12 @@ describe('RelatedProgramsBadge hooks', () => {
|
||||
expect(out.numPrograms).toEqual(numPrograms);
|
||||
});
|
||||
test('returns empty programsMessage if no programs', () => {
|
||||
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 0 });
|
||||
useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [] } });
|
||||
out = hooks.useRelatedProgramsBadgeData({ cardId });
|
||||
expect(out.programsMessage).toEqual('');
|
||||
});
|
||||
test('returns badgeLabelSingular programsMessage if 1 programs', () => {
|
||||
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 1 });
|
||||
useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [{}] } });
|
||||
out = hooks.useRelatedProgramsBadgeData({ cardId });
|
||||
expect(out.programsMessage).toEqual(formatMessage(
|
||||
messages.badgeLabelSingular,
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useCourseData, useEntitlementInfo, useIsMasquerading } from 'hooks';
|
||||
|
||||
export const useActionDisabledState = (cardId) => {
|
||||
const { isMasquerading } = reduxHooks.useMasqueradeData();
|
||||
const courseData = useCourseData(cardId);
|
||||
const isMasquerading = useIsMasquerading();
|
||||
|
||||
const {
|
||||
hasAccess, isAudit, isAuditAccessExpired,
|
||||
} = reduxHooks.useCardEnrollmentData(cardId);
|
||||
isAudit, isAuditAccessExpired,
|
||||
} = courseData.enrollment || {};
|
||||
const { isStaff, hasUnmetPrereqs, isTooEarly } = courseData.enrollment?.coursewareAccess || {};
|
||||
const hasAccess = isStaff || !(hasUnmetPrereqs || isTooEarly);
|
||||
const {
|
||||
isEntitlement, isFulfilled, canChange, hasSessions,
|
||||
} = reduxHooks.useCardEntitlementData(cardId);
|
||||
|
||||
const { resumeUrl, homeUrl } = reduxHooks.useCardCourseRunData(cardId);
|
||||
} = useEntitlementInfo(courseData);
|
||||
|
||||
const { resumeUrl, homeUrl } = courseData.courseRun || {};
|
||||
const disableBeginCourse = !homeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired));
|
||||
const disableResumeCourse = !resumeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired));
|
||||
const disableViewCourse = !hasAccess || (isAudit && isAuditAccessExpired);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { reduxHooks } from 'hooks';
|
||||
|
||||
import { useCourseData, useIsMasquerading } from 'hooks';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useMemo: jest.fn((fn) => fn()),
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useMasqueradeData: jest.fn(),
|
||||
useCardEnrollmentData: jest.fn(),
|
||||
useCardEntitlementData: jest.fn(),
|
||||
useCardCourseRunData: jest.fn(),
|
||||
},
|
||||
...jest.requireActual('hooks'),
|
||||
useCourseData: jest.fn(),
|
||||
useIsMasquerading: jest.fn(),
|
||||
}));
|
||||
|
||||
const cardId = 'my-test-course-number';
|
||||
@@ -38,25 +39,38 @@ describe('useActionDisabledState', () => {
|
||||
isAuditAccessExpired,
|
||||
resumeUrl,
|
||||
homeUrl,
|
||||
availableSessions,
|
||||
} = { ...defaultData, ...args };
|
||||
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading });
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
|
||||
hasAccess,
|
||||
isAudit,
|
||||
isAuditAccessExpired,
|
||||
});
|
||||
reduxHooks.useCardEntitlementData.mockReturnValueOnce({
|
||||
isEntitlement,
|
||||
isFulfilled,
|
||||
canChange,
|
||||
hasSessions,
|
||||
});
|
||||
reduxHooks.useCardCourseRunData.mockReturnValueOnce({
|
||||
resumeUrl,
|
||||
homeUrl,
|
||||
useIsMasquerading.mockReturnValue(isMasquerading);
|
||||
useCourseData.mockReturnValue({
|
||||
enrollment: {
|
||||
hasAccess,
|
||||
isAudit,
|
||||
isAuditAccessExpired,
|
||||
coursewareAccess: {
|
||||
isStaff: false,
|
||||
hasUnmetPrereqs: !hasAccess,
|
||||
isTooEarly: !hasAccess,
|
||||
},
|
||||
},
|
||||
entitlement: isEntitlement ? {
|
||||
isEntitlement: true,
|
||||
isFulfilled,
|
||||
canChange,
|
||||
hasSessions,
|
||||
availableSessions,
|
||||
} : {},
|
||||
courseRun: {
|
||||
resumeUrl,
|
||||
homeUrl,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const runHook = () => hooks.useActionDisabledState(cardId);
|
||||
describe('disableBeginCourse', () => {
|
||||
const testDisabled = (data, expected) => {
|
||||
@@ -142,6 +156,7 @@ describe('useActionDisabledState', () => {
|
||||
hasAccess: true,
|
||||
canChange: true,
|
||||
hasSessions: true,
|
||||
availableSessions: ['session1'],
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useWindowSize, breakpoints } from '@openedx/paragon';
|
||||
import { reduxHooks } from 'hooks';
|
||||
|
||||
export const useIsCollapsed = () => {
|
||||
const { width } = useWindowSize();
|
||||
return width < breakpoints.small.maxWidth;
|
||||
};
|
||||
|
||||
export const useCardData = ({ cardId }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { title, bannerImgSrc } = reduxHooks.useCardCourseData(cardId);
|
||||
const { isEnrolled } = reduxHooks.useCardEnrollmentData(cardId);
|
||||
|
||||
return {
|
||||
isEnrolled,
|
||||
title,
|
||||
bannerImgSrc,
|
||||
formatMessage,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCardData;
|
||||
|
||||
@@ -1,58 +1,32 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useWindowSize } from '@openedx/paragon';
|
||||
import { useIsCollapsed } from './hooks';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardCourseData: jest.fn(),
|
||||
useCardEnrollmentData: jest.fn(),
|
||||
jest.mock('@openedx/paragon', () => ({
|
||||
useWindowSize: jest.fn(),
|
||||
breakpoints: {
|
||||
small: {
|
||||
maxWidth: 576,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
const { formatMessage } = jest.requireActual('testUtils');
|
||||
return {
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
useIntl: () => ({
|
||||
formatMessage,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const cardId = 'my-test-course-number';
|
||||
|
||||
describe('CourseCard hooks', () => {
|
||||
let out;
|
||||
const { formatMessage } = useIntl();
|
||||
beforeEach(() => {
|
||||
describe('useIsCollapsed', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useCardData', () => {
|
||||
const courseData = {
|
||||
title: 'fake-title',
|
||||
bannerImgSrc: 'my-banner-url',
|
||||
};
|
||||
const runHook = ({ course = {} }) => {
|
||||
reduxHooks.useCardCourseData.mockReturnValueOnce({
|
||||
...courseData,
|
||||
...course,
|
||||
});
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValue({ isEnrolled: 'test-is-enrolled' });
|
||||
out = hooks.useCardData({ cardId });
|
||||
};
|
||||
beforeEach(() => {
|
||||
runHook({});
|
||||
});
|
||||
it('forwards formatMessage from useIntl', () => {
|
||||
expect(out.formatMessage).toEqual(formatMessage);
|
||||
});
|
||||
it('passes course title and banner URL form course data', () => {
|
||||
expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(cardId);
|
||||
expect(out.title).toEqual(courseData.title);
|
||||
expect(out.bannerImgSrc).toEqual(courseData.bannerImgSrc);
|
||||
});
|
||||
it('should return true when window width is smaller than small breakpoint', () => {
|
||||
useWindowSize.mockReturnValue({ width: 500 });
|
||||
const { result } = renderHook(() => useIsCollapsed());
|
||||
expect(result.current).toBe(true);
|
||||
expect(useWindowSize).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when window width is larger than small breakpoint', () => {
|
||||
useWindowSize.mockReturnValue({ width: 800 });
|
||||
const { result } = renderHook(() => useIsCollapsed());
|
||||
expect(result.current).toBe(false);
|
||||
expect(useWindowSize).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Button, Chip } from '@openedx/paragon';
|
||||
import { CloseSmall } from '@openedx/paragon/icons';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useFilters } from 'data/context';
|
||||
|
||||
import messages from './messages';
|
||||
import './index.scss';
|
||||
|
||||
export const ActiveCourseFilters = ({
|
||||
filters,
|
||||
handleRemoveFilter,
|
||||
}) => {
|
||||
export const ActiveCourseFilters = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const clearFilters = reduxHooks.useClearFilters();
|
||||
const { filters, clearFilters, removeFilter } = useFilters();
|
||||
|
||||
return (
|
||||
<div id="course-list-active-filters">
|
||||
{filters.map(filter => (
|
||||
<Chip
|
||||
key={filter}
|
||||
iconAfter={CloseSmall}
|
||||
onClick={handleRemoveFilter(filter)}
|
||||
onClick={() => removeFilter(filter)}
|
||||
>
|
||||
{formatMessage(messages[filter])}
|
||||
</Chip>
|
||||
@@ -32,9 +29,5 @@ export const ActiveCourseFilters = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
ActiveCourseFilters.propTypes = {
|
||||
filters: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
handleRemoveFilter: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ActiveCourseFilters;
|
||||
|
||||
@@ -1,28 +1,54 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { formatMessage } from 'testUtils';
|
||||
import { useFilters } from 'data/context';
|
||||
|
||||
import { FilterKeys } from 'data/constants/app';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ActiveCourseFilters from './ActiveCourseFilters';
|
||||
import messages from './messages';
|
||||
|
||||
const filters = Object.values(FilterKeys);
|
||||
|
||||
jest.mock('data/context', () => ({
|
||||
useFilters: jest.fn(),
|
||||
}));
|
||||
|
||||
const removeFiltersMock = jest.fn().mockName('removeFilter');
|
||||
const clearFiltersMock = jest.fn().mockName('clearFilters');
|
||||
useFilters.mockReturnValue({
|
||||
filters,
|
||||
removeFilter: removeFiltersMock,
|
||||
clearFilters: clearFiltersMock,
|
||||
});
|
||||
|
||||
describe('ActiveCourseFilters', () => {
|
||||
const props = {
|
||||
filters,
|
||||
handleRemoveFilter: jest.fn().mockName('handleRemoveFilter'),
|
||||
};
|
||||
it('renders chips correctly', () => {
|
||||
render(<IntlProvider locale="en"><ActiveCourseFilters {...props} /></IntlProvider>);
|
||||
render(<IntlProvider locale="en"><ActiveCourseFilters /></IntlProvider>);
|
||||
filters.map((key) => {
|
||||
const chip = screen.getByText(formatMessage(messages[key]));
|
||||
return expect(chip).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('renders button correctly', () => {
|
||||
render(<IntlProvider locale="en"><ActiveCourseFilters {...props} /></IntlProvider>);
|
||||
render(<IntlProvider locale="en"><ActiveCourseFilters /></IntlProvider>);
|
||||
const button = screen.getByRole('button', { name: formatMessage(messages.clearAll) });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
it('should call onClick when button is clicked remove filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<IntlProvider locale="en"><ActiveCourseFilters /></IntlProvider>);
|
||||
const removeButton = screen.getByRole('button', { name: formatMessage(messages[filters[0]]) });
|
||||
await user.click(removeButton);
|
||||
expect(removeFiltersMock).toHaveBeenCalledTimes(1);
|
||||
expect(removeFiltersMock).toHaveBeenCalledWith(filters[0]);
|
||||
});
|
||||
it('should call onClick when button is clicked clear all filters', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<IntlProvider locale="en"><ActiveCourseFilters /></IntlProvider>);
|
||||
screen.debug();
|
||||
const clearAllButton = screen.getByRole('button', { name: formatMessage(messages.clearAll) });
|
||||
await user.click(clearAllButton);
|
||||
expect(clearFiltersMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import track from 'tracking';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
@@ -14,44 +13,51 @@ import {
|
||||
} from '@openedx/paragon';
|
||||
import { Close, Tune } from '@openedx/paragon/icons';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import { useFilters } from 'data/context';
|
||||
import FilterForm from './components/FilterForm';
|
||||
import SortForm from './components/SortForm';
|
||||
import useCourseFilterControlsData from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
export const CourseFilterControls = ({
|
||||
sortBy,
|
||||
setSortBy,
|
||||
filters,
|
||||
}) => {
|
||||
export const CourseFilterControls = () => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [targetRef, setTargetRef] = React.useState(null);
|
||||
const { formatMessage } = useIntl();
|
||||
const hasCourses = reduxHooks.useHasCourses();
|
||||
const { data } = useInitializeLearnerHome();
|
||||
const hasCourses = React.useMemo(() => data?.courses?.length > 0, [data]);
|
||||
const {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
target,
|
||||
setTarget,
|
||||
handleFilterChange,
|
||||
handleSortChange,
|
||||
} = useCourseFilterControlsData({
|
||||
filters,
|
||||
setSortBy,
|
||||
});
|
||||
filters, sortBy, setSortBy, addFilter, removeFilter,
|
||||
} = useFilters();
|
||||
|
||||
const openFiltersOptions = () => {
|
||||
track.filter.filterClicked();
|
||||
setIsOpen(true);
|
||||
};
|
||||
const closeFiltersOptions = () => {
|
||||
track.filter.filterOptionSelected(filters);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSortChange = (event) => {
|
||||
setSortBy(event.target.value);
|
||||
};
|
||||
|
||||
const handleFilterChange = ({ target: { checked, value } }) => {
|
||||
const update = checked ? addFilter : removeFilter;
|
||||
update(value);
|
||||
};
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width < breakpoints.small.minWidth;
|
||||
|
||||
return (
|
||||
<div id="course-filter-controls">
|
||||
<Button
|
||||
ref={setTarget}
|
||||
ref={setTargetRef}
|
||||
variant="outline-primary"
|
||||
iconBefore={Tune}
|
||||
onClick={open}
|
||||
onClick={openFiltersOptions}
|
||||
disabled={!hasCourses}
|
||||
>
|
||||
{formatMessage(messages.refine)}
|
||||
@@ -63,7 +69,7 @@ export const CourseFilterControls = ({
|
||||
className="w-75"
|
||||
position="left"
|
||||
show={isOpen}
|
||||
onClose={close}
|
||||
onClose={closeFiltersOptions}
|
||||
>
|
||||
<div className="p-1 mr-3">
|
||||
<b>{formatMessage(messages.refine)}</b>
|
||||
@@ -76,16 +82,16 @@ export const CourseFilterControls = ({
|
||||
<SortForm {...{ sortBy, handleSortChange }} />
|
||||
</div>
|
||||
<div className="pgn__modal-close-container">
|
||||
<ModalCloseButton variant="tertiary" onClick={close}>
|
||||
<ModalCloseButton variant="tertiary" onClick={closeFiltersOptions}>
|
||||
<Icon src={Close} />
|
||||
</ModalCloseButton>
|
||||
</div>
|
||||
</Sheet>
|
||||
) : (
|
||||
<ModalPopup
|
||||
positionRef={target}
|
||||
positionRef={targetRef}
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
onClose={closeFiltersOptions}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div
|
||||
@@ -106,10 +112,5 @@ export const CourseFilterControls = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
CourseFilterControls.propTypes = {
|
||||
sortBy: PropTypes.string.isRequired,
|
||||
setSortBy: PropTypes.func.isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
export default CourseFilterControls;
|
||||
|
||||
@@ -1,75 +1,150 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { formatMessage } from 'testUtils';
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { FilterKeys, SortKeys } from 'data/constants/app';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import { useFilters } from 'data/context';
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import messages from './messages';
|
||||
import CourseFilterControls from './CourseFilterControls';
|
||||
import useCourseFilterControlsData from './hooks';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: { useHasCourses: jest.fn() },
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn().mockReturnValue({ data: { courses: [1, 2, 3] } }),
|
||||
}));
|
||||
|
||||
jest.mock('./hooks', () => jest.fn());
|
||||
|
||||
jest.mock('@openedx/paragon', () => ({
|
||||
...jest.requireActual('@openedx/paragon'),
|
||||
useWindowSize: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('tracking', () => ({
|
||||
filter: {
|
||||
filterClicked: jest.fn().mockName('segment.filterClicked'),
|
||||
filterOptionSelected: jest.fn().mockName('segment.filterOptionSelected'),
|
||||
},
|
||||
}));
|
||||
|
||||
const filters = Object.values(FilterKeys);
|
||||
|
||||
const mockControlsData = {
|
||||
isOpen: false,
|
||||
open: jest.fn().mockName('open'),
|
||||
close: jest.fn().mockName('close'),
|
||||
target: 'target-test',
|
||||
setTarget: jest.fn(),
|
||||
handleFilterChange: jest.fn().mockName('handleFilterChange'),
|
||||
handleSortChange: jest.fn().mockName('handleSortChange'),
|
||||
};
|
||||
jest.mock('data/context', () => ({
|
||||
useFilters: jest.fn(),
|
||||
}));
|
||||
|
||||
const setSortByMock = jest.fn().mockName('setSortBy');
|
||||
useFilters.mockReturnValue({
|
||||
filters,
|
||||
removeFilter: jest.fn().mockName('removeFilter'),
|
||||
clearFilters: jest.fn().mockName('clearFilters'),
|
||||
setSortBy: setSortByMock,
|
||||
addFilter: jest.fn().mockName('addFilter'),
|
||||
});
|
||||
|
||||
describe('CourseFilterControls', () => {
|
||||
const props = {
|
||||
sortBy: SortKeys.enrolled,
|
||||
setSortBy: jest.fn().mockName('setSortBy'),
|
||||
filters,
|
||||
};
|
||||
|
||||
describe('mobile and open', () => {
|
||||
it('should render sheet', () => {
|
||||
reduxHooks.useHasCourses.mockReturnValue(true);
|
||||
useCourseFilterControlsData.mockReturnValue({ ...mockControlsData, isOpen: true });
|
||||
useWindowSize.mockReturnValueOnce({ width: breakpoints.small.minWidth - 1 });
|
||||
render(<IntlProvider locale="en"><CourseFilterControls {...props} /></IntlProvider>);
|
||||
const sheet = screen.getByRole('presentation', { hidden: true });
|
||||
expect(sheet).toBeInTheDocument();
|
||||
expect(sheet.parentElement).toHaveClass('sheet-container');
|
||||
it('should render sheet', async () => {
|
||||
const user = userEvent.setup();
|
||||
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth - 1 });
|
||||
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
|
||||
const filtersButton = screen.getByRole('button', { name: 'Refine' });
|
||||
await user.click(filtersButton);
|
||||
await waitFor(() => {
|
||||
const sheet = screen.getByRole('presentation', { hidden: true });
|
||||
expect(sheet).toBeInTheDocument();
|
||||
expect(sheet.parentElement).toHaveClass('sheet-container');
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('is not mobile', () => {
|
||||
it('should have button disabled', () => {
|
||||
reduxHooks.useHasCourses.mockReturnValue(true);
|
||||
useCourseFilterControlsData.mockReturnValue({ ...mockControlsData, isOpen: true });
|
||||
useWindowSize.mockReturnValueOnce({ width: breakpoints.small.minWidth });
|
||||
render(<IntlProvider locale="en"><CourseFilterControls {...props} /></IntlProvider>);
|
||||
const filterForm = screen.getByText(messages.courseStatus.defaultMessage);
|
||||
const modal = filterForm.closest('div.pgn__modal-popup__tooltip');
|
||||
expect(modal).toBeInTheDocument();
|
||||
it('should have button disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
|
||||
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
|
||||
const filtersButton = screen.getByRole('button', { name: 'Refine' });
|
||||
await user.click(filtersButton);
|
||||
await waitFor(() => {
|
||||
const filterForm = screen.getByText(messages.courseStatus.defaultMessage);
|
||||
const modal = filterForm.closest('div.pgn__modal-popup__tooltip');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('no courses', () => {
|
||||
it('should have button disabled', () => {
|
||||
reduxHooks.useHasCourses.mockReturnValue(false);
|
||||
useCourseFilterControlsData.mockReturnValue(mockControlsData);
|
||||
useInitializeLearnerHome.mockReturnValue({ data: { courses: [] } });
|
||||
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
|
||||
render(<IntlProvider locale="en"><CourseFilterControls {...props} /></IntlProvider>);
|
||||
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
|
||||
const button = screen.getByRole('button', { name: formatMessage(messages.refine) });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
describe('with courses', () => {
|
||||
it('should have button enabled', () => {
|
||||
useInitializeLearnerHome.mockReturnValue({ data: { courses: [1, 2, 3] } });
|
||||
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
|
||||
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
|
||||
const button = screen.getByRole('button', { name: formatMessage(messages.refine) });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
it('should call setSortBy on sort change', async () => {
|
||||
const user = userEvent.setup();
|
||||
useInitializeLearnerHome.mockReturnValue({ data: { courses: [1, 2, 3] } });
|
||||
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
|
||||
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
|
||||
const filtersButton = screen.getByRole('button', { name: 'Refine' });
|
||||
await user.click(filtersButton);
|
||||
await waitFor(async () => {
|
||||
const sortRadio = screen.getByRole('radio', { name: formatMessage(messages.sortTitle) });
|
||||
await user.click(sortRadio);
|
||||
expect(setSortByMock).toHaveBeenCalledWith(SortKeys.title);
|
||||
});
|
||||
});
|
||||
it('should call addFilter on filter check', async () => {
|
||||
const user = userEvent.setup();
|
||||
const addFilterMock = jest.fn().mockName('addFilter');
|
||||
const removeFilterMock = jest.fn().mockName('removeFilter');
|
||||
useFilters.mockReturnValue({
|
||||
filters: [],
|
||||
removeFilter: removeFilterMock,
|
||||
clearFilters: jest.fn().mockName('clearFilters'),
|
||||
setSortBy: jest.fn().mockName('setSortBy'),
|
||||
addFilter: addFilterMock,
|
||||
});
|
||||
useInitializeLearnerHome.mockReturnValue({ data: { courses: [1, 2, 3] } });
|
||||
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
|
||||
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
|
||||
const filtersButton = screen.getByRole('button', { name: 'Refine' });
|
||||
await user.click(filtersButton);
|
||||
await waitFor(async () => {
|
||||
const filterCheckbox = screen.getByText('In-Progress');
|
||||
await user.click(filterCheckbox);
|
||||
expect(addFilterMock).toHaveBeenCalledWith(FilterKeys.inProgress);
|
||||
});
|
||||
});
|
||||
it('should call removeFilter on filter uncheck', async () => {
|
||||
const user = userEvent.setup();
|
||||
const addFilterMock = jest.fn().mockName('addFilter');
|
||||
const removeFilterMock = jest.fn().mockName('removeFilter');
|
||||
useFilters.mockReturnValue({
|
||||
filters: [FilterKeys.inProgress],
|
||||
removeFilter: removeFilterMock,
|
||||
clearFilters: jest.fn().mockName('clearFilters'),
|
||||
setSortBy: jest.fn().mockName('setSortBy'),
|
||||
addFilter: addFilterMock,
|
||||
});
|
||||
useInitializeLearnerHome.mockReturnValue({ data: { courses: [1, 2, 3] } });
|
||||
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
|
||||
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
|
||||
const filtersButton = screen.getByRole('button', { name: 'Refine' });
|
||||
await user.click(filtersButton);
|
||||
await waitFor(async () => {
|
||||
const filterCheckbox = screen.getByText('In-Progress');
|
||||
await user.click(filterCheckbox);
|
||||
expect(removeFilterMock).toHaveBeenCalledWith(FilterKeys.inProgress);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
|
||||
import { StrictDict } from 'utils';
|
||||
import track from 'tracking';
|
||||
import { reduxHooks } from 'hooks';
|
||||
|
||||
import * as module from './hooks';
|
||||
|
||||
export const state = StrictDict({
|
||||
target: (val) => React.useState(val), // eslint-disable-line
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets up a toggle for the modal as well as helper functions for handling changes to the form controls.
|
||||
*
|
||||
* @param {array} filters Currently active course filters
|
||||
* @param {function} setSortBy Set function for sorting the course list
|
||||
* @returns {object} data and functions for managing the CourseFilterControls component
|
||||
*/
|
||||
export const useCourseFilterControlsData = ({
|
||||
filters,
|
||||
setSortBy,
|
||||
}) => {
|
||||
const [isOpen, toggleOpen, toggleClose] = useToggle(false);
|
||||
const [target, setTarget] = module.state.target(null);
|
||||
|
||||
const addFilter = reduxHooks.useAddFilter();
|
||||
const removeFilter = reduxHooks.useRemoveFilter();
|
||||
|
||||
const handleFilterChange = ({ target: { checked, value } }) => {
|
||||
const update = checked ? addFilter : removeFilter;
|
||||
update(value);
|
||||
};
|
||||
const handleSortChange = ({ target: { value } }) => {
|
||||
setSortBy(value);
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
track.filter.filterClicked();
|
||||
toggleOpen();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
track.filter.filterOptionSelected(filters);
|
||||
toggleClose();
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
target,
|
||||
setTarget,
|
||||
handleFilterChange,
|
||||
handleSortChange,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCourseFilterControlsData;
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
|
||||
import { MockUseState } from 'testUtils';
|
||||
import { reduxHooks } from 'hooks';
|
||||
|
||||
import track from 'tracking';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('@openedx/paragon', () => ({
|
||||
...jest.requireActual('@openedx/paragon'),
|
||||
useToggle: jest.fn().mockImplementation((val) => [
|
||||
val,
|
||||
jest.fn().mockName('useToggle.setTrue'),
|
||||
jest.fn().mockName('useToggle.setFalse'),
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('tracking', () => ({
|
||||
filter: {
|
||||
filterClicked: jest.fn(),
|
||||
filterOptionSelected: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useAddFilter: jest.fn(),
|
||||
useRemoveFilter: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const state = new MockUseState(hooks);
|
||||
|
||||
describe('CourseFilterControls hooks', () => {
|
||||
let out;
|
||||
const filters = ['a', 'b', 'c'];
|
||||
const setSortBy = jest.fn();
|
||||
|
||||
const removeFilter = jest.fn();
|
||||
reduxHooks.useRemoveFilter.mockReturnValue(removeFilter);
|
||||
const addFilter = jest.fn();
|
||||
reduxHooks.useAddFilter.mockReturnValue(addFilter);
|
||||
|
||||
const toggleOpen = jest.fn();
|
||||
const toggleClose = jest.fn();
|
||||
|
||||
describe('state values', () => {
|
||||
state.testGetter(state.keys.target);
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useCourseFilterControlsData', () => {
|
||||
beforeEach(() => {
|
||||
useToggle.mockReturnValueOnce([false, toggleOpen, toggleClose]);
|
||||
state.mock();
|
||||
out = hooks.useCourseFilterControlsData({
|
||||
filters,
|
||||
setSortBy,
|
||||
});
|
||||
});
|
||||
afterEach(state.restore);
|
||||
|
||||
test('default state', () => {
|
||||
expect(out.isOpen).toEqual(false);
|
||||
expect(out.target).toEqual(state.stateVals.target);
|
||||
});
|
||||
|
||||
test('open calls toggleOpen and track.filter.filterClicked', () => {
|
||||
out.open();
|
||||
expect(toggleOpen).toHaveBeenCalled();
|
||||
expect(track.filter.filterClicked).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('close calls toggleClose and track.filter.filterOptionSelected', () => {
|
||||
out.close();
|
||||
expect(toggleClose).toHaveBeenCalled();
|
||||
expect(track.filter.filterOptionSelected).toHaveBeenCalledWith(filters);
|
||||
});
|
||||
|
||||
test('isOpen is true when target is set', () => {
|
||||
useToggle.mockReturnValueOnce([true, toggleOpen, toggleClose]);
|
||||
expect(out.target).toEqual(null);
|
||||
state.mockVal(state.keys.target, 'foo');
|
||||
out = hooks.useCourseFilterControlsData({
|
||||
filters,
|
||||
setSortBy,
|
||||
});
|
||||
expect(out.isOpen).toEqual(true);
|
||||
expect(out.target).toEqual('foo');
|
||||
});
|
||||
|
||||
test('handle filter change', () => {
|
||||
const value = 'a';
|
||||
out.handleFilterChange({
|
||||
target: {
|
||||
checked: true,
|
||||
value,
|
||||
},
|
||||
});
|
||||
expect(addFilter).toHaveBeenCalledWith(value);
|
||||
out.handleFilterChange({
|
||||
target: {
|
||||
checked: false,
|
||||
value,
|
||||
},
|
||||
});
|
||||
expect(removeFilter).toHaveBeenCalledWith(value);
|
||||
});
|
||||
test('handle sort change', () => {
|
||||
const value = 'a';
|
||||
out.handleSortChange({
|
||||
target: {
|
||||
value,
|
||||
},
|
||||
});
|
||||
expect(setSortBy).toHaveBeenCalledWith(value);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,14 +11,15 @@ import { useIsCollapsed } from './hooks';
|
||||
|
||||
export const CourseList = ({ courseListData }) => {
|
||||
const {
|
||||
filterOptions, setPageNumber, numPages, showFilters, visibleList,
|
||||
setPageNumber, numPages, visibleList, showFilters,
|
||||
} = courseListData;
|
||||
|
||||
const isCollapsed = useIsCollapsed();
|
||||
return (
|
||||
<>
|
||||
{showFilters && (
|
||||
<div id="course-list-active-filters-container">
|
||||
<ActiveCourseFilters {...filterOptions} />
|
||||
<ActiveCourseFilters />
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex flex-column flex-grow-1">
|
||||
@@ -42,7 +43,6 @@ export const CourseList = ({ courseListData }) => {
|
||||
export const courseListDataShape = PropTypes.shape({
|
||||
showFilters: PropTypes.bool.isRequired,
|
||||
visibleList: PropTypes.arrayOf(PropTypes.shape()).isRequired,
|
||||
filterOptions: PropTypes.shape().isRequired,
|
||||
numPages: PropTypes.number.isRequired,
|
||||
setPageNumber: PropTypes.func.isRequired,
|
||||
});
|
||||
|
||||
@@ -5,14 +5,15 @@ import { Search } from '@openedx/paragon/icons';
|
||||
import { baseAppUrl } from 'data/services/lms/urls';
|
||||
|
||||
import emptyCourseSVG from 'assets/empty-course.svg';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
|
||||
import messages from './messages';
|
||||
import './index.scss';
|
||||
|
||||
export const NoCoursesView = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { courseSearchUrl } = reduxHooks.usePlatformSettingsData();
|
||||
const { data: learnerData } = useInitializeLearnerHome();
|
||||
const courseSearchUrl = learnerData?.platformSettings?.courseSearchUrl || '';
|
||||
return (
|
||||
<div
|
||||
id="no-courses-content-view"
|
||||
|
||||
@@ -8,12 +8,14 @@ import messages from './messages';
|
||||
|
||||
const courseSearchUrl = '/course-search-url';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
usePlatformSettingsData: jest.fn(() => ({
|
||||
courseSearchUrl,
|
||||
})),
|
||||
},
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn(() => ({
|
||||
data: {
|
||||
platformSettings: {
|
||||
courseSearchUrl,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('NoCoursesView', () => {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ListPageSize, SortKeys } from 'data/constants/app';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { StrictDict } from 'utils';
|
||||
|
||||
import * as module from './hooks';
|
||||
|
||||
export const state = StrictDict({
|
||||
sortBy: (val) => React.useState(val), // eslint-disable-line
|
||||
});
|
||||
|
||||
/**
|
||||
* Filters are fetched from the store and used to generate a list of "visible" courses.
|
||||
* Other values returned and used for the layout of the CoursesPanel component are:
|
||||
* the current page number, the sorting method, and whether or not to enable filters and pagination.
|
||||
*
|
||||
* @returns data for the CoursesPanel component
|
||||
*/
|
||||
export const useCourseListData = () => {
|
||||
const filters = reduxHooks.useFilters();
|
||||
const removeFilter = reduxHooks.useRemoveFilter();
|
||||
const pageNumber = reduxHooks.usePageNumber();
|
||||
const setPageNumber = reduxHooks.useSetPageNumber();
|
||||
|
||||
const [sortBy, setSortBy] = module.state.sortBy(SortKeys.enrolled);
|
||||
|
||||
const querySearch = new URLSearchParams(window.location.search);
|
||||
const disablePagination = querySearch.get('disable_pagination');
|
||||
|
||||
const { numPages, visibleList } = reduxHooks.useCurrentCourseList({
|
||||
sortBy,
|
||||
filters,
|
||||
pageSize: Number(disablePagination) === 1 ? 0 : ListPageSize,
|
||||
});
|
||||
|
||||
const handleRemoveFilter = (filter) => () => removeFilter(filter);
|
||||
|
||||
return {
|
||||
pageNumber,
|
||||
numPages,
|
||||
setPageNumber,
|
||||
visibleList,
|
||||
filterOptions: {
|
||||
sortBy,
|
||||
setSortBy,
|
||||
filters,
|
||||
handleRemoveFilter,
|
||||
},
|
||||
showFilters: filters.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCourseListData;
|
||||
@@ -1,115 +0,0 @@
|
||||
import { MockUseState } from 'testUtils';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { ListPageSize, SortKeys } from 'data/constants/app';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCurrentCourseList: jest.fn(),
|
||||
usePageNumber: jest.fn(() => 23),
|
||||
useSetPageNumber: jest.fn(),
|
||||
useFilters: jest.fn(),
|
||||
useRemoveFilter: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockGet = jest.fn(() => ({}));
|
||||
|
||||
global.URLSearchParams = jest.fn().mockImplementation(() => ({
|
||||
get: mockGet,
|
||||
}));
|
||||
|
||||
const state = new MockUseState(hooks);
|
||||
|
||||
const testList = ['a', 'b'];
|
||||
const testListData = {
|
||||
numPages: 52,
|
||||
visibleList: testList,
|
||||
};
|
||||
const testSortBy = 'fake sort option';
|
||||
const testFilters = ['some', 'fake', 'filters'];
|
||||
|
||||
const setPageNumber = jest.fn(val => ({ setPageNumber: val }));
|
||||
reduxHooks.useSetPageNumber.mockReturnValue(setPageNumber);
|
||||
|
||||
const removeFilter = jest.fn();
|
||||
reduxHooks.useRemoveFilter.mockReturnValue(removeFilter);
|
||||
reduxHooks.useFilters.mockReturnValue(['some', 'fake', 'filters']);
|
||||
|
||||
describe('CourseList hooks', () => {
|
||||
let out;
|
||||
|
||||
reduxHooks.useCurrentCourseList.mockReturnValue(testListData);
|
||||
|
||||
describe('state values', () => {
|
||||
state.testGetter(state.keys.sortBy);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useCourseListData', () => {
|
||||
afterEach(state.restore);
|
||||
beforeEach(() => {
|
||||
state.mock();
|
||||
state.mockVal(state.keys.sortBy, testSortBy);
|
||||
out = hooks.useCourseListData();
|
||||
});
|
||||
describe('behavior', () => {
|
||||
it('initializes sort with enrollment date', () => {
|
||||
state.expectInitializedWith(state.keys.sortBy, SortKeys.enrolled);
|
||||
});
|
||||
it('loads current course list with sortBy, filters, and page size', () => {
|
||||
expect(reduxHooks.useCurrentCourseList).toHaveBeenCalledWith({
|
||||
sortBy: testSortBy,
|
||||
filters: testFilters,
|
||||
pageSize: ListPageSize,
|
||||
});
|
||||
});
|
||||
it('loads current course list with page size 0 if/when there is query param disable_pagination=1', () => {
|
||||
state.mock();
|
||||
state.mockVal(state.keys.sortBy, testSortBy);
|
||||
mockGet.mockReturnValueOnce('1');
|
||||
out = hooks.useCourseListData();
|
||||
expect(reduxHooks.useCurrentCourseList).toHaveBeenCalledWith({
|
||||
sortBy: testSortBy,
|
||||
filters: testFilters,
|
||||
pageSize: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('output', () => {
|
||||
test('pageNumber loads from usePageNumber hook', () => {
|
||||
expect(out.pageNumber).toEqual(reduxHooks.usePageNumber());
|
||||
});
|
||||
test('numPages and visible list load from useCurrentCourseList hook', () => {
|
||||
expect(out.numPages).toEqual(testListData.numPages);
|
||||
expect(out.visibleList).toEqual(testListData.visibleList);
|
||||
});
|
||||
test('showFilters is true iff filters is not empty', () => {
|
||||
expect(out.showFilters).toEqual(true);
|
||||
state.mockVal(state.keys.sortBy, testSortBy);
|
||||
reduxHooks.useFilters.mockReturnValueOnce([]);
|
||||
out = hooks.useCourseListData();
|
||||
// don't show filter when list is empty.
|
||||
expect(out.showFilters).toEqual(false);
|
||||
});
|
||||
describe('filterOptions', () => {
|
||||
test('sortBy and setSortBy are connected to the state value', () => {
|
||||
expect(out.filterOptions.sortBy).toEqual(testSortBy);
|
||||
expect(out.filterOptions.setSortBy).toEqual(state.setState.sortBy);
|
||||
});
|
||||
test('filters passed by useFilters hook', () => {
|
||||
expect(out.filterOptions.filters).toEqual(testFilters);
|
||||
});
|
||||
test('handleRemoveFilter creates callback to call setFilter.remove', () => {
|
||||
const cb = out.filterOptions.handleRemoveFilter(testFilters[0]);
|
||||
expect(removeFilter).not.toHaveBeenCalled();
|
||||
cb();
|
||||
expect(removeFilter).toHaveBeenCalledWith(testFilters[0]);
|
||||
});
|
||||
test('setPageNumber dispatches setPageNumber action with passed value', () => {
|
||||
expect(out.setPageNumber(2)).toEqual(setPageNumber(2));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import {
|
||||
CourseFilterControls,
|
||||
} from 'containers/CourseFilterControls';
|
||||
import CourseListSlot from 'plugin-slots/CourseListSlot';
|
||||
import NoCoursesViewSlot from 'plugin-slots/NoCoursesViewSlot';
|
||||
import { useFilters } from 'data/context';
|
||||
|
||||
import { useCourseListData } from './hooks';
|
||||
import { getVisibleList, getTransformedCourseDataList } from 'utils/dataTransformers';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
@@ -22,14 +22,46 @@ import './index.scss';
|
||||
*/
|
||||
export const CoursesPanel = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const hasCourses = reduxHooks.useHasCourses();
|
||||
const courseListData = useCourseListData();
|
||||
const { data } = useInitializeLearnerHome();
|
||||
const hasCourses = useMemo(() => data?.courses?.length > 0, [data]);
|
||||
|
||||
const {
|
||||
filters, sortBy, pageNumber, setPageNumber,
|
||||
} = useFilters();
|
||||
const { visibleList, numPages } = useMemo(() => {
|
||||
let transformedCourses = [];
|
||||
if (data?.courses?.length) {
|
||||
transformedCourses = getTransformedCourseDataList(data.courses);
|
||||
}
|
||||
return getVisibleList(
|
||||
transformedCourses,
|
||||
filters,
|
||||
sortBy,
|
||||
pageNumber,
|
||||
);
|
||||
}, [data, filters, sortBy, pageNumber]);
|
||||
|
||||
// Clamp page number when filtered/mutated list shrinks
|
||||
React.useEffect(() => {
|
||||
if (numPages > 0 && pageNumber > numPages) {
|
||||
setPageNumber(1);
|
||||
}
|
||||
}, [numPages, pageNumber, setPageNumber]);
|
||||
|
||||
const courseListData = {
|
||||
filterOptions: filters,
|
||||
setPageNumber,
|
||||
numPages,
|
||||
visibleList,
|
||||
showFilters: filters.length > 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="course-list-container">
|
||||
<div className="course-list-heading-container">
|
||||
<h2 className="course-list-title">{formatMessage(messages.myCourses)}</h2>
|
||||
<div className="course-filter-controls-container">
|
||||
<CourseFilterControls {...courseListData.filterOptions} />
|
||||
<CourseFilterControls />
|
||||
</div>
|
||||
</div>
|
||||
{hasCourses ? <CourseListSlot courseListData={courseListData} /> : <NoCoursesViewSlot />}
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { FilterKeys } from 'data/constants/app';
|
||||
import { reduxHooks } from 'hooks';
|
||||
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import { useFilters } from 'data/context';
|
||||
import * as dataTransformers from 'utils/dataTransformers';
|
||||
import messagesNoCourses from 'containers/CoursesPanel/NoCoursesView/messages';
|
||||
import { useCourseListData } from './hooks';
|
||||
import CoursesPanel from '.';
|
||||
import messages from './messages';
|
||||
|
||||
const courseSearchUrl = '/course-search-url';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useHasCourses: jest.fn(),
|
||||
usePlatformSettingsData: jest.fn(() => ({
|
||||
courseSearchUrl,
|
||||
})),
|
||||
},
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn(() => ({
|
||||
data: {
|
||||
courses: [{ id: 1 }, { id: 2 }],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
useCourseListData: jest.fn(),
|
||||
jest.mock('data/context', () => ({
|
||||
useFilters: jest.fn(() => ({
|
||||
filters: [],
|
||||
sortBy: 'enrolled',
|
||||
pageNumber: 1,
|
||||
setPageNumber: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('containers/CourseCard', () => jest.fn(() => <div>CourseCard</div>));
|
||||
@@ -33,30 +34,14 @@ jest.mock('@openedx/frontend-plugin-framework', () => ({
|
||||
PluginSlot: 'PluginSlot',
|
||||
}));
|
||||
|
||||
const filters = Object.values(FilterKeys);
|
||||
|
||||
reduxHooks.useHasCourses.mockReturnValue(true);
|
||||
|
||||
describe('CoursesPanel', () => {
|
||||
const defaultCourseListData = {
|
||||
filterOptions: { filters, handleRemoveFilter: jest.fn() },
|
||||
numPages: 1,
|
||||
setPageNumber: jest.fn().mockName('setPageNumber'),
|
||||
showFilters: false,
|
||||
visibleList: [],
|
||||
};
|
||||
|
||||
const createWrapper = (courseListData) => {
|
||||
useCourseListData.mockReturnValue({
|
||||
...defaultCourseListData,
|
||||
...courseListData,
|
||||
});
|
||||
useInitializeLearnerHome.mockReturnValue({ data: { courses: courseListData?.visibleList || [] } });
|
||||
return render(<IntlProvider locale="en"><CoursesPanel /></IntlProvider>);
|
||||
};
|
||||
|
||||
describe('no courses', () => {
|
||||
it('should render no courses view slot', () => {
|
||||
reduxHooks.useHasCourses.mockReturnValue(false);
|
||||
createWrapper();
|
||||
const imgNoCourses = screen.getByRole('img', { name: messagesNoCourses.bannerAlt.defaultMessage });
|
||||
expect(imgNoCourses).toBeInTheDocument();
|
||||
@@ -67,23 +52,106 @@ describe('CoursesPanel', () => {
|
||||
describe('with courses', () => {
|
||||
it('should render courselist', () => {
|
||||
const visibleList = [{ cardId: 'foo' }, { cardId: 'bar' }, { cardId: 'baz' }];
|
||||
reduxHooks.useHasCourses.mockReturnValue(true);
|
||||
createWrapper({ visibleList });
|
||||
const courseCards = screen.getAllByText('CourseCard');
|
||||
expect(courseCards.length).toEqual(visibleList.length);
|
||||
});
|
||||
it('displays course filter controls', () => {
|
||||
reduxHooks.useHasCourses.mockReturnValue(true);
|
||||
createWrapper();
|
||||
expect(screen.getByText('CourseFilterControls')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays course list slot when courses exist', () => {
|
||||
reduxHooks.useHasCourses.mockReturnValue(true);
|
||||
const visibleList = [{ cardId: 'foo' }, { cardId: 'bar' }, { cardId: 'baz' }];
|
||||
createWrapper({ visibleList });
|
||||
const heading = screen.getByText(messages.myCourses.defaultMessage);
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('page number clamping', () => {
|
||||
const mockSetPageNumber = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(dataTransformers, 'getTransformedCourseDataList');
|
||||
jest.spyOn(dataTransformers, 'getVisibleList');
|
||||
});
|
||||
|
||||
it('clamps page number to 1 when current page exceeds total pages', () => {
|
||||
useFilters.mockReturnValue({
|
||||
filters: [],
|
||||
sortBy: 'enrolled',
|
||||
pageNumber: 5, // User is on page 5
|
||||
setPageNumber: mockSetPageNumber,
|
||||
});
|
||||
|
||||
dataTransformers.getTransformedCourseDataList.mockReturnValue([{ id: 1 }, { id: 2 }]);
|
||||
dataTransformers.getVisibleList.mockReturnValue({
|
||||
visibleList: [{ id: 1 }],
|
||||
numPages: 2,
|
||||
});
|
||||
|
||||
createWrapper({ visibleList: [{ id: 1 }] });
|
||||
|
||||
expect(mockSetPageNumber).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not clamp page number when current page is valid', () => {
|
||||
useFilters.mockReturnValue({
|
||||
filters: [],
|
||||
sortBy: 'enrolled',
|
||||
pageNumber: 2,
|
||||
setPageNumber: mockSetPageNumber,
|
||||
});
|
||||
|
||||
dataTransformers.getTransformedCourseDataList.mockReturnValue([{ id: 1 }, { id: 2 }]);
|
||||
dataTransformers.getVisibleList.mockReturnValue({
|
||||
visibleList: [{ id: 1 }],
|
||||
numPages: 3,
|
||||
});
|
||||
|
||||
createWrapper({ visibleList: [{ id: 1 }] });
|
||||
|
||||
expect(mockSetPageNumber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not clamp when numPages is 0', () => {
|
||||
useFilters.mockReturnValue({
|
||||
filters: [],
|
||||
sortBy: 'enrolled',
|
||||
pageNumber: 2,
|
||||
setPageNumber: mockSetPageNumber,
|
||||
});
|
||||
|
||||
dataTransformers.getTransformedCourseDataList.mockReturnValue([]);
|
||||
dataTransformers.getVisibleList.mockReturnValue({
|
||||
visibleList: [],
|
||||
numPages: 0,
|
||||
});
|
||||
|
||||
createWrapper({ visibleList: [] });
|
||||
|
||||
expect(mockSetPageNumber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles edge case when pageNumber equals numPages', () => {
|
||||
useFilters.mockReturnValue({
|
||||
filters: [],
|
||||
sortBy: 'enrolled',
|
||||
pageNumber: 2,
|
||||
setPageNumber: mockSetPageNumber,
|
||||
});
|
||||
|
||||
dataTransformers.getTransformedCourseDataList.mockReturnValue([{ id: 1 }, { id: 2 }]);
|
||||
dataTransformers.getVisibleList.mockReturnValue({
|
||||
visibleList: [{ id: 1 }],
|
||||
numPages: 2,
|
||||
});
|
||||
|
||||
createWrapper({ visibleList: [{ id: 1 }] });
|
||||
|
||||
expect(mockSetPageNumber).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,16 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import hooks from './hooks';
|
||||
import DashboardLayout from './DashboardLayout';
|
||||
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn().mockReturnValue({
|
||||
data: {
|
||||
platformSettings: {
|
||||
courseSearchUrl: '/courses',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
useDashboardLayoutData: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useWindowSize, breakpoints } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { apiHooks } from 'hooks';
|
||||
import { StrictDict } from 'utils';
|
||||
|
||||
import appMessages from 'messages';
|
||||
@@ -11,11 +10,6 @@ export const state = StrictDict({
|
||||
sidebarShowing: (val) => React.useState(val), // eslint-disable-line
|
||||
});
|
||||
|
||||
export const useInitializeDashboard = () => {
|
||||
const initialize = apiHooks.useInitializeApp();
|
||||
React.useEffect(() => { initialize(); }, []); // eslint-disable-line
|
||||
};
|
||||
|
||||
export const useDashboardMessages = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
return {
|
||||
@@ -37,6 +31,5 @@ export const useDashboardLayoutData = () => {
|
||||
|
||||
export default {
|
||||
useDashboardLayoutData,
|
||||
useInitializeDashboard,
|
||||
useDashboardMessages,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useWindowSize, breakpoints } from '@openedx/paragon';
|
||||
|
||||
import { apiHooks } from 'hooks';
|
||||
import { MockUseState } from 'testUtils';
|
||||
|
||||
import appMessages from 'messages';
|
||||
@@ -25,12 +22,6 @@ jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
apiHooks: {
|
||||
useInitializeApp: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
|
||||
@@ -38,8 +29,6 @@ jest.mock('react', () => ({
|
||||
|
||||
const state = new MockUseState(hooks);
|
||||
|
||||
const initializeApp = jest.fn();
|
||||
apiHooks.useInitializeApp.mockReturnValue(initializeApp);
|
||||
useWindowSize.mockReturnValue({ width: 20 });
|
||||
breakpoints.large = { maxWidth: 30 };
|
||||
describe('CourseCard hooks', () => {
|
||||
@@ -77,16 +66,6 @@ describe('CourseCard hooks', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('useInitializeDashboard', () => {
|
||||
it('dispatches initialize thunk action on component load', () => {
|
||||
hooks.useInitializeDashboard();
|
||||
const [cb, prereqs] = React.useEffect.mock.calls[0];
|
||||
expect(prereqs).toEqual([]);
|
||||
expect(initializeApp).not.toHaveBeenCalled();
|
||||
cb();
|
||||
expect(initializeApp).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe('useDashboardMessages', () => {
|
||||
it('returns spinner screen reader text', () => {
|
||||
expect(hooks.useDashboardMessages().spinnerScreenReaderText).toEqual(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
import { useSelectSessionModal } from 'data/context';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import SelectSessionModal from 'containers/SelectSessionModal';
|
||||
import CoursesPanel from 'containers/CoursesPanel';
|
||||
import DashboardModalSlot from 'plugin-slots/DashboardModalSlot';
|
||||
@@ -12,23 +12,24 @@ import hooks from './hooks';
|
||||
import './index.scss';
|
||||
|
||||
export const Dashboard = () => {
|
||||
hooks.useInitializeDashboard();
|
||||
const { data, isPending } = useInitializeLearnerHome();
|
||||
const { pageTitle } = hooks.useDashboardMessages();
|
||||
const hasCourses = reduxHooks.useHasCourses();
|
||||
const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
|
||||
const showSelectSessionModal = reduxHooks.useShowSelectSessionModal();
|
||||
const { selectSessionModal } = useSelectSessionModal();
|
||||
const showSelectSessionModal = selectSessionModal.cardId !== null;
|
||||
|
||||
const hasCourses = useMemo(() => data?.courses?.length > 0, [data]);
|
||||
|
||||
return (
|
||||
<div id="dashboard-container" className="d-flex flex-column p-2 pt-0">
|
||||
<h1 className="sr-only">{pageTitle}</h1>
|
||||
{!initIsPending && (
|
||||
{!isPending && (
|
||||
<>
|
||||
<DashboardModalSlot />
|
||||
{(hasCourses && showSelectSessionModal) && <SelectSessionModal />}
|
||||
</>
|
||||
)}
|
||||
<div id="dashboard-content" data-testid="dashboard-content">
|
||||
{initIsPending
|
||||
{isPending
|
||||
? (<LoadingView />)
|
||||
: (
|
||||
<DashboardLayout>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useSelectSessionModal } from 'data/context';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
|
||||
import { reduxHooks } from 'hooks';
|
||||
import hooks from './hooks';
|
||||
import Dashboard from '.';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useHasCourses: jest.fn(),
|
||||
useShowSelectSessionModal: jest.fn(),
|
||||
useRequestIsPending: jest.fn(),
|
||||
},
|
||||
jest.mock('data/context', () => ({
|
||||
useSelectSessionModal: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
@@ -34,9 +35,9 @@ describe('Dashboard', () => {
|
||||
showSelectSessionModal = true,
|
||||
} = props;
|
||||
hooks.useDashboardMessages.mockReturnValue({ pageTitle });
|
||||
reduxHooks.useHasCourses.mockReturnValue(hasCourses);
|
||||
reduxHooks.useRequestIsPending.mockReturnValue(initIsPending);
|
||||
reduxHooks.useShowSelectSessionModal.mockReturnValue(showSelectSessionModal);
|
||||
const dataMocked = { data: hasCourses ? { courses: [1, 2] } : { courses: [] }, isPending: initIsPending };
|
||||
useInitializeLearnerHome.mockReturnValue(dataMocked);
|
||||
useSelectSessionModal.mockReturnValue({ selectSessionModal: showSelectSessionModal ? { cardId: 1 } : null });
|
||||
return render(<IntlProvider locale="en"><Dashboard /></IntlProvider>);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import { StrictDict } from 'utils';
|
||||
import { reduxHooks, apiHooks } from 'hooks';
|
||||
import { useUpdateEmailSettings } from 'data/hooks';
|
||||
import { useCourseData } from 'hooks';
|
||||
|
||||
import * as module from './hooks';
|
||||
|
||||
@@ -13,12 +14,14 @@ export const useEmailData = ({
|
||||
closeModal,
|
||||
cardId,
|
||||
}) => {
|
||||
const { hasOptedOutOfEmail } = reduxHooks.useCardEnrollmentData(cardId);
|
||||
const courseData = useCourseData(cardId);
|
||||
const hasOptedOutOfEmail = courseData?.enrollment?.hasOptedOutOfEmail || false;
|
||||
const courseId = courseData?.courseRun?.courseId;
|
||||
const [isOptedOut, setIsOptedOut] = module.state.toggle(hasOptedOutOfEmail);
|
||||
const updateEmailSettings = apiHooks.useUpdateEmailSettings(cardId);
|
||||
const { mutate: updateEmailSettings } = useUpdateEmailSettings();
|
||||
const onToggle = () => setIsOptedOut(!isOptedOut);
|
||||
const save = () => {
|
||||
updateEmailSettings(!isOptedOut);
|
||||
updateEmailSettings({ courseId, enable: !isOptedOut });
|
||||
closeModal();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { MockUseState } from 'testUtils';
|
||||
import { reduxHooks, apiHooks } from 'hooks';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useCardEnrollmentData: jest.fn(),
|
||||
},
|
||||
apiHooks: {
|
||||
useUpdateEmailSettings: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const cardId = 'my-test-course-number';
|
||||
const closeModal = jest.fn();
|
||||
const updateEmailSettings = jest.fn();
|
||||
apiHooks.useUpdateEmailSettings.mockReturnValue(updateEmailSettings);
|
||||
|
||||
const state = new MockUseState(hooks);
|
||||
|
||||
describe('EmailSettingsModal hooks', () => {
|
||||
let out;
|
||||
describe('state values', () => {
|
||||
state.testGetter(state.keys.toggle);
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('useEmailData', () => {
|
||||
beforeEach(() => {
|
||||
state.mock();
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ hasOptedOutOfEmail: true });
|
||||
out = hooks.useEmailData({ closeModal, cardId });
|
||||
});
|
||||
afterEach(state.restore);
|
||||
|
||||
it('loads enrollment data based on course number', () => {
|
||||
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
|
||||
it('initializes toggle value to cardData.hasOptedOutOfEmail', () => {
|
||||
state.expectInitializedWith(state.keys.toggle, true);
|
||||
expect(out.isOptedOut).toEqual(true);
|
||||
|
||||
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ hasOptedOutOfEmail: false });
|
||||
out = hooks.useEmailData({ closeModal, cardId });
|
||||
state.expectInitializedWith(state.keys.toggle, false);
|
||||
expect(out.isOptedOut).toEqual(false);
|
||||
});
|
||||
it('initializes email settings hok with cardId', () => {
|
||||
expect(apiHooks.useUpdateEmailSettings).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
describe('onToggle - returned callback', () => {
|
||||
it('sets toggle state value to opposite current value', () => {
|
||||
out.onToggle();
|
||||
expect(state.setState.toggle).toHaveBeenCalledWith(!out.isOptedOut);
|
||||
});
|
||||
});
|
||||
describe('save', () => {
|
||||
it('calls updateEmailSettings', () => {
|
||||
out.save();
|
||||
expect(updateEmailSettings).toHaveBeenCalledWith(!out.isOptedOut);
|
||||
});
|
||||
it('calls closeModal', () => {
|
||||
out.save();
|
||||
expect(closeModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
134
src/containers/EmailSettingsModal/hooks.test.jsx
Normal file
134
src/containers/EmailSettingsModal/hooks.test.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useCourseData } from 'hooks';
|
||||
import * as api from 'data/services/lms/api';
|
||||
|
||||
import { useEmailData } from './hooks';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
useCourseData: jest.fn(() => ({
|
||||
enrollment: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('data/services/lms/api', () => ({
|
||||
updateEmailSettings: jest.fn(),
|
||||
}));
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
const cardId = 'my-test-course-number';
|
||||
const closeModal = jest.fn();
|
||||
|
||||
describe('EmailSettingsModal hooks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useEmailData', () => {
|
||||
it('loads enrollment data based on course number', () => {
|
||||
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: true } });
|
||||
|
||||
renderHook(() => useEmailData({ closeModal, cardId }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(useCourseData).toHaveBeenCalledWith(cardId);
|
||||
});
|
||||
|
||||
it('initializes toggle value to cardData.hasOptedOutOfEmail when true', () => {
|
||||
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: true } });
|
||||
|
||||
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isOptedOut).toEqual(true);
|
||||
});
|
||||
|
||||
it('initializes toggle value to cardData.hasOptedOutOfEmail when false', () => {
|
||||
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: false } });
|
||||
|
||||
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isOptedOut).toEqual(false);
|
||||
});
|
||||
|
||||
it('initializes toggle value to false when hasOptedOutOfEmail is undefined', () => {
|
||||
useCourseData.mockReturnValue({ enrollment: {} });
|
||||
|
||||
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isOptedOut).toEqual(false);
|
||||
});
|
||||
|
||||
it('toggles state value when onToggle is called', () => {
|
||||
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: true } });
|
||||
|
||||
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isOptedOut).toEqual(true);
|
||||
|
||||
act(() => {
|
||||
result.current.onToggle();
|
||||
});
|
||||
|
||||
expect(result.current.isOptedOut).toEqual(false);
|
||||
|
||||
act(() => {
|
||||
result.current.onToggle();
|
||||
});
|
||||
|
||||
expect(result.current.isOptedOut).toEqual(true);
|
||||
});
|
||||
|
||||
it('calls updateEmailSettings api and closeModal when save is called', async () => {
|
||||
const courseId = 'test-course-id';
|
||||
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: true }, courseRun: { courseId } });
|
||||
api.updateEmailSettings.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.save();
|
||||
});
|
||||
|
||||
const expectedArg = { courseId, enable: !result.current.isOptedOut };
|
||||
expect(api.updateEmailSettings).toHaveBeenCalledWith(expectedArg);
|
||||
expect(closeModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls updateEmailSettings with enable:true when isOptedOut is false', async () => {
|
||||
const courseId = 'test-course-id';
|
||||
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: false }, courseRun: { courseId } });
|
||||
api.updateEmailSettings.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.save();
|
||||
});
|
||||
|
||||
expect(api.updateEmailSettings).toHaveBeenCalledWith({
|
||||
courseId,
|
||||
enable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { StrictDict } from 'utils';
|
||||
import { apiHooks, reduxHooks } from 'hooks';
|
||||
import { useInitializeLearnerHome, useSendConfirmEmail } from 'data/hooks';
|
||||
|
||||
import * as module from './hooks';
|
||||
|
||||
@@ -11,13 +11,15 @@ export const state = StrictDict({
|
||||
});
|
||||
|
||||
export const useConfirmEmailBannerData = () => {
|
||||
const { isNeeded } = reduxHooks.useEmailConfirmationData();
|
||||
const { data: learnerData } = useInitializeLearnerHome();
|
||||
const isNeeded = learnerData?.emailConfirmation?.isNeeded || false;
|
||||
const sendEmailUrl = learnerData?.emailConfirmation?.sendEmailUrl || '';
|
||||
const { mutate: sendConfirmEmail } = useSendConfirmEmail(sendEmailUrl);
|
||||
const [showPageBanner, setShowPageBanner] = module.state.showPageBanner(isNeeded);
|
||||
const [showConfirmModal, setShowConfirmModal] = module.state.showConfirmModal(false);
|
||||
const closePageBanner = () => setShowPageBanner(false);
|
||||
const closeConfirmModal = () => setShowConfirmModal(false);
|
||||
const openConfirmModal = () => setShowConfirmModal(true);
|
||||
const sendConfirmEmail = apiHooks.useSendConfirmEmail();
|
||||
|
||||
const openConfirmModalButtonClick = () => {
|
||||
sendConfirmEmail();
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { MockUseState } from 'testUtils';
|
||||
import { reduxHooks, apiHooks } from 'hooks';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
useEmailConfirmationData: jest.fn(),
|
||||
},
|
||||
apiHooks: {
|
||||
useSendConfirmEmail: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const sendConfirmEmail = jest.fn();
|
||||
apiHooks.useSendConfirmEmail.mockReturnValue(sendConfirmEmail);
|
||||
|
||||
const emailConfirmation = {
|
||||
isNeeded: true,
|
||||
};
|
||||
|
||||
const state = new MockUseState(hooks);
|
||||
|
||||
describe('ConfirmEmailBanner hooks', () => {
|
||||
let out;
|
||||
describe('state values', () => {
|
||||
state.testGetter(state.keys.showPageBanner);
|
||||
state.testGetter(state.keys.showConfirmModal);
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('useEmailConfirmationData', () => {
|
||||
beforeEach(() => state.mock());
|
||||
afterEach(state.restore);
|
||||
|
||||
test('show page banner on unverified email', () => {
|
||||
reduxHooks.useEmailConfirmationData.mockReturnValueOnce({ ...emailConfirmation });
|
||||
out = hooks.useConfirmEmailBannerData();
|
||||
expect(out.isNeeded).toEqual(emailConfirmation.isNeeded);
|
||||
reduxHooks.useEmailConfirmationData.mockReturnValueOnce({ isNeeded: false });
|
||||
});
|
||||
|
||||
test('hide page banner on verified email', () => {
|
||||
reduxHooks.useEmailConfirmationData.mockReturnValueOnce({ isNeeded: false });
|
||||
out = hooks.useConfirmEmailBannerData();
|
||||
expect(out.isNeeded).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('behavior', () => {
|
||||
beforeEach(() => {
|
||||
state.mock();
|
||||
reduxHooks.useEmailConfirmationData.mockReturnValueOnce({ ...emailConfirmation });
|
||||
out = hooks.useConfirmEmailBannerData();
|
||||
});
|
||||
afterEach(state.restore);
|
||||
test('closePageBanner', () => {
|
||||
out.closePageBanner();
|
||||
expect(state.values.showPageBanner).toEqual(false);
|
||||
});
|
||||
test('closeConfirmModal', () => {
|
||||
out.closeConfirmModal();
|
||||
expect(state.values.showConfirmModal).toEqual(false);
|
||||
});
|
||||
test('openConfirmModalButtonClick', () => {
|
||||
out.openConfirmModalButtonClick();
|
||||
expect(state.values.showConfirmModal).toEqual(true);
|
||||
expect(sendConfirmEmail).toBeCalled();
|
||||
});
|
||||
test('userConfirmEmailButtonClick', () => {
|
||||
out.userConfirmEmailButtonClick();
|
||||
expect(state.values.showConfirmModal).toEqual(false);
|
||||
expect(state.values.showPageBanner).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import * as api from 'data/services/lms/api';
|
||||
import * as hooks from './hooks';
|
||||
|
||||
jest.mock('data/hooks', () => ({
|
||||
...jest.requireActual('data/hooks'),
|
||||
useInitializeLearnerHome: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('data/services/lms/api', () => ({
|
||||
sendConfirmEmail: jest.fn(),
|
||||
}));
|
||||
|
||||
const emailConfirmation = {
|
||||
isNeeded: true,
|
||||
};
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe('ConfirmEmailBanner hooks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
api.sendConfirmEmail.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe('useEmailConfirmationData', () => {
|
||||
it('show page banner on unverified email', () => {
|
||||
useInitializeLearnerHome.mockReturnValue({ data: { emailConfirmation: { ...emailConfirmation } } });
|
||||
|
||||
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isNeeded).toEqual(emailConfirmation.isNeeded);
|
||||
});
|
||||
|
||||
it('hide page banner on verified email', () => {
|
||||
useInitializeLearnerHome.mockReturnValue({ data: { emailConfirmation: { isNeeded: false } } });
|
||||
|
||||
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isNeeded).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('behavior', () => {
|
||||
beforeEach(() => {
|
||||
useInitializeLearnerHome.mockReturnValue({ data: { emailConfirmation: { ...emailConfirmation } } });
|
||||
});
|
||||
|
||||
it('closePageBanner', () => {
|
||||
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closePageBanner();
|
||||
});
|
||||
|
||||
expect(result.current.showPageBanner).toEqual(false);
|
||||
});
|
||||
|
||||
it('closeConfirmModal', () => {
|
||||
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.closeConfirmModal();
|
||||
});
|
||||
|
||||
expect(result.current.showConfirmModal).toEqual(false);
|
||||
});
|
||||
|
||||
it('openConfirmModalButtonClick', async () => {
|
||||
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.openConfirmModalButtonClick();
|
||||
});
|
||||
|
||||
expect(result.current.showConfirmModal).toEqual(true);
|
||||
expect(api.sendConfirmEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('userConfirmEmailButtonClick', () => {
|
||||
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.userConfirmEmailButtonClick();
|
||||
});
|
||||
|
||||
expect(result.current.showConfirmModal).toEqual(false);
|
||||
expect(result.current.showPageBanner).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import MasqueradeBar from 'containers/MasqueradeBar';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import Header from '@edx/frontend-component-header';
|
||||
import { reduxHooks } from 'hooks';
|
||||
import { useInitializeLearnerHome } from 'data/hooks';
|
||||
import urls from 'data/services/lms/urls';
|
||||
|
||||
import ConfirmEmailBanner from './ConfirmEmailBanner';
|
||||
@@ -14,7 +14,8 @@ import './index.scss';
|
||||
|
||||
export const LearnerDashboardHeader = () => {
|
||||
const { authenticatedUser } = React.useContext(AppContext);
|
||||
const { courseSearchUrl } = reduxHooks.usePlatformSettingsData();
|
||||
const { data: learnerData } = useInitializeLearnerHome();
|
||||
const courseSearchUrl = learnerData?.platformSettings?.courseSearchUrl || '';
|
||||
|
||||
const exploreCoursesClick = () => {
|
||||
findCoursesNavClicked(urls.baseAppUrl(courseSearchUrl));
|
||||
|
||||
@@ -8,12 +8,14 @@ import { findCoursesNavClicked } from './hooks';
|
||||
|
||||
const courseSearchUrl = '/course-search-url';
|
||||
|
||||
jest.mock('hooks', () => ({
|
||||
reduxHooks: {
|
||||
usePlatformSettingsData: jest.fn(() => ({
|
||||
courseSearchUrl,
|
||||
})),
|
||||
},
|
||||
jest.mock('data/hooks', () => ({
|
||||
useInitializeLearnerHome: jest.fn().mockReturnValue({
|
||||
data: {
|
||||
platformSettings: {
|
||||
courseSearchUrl,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mock('./hooks', () => ({
|
||||
...jest.requireActual('./hooks'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user