Compare commits

..

1 Commits

Author SHA1 Message Date
Deimer Morales
2b1c56e850 fix: include frontend component header translation (#793) [Ulmo backport] (#800) 2026-02-19 13:46:43 -05:00
196 changed files with 7877 additions and 13613 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={}

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup Nodejs
uses: actions/setup-node@v6

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
@@ -54,10 +49,9 @@ pull_translations:
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-header frontend-component-footer frontend-app-learner-dashboard
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:

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

6239
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,7 @@
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^8.0.0",
"@edx/frontend-component-header": "^6.6.0",
"@edx/frontend-enterprise-hotjar": "7.2.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0",
@@ -41,9 +41,11 @@
"@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.46.0",
"filesize": "^10.0.0",
"font-awesome": "4.7.0",
"history": "5.3.0",
"lodash": "^4.17.21",
@@ -53,9 +55,15 @@
"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.30.1",
"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": {
@@ -67,11 +75,12 @@
"@testing-library/user-event": "^14.6.1",
"copy-webpack-plugin": "^13.0.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.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 track from 'tracking';
import fakeData from 'data/services/lms/fakeData/courses';
import AppWrapper from 'containers/AppWrapper';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import { getConfig } from '@edx/frontend-platform';
import { useInitializeLearnerHome } from 'data/hooks';
import { useMasquerade } from 'data/context';
import messages from './messages';
import './App.scss';
export const App = () => {
const { authenticatedUser } = React.useContext(AppContext);
const { formatMessage } = useIntl();
const { 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,24 +3,30 @@ 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('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(() => ({})),
@@ -31,15 +37,11 @@ jest.mock('@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

@@ -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,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>));
@@ -19,22 +24,26 @@ 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 +63,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 +72,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 +82,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,20 +1,20 @@
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(),
}));
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(),
reduxHooks: {
useCardCertificateData: jest.fn(),
useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardGradeData: jest.fn(),
usePlatformSettingsData: jest.fn(),
},
}));
const defaultCertificate = {
@@ -35,14 +35,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 +46,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 +222,7 @@ describe('CertificateBanner', () => {
isPassing: true,
},
certificate: {
isEarned: true,
availableDate: '10/20/3030',
isEarnedButUnavailable: true,
},
});
const banner = screen.getByRole('alert');
@@ -251,27 +239,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,17 +1,20 @@
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(),
},
}));
const cardId = 'test-card-id';
@@ -36,15 +39,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 +53,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

