Compare commits

..

3 Commits

Author SHA1 Message Date
MaxFrank13
0cb73b4356 test: lint 2025-07-01 18:11:49 +00:00
MaxFrank13
3030140e17 test: testing mock configurations 2025-07-01 18:09:34 +00:00
diana-villalvazo-wgu
d542fb84b6 test: remove paragon, react and i18n mocks and added when needed 2025-06-30 16:15:52 -06:00
231 changed files with 8952 additions and 12909 deletions

3
.env
View File

@@ -1,5 +1,4 @@
NODE_ENV='production'
APP_ID='learner-dashboard'
NODE_PATH=./src
BASE_URL=''
LMS_BASE_URL=''
@@ -38,10 +37,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={}

View File

@@ -1,5 +1,4 @@
NODE_ENV='development'
APP_ID='learner-dashboard'
PORT=1996
BASE_URL='localhost:1996'
LMS_BASE_URL='http://localhost:18000'
@@ -44,10 +43,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={}

View File

@@ -1,5 +1,4 @@
NODE_ENV='test'
APP_ID='learner-dashboard'
PORT=1996
BASE_URL='localhost:1996'
LMS_BASE_URL='http://localhost:18000'
@@ -43,9 +42,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={}

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @openedx/2U-aperture

View File

@@ -14,10 +14,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Nodejs
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
@@ -29,9 +29,6 @@ jobs:
- name: Lint
run: npm run lint
- name: Type check
run: npm run types
- name: Test
run: npm run test

2
.nvmrc
View File

@@ -1 +1 @@
24
20

View File

@@ -12,11 +12,6 @@ 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
@@ -52,12 +47,10 @@ 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 \
$(ATLAS_EXTRA_SOURCES)
translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-learner-dashboard $(ATLAS_EXTRA_INTL_IMPORTS)
$(intl_imports) frontend-platform paragon frontend-component-footer frontend-app-learner-dashboard
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:

View File

@@ -39,12 +39,22 @@ The parts of this MFE that can be customized in that manner are documented `here
Contributing
------------
Contributions are very welcome. Please read `So you want to contribute to Open edX <https://docs.openedx.org/en/latest/developers/quickstarts/so_you_want_to_contribute.html>`_ for details on how to get started as an Open edX contributor.
A core goal of this app is to provide a clean experimentation interface. To promote this end, we have provided a
silo'ed code directory at ``src/widgets`` in which contributors should add their custom widget components. In order to
ensure our ability to maintain the code stability of the app, the code for these widgets should be strictly contained
within the bounds of that directory.
This project is currently accepting all types of contributions — bug fixes, security fixes, maintenance work, or new features.
However, if you intend to add a new feature, make sure it has gone through the `Product Review process <https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review>`_.
Once written, the widgets can be configured into one of our widget containers at ``src/containers/WidgetContainers``.
This can include conditional logic, as well as Optimizely triggers. It is important to note that our integration tests
will isolate and ignore these containers, and thus testing your widget is the response of the creator/maintainer of the
widget itself.
When proposing a change, create an issue in this repo to get the discussion started.
Some guidelines for writing widgets:
* Code for the widget should be strictly confined to the ``src/widgets`` directory.
* You can load data from the redux store, but should not add or modify fields in that structure.
* Network events should be managed in component hooks, though can use our ``data/constants/requests:requestStates`` for
ease of tracking the request states.
License
-------

View File

@@ -19,7 +19,6 @@ 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',
@@ -68,7 +67,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
};

6035
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,7 @@
"dev": "PUBLIC_PATH=/learner-dashboard/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"quality": "npm run lint-fix && npm run test",
"watch-tests": "jest --watch",
"types": "tsc --noEmit"
"watch-tests": "jest --watch"
},
"author": "edX",
"license": "AGPL-3.0",
@@ -31,19 +30,22 @@
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^8.0.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-enterprise-hotjar": "7.2.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0",
"@edx/react-unit-test-utils": "^4.0.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.2.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5",
"@tanstack/react-query": "^5.90.16",
"@redux-devtools/extension": "3.3.0",
"@reduxjs/toolkit": "^2.0.0",
"classnames": "^2.3.1",
"core-js": "3.48.0",
"core-js": "3.42.0",
"filesize": "^10.0.0",
"font-awesome": "4.7.0",
"history": "5.3.0",
"lodash": "^4.17.21",
@@ -53,25 +55,32 @@
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-intl": "6.8.9",
"react-router-dom": "6.30.3",
"react-share": "^5.2.2",
"react-redux": "^7.2.4",
"react-router-dom": "6.29.0",
"react-share": "^4.4.0",
"redux": "4.2.1",
"redux-logger": "3.0.6",
"redux-thunk": "2.4.2",
"regenerator-runtime": "^0.14.0",
"reselect": "^4.0.0",
"universal-cookie": "^4.0.4",
"util": "^0.12.4"
},
"devDependencies": {
"@edx/browserslist-config": "^1.3.0",
"@edx/typescript-config": "^1.1.0",
"@openedx/frontend-build": "^14.6.2",
"@edx/reactifex": "^2.1.1",
"@openedx/frontend-build": "^14.3.3",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"copy-webpack-plugin": "^14.0.0",
"copy-webpack-plugin": "^12.0.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-expect-message": "^1.1.3",
"jest-when": "^3.6.0",
"react-dev-utils": "^12.0.0",
"react-test-renderer": "^18.3.1"
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4"
}
}

View File