@@ -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,13 +1,15 @@
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(),
},
}));
const cardId = 'test-card-id';
@@ -15,14 +17,14 @@ 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 +56,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', () => ({
@@ -23,7 +26,8 @@ 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 +35,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 +63,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,8 +12,10 @@ jest.mock('./hooks', () => ({
}));
jest.mock('hooks', () => ({
useCourseData: jest.fn(),
useIsMasquerading: jest.fn(),
reduxHooks: {
useMasqueradeData: jest.fn(),
useCardCreditData: jest.fn(),
},
}));
const cardId = 'test-card-id';
@@ -41,12 +44,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 +90,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,23 @@
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() },
}));
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 +28,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 +56,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,11 +1,13 @@
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(),
},
}));
const cardId = 'test-card-id';
@@ -13,9 +15,7 @@ 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 +23,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,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,11 +1,13 @@
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(),
},
}));
const cardId = 'test-card-id';
@@ -21,12 +23,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,40 +1,22 @@
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})`),
),
},
}));
const cardId = 'test-card-id';
@@ -50,20 +32,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 +56,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 +64,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,13 @@
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.mock('hooks', () => ({
useCourseData: jest.fn(),
reduxHooks: {
useCardRelatedProgramsData: jest.fn(),
},
}));
const cardId = 'test-card-id';
@@ -25,21 +27,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,26 +1,23 @@
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', () => {
@@ -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,7 +1,7 @@
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';
@@ -10,14 +10,14 @@ 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 +30,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 +41,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 +52,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 +61,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,13 +15,16 @@ 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(),
}));
const props = {
@@ -54,25 +57,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 +87,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>));
@@ -67,14 +69,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 +87,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,8 +12,11 @@ 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 })));
@@ -29,11 +32,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 +62,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,13 +1,15 @@
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', () => {
@@ -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

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

@@ -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,28 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { useFilters } from 'data/context';
import { FilterKeys } from 'data/constants/app';
import userEvent from '@testing-library/user-event';
import ActiveCourseFilters from './ActiveCourseFilters';
import messages from './messages';
const filters = Object.values(FilterKeys);
jest.mock('data/context', () => ({
useFilters: jest.fn(),
}));
const removeFiltersMock = jest.fn().mockName('removeFilter');
const clearFiltersMock = jest.fn().mockName('clearFilters');
useFilters.mockReturnValue({
filters,
removeFilter: removeFiltersMock,
clearFilters: clearFiltersMock,
});
describe('ActiveCourseFilters', () => {
const props = {
filters,
handleRemoveFilter: jest.fn().mockName('handleRemoveFilter'),
};
it('renders chips correctly', () => {
render(<IntlProvider locale="en"><ActiveCourseFilters /></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,75 @@
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.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

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

@@ -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,14 +8,12 @@ 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,
})),
},
}));
describe('NoCoursesView', () => {

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { ListPageSize, SortKeys } from 'data/constants/app';
import { reduxHooks } from 'hooks';
import { StrictDict } from 'utils';
import * as module from './hooks';
export const state = StrictDict({
sortBy: (val) => React.useState(val), // eslint-disable-line
});
/**
* Filters are fetched from the store and used to generate a list of "visible" courses.
* Other values returned and used for the layout of the CoursesPanel component are:
* the current page number, the sorting method, and whether or not to enable filters and pagination.
*
* @returns data for the CoursesPanel component
*/
export const useCourseListData = () => {
const filters = reduxHooks.useFilters();
const removeFilter = reduxHooks.useRemoveFilter();
const pageNumber = reduxHooks.usePageNumber();
const setPageNumber = reduxHooks.useSetPageNumber();
const [sortBy, setSortBy] = module.state.sortBy(SortKeys.enrolled);
const querySearch = new URLSearchParams(window.location.search);
const disablePagination = querySearch.get('disable_pagination');
const { numPages, visibleList } = reduxHooks.useCurrentCourseList({
sortBy,
filters,
pageSize: Number(disablePagination) === 1 ? 0 : ListPageSize,
});
const handleRemoveFilter = (filter) => () => removeFilter(filter);
return {
pageNumber,
numPages,
setPageNumber,
visibleList,
filterOptions: {
sortBy,
setSortBy,
filters,
handleRemoveFilter,
},
showFilters: filters.length > 0,
};
};
export default useCourseListData;

View File

@@ -0,0 +1,115 @@
import { MockUseState } from 'testUtils';
import { reduxHooks } from 'hooks';
import { ListPageSize, SortKeys } from 'data/constants/app';
import * as hooks from './hooks';
jest.mock('hooks', () => ({
reduxHooks: {
useCurrentCourseList: jest.fn(),
usePageNumber: jest.fn(() => 23),
useSetPageNumber: jest.fn(),
useFilters: jest.fn(),
useRemoveFilter: jest.fn(),
},
}));
const mockGet = jest.fn(() => ({}));
global.URLSearchParams = jest.fn().mockImplementation(() => ({
get: mockGet,
}));
const state = new MockUseState(hooks);
const testList = ['a', 'b'];
const testListData = {
numPages: 52,
visibleList: testList,
};
const testSortBy = 'fake sort option';
const testFilters = ['some', 'fake', 'filters'];
const setPageNumber = jest.fn(val => ({ setPageNumber: val }));
reduxHooks.useSetPageNumber.mockReturnValue(setPageNumber);
const removeFilter = jest.fn();
reduxHooks.useRemoveFilter.mockReturnValue(removeFilter);
reduxHooks.useFilters.mockReturnValue(['some', 'fake', 'filters']);
describe('CourseList hooks', () => {
let out;
reduxHooks.useCurrentCourseList.mockReturnValue(testListData);
describe('state values', () => {
state.testGetter(state.keys.sortBy);
jest.clearAllMocks();
});
describe('useCourseListData', () => {
afterEach(state.restore);
beforeEach(() => {
state.mock();
state.mockVal(state.keys.sortBy, testSortBy);
out = hooks.useCourseListData();
});
describe('behavior', () => {
it('initializes sort with enrollment date', () => {
state.expectInitializedWith(state.keys.sortBy, SortKeys.enrolled);
});
it('loads current course list with sortBy, filters, and page size', () => {
expect(reduxHooks.useCurrentCourseList).toHaveBeenCalledWith({
sortBy: testSortBy,
filters: testFilters,
pageSize: ListPageSize,
});
});
it('loads current course list with page size 0 if/when there is query param disable_pagination=1', () => {
state.mock();
state.mockVal(state.keys.sortBy, testSortBy);
mockGet.mockReturnValueOnce('1');
out = hooks.useCourseListData();
expect(reduxHooks.useCurrentCourseList).toHaveBeenCalledWith({
sortBy: testSortBy,
filters: testFilters,
pageSize: 0,
});
});
});
describe('output', () => {
test('pageNumber loads from usePageNumber hook', () => {
expect(out.pageNumber).toEqual(reduxHooks.usePageNumber());
});
test('numPages and visible list load from useCurrentCourseList hook', () => {
expect(out.numPages).toEqual(testListData.numPages);
expect(out.visibleList).toEqual(testListData.visibleList);
});
test('showFilters is true iff filters is not empty', () => {
expect(out.showFilters).toEqual(true);
state.mockVal(state.keys.sortBy, testSortBy);
reduxHooks.useFilters.mockReturnValueOnce([]);
out = hooks.useCourseListData();
// don't show filter when list is empty.
expect(out.showFilters).toEqual(false);
});
describe('filterOptions', () => {
test('sortBy and setSortBy are connected to the state value', () => {
expect(out.filterOptions.sortBy).toEqual(testSortBy);
expect(out.filterOptions.setSortBy).toEqual(state.setState.sortBy);
});
test('filters passed by useFilters hook', () => {
expect(out.filterOptions.filters).toEqual(testFilters);
});
test('handleRemoveFilter creates callback to call setFilter.remove', () => {
const cb = out.filterOptions.handleRemoveFilter(testFilters[0]);
expect(removeFilter).not.toHaveBeenCalled();
cb();
expect(removeFilter).toHaveBeenCalledWith(testFilters[0]);
});
test('setPageNumber dispatches setPageNumber action with passed value', () => {
expect(out.setPageNumber(2)).toEqual(setPageNumber(2));
});
});
});
});
});

View File

@@ -1,15 +1,15 @@
import React, { useMemo } from 'react';
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useInitializeLearnerHome } from 'data/hooks';
import { reduxHooks } from 'hooks';
import {
CourseFilterControls,
} from 'containers/CourseFilterControls';
import CourseListSlot from 'plugin-slots/CourseListSlot';
import NoCoursesViewSlot from 'plugin-slots/NoCoursesViewSlot';
import { useFilters } from 'data/context';
import { getVisibleList, getTransformedCourseDataList } from 'utils/dataTransformers';
import { useCourseListData } from './hooks';
import messages from './messages';
@@ -22,46 +22,14 @@ import './index.scss';
*/
export const CoursesPanel = () => {
const { formatMessage } = useIntl();
const { data } = useInitializeLearnerHome();
const hasCourses = useMemo(() => data?.courses?.length > 0, [data]);
const {
filters, sortBy, pageNumber, setPageNumber,
} = useFilters();
const { visibleList, numPages } = useMemo(() => {
let transformedCourses = [];
if (data?.courses?.length) {
transformedCourses = getTransformedCourseDataList(data.courses);
}
return getVisibleList(
transformedCourses,
filters,
sortBy,
pageNumber,
);
}, [data, filters, sortBy, pageNumber]);
// Clamp page number when filtered/mutated list shrinks
React.useEffect(() => {
if (numPages > 0 && pageNumber > numPages) {
setPageNumber(1);
}
}, [numPages, pageNumber, setPageNumber]);
const courseListData = {
filterOptions: filters,
setPageNumber,
numPages,
visibleList,
showFilters: filters.length > 0,
};
const hasCourses = reduxHooks.useHasCourses();
const courseListData = useCourseListData();
return (
<div className="course-list-container">
<div className="course-list-heading-container">
<h2 className="course-list-title">{formatMessage(messages.myCourses)}</h2>
<div className="course-filter-controls-container">
<CourseFilterControls />
<CourseFilterControls {...courseListData.filterOptions} />
</div>
</div>
{hasCourses ? <CourseListSlot courseListData={courseListData} /> : <NoCoursesViewSlot />}

View File

@@ -1,27 +1,26 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useInitializeLearnerHome } from 'data/hooks';
import { useFilters } from 'data/context';
import * as dataTransformers from 'utils/dataTransformers';
import { FilterKeys } from 'data/constants/app';
import { reduxHooks } from 'hooks';
import messagesNoCourses from 'containers/CoursesPanel/NoCoursesView/messages';
import { useCourseListData } from './hooks';
import CoursesPanel from '.';
import messages from './messages';
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(() => ({
data: {
courses: [{ id: 1 }, { id: 2 }],
},
})),
const courseSearchUrl = '/course-search-url';
jest.mock('hooks', () => ({
reduxHooks: {
useHasCourses: jest.fn(),
usePlatformSettingsData: jest.fn(() => ({
courseSearchUrl,
})),
},
}));
jest.mock('data/context', () => ({
useFilters: jest.fn(() => ({
filters: [],
sortBy: 'enrolled',
pageNumber: 1,
setPageNumber: jest.fn(),
})),
jest.mock('./hooks', () => ({
useCourseListData: jest.fn(),
}));
jest.mock('containers/CourseCard', () => jest.fn(() => <div>CourseCard</div>));
@@ -34,14 +33,30 @@ jest.mock('@openedx/frontend-plugin-framework', () => ({
PluginSlot: 'PluginSlot',
}));
const filters = Object.values(FilterKeys);
reduxHooks.useHasCourses.mockReturnValue(true);
describe('CoursesPanel', () => {
const defaultCourseListData = {
filterOptions: { filters, handleRemoveFilter: jest.fn() },
numPages: 1,
setPageNumber: jest.fn().mockName('setPageNumber'),
showFilters: false,
visibleList: [],
};
const createWrapper = (courseListData) => {
useInitializeLearnerHome.mockReturnValue({ data: { courses: courseListData?.visibleList || [] } });
useCourseListData.mockReturnValue({
...defaultCourseListData,
...courseListData,
});
return render(<IntlProvider locale="en"><CoursesPanel /></IntlProvider>);
};
describe('no courses', () => {
it('should render no courses view slot', () => {
reduxHooks.useHasCourses.mockReturnValue(false);
createWrapper();
const imgNoCourses = screen.getByRole('img', { name: messagesNoCourses.bannerAlt.defaultMessage });
expect(imgNoCourses).toBeInTheDocument();
@@ -52,106 +67,23 @@ describe('CoursesPanel', () => {
describe('with courses', () => {
it('should render courselist', () => {
const visibleList = [{ cardId: 'foo' }, { cardId: 'bar' }, { cardId: 'baz' }];
reduxHooks.useHasCourses.mockReturnValue(true);
createWrapper({ visibleList });
const courseCards = screen.getAllByText('CourseCard');
expect(courseCards.length).toEqual(visibleList.length);
});
it('displays course filter controls', () => {
reduxHooks.useHasCourses.mockReturnValue(true);
createWrapper();
expect(screen.getByText('CourseFilterControls')).toBeInTheDocument();
});
it('displays course list slot when courses exist', () => {
reduxHooks.useHasCourses.mockReturnValue(true);
const visibleList = [{ cardId: 'foo' }, { cardId: 'bar' }, { cardId: 'baz' }];
createWrapper({ visibleList });
const heading = screen.getByText(messages.myCourses.defaultMessage);
expect(heading).toBeInTheDocument();
});
});
describe('page number clamping', () => {
const mockSetPageNumber = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(dataTransformers, 'getTransformedCourseDataList');
jest.spyOn(dataTransformers, 'getVisibleList');
});
it('clamps page number to 1 when current page exceeds total pages', () => {
useFilters.mockReturnValue({
filters: [],
sortBy: 'enrolled',
pageNumber: 5, // User is on page 5
setPageNumber: mockSetPageNumber,
});
dataTransformers.getTransformedCourseDataList.mockReturnValue([{ id: 1 }, { id: 2 }]);
dataTransformers.getVisibleList.mockReturnValue({
visibleList: [{ id: 1 }],
numPages: 2,
});
createWrapper({ visibleList: [{ id: 1 }] });
expect(mockSetPageNumber).toHaveBeenCalledWith(1);
});
it('does not clamp page number when current page is valid', () => {
useFilters.mockReturnValue({
filters: [],
sortBy: 'enrolled',
pageNumber: 2,
setPageNumber: mockSetPageNumber,
});
dataTransformers.getTransformedCourseDataList.mockReturnValue([{ id: 1 }, { id: 2 }]);
dataTransformers.getVisibleList.mockReturnValue({
visibleList: [{ id: 1 }],
numPages: 3,
});
createWrapper({ visibleList: [{ id: 1 }] });
expect(mockSetPageNumber).not.toHaveBeenCalled();
});
it('does not clamp when numPages is 0', () => {
useFilters.mockReturnValue({
filters: [],
sortBy: 'enrolled',
pageNumber: 2,
setPageNumber: mockSetPageNumber,
});
dataTransformers.getTransformedCourseDataList.mockReturnValue([]);
dataTransformers.getVisibleList.mockReturnValue({
visibleList: [],
numPages: 0,
});
createWrapper({ visibleList: [] });
expect(mockSetPageNumber).not.toHaveBeenCalled();
});
it('handles edge case when pageNumber equals numPages', () => {
useFilters.mockReturnValue({
filters: [],
sortBy: 'enrolled',
pageNumber: 2,
setPageNumber: mockSetPageNumber,
});
dataTransformers.getTransformedCourseDataList.mockReturnValue([{ id: 1 }, { id: 2 }]);
dataTransformers.getVisibleList.mockReturnValue({
visibleList: [{ id: 1 }],
numPages: 2,
});
createWrapper({ visibleList: [{ id: 1 }] });
expect(mockSetPageNumber).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,16 +4,6 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import hooks from './hooks';
import DashboardLayout from './DashboardLayout';
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn().mockReturnValue({
data: {
platformSettings: {
courseSearchUrl: '/courses',
},
},
}),
}));
jest.mock('./hooks', () => ({
useDashboardLayoutData: jest.fn(),
}));

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useWindowSize, breakpoints } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { apiHooks } from 'hooks';
import { StrictDict } from 'utils';
import appMessages from 'messages';
@@ -10,6 +11,11 @@ export const state = StrictDict({
sidebarShowing: (val) => React.useState(val), // eslint-disable-line
});
export const useInitializeDashboard = () => {
const initialize = apiHooks.useInitializeApp();
React.useEffect(() => { initialize(); }, []); // eslint-disable-line
};
export const useDashboardMessages = () => {
const { formatMessage } = useIntl();
return {
@@ -31,5 +37,6 @@ export const useDashboardLayoutData = () => {
export default {
useDashboardLayoutData,
useInitializeDashboard,
useDashboardMessages,
};

View File

@@ -1,6 +1,9 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useWindowSize, breakpoints } from '@openedx/paragon';
import { apiHooks } from 'hooks';
import { MockUseState } from 'testUtils';
import appMessages from 'messages';
@@ -22,6 +25,12 @@ jest.mock('@edx/frontend-platform/i18n', () => {
};
});
jest.mock('hooks', () => ({
apiHooks: {
useInitializeApp: jest.fn(),
},
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
@@ -29,6 +38,8 @@ jest.mock('react', () => ({
const state = new MockUseState(hooks);
const initializeApp = jest.fn();
apiHooks.useInitializeApp.mockReturnValue(initializeApp);
useWindowSize.mockReturnValue({ width: 20 });
breakpoints.large = { maxWidth: 30 };
describe('CourseCard hooks', () => {
@@ -66,6 +77,16 @@ describe('CourseCard hooks', () => {
});
});
});
describe('useInitializeDashboard', () => {
it('dispatches initialize thunk action on component load', () => {
hooks.useInitializeDashboard();
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs).toEqual([]);
expect(initializeApp).not.toHaveBeenCalled();
cb();
expect(initializeApp).toHaveBeenCalledWith();
});
});
describe('useDashboardMessages', () => {
it('returns spinner screen reader text', () => {
expect(hooks.useDashboardMessages().spinnerScreenReaderText).toEqual(

View File

@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import React from 'react';
import { useSelectSessionModal } from 'data/context';
import { useInitializeLearnerHome } from 'data/hooks';
import { reduxHooks } from 'hooks';
import { RequestKeys } from 'data/constants/requests';
import SelectSessionModal from 'containers/SelectSessionModal';
import CoursesPanel from 'containers/CoursesPanel';
import DashboardModalSlot from 'plugin-slots/DashboardModalSlot';
@@ -12,24 +12,23 @@ import hooks from './hooks';
import './index.scss';
export const Dashboard = () => {
const { data, isPending } = useInitializeLearnerHome();
hooks.useInitializeDashboard();
const { pageTitle } = hooks.useDashboardMessages();
const { selectSessionModal } = useSelectSessionModal();
const showSelectSessionModal = selectSessionModal.cardId !== null;
const hasCourses = useMemo(() => data?.courses?.length > 0, [data]);
const hasCourses = reduxHooks.useHasCourses();
const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
const showSelectSessionModal = reduxHooks.useShowSelectSessionModal();
return (
<div id="dashboard-container" className="d-flex flex-column p-2 pt-0">
<h1 className="sr-only">{pageTitle}</h1>
{!isPending && (
{!initIsPending && (
<>
<DashboardModalSlot />
{(hasCourses && showSelectSessionModal) && <SelectSessionModal />}
</>
)}
<div id="dashboard-content" data-testid="dashboard-content">
{isPending
{initIsPending
? (<LoadingView />)
: (
<DashboardLayout>

View File

@@ -1,17 +1,16 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useSelectSessionModal } from 'data/context';
import { useInitializeLearnerHome } from 'data/hooks';
import { reduxHooks } from 'hooks';
import hooks from './hooks';
import Dashboard from '.';
jest.mock('data/context', () => ({
useSelectSessionModal: jest.fn(),
}));
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn(),
jest.mock('hooks', () => ({
reduxHooks: {
useHasCourses: jest.fn(),
useShowSelectSessionModal: jest.fn(),
useRequestIsPending: jest.fn(),
},
}));
jest.mock('./hooks', () => ({
@@ -35,9 +34,9 @@ describe('Dashboard', () => {
showSelectSessionModal = true,
} = props;
hooks.useDashboardMessages.mockReturnValue({ pageTitle });
const dataMocked = { data: hasCourses ? { courses: [1, 2] } : { courses: [] }, isPending: initIsPending };
useInitializeLearnerHome.mockReturnValue(dataMocked);
useSelectSessionModal.mockReturnValue({ selectSessionModal: showSelectSessionModal ? { cardId: 1 } : null });
reduxHooks.useHasCourses.mockReturnValue(hasCourses);
reduxHooks.useRequestIsPending.mockReturnValue(initIsPending);
reduxHooks.useShowSelectSessionModal.mockReturnValue(showSelectSessionModal);
return render(<IntlProvider locale="en"><Dashboard /></IntlProvider>);
};

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { StrictDict } from 'utils';
import { useUpdateEmailSettings } from 'data/hooks';
import { useCourseData } from 'hooks';
import { reduxHooks, apiHooks } from 'hooks';
import * as module from './hooks';
@@ -14,14 +13,12 @@ export const useEmailData = ({
closeModal,
cardId,
}) => {
const courseData = useCourseData(cardId);
const hasOptedOutOfEmail = courseData?.enrollment?.hasOptedOutOfEmail || false;
const courseId = courseData?.courseRun?.courseId;
const { hasOptedOutOfEmail } = reduxHooks.useCardEnrollmentData(cardId);
const [isOptedOut, setIsOptedOut] = module.state.toggle(hasOptedOutOfEmail);
const { mutate: updateEmailSettings } = useUpdateEmailSettings();
const updateEmailSettings = apiHooks.useUpdateEmailSettings(cardId);
const onToggle = () => setIsOptedOut(!isOptedOut);
const save = () => {
updateEmailSettings({ courseId, enable: !isOptedOut });
updateEmailSettings(!isOptedOut);
closeModal();
};

View File

@@ -0,0 +1,71 @@
import { MockUseState } from 'testUtils';
import { reduxHooks, apiHooks } from 'hooks';
import * as hooks from './hooks';
jest.mock('hooks', () => ({
reduxHooks: {
useCardEnrollmentData: jest.fn(),
},
apiHooks: {
useUpdateEmailSettings: jest.fn(),
},
}));
const cardId = 'my-test-course-number';
const closeModal = jest.fn();
const updateEmailSettings = jest.fn();
apiHooks.useUpdateEmailSettings.mockReturnValue(updateEmailSettings);
const state = new MockUseState(hooks);
describe('EmailSettingsModal hooks', () => {
let out;
describe('state values', () => {
state.testGetter(state.keys.toggle);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('useEmailData', () => {
beforeEach(() => {
state.mock();
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ hasOptedOutOfEmail: true });
out = hooks.useEmailData({ closeModal, cardId });
});
afterEach(state.restore);
it('loads enrollment data based on course number', () => {
expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
});
it('initializes toggle value to cardData.hasOptedOutOfEmail', () => {
state.expectInitializedWith(state.keys.toggle, true);
expect(out.isOptedOut).toEqual(true);
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ hasOptedOutOfEmail: false });
out = hooks.useEmailData({ closeModal, cardId });
state.expectInitializedWith(state.keys.toggle, false);
expect(out.isOptedOut).toEqual(false);
});
it('initializes email settings hok with cardId', () => {
expect(apiHooks.useUpdateEmailSettings).toHaveBeenCalledWith(cardId);
});
describe('onToggle - returned callback', () => {
it('sets toggle state value to opposite current value', () => {
out.onToggle();
expect(state.setState.toggle).toHaveBeenCalledWith(!out.isOptedOut);
});
});
describe('save', () => {
it('calls updateEmailSettings', () => {
out.save();
expect(updateEmailSettings).toHaveBeenCalledWith(!out.isOptedOut);
});
it('calls closeModal', () => {
out.save();
expect(closeModal).toHaveBeenCalled();
});
});
});
});

View File

@@ -1,134 +0,0 @@
import { renderHook, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCourseData } from 'hooks';
import * as api from 'data/services/lms/api';
import { useEmailData } from './hooks';
jest.mock('hooks', () => ({
useCourseData: jest.fn(() => ({
enrollment: {},
})),
}));
jest.mock('data/services/lms/api', () => ({
updateEmailSettings: jest.fn(),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const wrapper = ({ children }) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
return wrapper;
};
const cardId = 'my-test-course-number';
const closeModal = jest.fn();
describe('EmailSettingsModal hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('useEmailData', () => {
it('loads enrollment data based on course number', () => {
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: true } });
renderHook(() => useEmailData({ closeModal, cardId }), {
wrapper: createWrapper(),
});
expect(useCourseData).toHaveBeenCalledWith(cardId);
});
it('initializes toggle value to cardData.hasOptedOutOfEmail when true', () => {
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: true } });
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
wrapper: createWrapper(),
});
expect(result.current.isOptedOut).toEqual(true);
});
it('initializes toggle value to cardData.hasOptedOutOfEmail when false', () => {
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: false } });
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
wrapper: createWrapper(),
});
expect(result.current.isOptedOut).toEqual(false);
});
it('initializes toggle value to false when hasOptedOutOfEmail is undefined', () => {
useCourseData.mockReturnValue({ enrollment: {} });
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
wrapper: createWrapper(),
});
expect(result.current.isOptedOut).toEqual(false);
});
it('toggles state value when onToggle is called', () => {
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: true } });
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
wrapper: createWrapper(),
});
expect(result.current.isOptedOut).toEqual(true);
act(() => {
result.current.onToggle();
});
expect(result.current.isOptedOut).toEqual(false);
act(() => {
result.current.onToggle();
});
expect(result.current.isOptedOut).toEqual(true);
});
it('calls updateEmailSettings api and closeModal when save is called', async () => {
const courseId = 'test-course-id';
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: true }, courseRun: { courseId } });
api.updateEmailSettings.mockResolvedValue({});
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
wrapper: createWrapper(),
});
await act(async () => {
result.current.save();
});
const expectedArg = { courseId, enable: !result.current.isOptedOut };
expect(api.updateEmailSettings).toHaveBeenCalledWith(expectedArg);
expect(closeModal).toHaveBeenCalled();
});
it('calls updateEmailSettings with enable:true when isOptedOut is false', async () => {
const courseId = 'test-course-id';
useCourseData.mockReturnValue({ enrollment: { hasOptedOutOfEmail: false }, courseRun: { courseId } });
api.updateEmailSettings.mockResolvedValue({});
const { result } = renderHook(() => useEmailData({ closeModal, cardId }), {
wrapper: createWrapper(),
});
await act(async () => {
result.current.save();
});
expect(api.updateEmailSettings).toHaveBeenCalledWith({
courseId,
enable: true,
});
});
});
});

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { StrictDict } from 'utils';
import { useInitializeLearnerHome, useSendConfirmEmail } from 'data/hooks';
import { apiHooks, reduxHooks } from 'hooks';
import * as module from './hooks';
@@ -11,15 +11,13 @@ export const state = StrictDict({
});
export const useConfirmEmailBannerData = () => {
const { data: learnerData } = useInitializeLearnerHome();
const isNeeded = learnerData?.emailConfirmation?.isNeeded || false;
const sendEmailUrl = learnerData?.emailConfirmation?.sendEmailUrl || '';
const { mutate: sendConfirmEmail } = useSendConfirmEmail(sendEmailUrl);
const { isNeeded } = reduxHooks.useEmailConfirmationData();
const [showPageBanner, setShowPageBanner] = module.state.showPageBanner(isNeeded);
const [showConfirmModal, setShowConfirmModal] = module.state.showConfirmModal(false);
const closePageBanner = () => setShowPageBanner(false);
const closeConfirmModal = () => setShowConfirmModal(false);
const openConfirmModal = () => setShowConfirmModal(true);
const sendConfirmEmail = apiHooks.useSendConfirmEmail();
const openConfirmModalButtonClick = () => {
sendConfirmEmail();

View File

@@ -0,0 +1,77 @@
import { MockUseState } from 'testUtils';
import { reduxHooks, apiHooks } from 'hooks';
import * as hooks from './hooks';
jest.mock('hooks', () => ({
reduxHooks: {
useEmailConfirmationData: jest.fn(),
},
apiHooks: {
useSendConfirmEmail: jest.fn(),
},
}));
const sendConfirmEmail = jest.fn();
apiHooks.useSendConfirmEmail.mockReturnValue(sendConfirmEmail);
const emailConfirmation = {
isNeeded: true,
};
const state = new MockUseState(hooks);
describe('ConfirmEmailBanner hooks', () => {
let out;
describe('state values', () => {
state.testGetter(state.keys.showPageBanner);
state.testGetter(state.keys.showConfirmModal);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('useEmailConfirmationData', () => {
beforeEach(() => state.mock());
afterEach(state.restore);
test('show page banner on unverified email', () => {
reduxHooks.useEmailConfirmationData.mockReturnValueOnce({ ...emailConfirmation });
out = hooks.useConfirmEmailBannerData();
expect(out.isNeeded).toEqual(emailConfirmation.isNeeded);
reduxHooks.useEmailConfirmationData.mockReturnValueOnce({ isNeeded: false });
});
test('hide page banner on verified email', () => {
reduxHooks.useEmailConfirmationData.mockReturnValueOnce({ isNeeded: false });
out = hooks.useConfirmEmailBannerData();
expect(out.isNeeded).toEqual(false);
});
});
describe('behavior', () => {
beforeEach(() => {
state.mock();
reduxHooks.useEmailConfirmationData.mockReturnValueOnce({ ...emailConfirmation });
out = hooks.useConfirmEmailBannerData();
});
afterEach(state.restore);
test('closePageBanner', () => {
out.closePageBanner();
expect(state.values.showPageBanner).toEqual(false);
});
test('closeConfirmModal', () => {
out.closeConfirmModal();
expect(state.values.showConfirmModal).toEqual(false);
});
test('openConfirmModalButtonClick', () => {
out.openConfirmModalButtonClick();
expect(state.values.showConfirmModal).toEqual(true);
expect(sendConfirmEmail).toBeCalled();
});
test('userConfirmEmailButtonClick', () => {
out.userConfirmEmailButtonClick();
expect(state.values.showConfirmModal).toEqual(false);
expect(state.values.showPageBanner).toEqual(false);
});
});
});

View File

@@ -1,111 +0,0 @@
import { renderHook, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useInitializeLearnerHome } from 'data/hooks';
import * as api from 'data/services/lms/api';
import * as hooks from './hooks';
jest.mock('data/hooks', () => ({
...jest.requireActual('data/hooks'),
useInitializeLearnerHome: jest.fn(),
}));
jest.mock('data/services/lms/api', () => ({
sendConfirmEmail: jest.fn(),
}));
const emailConfirmation = {
isNeeded: true,
};
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const wrapper = ({ children }) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
return wrapper;
};
describe('ConfirmEmailBanner hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
api.sendConfirmEmail.mockResolvedValue({});
});
describe('useEmailConfirmationData', () => {
it('show page banner on unverified email', () => {
useInitializeLearnerHome.mockReturnValue({ data: { emailConfirmation: { ...emailConfirmation } } });
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
wrapper: createWrapper(),
});
expect(result.current.isNeeded).toEqual(emailConfirmation.isNeeded);
});
it('hide page banner on verified email', () => {
useInitializeLearnerHome.mockReturnValue({ data: { emailConfirmation: { isNeeded: false } } });
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
wrapper: createWrapper(),
});
expect(result.current.isNeeded).toEqual(false);
});
});
describe('behavior', () => {
beforeEach(() => {
useInitializeLearnerHome.mockReturnValue({ data: { emailConfirmation: { ...emailConfirmation } } });
});
it('closePageBanner', () => {
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
wrapper: createWrapper(),
});
act(() => {
result.current.closePageBanner();
});
expect(result.current.showPageBanner).toEqual(false);
});
it('closeConfirmModal', () => {
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
wrapper: createWrapper(),
});
act(() => {
result.current.closeConfirmModal();
});
expect(result.current.showConfirmModal).toEqual(false);
});
it('openConfirmModalButtonClick', async () => {
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
wrapper: createWrapper(),
});
await act(async () => {
result.current.openConfirmModalButtonClick();
});
expect(result.current.showConfirmModal).toEqual(true);
expect(api.sendConfirmEmail).toHaveBeenCalled();
});
it('userConfirmEmailButtonClick', () => {
const { result } = renderHook(() => hooks.useConfirmEmailBannerData(), {
wrapper: createWrapper(),
});
act(() => {
result.current.userConfirmEmailButtonClick();
});
expect(result.current.showConfirmModal).toEqual(false);
expect(result.current.showPageBanner).toEqual(false);
});
});
});

View File

@@ -3,7 +3,7 @@ import React from 'react';
import MasqueradeBar from 'containers/MasqueradeBar';
import { AppContext } from '@edx/frontend-platform/react';
import Header from '@edx/frontend-component-header';
import { useInitializeLearnerHome } from 'data/hooks';
import { reduxHooks } from 'hooks';
import urls from 'data/services/lms/urls';
import ConfirmEmailBanner from './ConfirmEmailBanner';
@@ -14,8 +14,7 @@ import './index.scss';
export const LearnerDashboardHeader = () => {
const { authenticatedUser } = React.useContext(AppContext);
const { data: learnerData } = useInitializeLearnerHome();
const courseSearchUrl = learnerData?.platformSettings?.courseSearchUrl || '';
const { courseSearchUrl } = reduxHooks.usePlatformSettingsData();
const exploreCoursesClick = () => {
findCoursesNavClicked(urls.baseAppUrl(courseSearchUrl));

View File

@@ -8,14 +8,12 @@ import { findCoursesNavClicked } from './hooks';
const courseSearchUrl = '/course-search-url';
jest.mock('data/hooks', () => ({
useInitializeLearnerHome: jest.fn().mockReturnValue({
data: {
platformSettings: {
courseSearchUrl,
},
},
}),
jest.mock('hooks', () => ({
reduxHooks: {
usePlatformSettingsData: jest.fn(() => ({
courseSearchUrl,
})),
},
}));
jest.mock('./hooks', () => ({
...jest.requireActual('./hooks'),

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