@@ -5,30 +5,60 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging';
import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { ErrorPage } from '@edx/frontend-platform/react';
import { ErrorPage, AppContext } 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 AppWrapper from 'containers/AppWrapper';
import track from 'tracking';
import fakeData from 'data/services/lms/fakeData/courses';
import AppWrapper from 'containers/WidgetContainers/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 { masqueradeUser } = useMasquerade();
const { data, isError } = useInitializeLearnerHome();
const hasNetworkFailure = !masqueradeUser && isError;
const supportEmail = data?.platformSettings?.supportEmail || undefined;
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();
/* 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({
@@ -40,7 +70,7 @@ export const App = () => {
logError(error);
}
}
}, []);
}, [authenticatedUser, loadData]);
return (
<>
<Helmet>

View File

@@ -3,43 +3,45 @@ import { render, screen, waitFor } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { useInitializeLearnerHome } from 'data/hooks';
import { RequestKeys } from 'data/constants/requests';
import { reduxHooks } from '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('containers/WidgetContainers/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(() => ({})),
}));
jest.mock('@edx/frontend-platform/react', () => ({
...jest.requireActual('@edx/frontend-platform/react'),
ErrorPage: () => 'ErrorPage',
}));
// jest.mock('@edx/frontend-platform/react', () => ({
// ...jest.requireActual('@edx/frontend-platform/react'),
// ErrorPage: () => 'ErrorPage',
// }));
const loadData = jest.fn();
reduxHooks.useLoadData.mockReturnValue(loadData);
const supportEmail = 'test@support.com';
useInitializeLearnerHome.mockReturnValue({
data: {
platformSettings: {
supportEmail,
},
},
isError: false,
});
reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail });
describe('App router component', () => {
describe('component', () => {
@@ -64,6 +66,7 @@ describe('App router component', () => {
describe('no network failure', () => {
beforeEach(() => {
jest.clearAllMocks();
reduxHooks.useRequestIsFailed.mockReturnValue(false);
getConfig.mockReturnValue({});
render(<IntlProvider locale="en"><App /></IntlProvider>);
});
@@ -76,6 +79,7 @@ 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>);
});
@@ -88,6 +92,7 @@ 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>);
});
@@ -100,10 +105,7 @@ describe('App router component', () => {
describe('initialize failure', () => {
beforeEach(() => {
jest.clearAllMocks();
useInitializeLearnerHome.mockReturnValue({
data: null,
isError: true,
});
reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.initialize);
getConfig.mockReturnValue({});
render(<IntlProvider locale="en" messages={messages}><App /></IntlProvider>);
});
@@ -117,6 +119,7 @@ describe('App router component', () => {
});
describe('refresh failure', () => {
beforeEach(() => {
reduxHooks.useRequestIsFailed.mockImplementation((key) => key === RequestKeys.refreshList);
getConfig.mockReturnValue({});
render(<IntlProvider locale="en"><App /></IntlProvider>);
});

View File

@@ -1,6 +1,9 @@
import { render, screen } from '@testing-library/react';
import Banner from './Banner';
jest.unmock('@openedx/paragon');
jest.unmock('react');
describe('Banner component', () => {
it('renders children content', () => {
render(<Banner>Test content</Banner>);

View File

@@ -0,0 +1,25 @@
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 };

View File

@@ -0,0 +1,65 @@
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);
});
});
});
});

View File

@@ -0,0 +1,40 @@
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;

View File

@@ -0,0 +1,99 @@
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);
});
});
});
});

View File

@@ -0,0 +1,25 @@
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;

View File

@@ -0,0 +1,36 @@
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();
});
});
});

View File

@@ -0,0 +1,11 @@
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;

View File

@@ -1,6 +1,5 @@
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,
@@ -15,13 +14,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 = {};

View File

@@ -1,15 +0,0 @@
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();
});
});

View File

@@ -1,32 +1,32 @@
import { useWindowSize, breakpoints } from '@openedx/paragon';
import useIsCollapsed from './hooks';
jest.mock('@openedx/paragon', () => ({
...jest.requireActual('@openedx/paragon'),
useWindowSize: jest.fn(),
breakpoints: {
extraSmall: {
minWidth: 0,
maxWidth: 575,
},
small: {
minWidth: 576,
maxWidth: 767,
},
medium: {
minWidth: 768,
maxWidth: 991,
},
large: {
minWidth: 992,
maxWidth: 1199,
},
extraLarge: {
minWidth: 1200,
maxWidth: 100000,
},
},
}));
// jest.mock('@openedx/paragon', () => ({
// ...jest.requireActual('@openedx/paragon'),
// useWindowSize: jest.fn(),
// breakpoints: {
// extraSmall: {
// minWidth: 0,
// maxWidth: 575,
// },
// small: {
// minWidth: 576,
// maxWidth: 767,
// },
// medium: {
// minWidth: 768,
// maxWidth: 991,
// },
// large: {
// minWidth: 992,
// maxWidth: 1199,
// },
// extraLarge: {
// minWidth: 1200,
// maxWidth: 100000,
// },
// },
// }));
describe('useIsCollapsed', () => {
it('returns true only when it is between medium and small', () => {

View File

@@ -5,6 +5,8 @@ import useIsCollapsed from './hooks';
jest.mock('./hooks', () => jest.fn());
jest.unmock('@openedx/paragon');
describe('ActionButton', () => {
const props = {
className: 'custom-class',

View File

@@ -1,29 +1,21 @@
import React, { useMemo } from 'react';
import React 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 { useCourseData, useCourseTrackingEvent } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const BeginCourseButton = ({ cardId }) => {
const { formatMessage } = useIntl();
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 { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId);
const { disableBeginCourse } = useActionDisabledState(cardId);
const handleClick = useCourseTrackingEvent(
const handleClick = reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
cardId,
homeUrl + execEdTrackingParam,

View File

@@ -1,42 +1,36 @@
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',
@@ -51,7 +45,11 @@ describe('BeginCourseButton', () => {
describe('initiliaze hooks', () => {
it('initializes course run data with cardId', () => {
renderComponent();
expect(useCourseData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
});
it('loads exec education path param', () => {
renderComponent();
expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
});
it('loads disabled states for begin action from action hooks', () => {
renderComponent();
@@ -75,15 +73,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', () => {
it('should track enter course clicked event on click, with exec ed param', async () => {
renderComponent();
const user = userEvent.setup();
const button = screen.getByRole('button', { name: 'Begin Course' });
user.click(button);
expect(useCourseTrackingEvent).toHaveBeenCalledWith(
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
track.course.enterCourseClicked,
props.cardId,
`${homeUrl}?org_id=test-org-id`,
homeUrl + execEdPath(props.cardId),
);
});
});

View File

@@ -1,29 +1,21 @@
import React, { useMemo } from 'react';
import React 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 { useCourseTrackingEvent, useCourseData } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const ResumeButton = ({ cardId }) => {
const { formatMessage } = useIntl();
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 { resumeUrl } = reduxHooks.useCardCourseRunData(cardId);
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId);
const { disableResumeCourse } = useActionDisabledState(cardId);
const handleClick = useCourseTrackingEvent(
const handleClick = reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
cardId,
resumeUrl + execEdTrackingParam,

View File

@@ -1,47 +1,36 @@
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));
useCourseData.mockReturnValue({
enrollment: { mode: 'executive-education' },
courseRun: { resumeUrl: 'home-url' },
});
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 } }),
);
describe('ResumeButton', () => {
const props = {
@@ -50,7 +39,10 @@ describe('ResumeButton', () => {
describe('initialize hooks', () => {
beforeEach(() => render(<IntlProvider locale="en"><ResumeButton {...props} /></IntlProvider>));
it('initializes course run data with cardId', () => {
expect(useCourseData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
});
it('loads exec education path param', () => {
expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
});
it('loads disabled states for resume action from action hooks', () => {
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
@@ -81,10 +73,10 @@ describe('ResumeButton', () => {
const user = userEvent.setup();
const button = screen.getByRole('button', { name: 'Resume' });
user.click(button);
expect(useCourseTrackingEvent).toHaveBeenCalledWith(
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
track.course.enterCourseClicked,
props.cardId,
`home-url?org_id=${authOrgId}`,
resumeUrl + execEdPath(props.cardId),
);
});
});

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelectSessionModal } from 'data/context';
import { reduxHooks } from 'hooks';
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 { updateSelectSessionModal } = useSelectSessionModal();
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId);
return (
<ActionButton
disabled={disableSelectSession}
onClick={() => updateSelectSessionModal(cardId)}
onClick={openSessionModal}
>
{formatMessage(messages.selectSession)}
</ActionButton>

View File

@@ -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('data/context', () => ({
useSelectSessionModal: jest.fn().mockReturnValue({
updateSelectSessionModal: jest.fn(),
}),
jest.mock('hooks', () => ({
reduxHooks: {
useUpdateSelectSessionModalCallback: jest.fn(),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableSelectSession: false })));
@@ -33,15 +33,11 @@ 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(mockedUpdateSelectSessionModal).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(props.cardId);
});
});
});

View File

@@ -4,18 +4,17 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import track from 'tracking';
import { useCourseTrackingEvent, useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const ViewCourseButton = ({ cardId }) => {
const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const homeUrl = courseData?.courseRun?.homeUrl;
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { disableViewCourse } = useActionDisabledState(cardId);
const handleClick = useCourseTrackingEvent(
const handleClick = reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
cardId,
homeUrl,

View File

@@ -1,27 +1,24 @@
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));
@@ -38,18 +35,15 @@ 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(useCourseTrackingEvent).toHaveBeenCalledWith(
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
track.course.enterCourseClicked,
defaultProps.cardId,
homeUrl,
);
expect(mockedTrackCourseEvent).toHaveBeenCalled();
});
it('learner cannot view course', () => {
useActionDisabledState.mockReturnValueOnce({ disableViewCourse: true });

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { ActionRow } from '@openedx/paragon';
import { useCourseData, useEntitlementInfo } from 'hooks';
import { reduxHooks } from 'hooks';
import CourseCardActionSlot from 'plugin-slots/CourseCardActionSlot';
import SelectSessionButton from './SelectSessionButton';
@@ -12,10 +12,11 @@ import ResumeButton from './ResumeButton';
import ViewCourseButton from './ViewCourseButton';
export const CourseCardActions = ({ cardId }) => {
const cardData = useCourseData(cardId);
const hasStarted = cardData.enrollment.hasStarted || false;
const { isEntitlement, isFulfilled } = useEntitlementInfo(cardData);
const isArchived = cardData.courseRun.isArchived || false;
const { isEntitlement, isFulfilled } = reduxHooks.useCardEntitlementData(cardId);
const {
hasStarted,
} = reduxHooks.useCardEnrollmentData(cardId);
const { isArchived } = reduxHooks.useCardCourseRunData(cardId);
return (
<ActionRow data-test-id="CourseCardActions">

View File

@@ -1,10 +1,15 @@
import { render, screen } from '@testing-library/react';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import CourseCardActions from '.';
jest.mock('hooks', () => ({
...jest.requireActual('hooks'),
useCourseData: jest.fn(),
reduxHooks: {
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardEntitlementData: jest.fn(),
useMasqueradeData: jest.fn(),
},
}));
jest.mock('plugin-slots/CourseCardActionSlot', () => jest.fn(() => <div>CourseCardActionSlot</div>));
@@ -13,28 +18,34 @@ jest.mock('./ViewCourseButton', () => jest.fn(() => <div>ViewCourseButton</div>)
jest.mock('./BeginCourseButton', () => jest.fn(() => <div>BeginCourseButton</div>));
jest.mock('./ResumeButton', () => jest.fn(() => <div>ResumeButton</div>));
jest.unmock('@openedx/paragon');
const cardId = 'test-card-id';
const props = { cardId };
describe('CourseCardActions', () => {
const mockHooks = ({
isEntitlement = false,
isExecEd2UCourse = false,
isFulfilled = false,
isArchived = false,
isVerified = false,
hasStarted = false,
isMasquerading = false,
} = {}) => {
useCourseData.mockReturnValueOnce({
enrollment: { hasStarted },
courseRun: { isArchived },
entitlement: isEntitlement !== null ? { isEntitlement, isFulfilled } : null,
});
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isFulfilled });
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ isArchived });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isExecEd2UCourse, isVerified, hasStarted });
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading });
};
const renderComponent = () => render(<CourseCardActions {...props} />);
describe('hooks', () => {
it('initializes hooks', () => {
it('initializes redux hooks', () => {
mockHooks();
renderComponent();
expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
});
});
describe('output', () => {
@@ -54,7 +65,7 @@ describe('CourseCardActions', () => {
});
describe('not entitlement, verified, or exec ed', () => {
it('renders CourseCardActionSlot and ViewCourseButton for archived courses', () => {
mockHooks({ isArchived: true, isEntitlement: null });
mockHooks({ isArchived: true });
renderComponent();
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
expect(CourseCardActionSlot).toBeInTheDocument();
@@ -63,7 +74,7 @@ describe('CourseCardActions', () => {
});
describe('unstarted courses', () => {
it('renders CourseCardActionSlot and BeginCourseButton', () => {
mockHooks({ isEntitlement: null });
mockHooks();
renderComponent();
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
expect(CourseCardActionSlot).toBeInTheDocument();
@@ -73,7 +84,7 @@ describe('CourseCardActions', () => {
});
describe('active courses (started, and not archived)', () => {
it('renders CourseCardActionSlot and ResumeButton', () => {
mockHooks({ hasStarted: true, isEntitlement: null });
mockHooks({ hasStarted: true });
renderComponent();
const CourseCardActionSlot = screen.getByText('CourseCardActionSlot');
expect(CourseCardActionSlot).toBeInTheDocument();

View File

@@ -1,14 +1,12 @@
/* eslint-disable max-len */
import React, { useMemo } from 'react';
import React 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 { useInitializeLearnerHome } from 'data/hooks';
import { utilHooks, useCourseData } from 'hooks';
import { utilHooks, reduxHooks } from 'hooks';
import Banner from 'components/Banner';
import messages from './messages';
@@ -16,32 +14,15 @@ import messages from './messages';
const { useFormatDate } = utilHooks;
export const CertificateBanner = ({ cardId }) => {
const { data: learnerHomeData } = useInitializeLearnerHome();
const courseData = useCourseData(cardId);
const certificate = reduxHooks.useCardCertificateData(cardId);
const {
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],
);
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();
const { formatMessage } = useIntl();
const formatDate = useFormatDate();
@@ -94,7 +75,7 @@ export const CertificateBanner = ({ cardId }) => {
</Banner>
);
}
if (certificate.isEarned && new Date(certificate.availableDate) > new Date()) {
if (certificate.isEarnedButUnavailable) {
return (
<Banner>
{formatMessage(

View File

@@ -1,21 +1,26 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import { reduxHooks } from 'hooks';
import CertificateBanner from './CertificateBanner';
jest.mock('hooks', () => ({
utilHooks: {
useFormatDate: jest.fn(() => date => date),
},
useCourseData: jest.fn(),
reduxHooks: {
useCardCertificateData: jest.fn(),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardGradeData: jest.fn(),
usePlatformSettingsData: jest.fn(),
},
}));
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(),
}));
jest.unmock('@openedx/paragon');
jest.unmock('@openedx/paragon/icons');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
const defaultCertificate = {
availableDate: '10/20/3030',
@@ -35,14 +40,9 @@ const supportEmail = 'suport@email.com';
const billingEmail = 'billing@email.com';
describe('CertificateBanner', () => {
useCourseData.mockReturnValue({
enrollment: {},
certificate: {},
gradeData: {},
courseRun: {
minPassingGrade: 0.8,
progressUrl: 'progressUrl',
},
reduxHooks.useCardCourseRunData.mockReturnValue({
minPassingGrade: 0.8,
progressUrl: 'progressUrl',
});
const createWrapper = ({
certificate = {},
@@ -51,17 +51,11 @@ describe('CertificateBanner', () => {
courseRun = {},
platformSettings = {},
}) => {
useCourseData.mockReturnValue({
enrollment: { ...defaultEnrollment, ...enrollment },
certificate: { ...defaultCertificate, ...certificate },
gradeData: { ...defaultGrade, ...grade },
courseRun: {
...defaultCourseRun,
...courseRun,
},
});
const lernearData = { data: { platformSettings: { ...defaultPlatformSettings, ...platformSettings } } };
useInitializeLearnerHome.mockReturnValue(lernearData);
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 });
return render(<IntlProvider locale="en"><CertificateBanner {...props} /></IntlProvider>);
};
beforeEach(() => {
@@ -233,8 +227,7 @@ describe('CertificateBanner', () => {
isPassing: true,
},
certificate: {
isEarned: true,
availableDate: '10/20/3030',
isEarnedButUnavailable: true,
},
});
const banner = screen.getByRole('alert');
@@ -251,27 +244,4 @@ 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();
});
});

View File

@@ -1,26 +1,21 @@
/* eslint-disable max-len */
import React, { useMemo } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { Hyperlink } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { utilHooks, useCourseData } from 'hooks';
import { utilHooks, reduxHooks } from 'hooks';
import Banner from 'components/Banner';
import messages from './messages';
export const CourseBanner = ({ cardId }) => {
const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const {
isVerified = false,
isAuditAccessExpired = false,
isVerified,
isAuditAccessExpired,
coursewareAccess = {},
} = useMemo(() => ({
isVerified: courseData.enrollment?.isVerified,
isAuditAccessExpired: courseData.enrollment?.isAuditAccessExpired,
coursewareAccess: courseData.enrollment?.coursewareAccess || {},
}), [courseData]);
const courseRun = courseData?.courseRun || {};
} = reduxHooks.useCardEnrollmentData(cardId);
const courseRun = reduxHooks.useCardCourseRunData(cardId);
const { formatMessage } = useIntl();
const formatDate = utilHooks.useFormatDate();
const { hasUnmetPrerequisites, isStaff, isTooEarly } = coursewareAccess;

View File

@@ -1,19 +1,26 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData } from 'hooks';
import { reduxHooks } 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(),
},
}));
jest.unmock('@openedx/paragon');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
const cardId = 'test-card-id';
const enrollmentData = {
@@ -36,15 +43,13 @@ const renderCourseBanner = (overrides = {}) => {
courseRun = {},
enrollment = {},
} = overrides;
useCourseData.mockReturnValue({
courseRun: {
...courseRunData,
...courseRun,
},
enrollment: {
...enrollmentData,
...enrollment,
},
reduxHooks.useCardCourseRunData.mockReturnValueOnce({
...courseRunData,
...courseRun,
});
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
...enrollmentData,
...enrollment,
});
return render(<IntlProvider locale="en"><CourseBanner cardId={cardId} /></IntlProvider>);
};
@@ -52,20 +57,13 @@ const renderCourseBanner = (overrides = {}) => {
describe('CourseBanner', () => {
it('initializes data with course number from enrollment, course and course run data', () => {
renderCourseBanner();
expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEnrollmentData).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 } });

View File

@@ -1,8 +1,6 @@
import { useMemo } from 'react';
import { useInitializeLearnerHome } from 'data/hooks';
import { StrictDict } from 'utils';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import ApprovedContent from './views/ApprovedContent';
import EligibleContent from './views/EligibleContent';
@@ -17,29 +15,9 @@ export const statusComponents = StrictDict({
});
export const useCreditBannerData = (cardId) => {
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 credit = reduxHooks.useCardCreditData(cardId);
const { supportEmail } = reduxHooks.usePlatformSettingsData();
if (!credit.isEligible) { return null; }
const { error, purchased, requestStatus } = credit;
let ContentComponent = EligibleContent;

View File

@@ -1,6 +1,5 @@
import { keyStore } from 'utils';
import { useCourseData } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import { reduxHooks } from 'hooks';
import ApprovedContent from './views/ApprovedContent';
import EligibleContent from './views/EligibleContent';
@@ -10,19 +9,12 @@ import RejectedContent from './views/RejectedContent';
import * as hooks from './hooks';
jest.mock('react', () => ({
...jest.requireActual('react'),
useMemo: (fn) => fn(),
}));
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
reduxHooks: {
useCardCreditData: jest.fn(),
usePlatformSettingsData: jest.fn(),
},
}));
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(),
}));
jest.mock('./views/ApprovedContent', () => 'ApprovedContent');
jest.mock('./views/EligibleContent', () => 'EligibleContent');
jest.mock('./views/MustRequestContent', () => 'MustRequestContent');
@@ -42,18 +34,18 @@ const defaultProps = {
};
const loadHook = (creditData = {}) => {
useCourseData.mockReturnValue({ credit: { ...defaultProps, ...creditData } });
reduxHooks.useCardCreditData.mockReturnValue({ ...defaultProps, ...creditData });
out = hooks.useCreditBannerData(cardId);
};
describe('useCreditBannerData hook', () => {
beforeEach(() => {
useInitializeLearnerHome.mockReturnValue({ data: { platformSettings: { supportEmail } } });
reduxHooks.usePlatformSettingsData.mockReturnValue({ supportEmail });
});
it('loads card credit data with cardID and loads platform settings data', () => {
loadHook({ isEligible: false });
expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(useInitializeLearnerHome).toHaveBeenCalledWith();
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.usePlatformSettingsData).toHaveBeenCalledWith();
});
describe('non-credit-eligible learner', () => {
it('returns null if the learner is not credit eligible', () => {

View File

@@ -7,6 +7,10 @@ jest.mock('./hooks', () => ({
useCreditBannerData: jest.fn(),
}));
jest.unmock('@openedx/paragon');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
describe('CreditBanner', () => {
const mockCardId = 'test-card-id';
const mockContentComponent = () => <div data-testid="mock-content">Test Content</div>;

View File

@@ -1,24 +1,17 @@
import React, { useMemo } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useCourseData, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
import CreditContent from './components/CreditContent';
import ProviderLink from './components/ProviderLink';
import messages from './messages';
export const ApprovedContent = ({ cardId }) => {
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 { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { formatMessage } = useIntl();
return (
<CreditContent

View File

@@ -1,28 +1,34 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { useCourseData, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
import messages from './messages';
import ApprovedContent from './ApprovedContent';
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useIsMasquerading: jest.fn(),
reduxHooks: {
useCardCreditData: jest.fn(),
useMasqueradeData: jest.fn(),
},
}));
jest.unmock('@openedx/paragon');
jest.unmock('react');
jest.unmock('@edx/frontend-platform/i18n');
const cardId = 'test-card-id';
const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name',
};
useCourseData.mockReturnValue({ credit });
useIsMasquerading.mockReturnValue(false);
reduxHooks.useCardCreditData.mockReturnValue(credit);
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
describe('ApprovedContent component', () => {
describe('hooks', () => {
it('initializes credit data with cardId', () => {
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {
@@ -54,7 +60,7 @@ describe('ApprovedContent component', () => {
});
describe('when masquerading', () => {
beforeEach(() => {
useIsMasquerading.mockReturnValue(true);
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
render(<IntlProvider locale="en"><ApprovedContent cardId={cardId} /></IntlProvider>);
});

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import CreditContent from './components/CreditContent';
@@ -11,9 +11,8 @@ import messages from './messages';
export const EligibleContent = ({ cardId }) => {
const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const providerName = courseData?.credit?.providerName;
const courseId = courseData?.courseRun?.courseId;
const { providerName } = reduxHooks.useCardCreditData(cardId);
const { courseId } = reduxHooks.useCardCourseRunData(cardId);
const onClick = track.credit.purchase(courseId);
const getCredit = formatMessage(messages.getCredit);

View File

@@ -2,14 +2,17 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import messages from './messages';
import EligibleContent from './EligibleContent';
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
reduxHooks: {
useCardCreditData: jest.fn(),
useCardCourseRunData: jest.fn(),
},
}));
jest.mock('tracking', () => ({
@@ -18,12 +21,17 @@ jest.mock('tracking', () => ({
},
}));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const cardId = 'test-card-id';
const courseId = 'test-course-id';
const credit = {
providerName: 'test-credit-provider-name',
};
useCourseData.mockReturnValue({ credit, courseRun: { courseId } });
reduxHooks.useCardCreditData.mockReturnValue(credit);
reduxHooks.useCardCourseRunData.mockReturnValue({ courseId });
const renderEligibleContent = () => render(<IntlProvider locale="en" messages={{}}><EligibleContent cardId={cardId} /></IntlProvider>);
@@ -31,7 +39,11 @@ describe('EligibleContent component', () => {
describe('hooks', () => {
it('initializes credit data with cardId', () => {
renderEligibleContent();
expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
it('initializes course run data with cardId', () => {
renderEligibleContent();
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
});
});
describe('behavior', () => {
@@ -55,7 +67,7 @@ describe('EligibleContent component', () => {
expect(eligibleMessage).toHaveTextContent(credit.providerName);
});
it('message is formatted eligible message if no provider', () => {
useCourseData.mockReturnValue({ credit: {}, courseRun: { courseId } });
reduxHooks.useCardCreditData.mockReturnValue({});
renderEligibleContent();
const eligibleMessage = screen.getByTestId('credit-msg');
expect(eligibleMessage).toBeInTheDocument();

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useIsMasquerading } from 'hooks';
import { reduxHooks } 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 = useIsMasquerading();
const { isMasquerading } = reduxHooks.useMasqueradeData();
return (
<CreditContent
action={{

View File

@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import userEvent from '@testing-library/user-event';
import { useCourseData, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
import messages from './messages';
import hooks from './hooks';
import MustRequestContent from './MustRequestContent';
@@ -11,10 +12,16 @@ jest.mock('./hooks', () => ({
}));
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useIsMasquerading: jest.fn(),
reduxHooks: {
useMasqueradeData: jest.fn(),
useCardCreditData: jest.fn(),
},
}));
jest.unmock('@openedx/paragon');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
const cardId = 'test-card-id';
const requestData = {
url: 'test-request-data-url',
@@ -41,12 +48,10 @@ describe('MustRequestContent component', () => {
requestData,
createCreditRequest,
});
useIsMasquerading.mockReturnValue(false);
useCourseData.mockReturnValue({
credit: {
providerName,
providerStatusUrl,
},
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
reduxHooks.useCardCreditData.mockReturnValue({
providerName,
providerStatusUrl,
});
});
@@ -89,7 +94,7 @@ describe('MustRequestContent component', () => {
describe('when masquerading', () => {
beforeEach(() => {
useIsMasquerading.mockReturnValue(true);
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
renderMustRequestContent();
});

View File

@@ -3,14 +3,13 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useCourseData, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
import CreditContent from './components/CreditContent';
import messages from './messages';
export const PendingContent = ({ cardId }) => {
const courseData = useCourseData(cardId);
const { providerStatusUrl: href, providerName } = courseData?.credit || {};
const isMasquerading = useIsMasquerading();
const { providerStatusUrl: href, providerName } = reduxHooks.useCardCreditData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { formatMessage } = useIntl();
return (
<CreditContent

View File

@@ -1,25 +1,27 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
import messages from './messages';
import PendingContent from './PendingContent';
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useIsMasquerading: jest.fn(),
reduxHooks: { useCardCreditData: jest.fn(), useMasqueradeData: jest.fn() },
}));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const cardId = 'test-card-id';
const providerName = 'test-credit-provider-name';
const providerStatusUrl = 'test-credit-provider-status-url';
useIsMasquerading.mockReturnValue(false);
useCourseData.mockReturnValue({
credit: {
providerName,
providerStatusUrl,
},
reduxHooks.useCardCreditData.mockReturnValue({
providerName,
providerStatusUrl,
});
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
const renderPendingContent = () => render(
<IntlProvider messages={{}} locale="en">
@@ -30,7 +32,7 @@ describe('PendingContent component', () => {
describe('hooks', () => {
it('initializes card credit data with cardId', () => {
renderPendingContent();
expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('behavior', () => {
@@ -58,7 +60,7 @@ describe('PendingContent component', () => {
});
describe('when masqueradeData is true', () => {
it('disables the view details button', () => {
useIsMasquerading.mockReturnValue(true);
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
renderPendingContent();
const button = screen.getByRole('link', { name: messages.viewDetails.defaultMessage });
expect(button).toHaveClass('disabled');

View File

@@ -3,19 +3,18 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import CreditContent from './components/CreditContent';
import ProviderLink from './components/ProviderLink';
import messages from './messages';
export const RejectedContent = ({ cardId }) => {
const courseData = useCourseData(cardId);
const credit = courseData?.credit;
const credit = reduxHooks.useCardCreditData(cardId);
const { formatMessage } = useIntl();
return (
<CreditContent
message={formatMessage(messages.rejected, {
providerName: credit?.providerName,
providerName: credit.providerName,
linkToProviderSite: (<ProviderLink cardId={cardId} />),
})}
/>

View File

@@ -1,21 +1,25 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import RejectedContent from './RejectedContent';
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
reduxHooks: {
useCardCreditData: jest.fn(),
},
}));
jest.unmock('@openedx/paragon');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
const cardId = 'test-card-id';
const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
providerName: 'test-credit-provider-name',
};
useCourseData.mockReturnValue({
credit,
});
reduxHooks.useCardCreditData.mockReturnValue(credit);
const renderRejectedContent = () => render(<IntlProvider><RejectedContent cardId={cardId} /></IntlProvider>);
@@ -23,7 +27,7 @@ describe('RejectedContent component', () => {
describe('hooks', () => {
it('initializes credit data with cardId', () => {
renderRejectedContent();
expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {

View File

@@ -2,6 +2,9 @@ import { render, screen } from '@testing-library/react';
import CreditContent from './CreditContent';
jest.unmock('@openedx/paragon');
jest.unmock('react');
const action = {
href: 'test-action-href',
onClick: jest.fn().mockName('test-action-onClick'),

View File

@@ -4,11 +4,11 @@ import useCreditRequestFormData from './hooks';
const requestData = 'test-request-data';
jest.mock('react', () => ({
...jest.requireActual('react'),
useRef: jest.fn((val) => ({ current: val, useRef: true })),
useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
}));
// jest.mock('react', () => ({
// ...jest.requireActual('react'),
// useRef: jest.fn((val) => ({ current: val, useRef: true })),
// useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
// }));
let out;
const ref = {

View File

@@ -10,6 +10,9 @@ jest.mock('./hooks', () => ({
default: jest.fn(),
}));
jest.unmock('@openedx/paragon');
jest.unmock('react');
const ref = { current: { click: jest.fn() }, useRef: jest.fn() };
const requestData = {

View File

@@ -4,6 +4,9 @@ import { render } from '@testing-library/react';
import useCreditRequestFormData from './hooks';
import CreditRequestForm from '.';
jest.unmock('@openedx/paragon');
jest.unmock('react');
jest.mock('./hooks', () => ({
__esModule: true,
default: jest.fn(),

View File

@@ -2,12 +2,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import { Hyperlink } from '@openedx/paragon';
export const ProviderLink = ({ cardId }) => {
const courseData = useCourseData(cardId);
const credit = courseData?.credit || {};
const credit = reduxHooks.useCardCreditData(cardId);
return (
<Hyperlink
href={credit.providerStatusUrl}

View File

@@ -1,13 +1,19 @@
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', () => ({
useCourseData: jest.fn(),
reduxHooks: {
useCardCreditData: jest.fn(),
},
}));
jest.unmock('@openedx/paragon');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
const cardId = 'test-card-id';
const credit = {
providerStatusUrl: 'test-credit-provider-status-url',
@@ -21,12 +27,12 @@ const renderProviderLink = () => render(
describe('ProviderLink component', () => {
beforeEach(() => {
jest.clearAllMocks();
useCourseData.mockReturnValue({ credit });
reduxHooks.useCardCreditData.mockReturnValue(credit);
renderProviderLink();
});
describe('hooks', () => {
it('initializes credit hook with cardId', () => {
expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCreditData).toHaveBeenCalledWith(cardId);
});
});
describe('render', () => {

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { StrictDict } from 'utils';
import { useCourseData } from 'hooks';
import { useCreateCreditRequest } from 'data/hooks';
import { apiHooks } from 'hooks';
import * as module from './hooks';
@@ -12,19 +11,13 @@ export const state = StrictDict({
export const useCreditRequestData = (cardId) => {
const [requestData, setRequestData] = module.state.creditRequestData(null);
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 createCreditApiRequest = apiHooks.useCreateCreditRequest(cardId);
const createCreditRequest = (e) => {
e.preventDefault();
createCreditMutation({ providerId, courseId, username }, {
onSuccess: (response) => {
setRequestData(response.data);
},
});
createCreditApiRequest()
.then((request) => {
setRequestData(request.data);
});
};
return { requestData, createCreditRequest };
};

View File

@@ -0,0 +1,56 @@
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);
});
});
});
});
});

View File

@@ -1,192 +0,0 @@
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();
});
});
});

View File

@@ -1,21 +1,16 @@
import React, { useMemo } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, MailtoLink } from '@openedx/paragon';
import { utilHooks, useCourseData, useEntitlementInfo } from 'hooks';
import { useSelectSessionModal } from 'data/context';
import Banner from 'components/Banner';
import { useInitializeLearnerHome } from 'data/hooks';
import { utilHooks, reduxHooks } from 'hooks';
import Banner from 'components/Banner';
import messages from './messages';
export const EntitlementBanner = ({ cardId }) => {
const { formatMessage } = useIntl();
const { data: learnerHomeData } = useInitializeLearnerHome();
const courseData = useCourseData(cardId);
const {
isEntitlement,
hasSessions,
@@ -23,12 +18,9 @@ export const EntitlementBanner = ({ cardId }) => {
changeDeadline,
showExpirationWarning,
isExpired,
} = useEntitlementInfo(courseData);
const supportEmail = useMemo(
() => learnerHomeData?.platformSettings?.supportEmail,
[learnerHomeData],
);
const { updateSelectSessionModal } = useSelectSessionModal();
} = reduxHooks.useCardEntitlementData(cardId);
const { supportEmail } = reduxHooks.usePlatformSettingsData();
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId);
const formatDate = utilHooks.useFormatDate();
if (!isEntitlement) {
@@ -50,7 +42,7 @@ export const EntitlementBanner = ({ cardId }) => {
{formatMessage(messages.entitlementExpiringSoon, {
changeDeadline: formatDate(changeDeadline),
selectSessionButton: (
<Button variant="link" size="inline" className="m-0 p-0" onClick={() => updateSelectSessionModal(cardId)}>
<Button variant="link" size="inline" className="m-0 p-0" onClick={openSessionModal}>
{formatMessage(messages.selectSession)}
</Button>
),

View File

@@ -1,42 +1,28 @@
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 { useCourseData } from 'hooks';
import { reduxHooks } 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?.toDateString(),
useFormatDate: () => date => date,
},
reduxHooks: {
usePlatformSettingsData: jest.fn(),
useCardEntitlementData: jest.fn(),
useUpdateSelectSessionModalCallback: jest.fn(
(cardId) => jest.fn().mockName(`updateSelectSessionModalCallback(${cardId})`),
),
},
}));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const cardId = 'test-card-id';
const entitlementData = {
@@ -50,20 +36,16 @@ const platformData = { supportEmail: 'test-support-email' };
const renderComponent = (overrides = {}) => {
const { entitlement = {} } = overrides;
useCourseData.mockReturnValue({
entitlement: { ...entitlementData, ...entitlement },
platformSettings: platformData,
});
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ ...entitlementData, ...entitlement });
reduxHooks.usePlatformSettingsData.mockReturnValueOnce(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(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useUpdateSelectSessionModalCallback).toHaveBeenCalledWith(cardId);
});
it('no display if not an entitlement', () => {
renderComponent({ entitlement: { isEntitlement: false } });
@@ -78,10 +60,7 @@ describe('EntitlementBanner', () => {
expect(banner.innerHTML).toContain(platformData.supportEmail);
});
it('renders when expiration warning', () => {
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] } });
renderComponent({ entitlement: { showExpirationWarning: true } });
const banner = screen.getByRole('alert');
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('alert-info');
@@ -89,37 +68,9 @@ describe('EntitlementBanner', () => {
expect(button).toBeInTheDocument();
});
it('renders expired banner', () => {
renderComponent({ entitlement: { isExpired: true, availableSessions: [1, 2, 3] } });
renderComponent({ entitlement: { isExpired: true } });
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();
});
});

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { Program } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useCourseData } from 'hooks';
import { reduxHooks } 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;
if (!courseData || !programData?.relatedPrograms.length) {
const programData = reduxHooks.useCardRelatedProgramsData(cardId);
if (!programData?.length) {
return null;
}
@@ -27,7 +27,7 @@ export const RelatedProgramsBanner = ({ cardId }) => {
<span className="font-weight-bolder">
{formatMessage(messages.relatedPrograms)}
</span>
<ProgramList programs={programData.relatedPrograms} />
<ProgramList programs={programData.list} />
</Banner>
);
};

View File

@@ -1,11 +1,18 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import RelatedProgramsBanner from '.';
jest.unmock('@openedx/paragon');
jest.unmock('@openedx/paragon/icons');
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('react');
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
reduxHooks: {
useCardRelatedProgramsData: jest.fn(),
},
}));
const cardId = 'test-card-id';
@@ -25,21 +32,21 @@ const programData = {
describe('RelatedProgramsBanner', () => {
it('render empty', () => {
useCourseData.mockReturnValue(null);
reduxHooks.useCardRelatedProgramsData.mockReturnValue({});
render(<IntlProvider locale="en"><RelatedProgramsBanner cardId={cardId} /></IntlProvider>);
const banner = screen.queryByRole('alert');
expect(banner).toBeNull();
});
it('render with programs', () => {
useCourseData.mockReturnValue({ programs: { relatedPrograms: programData.list } });
reduxHooks.useCardRelatedProgramsData.mockReturnValue(programData);
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', () => {
useCourseData.mockReturnValue({ programs: { relatedPrograms: programData.list } });
reduxHooks.useCardRelatedProgramsData.mockReturnValue(programData);
render(<IntlProvider locale="en"><RelatedProgramsBanner cardId={cardId} /></IntlProvider>);
const title = screen.getByText('Related Programs:');
expect(title).toBeInTheDocument();

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import CourseBannerSlot from 'plugin-slots/CourseBannerSlot';
import CertificateBanner from './CertificateBanner';
@@ -10,11 +10,7 @@ import EntitlementBanner from './EntitlementBanner';
import RelatedProgramsBanner from './RelatedProgramsBanner';
export const CourseCardBanners = ({ cardId }) => {
const courseData = useCourseData(cardId);
if (!courseData) {
return null;
}
const { isEnrolled = false } = courseData.enrollment;
const { isEnrolled } = reduxHooks.useCardEnrollmentData(cardId);
return (
<div className="course-card-banners" data-testid="CourseCardBanners">
<RelatedProgramsBanner cardId={cardId} />

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import CourseCardBanners from '.';
@@ -20,11 +20,9 @@ const mockedComponents = [
];
jest.mock('hooks', () => ({
useCourseData: jest.fn(() => ({
enrollment: {
isEnrolled: true,
},
})),
reduxHooks: {
useCardEnrollmentData: jest.fn(() => ({ isEnrolled: true })),
},
}));
describe('CourseCardBanners', () => {
@@ -38,13 +36,8 @@ 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', () => {
useCourseData.mockReturnValue({ enrollment: { isEnrolled: false } });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: false });
render(<IntlProvider locale="en"><CourseCardBanners {...props} /></IntlProvider>);
const mockedComponentsIfNotEnrolled = mockedComponents.slice(-2);
mockedComponentsIfNotEnrolled.map((componentName) => {

View File

@@ -1,21 +1,20 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { utilHooks, useCourseData, useEntitlementInfo } from 'hooks';
import { useSelectSessionModal } from 'data/context';
import { utilHooks, reduxHooks } from 'hooks';
import * as hooks from './hooks';
import messages from './messages';
export const useAccessMessage = ({ cardId }) => {
const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const { courseRun, enrollment } = courseData || {};
const enrollment = reduxHooks.useCardEnrollmentData(cardId);
const courseRun = reduxHooks.useCardCourseRunData(cardId);
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,
@@ -39,15 +38,15 @@ export const useAccessMessage = ({ cardId }) => {
export const useCardDetailsData = ({ cardId }) => {
const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const providerName = courseData?.courseProvider?.name;
const courseNumber = courseData?.course?.courseNumber;
const providerName = reduxHooks.useCardProviderData(cardId).name;
const { courseNumber } = reduxHooks.useCardCourseData(cardId);
const {
isEntitlement,
isFulfilled,
canChange,
} = useEntitlementInfo(courseData);
const { updateSelectSessionModal } = useSelectSessionModal();
} = reduxHooks.useCardEntitlementData(cardId);
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId);
return {
providerName: providerName || formatMessage(messages.unknownProviderName),
@@ -55,7 +54,7 @@ export const useCardDetailsData = ({ cardId }) => {
isEntitlement,
isFulfilled,
canChange,
openSessionModal: () => updateSelectSessionModal(cardId),
openSessionModal,
courseNumber,
changeOrLeaveSessionMessage: formatMessage(messages.changeOrLeaveSessionButton),
};

View File

@@ -1,37 +1,34 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { keyStore } from 'utils';
import { utilHooks, useCourseData } from 'hooks';
import { useSelectSessionModal } from 'data/context';
import { utilHooks, reduxHooks } from 'hooks';
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', () => {
const { formatMessage } = jest.requireActual('testUtils');
return {
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage,
}),
};
});
// jest.mock('@edx/frontend-platform/i18n', () => {
// const { formatMessage } = jest.requireActual('testUtils');
// return {
// ...jest.requireActual('@edx/frontend-platform/i18n'),
// useIntl: () => ({
// formatMessage,
// }),
// };
// });
const cardId = 'my-test-card-id';
const courseNumber = 'test-course-number';
@@ -63,13 +60,15 @@ describe('CourseCardDetails hooks', () => {
const runHook = ({ provider = {}, entitlement = {} }) => {
jest.spyOn(hooks, hookKeys.useAccessMessage)
.mockImplementationOnce(mockAccessMessage);
useCourseData.mockReturnValue({
courseProvider: { ...providerData, ...provider },
course: { courseNumber },
courseRun: {},
entitlement: { ...entitlementData, ...entitlement },
reduxHooks.useCardProviderData.mockReturnValueOnce({
...providerData,
...provider,
});
useSelectSessionModal.mockReturnValue({ updateSelectSessionModal: updateSelectSessionModalMock });
reduxHooks.useCardEntitlementData.mockReturnValueOnce({
...entitlementData,
...entitlement,
});
reduxHooks.useCardCourseData.mockReturnValueOnce({ courseNumber });
out = hooks.useCardDetailsData({ cardId });
};
beforeEach(() => {
@@ -86,10 +85,6 @@ 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', () => {
@@ -106,16 +101,21 @@ describe('CourseCardDetails hooks', () => {
endDate: '10/20/2000',
};
const runHook = ({ enrollment = {}, courseRun = {} }) => {
useCourseData.mockReturnValue({
courseRun: { ...courseRunData, ...courseRun },
enrollment: { ...enrollmentData, ...enrollment },
reduxHooks.useCardCourseRunData.mockReturnValueOnce({
...courseRunData,
...courseRun,
});
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
...enrollmentData,
...enrollment,
});
out = hooks.useAccessMessage({ cardId });
};
it('loads data from enrollment and course run data based on course number', () => {
runHook({});
expect(useCourseData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
});
describe('if not started yet', () => {

View File

@@ -1,12 +1,11 @@
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 { useCourseData, useCourseTrackingEvent } from 'hooks';
import { reduxHooks } from 'hooks';
import verifiedRibbon from 'assets/verified-ribbon.png';
import useActionDisabledState from './hooks';
@@ -16,10 +15,11 @@ const { courseImageClicked } = track.course;
export const CourseCardImage = ({ cardId, orientation }) => {
const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const { homeUrl } = courseData?.courseRun || {};
const { bannerImgSrc } = reduxHooks.useCardCourseData(cardId);
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { isVerified } = reduxHooks.useCardEnrollmentData(cardId);
const { disableCourseTitle } = useActionDisabledState(cardId);
const handleImageClicked = useCourseTrackingEvent(courseImageClicked, cardId, homeUrl);
const handleImageClicked = reduxHooks.useTrackCourseEvent(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={courseData?.course?.bannerImgSrc && baseAppUrl(courseData.course.bannerImgSrc)}
src={bannerImgSrc}
alt={formatMessage(messages.bannerAlt)}
/>
{
courseData?.enrollment?.isVerified && (
isVerified && (
<span
className="course-card-verify-ribbon-container"
title={formatMessage(messages.verifiedHoverDescription)}

View File

@@ -1,23 +1,27 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import useActionDisabledState from './hooks';
import { CourseCardImage } from './CourseCardImage';
import messages from '../messages';
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const homeUrl = 'https://example.com';
const bannerImgSrc = 'banner-img-src.jpg';
jest.mock('hooks', () => ({
useCourseData: jest.fn(() => ({
course: { bannerImgSrc },
courseRun: { homeUrl },
enrollment: {},
})),
useCourseTrackingEvent: jest.fn((eventName, cardId, url) => ({
trackCourseEvent: { eventName, cardId, url },
})),
reduxHooks: {
useCardCourseData: jest.fn(() => ({ bannerImgSrc })),
useCardCourseRunData: jest.fn(() => ({ homeUrl })),
useCardEnrollmentData: jest.fn(),
useTrackCourseEvent: jest.fn((eventName, cardId, url) => ({
trackCourseEvent: { eventName, cardId, url },
})),
},
}));
jest.mock('./hooks', () => jest.fn());
@@ -30,13 +34,7 @@ describe('CourseCardImage', () => {
it('renders course image with correct attributes', () => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: true });
useCourseData.mockReturnValue(
{
course: { bannerImgSrc },
courseRun: { homeUrl },
enrollment: { isVerified: true },
},
);
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true });
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
const image = screen.getByRole('img', { name: formatMessage(messages.bannerAlt) });
@@ -47,13 +45,7 @@ describe('CourseCardImage', () => {
it('isVerified, should render badge', () => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
useCourseData.mockReturnValue(
{
course: { bannerImgSrc },
courseRun: { homeUrl },
enrollment: { isVerified: true },
},
);
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true });
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
const badge = screen.getByText(formatMessage(messages.verifiedBanner));
@@ -64,13 +56,7 @@ describe('CourseCardImage', () => {
it('renders link with correct href if disableCourseTitle is false', () => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
useCourseData.mockReturnValue(
{
course: { bannerImgSrc },
courseRun: { homeUrl },
enrollment: { isVerified: false },
},
);
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: false });
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
const link = screen.getByRole('link');
@@ -79,15 +65,12 @@ describe('CourseCardImage', () => {
describe('hooks', () => {
it('initializes', () => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
useCourseData.mockReturnValue(
{
course: { bannerImgSrc },
courseRun: { homeUrl },
enrollment: { isVerified: true },
},
);
reduxHooks.useCardEnrollmentData.mockReturnValue({ isVerified: true });
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
expect(useCourseData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(
props.cardId,
);
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
});
});

View File

@@ -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 { useCourseTrackingEvent, useCourseData, useIsMasquerading } from 'hooks';
import { useCardSocialSettingsData } from './hooks';
import { reduxHooks } from 'hooks';
import messages from './messages';
export const testIds = {
@@ -16,15 +16,14 @@ 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 handleTwitterShare = useCourseTrackingEvent(track.socialShare, cardId, 'twitter');
const handleFacebookShare = useCourseTrackingEvent(track.socialShare, cardId, 'facebook');
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');
if (isExecEd2UCourse) {
return null;
@@ -51,7 +50,6 @@ export const SocialShareMenu = ({ cardId, emailSettings }) => {
})}
resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item"
aria-label="facebook"
>
{formatMessage(messages.shareToFacebook)}
</ReactShare.FacebookShareButton>
@@ -66,7 +64,6 @@ export const SocialShareMenu = ({ cardId, emailSettings }) => {
})}
resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item"
aria-label="twitter"
>
{formatMessage(messages.shareToTwitter)}
</ReactShare.TwitterShareButton>

View File

@@ -4,9 +4,9 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render, screen } from '@testing-library/react';
import track from 'tracking';
import { useCourseTrackingEvent, useCourseData, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
import { useEmailSettings, useCardSocialSettingsData } from './hooks';
import { useEmailSettings } from './hooks';
import SocialShareMenu from './SocialShareMenu';
import messages from './messages';
@@ -15,15 +15,22 @@ jest.mock('tracking', () => ({
}));
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useCourseTrackingEvent: jest.fn((...args) => ({ trackCourseEvent: args })),
useIsMasquerading: jest.fn(),
reduxHooks: {
useMasqueradeData: jest.fn(),
useCardCourseData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useTrackCourseEvent: jest.fn((...args) => ({ trackCourseEvent: args })),
},
}));
jest.mock('./hooks', () => ({
useEmailSettings: jest.fn(),
useCardSocialSettingsData: jest.fn(),
}));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const props = {
cardId: 'test-card-id',
emailSettings: { show: jest.fn() },
@@ -54,25 +61,23 @@ const socialShare = {
const mockHooks = (returnVals = {}) => {
mockHook(
useCourseData,
reduxHooks.useCardEnrollmentData,
{
enrollment: {
isEmailEnabled: !!returnVals.isEmailEnabled,
mode: returnVals.isExecEd2UCourse ? 'exec-ed-2u' : 'standard',
},
course: { courseName },
isEmailEnabled: !!returnVals.isEmailEnabled,
isExecEd2UCourse: !!returnVals.isExecEd2UCourse,
},
{ isCardHook: true },
);
mockHook(reduxHooks.useCardCourseData, { courseName }, { isCardHook: true });
mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading });
mockHook(
useCardSocialSettingsData,
reduxHooks.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>);
@@ -86,12 +91,13 @@ describe('SocialShareMenu', () => {
it('initializes local hooks', () => {
when(useEmailSettings).expectCalledWith();
});
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');
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');
});
});
describe('render', () => {

View File

@@ -1,8 +1,7 @@
import track from 'tracking';
import { useCourseData, useCourseTrackingEvent } from 'hooks';
import { reduxHooks } 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
@@ -28,7 +27,7 @@ export const useEmailSettings = () => {
};
export const useHandleToggleDropdown = (cardId) => {
const trackCourseEvent = useCourseTrackingEvent(
const trackCourseEvent = reduxHooks.useTrackCourseEvent(
track.course.courseOptionsDropdownClicked,
cardId,
);
@@ -37,30 +36,10 @@ 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 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 { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
const { isEarned } = reduxHooks.useCardCertificateData(cardId);
const shouldShowUnenrollItem = isEnrolled && !isEarned;
const shouldShowDropdown = (

View File

@@ -1,21 +1,20 @@
import { useCourseData, useCourseTrackingEvent } from 'hooks';
import { useInitializeLearnerHome } from 'data/hooks';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import { MockUseState } from 'testUtils';
import * as hooks from './hooks';
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(),
}));
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useCourseTrackingEvent: jest.fn(),
reduxHooks: {
useCardCertificateData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useTrackCourseEvent: jest.fn(),
},
}));
const trackCourseEvent = jest.fn();
useCourseTrackingEvent.mockReturnValue(trackCourseEvent);
reduxHooks.useTrackCourseEvent.mockReturnValue(trackCourseEvent);
const cardId = 'test-card-id';
let out;
@@ -72,7 +71,7 @@ describe('CourseCardMenu hooks', () => {
beforeEach(() => { out = hooks.useHandleToggleDropdown(cardId); });
describe('behavior', () => {
it('initializes course event tracker with event name and card ID', () => {
expect(useCourseTrackingEvent).toHaveBeenCalledWith(
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
track.course.courseOptionsDropdownClicked,
cardId,
);
@@ -89,61 +88,55 @@ describe('CourseCardMenu hooks', () => {
});
describe('useOptionVisibility', () => {
const mockHooks = (returnVals = {}) => {
useInitializeLearnerHome.mockReturnValue({
data: {
socialShareSettings: {
facebook: { isEnabled: !!returnVals.facebook?.isEnabled },
twitter: { isEnabled: !!returnVals.twitter?.isEnabled },
},
},
const mockReduxHooks = (returnVals = {}) => {
reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({
facebook: { isEnabled: !!returnVals.facebook?.isEnabled },
twitter: { isEnabled: !!returnVals.twitter?.isEnabled },
});
useCourseData.mockReturnValue({
enrollment: {
isEnrolled: !!returnVals.isEnrolled,
isEmailEnabled: !!returnVals.isEmailEnabled,
},
certificate: {
isEarned: !!returnVals.isEarned,
},
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
isEnrolled: !!returnVals.isEnrolled,
isEmailEnabled: !!returnVals.isEmailEnabled,
});
reduxHooks.useCardCertificateData.mockReturnValueOnce({
isEarned: !!returnVals.isEarned,
});
};
describe('shouldShowUnenrollItem', () => {
it('returns true if enrolled and not earned', () => {
mockHooks({ isEnrolled: true });
mockReduxHooks({ isEnrolled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(true);
});
it('returns false if not enrolled', () => {
mockHooks();
mockReduxHooks();
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false);
});
it('returns false if enrolled but also earned', () => {
mockHooks({ isEarned: true });
mockReduxHooks({ isEarned: true });
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false);
});
});
describe('shouldShowDropdown', () => {
it('returns false if not enrolled and both email and socials are disabled', () => {
mockHooks();
mockReduxHooks();
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false);
});
it('returns false if enrolled but already earned, and both email and socials are disabled', () => {
mockHooks({ isEnrolled: true, isEarned: true });
mockReduxHooks({ isEnrolled: true, isEarned: true });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false);
});
it('returns true if either social is enabled', () => {
mockHooks({ facebook: { isEnabled: true } });
mockReduxHooks({ facebook: { isEnabled: true } });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
mockHooks({ twitter: { isEnabled: true } });
mockReduxHooks({ twitter: { isEnabled: true } });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
});
it('returns true if email is enabled', () => {
mockHooks({ isEmailEnabled: true });
mockReduxHooks({ isEmailEnabled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
});
it('returns true if enrolled and not earned', () => {
mockHooks({ isEnrolled: true });
mockReduxHooks({ isEnrolled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
});
});

View File

@@ -6,7 +6,7 @@ import { MoreVert } from '@openedx/paragon/icons';
import EmailSettingsModal from 'containers/EmailSettingsModal';
import UnenrollConfirmModal from 'containers/UnenrollConfirmModal';
import { useCourseData, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
import SocialShareMenu from './SocialShareMenu';
import {
useEmailSettings,
@@ -23,15 +23,13 @@ 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 = useIsMasquerading();
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
if (!shouldShowDropdown) {
return null;

View File

@@ -4,14 +4,16 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useCourseData, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
import * as hooks from './hooks';
import CourseCardMenu from '.';
import messages from './messages';
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useIsMasquerading: jest.fn(),
reduxHooks: {
useMasqueradeData: jest.fn(),
useCardEnrollmentData: jest.fn(),
},
}));
jest.mock('./SocialShareMenu', () => jest.fn(() => <div>SocialShareMenu</div>));
jest.mock('containers/EmailSettingsModal', () => jest.fn(() => <div>EmailSettingsModal</div>));
@@ -23,6 +25,10 @@ jest.mock('./hooks', () => ({
useOptionVisibility: jest.fn(),
}));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const props = {
cardId: 'test-card-id',
};
@@ -67,14 +73,10 @@ const mockHooks = (returnVals = {}) => {
},
{ isCardHook: true },
);
mockHook(useIsMasquerading, !!returnVals.isMasquerading);
mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading });
mockHook(
useCourseData,
{
enrollment: {
isEmailEnabled: !!returnVals.isEmailEnabled,
},
},
reduxHooks.useCardEnrollmentData,
{ isEmailEnabled: !!returnVals.isEmailEnabled },
{ isCardHook: true },
);
};
@@ -89,10 +91,13 @@ 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 hook data ', () => {
when(useIsMasquerading).expectCalledWith();
when(useCourseData).expectCalledWith(props.cardId);
it('initializes redux hook data ', () => {
when(reduxHooks.useMasqueradeData).expectCalledWith();
when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId);
});
});
describe('render', () => {

View File

@@ -2,16 +2,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import track from 'tracking';
import { useCourseData, useCourseTrackingEvent } from 'hooks';
import { reduxHooks } from 'hooks';
import useActionDisabledState from './hooks';
const { courseTitleClicked } = track.course;
export const CourseCardTitle = ({ cardId }) => {
const courseData = useCourseData(cardId);
const courseName = courseData?.course?.courseName;
const homeUrl = courseData?.courseRun?.homeUrl;
const handleTitleClicked = useCourseTrackingEvent(
const { courseName } = reduxHooks.useCardCourseData(cardId);
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const handleTitleClicked = reduxHooks.useTrackCourseEvent(
courseTitleClicked,
cardId,
homeUrl,

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useCourseData, useCourseTrackingEvent } from 'hooks';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import useActionDisabledState from './hooks';
import CourseCardTitle from './CourseCardTitle';
@@ -12,12 +12,19 @@ jest.mock('tracking', () => ({
}));
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useCourseTrackingEvent: jest.fn(),
reduxHooks: {
useCardCourseData: jest.fn(),
useCardCourseRunData: jest.fn(),
useTrackCourseEvent: jest.fn(),
},
}));
jest.mock('./hooks', () => jest.fn(() => ({ disableCourseTitle: false })));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
describe('CourseCardTitle', () => {
const props = {
cardId: 'test-card-id',
@@ -29,11 +36,9 @@ describe('CourseCardTitle', () => {
beforeEach(() => {
jest.clearAllMocks();
useCourseData.mockReturnValue({
course: { courseName },
courseRun: { homeUrl },
});
useCourseTrackingEvent.mockReturnValue(handleTitleClick);
reduxHooks.useCardCourseData.mockReturnValue({ courseName });
reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl });
reduxHooks.useTrackCourseEvent.mockReturnValue(handleTitleClick);
});
it('renders course name as link when not disabled', async () => {
@@ -61,8 +66,9 @@ describe('CourseCardTitle', () => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: false });
render(<CourseCardTitle {...props} />);
expect(useCourseData).toHaveBeenCalledWith(props.cardId);
expect(useCourseTrackingEvent).toHaveBeenCalledWith(
expect(reduxHooks.useCardCourseData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
track.course.courseTitleClicked,
props.cardId,
homeUrl,

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import messages from './messages';
import * as module from './hooks';
@@ -14,8 +14,7 @@ export const state = StrictDict({
export const useRelatedProgramsBadgeData = ({ cardId }) => {
const [isOpen, setIsOpen] = module.state.isOpen(false);
const { formatMessage } = useIntl();
const courseData = useCourseData(cardId);
const numPrograms = courseData?.programs?.relatedPrograms?.length || 0;
const numPrograms = reduxHooks.useCardRelatedProgramsData(cardId).length;
let programsMessage = '';
if (numPrograms) {
programsMessage = formatMessage(

View File

@@ -1,24 +1,26 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { MockUseState } from 'testUtils';
import { useCourseData } from 'hooks';
import { reduxHooks } from 'hooks';
import * as hooks from './hooks';
import messages from './messages';
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
reduxHooks: {
useCardRelatedProgramsData: jest.fn(),
},
}));
jest.mock('@edx/frontend-platform/i18n', () => {
const { formatMessage } = jest.requireActual('testUtils');
return {
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
formatMessage,
}),
};
});
// jest.mock('@edx/frontend-platform/i18n', () => {
// const { formatMessage } = jest.requireActual('testUtils');
// return {
// ...jest.requireActual('@edx/frontend-platform/i18n'),
// useIntl: () => ({
// formatMessage,
// }),
// };
// });
const cardId = 'test-card-id';
@@ -37,10 +39,8 @@ describe('RelatedProgramsBadge hooks', () => {
describe('useRelatedProgramsBadgeData', () => {
beforeEach(() => {
state.mock();
useCourseData.mockReturnValue({
programs: {
relatedPrograms: new Array(numPrograms).fill({}),
},
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({
length: numPrograms,
});
out = hooks.useRelatedProgramsBadgeData({ cardId });
});
@@ -64,12 +64,12 @@ describe('RelatedProgramsBadge hooks', () => {
expect(out.numPrograms).toEqual(numPrograms);
});
test('returns empty programsMessage if no programs', () => {
useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [] } });
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 0 });
out = hooks.useRelatedProgramsBadgeData({ cardId });
expect(out.programsMessage).toEqual('');
});
test('returns badgeLabelSingular programsMessage if 1 programs', () => {
useCourseData.mockReturnValueOnce({ programs: { relatedPrograms: [{}] } });
reduxHooks.useCardRelatedProgramsData.mockReturnValueOnce({ length: 1 });
out = hooks.useRelatedProgramsBadgeData({ cardId });
expect(out.programsMessage).toEqual(formatMessage(
messages.badgeLabelSingular,

View File

@@ -7,6 +7,10 @@ import RelatedProgramsBadge from '.';
jest.mock('containers/RelatedProgramsModal', () => 'RelatedProgramsModal');
jest.mock('./hooks', () => jest.fn());
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const hookProps = {
isOpen: true,
openModal: jest.fn().mockName('useRelatedProgramsBadge.openModal'),

View File

@@ -1,19 +1,16 @@
import { useCourseData, useEntitlementInfo, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
export const useActionDisabledState = (cardId) => {
const courseData = useCourseData(cardId);
const isMasquerading = useIsMasquerading();
const { isMasquerading } = reduxHooks.useMasqueradeData();
const {
isAudit, isAuditAccessExpired,
} = courseData.enrollment || {};
const { isStaff, hasUnmetPrereqs, isTooEarly } = courseData.enrollment?.coursewareAccess || {};
const hasAccess = isStaff || !(hasUnmetPrereqs || isTooEarly);
hasAccess, isAudit, isAuditAccessExpired,
} = reduxHooks.useCardEnrollmentData(cardId);
const {
isEntitlement, isFulfilled, canChange, hasSessions,
} = useEntitlementInfo(courseData);
} = reduxHooks.useCardEntitlementData(cardId);
const { resumeUrl, homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { resumeUrl, homeUrl } = courseData.courseRun || {};
const disableBeginCourse = !homeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired));
const disableResumeCourse = !resumeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired));
const disableViewCourse = !hasAccess || (isAudit && isAuditAccessExpired);

View File

@@ -1,15 +1,14 @@
import { useCourseData, useIsMasquerading } from 'hooks';
import { reduxHooks } from 'hooks';
import * as hooks from './hooks';
jest.mock('react', () => ({
...jest.requireActual('react'),
useMemo: jest.fn((fn) => fn()),
}));
jest.mock('hooks', () => ({
...jest.requireActual('hooks'),
useCourseData: jest.fn(),
useIsMasquerading: jest.fn(),
reduxHooks: {
useMasqueradeData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardEntitlementData: jest.fn(),
useCardCourseRunData: jest.fn(),
},
}));
const cardId = 'my-test-course-number';
@@ -39,38 +38,25 @@ describe('useActionDisabledState', () => {
isAuditAccessExpired,
resumeUrl,
homeUrl,
availableSessions,
} = { ...defaultData, ...args };
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,
},
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
hasAccess,
isAudit,
isAuditAccessExpired,
});
reduxHooks.useCardEntitlementData.mockReturnValueOnce({
isEntitlement,
isFulfilled,
canChange,
hasSessions,
});
reduxHooks.useCardCourseRunData.mockReturnValueOnce({
resumeUrl,
homeUrl,
});
};
beforeEach(() => {
jest.clearAllMocks();
});
const runHook = () => hooks.useActionDisabledState(cardId);
describe('disableBeginCourse', () => {
const testDisabled = (data, expected) => {
@@ -156,7 +142,6 @@ describe('useActionDisabledState', () => {
hasAccess: true,
canChange: true,
hasSessions: true,
availableSessions: ['session1'],
},
false,
);

View File

@@ -1,6 +1,23 @@
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;

View File

@@ -1,32 +1,58 @@
import { renderHook } from '@testing-library/react';
import { useWindowSize } from '@openedx/paragon';
import { useIsCollapsed } from './hooks';
import { useIntl } from '@edx/frontend-platform/i18n';
jest.mock('@openedx/paragon', () => ({
useWindowSize: jest.fn(),
breakpoints: {
small: {
maxWidth: 576,
},
import { reduxHooks } from 'hooks';
import * as hooks from './hooks';
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseData: jest.fn(),
useCardEnrollmentData: jest.fn(),
},
}));
describe('useIsCollapsed', () => {
afterEach(() => {
// 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(() => {
jest.clearAllMocks();
});
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();
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);
});
});
});

View File

@@ -24,6 +24,10 @@ jest.mock('./components/CourseCardActions', () => jest.fn(() => <div>CourseCardA
jest.mock('./components/CourseCardDetails', () => jest.fn(() => <div>CourseCardDetails</div>));
jest.mock('./components/CourseCardTitle', () => jest.fn(() => <div>CourseCardTitle</div>));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const cardId = 'test-card-id';
describe('CourseCard component', () => {

View File

@@ -1,24 +1,27 @@
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 { useFilters } from 'data/context';
import { reduxHooks } from 'hooks';
import messages from './messages';
import './index.scss';
export const ActiveCourseFilters = () => {
export const ActiveCourseFilters = ({
filters,
handleRemoveFilter,
}) => {
const { formatMessage } = useIntl();
const { filters, clearFilters, removeFilter } = useFilters();
const clearFilters = reduxHooks.useClearFilters();
return (
<div id="course-list-active-filters">
{filters.map(filter => (
<Chip
key={filter}
iconAfter={CloseSmall}
onClick={() => removeFilter(filter)}
onClick={handleRemoveFilter(filter)}
>
{formatMessage(messages[filter])}
</Chip>
@@ -29,5 +32,9 @@ export const ActiveCourseFilters = () => {
</div>
);
};
ActiveCourseFilters.propTypes = {
filters: PropTypes.arrayOf(PropTypes.string).isRequired,
handleRemoveFilter: PropTypes.func.isRequired,
};
export default ActiveCourseFilters;

View File

@@ -1,54 +1,32 @@
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';
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
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 /></IntlProvider>);
render(<IntlProvider locale="en"><ActiveCourseFilters {...props} /></IntlProvider>);
filters.map((key) => {
const chip = screen.getByText(formatMessage(messages[key]));
return expect(chip).toBeInTheDocument();
});
});
it('renders button correctly', () => {
render(<IntlProvider locale="en"><ActiveCourseFilters /></IntlProvider>);
render(<IntlProvider locale="en"><ActiveCourseFilters {...props} /></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);
});
});

View File

@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import track from 'tracking';
import {
Button,
Form,
@@ -13,51 +14,44 @@ import {
} from '@openedx/paragon';
import { Close, Tune } from '@openedx/paragon/icons';
import { useInitializeLearnerHome } from 'data/hooks';
import { useFilters } from 'data/context';
import { reduxHooks } from 'hooks';
import FilterForm from './components/FilterForm';
import SortForm from './components/SortForm';
import useCourseFilterControlsData from './hooks';
import messages from './messages';
import './index.scss';
export const CourseFilterControls = () => {
const [isOpen, setIsOpen] = React.useState(false);
const [targetRef, setTargetRef] = React.useState(null);
export const CourseFilterControls = ({
sortBy,
setSortBy,
filters,
}) => {
const { formatMessage } = useIntl();
const { data } = useInitializeLearnerHome();
const hasCourses = React.useMemo(() => data?.courses?.length > 0, [data]);
const hasCourses = reduxHooks.useHasCourses();
const {
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);
};
isOpen,
open,
close,
target,
setTarget,
handleFilterChange,
handleSortChange,
} = useCourseFilterControlsData({
filters,
setSortBy,
});
const { width } = useWindowSize();
const isMobile = width < breakpoints.small.minWidth;
return (
<div id="course-filter-controls">
<Button
ref={setTargetRef}
ref={setTarget}
variant="outline-primary"
iconBefore={Tune}
onClick={openFiltersOptions}
onClick={open}
disabled={!hasCourses}
>
{formatMessage(messages.refine)}
@@ -69,7 +63,7 @@ export const CourseFilterControls = () => {
className="w-75"
position="left"
show={isOpen}
onClose={closeFiltersOptions}
onClose={close}
>
<div className="p-1 mr-3">
<b>{formatMessage(messages.refine)}</b>
@@ -82,16 +76,16 @@ export const CourseFilterControls = () => {
<SortForm {...{ sortBy, handleSortChange }} />
</div>
<div className="pgn__modal-close-container">
<ModalCloseButton variant="tertiary" onClick={closeFiltersOptions}>
<ModalCloseButton variant="tertiary" onClick={close}>
<Icon src={Close} />
</ModalCloseButton>
</div>
</Sheet>
) : (
<ModalPopup
positionRef={targetRef}
positionRef={target}
isOpen={isOpen}
onClose={closeFiltersOptions}
onClose={close}
placement="bottom-end"
>
<div
@@ -112,5 +106,10 @@ export const CourseFilterControls = () => {
</div>
);
};
CourseFilterControls.propTypes = {
sortBy: PropTypes.string.isRequired,
setSortBy: PropTypes.func.isRequired,
filters: PropTypes.arrayOf(PropTypes.string).isRequired,
};
export default CourseFilterControls;

View File

@@ -1,150 +1,79 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen } 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('data/hooks', () => ({
useInitializeLearnerHome: jest.fn().mockReturnValue({ data: { courses: [1, 2, 3] } }),
jest.mock('hooks', () => ({
reduxHooks: { useHasCourses: jest.fn() },
}));
jest.mock('./hooks', () => jest.fn());
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
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);
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'),
});
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'),
};
describe('CourseFilterControls', () => {
const props = {
sortBy: SortKeys.enrolled,
setSortBy: jest.fn().mockName('setSortBy'),
filters,
};
describe('mobile and open', () => {
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');
});
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');
});
});
describe('is not mobile', () => {
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();
});
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();
});
});
describe('no courses', () => {
it('should have button disabled', () => {
useInitializeLearnerHome.mockReturnValue({ data: { courses: [] } });
reduxHooks.useHasCourses.mockReturnValue(false);
useCourseFilterControlsData.mockReturnValue(mockControlsData);
useWindowSize.mockReturnValue({ width: breakpoints.small.minWidth });
render(<IntlProvider locale="en"><CourseFilterControls /></IntlProvider>);
render(<IntlProvider locale="en"><CourseFilterControls {...props} /></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);
});
});
});
});

View File

@@ -6,6 +6,10 @@ import { FilterKeys } from 'data/constants/app';
import Checkbox from './Checkbox';
import messages from '../messages';
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
describe('Checkbox', () => {
describe('renders correctly', () => {
Object.keys(FilterKeys).forEach((filterKey) => {

View File

@@ -5,6 +5,10 @@ import { FilterKeys } from 'data/constants/app';
import { FilterForm, filterOrder } from './FilterForm';
import messages from '../messages';
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
const mockHandleFilterChange = jest.fn();
const defaultProps = {

View File

@@ -6,6 +6,10 @@ import { SortKeys } from 'data/constants/app';
import SortForm from './SortForm';
import messages from '../messages';
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
describe('SortForm', () => {
const props = {
handleSortChange: jest.fn().mockName('handleSortChange'),

View File

@@ -0,0 +1,60 @@
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;

View File

@@ -0,0 +1,122 @@
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);
});
});
});

View File

@@ -11,15 +11,14 @@ import { useIsCollapsed } from './hooks';
export const CourseList = ({ courseListData }) => {
const {
setPageNumber, numPages, visibleList, showFilters,
filterOptions, setPageNumber, numPages, showFilters, visibleList,
} = courseListData;
const isCollapsed = useIsCollapsed();
return (
<>
{showFilters && (
<div id="course-list-active-filters-container">
<ActiveCourseFilters />
<ActiveCourseFilters {...filterOptions} />
</div>
)}
<div className="d-flex flex-column flex-grow-1">
@@ -43,6 +42,7 @@ 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,
});

View File

@@ -12,6 +12,10 @@ jest.mock('containers/CourseFilterControls', () => ({
ActiveCourseFilters: jest.fn(() => <div>ActiveCourseFilters</div>),
}));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
describe('CourseList', () => {
const defaultCourseListData = {
filterOptions: {},

View File

@@ -5,15 +5,14 @@ import { Search } from '@openedx/paragon/icons';
import { baseAppUrl } from 'data/services/lms/urls';
import emptyCourseSVG from 'assets/empty-course.svg';
import { useInitializeLearnerHome } from 'data/hooks';
import { reduxHooks } from 'hooks';
import messages from './messages';
import './index.scss';
export const NoCoursesView = () => {
const { formatMessage } = useIntl();
const { data: learnerData } = useInitializeLearnerHome();
const courseSearchUrl = learnerData?.platformSettings?.courseSearchUrl || '';
const { courseSearchUrl } = reduxHooks.usePlatformSettingsData();
return (
<div
id="no-courses-content-view"

View File

@@ -8,16 +8,18 @@ import messages from './messages';
const courseSearchUrl = '/course-search-url';
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(() => ({
data: {
platformSettings: {
courseSearchUrl,
},
},
})),
jest.mock('hooks', () => ({
reduxHooks: {
usePlatformSettingsData: jest.fn(() => ({
courseSearchUrl,
})),
},
}));
jest.unmock('@edx/frontend-platform/i18n');
jest.unmock('@openedx/paragon');
jest.unmock('react');
describe('NoCoursesView', () => {
it('should display image, heading and button', () => {
render(<IntlProvider locale="en"><EmptyCourse /></IntlProvider>);

Some files were not shown because too many files have changed in this diff Show More