Compare commits

..

47 Commits

Author SHA1 Message Date
Simon Chen
cdb0f48f08 feat!: upsell through a course cert preview pop up
Even if the learner is enrolled in the course as a audit learner, each course cards would show a link that, once clicked upon, will pop a modal with verified cert preview and upgrade button
2023-10-25 13:37:58 -04:00
Syed Ali Abbas Zaidi
3cdcc1fe61 chore: bump frontend-platform (#216) 2023-10-16 13:09:55 +05:00
Shahbaz Shabbir
82ff0d7ddb fix: get brand logo file path from env (#205)
Co-authored-by: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com>
2023-10-12 14:06:01 -04:00
Jenkins
1478956e34 chore(i18n): update translations 2023-10-08 12:47:23 -04:00
Syed Ali Abbas Zaidi
f049712430 feat: upgrade react router to v6 (#126)
Co-authored-by: Matthew Carter <mcarter@edx.org>
2023-10-03 15:10:27 -04:00
Juliana Kang
c977de2df9 feat: Update header user dropdown back to Order History (#208)
REV-3693
2023-10-02 15:20:43 -04:00
Juliana Kang
4b20c5bbdd Revert "Revert "feat: Update header user dropdown back to Order History"" 2023-09-18 14:14:21 -04:00
Juliana Kang
0c1fa2f030 Revert "feat: Update header user dropdown back to Order History" (#207)
REV-3693
2023-09-18 14:13:47 -04:00
Juliana Kang
91117cce6a Revert "feat: Update header user dropdown back to Order History" 2023-09-18 13:52:13 -04:00
Juliana Kang
e6dba8bdc2 feat: Update header user dropdown back to Order History (#206)
REV-3693
2023-09-18 10:34:49 -04:00
julianajlk
1d67ac5f24 test: Update snapshot for Order History 2023-09-15 12:27:05 -04:00
julianajlk
60d2f22c50 feat: Update header user dropdown back to Order History 2023-09-15 12:12:07 -04:00
Syed Sajjad Hussain Shah
5dc89d7404 fix: collapsed navbar icon fix (#204)
Co-authored-by: Matthew Carter <mcarter@edx.org>
2023-09-07 17:11:15 -04:00
Zainab Amir
0f24d3a52d fix: recommendations card design and painted door eventing (#203) 2023-09-06 19:26:21 +05:00
Syed Sajjad Hussain Shah
fc885d02dc fix: recommendations card design and painted door eventing 2023-09-06 17:22:27 +05:00
Syed Sajjad Hussain Shah
2e09d3632e feat: add painted door button for no recommendations (#198) 2023-09-01 10:50:56 +05:00
Syed Sajjad Hussain Shah
d8cb46da60 Merge branch 'master' into sajjad/VAN-1618 2023-09-01 10:32:16 +05:00
Mashal Malik
199d6e7c60 feat: update react & react-dom to v17 (#161) 2023-08-28 10:31:57 -04:00
mubbsharanwar
64563d58f9 fix: update dashboard recommendations url
Point cross product recommendations url from learner_recommendations to edx-recommendations plugin.

VAN-1596
2023-08-28 11:50:41 +05:00
Jenkins
1e9a0a87b6 chore(i18n): update translations 2023-08-27 12:47:12 -04:00
Syed Sajjad Hussain Shah
d42d0cdc59 feat: add painted door button for no recommendations
VAN-1618
2023-08-24 13:30:39 +05:00
Mubbshar Anwar
8fef92d94d fix: update dashboard recommendations url (#195)
Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
Co-authored-by: Matthew Carter <mcarter@edx.org>
2023-08-24 11:12:39 +05:00
Ben Warzeski
b41eee47c9 Bw/recommendations painted door exp (#197)
Co-authored-by: Syed Sajjad  Hussain Shah <ssajjad@2u.com>
2023-08-23 11:53:19 -04:00
Ben Warzeski
909f3f1f47 Bw/fix email modal (#193) 2023-08-22 15:12:54 -04:00
Ben Warzeski
ce269e8c8f feat: Exec Education flag around course card menu and actions (#188)
Co-authored-by: jajjibhai008 <ejazofficial122@gmail.com>
2023-08-15 16:27:32 -04:00
Ejaz Ahmad
86a4573405 feat: show unenrollment button for executive education courses (#185) 2023-08-11 19:14:26 +05:00
jajjibhai008
be2258e409 feat: show unenrollment button for executive education courses 2023-08-11 12:46:28 +05:00
Ejaz Ahmad
be8cb85773 feat: frontend changes for executive education courses on B2C dashboard (#181) 2023-08-10 12:24:17 +05:00
jajjibhai008
a2c003e542 feat: frontend changes for executive education courses on B2C dashboard 2023-08-09 19:35:33 +05:00
Leangseu Kim
f1cfe3de68 chore: update email for ci workflow 2023-08-07 10:18:00 -04:00
Omar Al-Ithawi
d43c17a663 feat: include paragon in atlas pull (#179)
This pull request is part of the [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) which is sparked by the [Translation Infrastructure update OEP-58](https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification).
2023-07-25 11:19:35 -04:00
Mashal Malik
c01042f1df chore: add paragon messages (#172) 2023-07-21 11:16:34 +05:00
Jody Bailey
ed2368222f fix: removed info Optimizely logs (#177) 2023-07-17 14:57:57 +02:00
Jody Bailey
103a67654c feat: Implemented product recommendations experiment (#174) 2023-07-11 16:45:14 +02:00
Jenkins
58c3720087 chore(i18n): update translations 2023-07-09 12:46:59 -04:00
leangseu-edx
4e47018a81 fix: stop user from unenroll after earned the certificate (#162) 2023-07-06 13:30:36 -04:00
Jody Bailey
e7d9255fe5 fix: initial optimizely and segment events (#170) 2023-06-30 12:40:59 +02:00
Jody Bailey
2c7e10ffc2 fix: adjusted footer widget to show placeholder for no recommendations (#169) 2023-06-29 08:58:17 +02:00
Jody Bailey
43aa5b088e fix: course list styling for hidden panel and new endpoint integration (#165)
Co-authored-by: Ben Warzeski <bwarzesk@gmail.com>
2023-06-27 16:28:07 +02:00
Jenkins
86b1f5df1a chore(i18n): update translations 2023-06-23 06:55:17 -04:00
Raza Dar
5c52b6861e feat: Changed Order History header menu item title to Orders & Subscriptions (#164) 2023-06-21 12:18:41 +05:00
Raza Dar
a358a6014f test: removed the old flag code 2023-06-21 00:03:07 +05:00
Raza Dar
6ebc94506b feat: update removed flag check for this change 2023-06-20 17:30:44 +05:00
Raza Dar
59ab63807f feat: update the flag SUBSCRIPTIONS_ORDERS_MENU_ITEM_ENABLED 2023-06-20 17:30:44 +05:00
Raza Dar
322a79afaa feat: update Order History changed to Orders & Subscriptions 2023-06-20 17:30:44 +05:00
Jenkins
c458f4942f chore(i18n): update translations 2023-06-18 12:46:55 -04:00
Jody Bailey
93a4dfb4d9 feat: Added cross product recommendations experiment initial render + query logic (#158) 2023-06-15 15:06:32 +02:00
142 changed files with 9938 additions and 5274 deletions

2
.env
View File

@@ -40,3 +40,5 @@ ACCOUNT_SETTINGS_URL=''
ACCOUNT_PROFILE_URL='' ACCOUNT_PROFILE_URL=''
ENABLE_NOTICES='' ENABLE_NOTICES=''
CAREER_LINK_URL='' CAREER_LINK_URL=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
EXPERIMENT_08_23_VAN_PAINTED_DOOR='true'

View File

@@ -47,3 +47,5 @@ ACCOUNT_SETTINGS_URL='http://localhost:1997'
ACCOUNT_PROFILE_URL='http://localhost:1995' ACCOUNT_PROFILE_URL='http://localhost:1995'
ENABLE_NOTICES='' ENABLE_NOTICES=''
CAREER_LINK_URL='' CAREER_LINK_URL=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
EXPERIMENT_08_23_VAN_PAINTED_DOOR=true

View File

@@ -46,3 +46,5 @@ ACCOUNT_SETTINGS_URL='http://account-settings-url.test'
ACCOUNT_PROFILE_URL='http://account-profile-url.test' ACCOUNT_PROFILE_URL='http://account-profile-url.test'
ENABLE_NOTICES='' ENABLE_NOTICES=''
CAREER_LINK_URL='' CAREER_LINK_URL=''
OPTIMIZELY_FULL_STACK_SDK_KEY='SDK Key'
EXPERIMENT_08_23_VAN_PAINTED_DOOR=true

View File

@@ -49,9 +49,9 @@ jobs:
server_port: 465 server_port: 465
username: ${{ secrets.EDX_SMTP_USERNAME }} username: ${{ secrets.EDX_SMTP_USERNAME }}
password: ${{ secrets.EDX_SMTP_PASSWORD }} password: ${{ secrets.EDX_SMTP_PASSWORD }}
subject: Upgrade python requirements workflow failed in ${{github.repository}} subject: CI workflow failed in ${{github.repository}}
to: masters-grades@edx.org to: masters-grades@edx.org
from: github-actions <github-actions@edx.org> from: github-actions <github-actions@edx.org>
body: Upgrade python requirements workflow in ${{github.repository}} failed! body: CI workflow in ${{github.repository}} failed!
For details see "github.com/${{ github.repository }}/actions/runs/${{ github.run_id For details see "github.com/${{ github.repository }}/actions/runs/${{ github.run_id
}}" }}"

View File

@@ -64,10 +64,11 @@ pull_translations:
mkdir src/i18n/messages mkdir src/i18n/messages
cd src/i18n/messages \ cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \ && atlas pull --filter=$(transifex_langs) \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \ translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard translations/frontend-app-learner-dashboard/src/i18n/messages:frontend-app-learner-dashboard
$(intl_imports) frontend-component-footer frontend-app-learner-dashboard $(intl_imports) paragon frontend-component-footer frontend-app-learner-dashboard
endif endif
# This target is used by CI. # This target is used by CI.

8420
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -28,14 +28,16 @@
"dependencies": { "dependencies": {
"@edx/brand": "npm:@edx/brand-edx.org@^2.0.3", "@edx/brand": "npm:@edx/brand-edx.org@^2.0.3",
"@edx/browserslist-config": "^1.1.0", "@edx/browserslist-config": "^1.1.0",
"@edx/frontend-component-footer": "^12.0.0", "@edx/frontend-component-footer": "^12.2.1",
"@edx/frontend-enterprise-hotjar": "^1.2.0", "@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-platform": "^4.2.0", "@edx/frontend-platform": "^5.5.4",
"@edx/paragon": "^20.32.0", "@edx/paragon": "^20.44.0",
"@edx/react-unit-test-utils": "^1.7.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.1.15", "@fortawesome/react-fontawesome": "^0.1.15",
"@optimizely/react-sdk": "^2.9.2",
"@redux-beacon/segment": "^1.1.0", "@redux-beacon/segment": "^1.1.0",
"@reduxjs/toolkit": "^1.6.1", "@reduxjs/toolkit": "^1.6.1",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
@@ -52,17 +54,18 @@
"history": "5.0.1", "history": "5.0.1",
"html-react-parser": "^1.3.0", "html-react-parser": "^1.3.0",
"jest": "^26.6.3", "jest": "^26.6.3",
"jest-when": "^3.6.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"prop-types": "15.7.2", "prop-types": "15.7.2",
"query-string": "7.0.1", "query-string": "7.0.1",
"react": "^16.14.0", "react": "^17.0.2",
"react-dom": "^16.14.0", "react-dom": "^17.0.2",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-intl": "^5.20.9", "react-intl": "^5.20.9",
"react-pdf": "^5.5.0", "react-pdf": "^5.5.0",
"react-redux": "^7.2.4", "react-redux": "^7.2.4",
"react-router-dom": "5.3.3", "react-router-dom": "6.15.0",
"react-share": "^4.4.0", "react-share": "^4.4.0",
"react-zendesk": "^0.1.13", "react-zendesk": "^0.1.13",
"redux": "4.1.1", "redux": "4.1.1",
@@ -81,15 +84,15 @@
"@edx/reactifex": "^2.1.1", "@edx/reactifex": "^2.1.1",
"@testing-library/jest-dom": "^5.14.1", "@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.0", "@testing-library/react": "^12.1.0",
"@wojtekmaj/enzyme-adapter-react-17": "0.8.0",
"axios-mock-adapter": "^1.20.0", "axios-mock-adapter": "^1.20.0",
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^11.0.0",
"enzyme-adapter-react-16": "^1.15.6",
"fetch-mock": "^9.11.0", "fetch-mock": "^9.11.0",
"husky": "^7.0.0", "husky": "^7.0.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest-expect-message": "^1.0.2", "jest-expect-message": "^1.0.2",
"react-dev-utils": "^11.0.4", "react-dev-utils": "^11.0.4",
"react-test-renderer": "^16.14.0", "react-test-renderer": "^17.0.2",
"redux-mock-store": "^1.5.4", "redux-mock-store": "^1.5.4",
"semantic-release": "^20.1.3" "semantic-release": "^20.1.3"
} }

View File

@@ -5,6 +5,15 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" /> <link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
<% if (process.env.OPTIMIZELY_URL) { %>
<script
src="<%= process.env.OPTIMIZELY_URL %>"
></script>
<% } else if (process.env.OPTIMIZELY_PROJECT_ID) { %>
<script
src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"
></script>
<% } %>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
@@ -19,14 +18,16 @@ import {
import { reduxHooks } from 'hooks'; import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard'; import Dashboard from 'containers/Dashboard';
import ZendeskFab from 'components/ZendeskFab'; import ZendeskFab from 'components/ZendeskFab';
import { ExperimentProvider } from 'ExperimentContext';
import track from 'tracking'; import track from 'tracking';
import fakeData from 'data/services/lms/fakeData/courses'; import fakeData from 'data/services/lms/fakeData/courses';
import LearnerDashboardHeader from './containers/LearnerDashboardHeader';
import AppWrapper from 'containers/WidgetContainers/AppWrapper';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import messages from './messages'; import messages from './messages';
import './App.scss'; import './App.scss';
export const App = () => { export const App = () => {
@@ -72,24 +73,30 @@ export const App = () => {
} }
}, [authenticatedUser, loadData]); }, [authenticatedUser, loadData]);
return ( return (
<Router> <>
<Helmet> <Helmet>
<title>{formatMessage(messages.pageTitle)}</title> <title>{formatMessage(messages.pageTitle)}</title>
</Helmet> </Helmet>
<div> <div>
<LearnerDashboardHeader /> <AppWrapper>
<main> <LearnerDashboardHeader />
{hasNetworkFailure <main>
? ( {hasNetworkFailure
<Alert variant="danger"> ? (
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} /> <Alert variant="danger">
</Alert> <ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} />
) : (<Dashboard />)} </Alert>
</main> ) : (
<ExperimentProvider>
<Dashboard />
</ExperimentProvider>
)}
</main>
</AppWrapper>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} /> <Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
<ZendeskFab /> <ZendeskFab />
</div> </div>
</Router> </>
); );
}; };

View File

@@ -1,18 +1,16 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { ErrorPage } from '@edx/frontend-platform/react'; import { shallow } from '@edx/react-unit-test-utils';
import { BrowserRouter as Router } from 'react-router-dom';
import Footer from '@edx/frontend-component-footer'; import Footer from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { RequestKeys } from 'data/constants/requests'; import { RequestKeys } from 'data/constants/requests';
import { reduxHooks } from 'hooks'; import { reduxHooks } from 'hooks';
import Dashboard from 'containers/Dashboard'; import Dashboard from 'containers/Dashboard';
import LearnerDashboardHeader from 'containers/LearnerDashboardHeader'; import LearnerDashboardHeader from 'containers/LearnerDashboardHeader';
import AppWrapper from 'containers/WidgetContainers/AppWrapper';
import { ExperimentProvider } from 'ExperimentContext';
import { App } from './App'; import { App } from './App';
import messages from './messages'; import messages from './messages';
@@ -21,6 +19,10 @@ jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('containers/Dashboard', () => 'Dashboard'); jest.mock('containers/Dashboard', () => 'Dashboard');
jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader'); jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader');
jest.mock('components/ZendeskFab', () => 'ZendeskFab'); jest.mock('components/ZendeskFab', () => 'ZendeskFab');
jest.mock('ExperimentContext', () => ({
ExperimentProvider: 'ExperimentProvider',
}));
jest.mock('containers/WidgetContainers/AppWrapper', () => 'AppWrapper');
jest.mock('data/redux', () => ({ jest.mock('data/redux', () => ({
selectors: 'redux.selectors', selectors: 'redux.selectors',
actions: 'redux.actions', actions: 'redux.actions',
@@ -49,18 +51,23 @@ describe('App router component', () => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
describe('component', () => { describe('component', () => {
const runBasicTests = () => { const runBasicTests = () => {
test('snapshot', () => { expect(el).toMatchSnapshot(); }); test('snapshot', () => { expect(el.snapshot).toMatchSnapshot(); });
it('displays title in helmet component', () => { it('displays title in helmet component', () => {
expect(el.find(Helmet).find('title').text()).toEqual(useIntl().formatMessage(messages.pageTitle)); const control = el.instance
.findByType(Helmet)[0]
.findByType('title')[0];
expect(control.children[0].el).toEqual(formatMessage(messages.pageTitle));
}); });
it('displays learner dashboard header', () => { it('displays learner dashboard header', () => {
expect(el.find(LearnerDashboardHeader).length).toEqual(1); expect(el.instance.findByType(LearnerDashboardHeader).length).toEqual(1);
});
it('wraps the page in a browser router', () => {
expect(el.find(Router)).toMatchObject(el);
}); });
test('Footer logo drawn from env variable', () => { test('Footer logo drawn from env variable', () => {
expect(el.find(Footer).props().logo).toEqual(logo); expect(el.instance.findByType(Footer)[0].props.logo).toEqual(logo);
});
it('wraps the header and main components in an AppWrapper widget container', () => {
const container = el.instance.findByType(AppWrapper)[0];
expect(container.children[0].type).toEqual('LearnerDashboardHeader');
expect(container.children[1].type).toEqual('main');
}); });
}; };
describe('no network failure', () => { describe('no network failure', () => {
@@ -70,9 +77,14 @@ describe('App router component', () => {
}); });
runBasicTests(); runBasicTests();
it('loads dashboard', () => { it('loads dashboard', () => {
expect(el.find('main')).toMatchObject(shallow( const main = el.instance.findByType('main')[0];
<main><Dashboard /></main>, expect(main.children.length).toEqual(1);
)); const expProvider = main.children[0];
expect(expProvider.type).toEqual('ExperimentProvider');
expect(expProvider.children.length).toEqual(1);
expect(
expProvider.matches(shallow(<ExperimentProvider><Dashboard /></ExperimentProvider>)),
).toEqual(true);
}); });
}); });
describe('initialize failure', () => { describe('initialize failure', () => {
@@ -82,13 +94,14 @@ describe('App router component', () => {
}); });
runBasicTests(); runBasicTests();
it('loads error page', () => { it('loads error page', () => {
expect(el.find('main')).toEqual(shallow( const main = el.instance.findByType('main')[0];
<main> expect(main.children.length).toEqual(1);
<Alert variant="danger"> const alert = main.children[0];
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} /> expect(alert.type).toEqual('Alert');
</Alert> expect(alert.children.length).toEqual(1);
</main>, const errorPage = alert.children[0];
)); expect(errorPage.type).toEqual('ErrorPage');
expect(errorPage.props.message).toEqual(formatMessage(messages.errorMessage, { supportEmail }));
}); });
}); });
describe('refresh failure', () => { describe('refresh failure', () => {
@@ -98,13 +111,14 @@ describe('App router component', () => {
}); });
runBasicTests(); runBasicTests();
it('loads error page', () => { it('loads error page', () => {
expect(el.find('main')).toEqual(shallow( const main = el.instance.findByType('main')[0];
<main> expect(main.children.length).toEqual(1);
<Alert variant="danger"> const alert = main.children[0];
<ErrorPage message={formatMessage(messages.errorMessage, { supportEmail })} /> expect(alert.type).toEqual('Alert');
</Alert> expect(alert.children.length).toEqual(1);
</main>, const errorPage = alert.children[0];
)); expect(errorPage.type).toEqual('ErrorPage');
expect(errorPage.props.message).toEqual(formatMessage(messages.errorMessage, { supportEmail }));
}); });
}); });
}); });

64
src/ExperimentContext.jsx Normal file
View File

@@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useWindowSize, breakpoints } from '@edx/paragon';
import { StrictDict } from 'utils';
import api from 'widgets/ProductRecommendations/api';
import * as module from './ExperimentContext';
export const state = StrictDict({
experiment: (val) => React.useState(val), // eslint-disable-line
countryCode: (val) => React.useState(val), // eslint-disable-line
});
export const useCountryCode = (setCountryCode) => {
React.useEffect(() => {
api
.fetchRecommendationsContext()
.then((response) => {
setCountryCode(response.data.countryCode);
})
.catch(() => {
setCountryCode('');
});
/* eslint-disable */
}, []);
};
export const ExperimentContext = React.createContext();
export const ExperimentProvider = ({ children }) => {
const [countryCode, setCountryCode] = module.state.countryCode(null);
const [experiment, setExperiment] = module.state.experiment({
isExperimentActive: false,
inRecommendationsVariant: true,
});
module.useCountryCode(setCountryCode);
const { width } = useWindowSize();
const isMobile = width < breakpoints.small.minWidth;
const contextValue = React.useMemo(
() => ({
experiment,
countryCode,
setExperiment,
setCountryCode,
isMobile,
}),
[experiment, countryCode, setExperiment, setCountryCode, isMobile]
);
return (
<ExperimentContext.Provider value={contextValue}>
{children}
</ExperimentContext.Provider>
);
};
export const useExperimentContext = () => React.useContext(ExperimentContext);
ExperimentProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export default { useCountryCode, useExperimentContext };

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { useWindowSize } from '@edx/paragon';
import api from 'widgets/ProductRecommendations/api';
import { MockUseState } from 'testUtils';
import * as experiment from 'ExperimentContext';
const state = new MockUseState(experiment);
jest.unmock('react');
jest.spyOn(React, 'useEffect').mockImplementation((cb, prereqs) => ({ useEffect: { cb, prereqs } }));
jest.mock('widgets/ProductRecommendations/api', () => ({
fetchRecommendationsContext: jest.fn(),
}));
describe('experiments context', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('useCountryCode', () => {
describe('behaviour', () => {
describe('useEffect call', () => {
let calls;
let cb;
const setCountryCode = jest.fn();
const successfulFetch = { data: { countryCode: 'ZA' } };
beforeEach(() => {
experiment.useCountryCode(setCountryCode);
({ calls } = React.useEffect.mock);
[[cb]] = calls;
});
it('calls useEffect once', () => {
expect(calls.length).toEqual(1);
});
describe('successfull fetch', () => {
it('sets the country code', async () => {
let resolveFn;
api.fetchRecommendationsContext.mockReturnValueOnce(
new Promise((resolve) => {
resolveFn = resolve;
}),
);
cb();
expect(api.fetchRecommendationsContext).toHaveBeenCalled();
expect(setCountryCode).not.toHaveBeenCalled();
resolveFn(successfulFetch);
await waitFor(() => {
expect(setCountryCode).toHaveBeenCalledWith(successfulFetch.data.countryCode);
});
});
});
describe('unsuccessfull fetch', () => {
it('sets the country code to an empty string', async () => {
let rejectFn;
api.fetchRecommendationsContext.mockReturnValueOnce(
new Promise((resolve, reject) => {
rejectFn = reject;
}),
);
cb();
expect(api.fetchRecommendationsContext).toHaveBeenCalled();
expect(setCountryCode).not.toHaveBeenCalled();
rejectFn();
await waitFor(() => {
expect(setCountryCode).toHaveBeenCalledWith('');
});
});
});
});
});
});
describe('ExperimentProvider', () => {
const { ExperimentProvider } = experiment;
const TestComponent = () => {
const {
experiment: exp,
setExperiment,
countryCode,
setCountryCode,
isMobile,
} = experiment.useExperimentContext();
expect(exp.isExperimentActive).toBeFalsy();
expect(exp.inRecommendationsVariant).toBeTruthy();
expect(countryCode).toBeNull();
expect(isMobile).toBe(false);
expect(setExperiment).toBeDefined();
expect(setCountryCode).toBeDefined();
return (
<div />
);
};
it('allows access to child components with the context stateful values', () => {
const countryCodeSpy = jest.spyOn(experiment, 'useCountryCode').mockImplementationOnce(() => {});
useWindowSize.mockImplementationOnce(() => ({ width: 577, height: 943 }));
state.mock();
mount(
<ExperimentProvider>
<TestComponent />
</ExperimentProvider>,
);
expect(countryCodeSpy).toHaveBeenCalledWith(state.setState.countryCode);
state.expectInitializedWith(state.keys.countryCode, null);
state.expectInitializedWith(state.keys.experiment, { isExperimentActive: false, inRecommendationsVariant: true });
});
});
});

View File

@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App router component component initialize failure snapshot 1`] = ` exports[`App router component component initialize failure snapshot 1`] = `
<BrowserRouter> <Fragment>
<HelmetWrapper <HelmetWrapper
defer={true} defer={true}
encodeSpecialCharacters={true} encodeSpecialCharacters={true}
@@ -11,26 +11,28 @@ exports[`App router component component initialize failure snapshot 1`] = `
</title> </title>
</HelmetWrapper> </HelmetWrapper>
<div> <div>
<LearnerDashboardHeader /> <AppWrapper>
<main> <LearnerDashboardHeader />
<Alert <main>
variant="danger" <Alert
> variant="danger"
<ErrorPage >
message="If you experience repeated failures, please email support at test-support-url" <ErrorPage
/> message="If you experience repeated failures, please email support at test-support-url"
</Alert> />
</main> </Alert>
</main>
</AppWrapper>
<Footer <Footer
logo="fakeLogo.png" logo="fakeLogo.png"
/> />
<ZendeskFab /> <ZendeskFab />
</div> </div>
</BrowserRouter> </Fragment>
`; `;
exports[`App router component component no network failure snapshot 1`] = ` exports[`App router component component no network failure snapshot 1`] = `
<BrowserRouter> <Fragment>
<HelmetWrapper <HelmetWrapper
defer={true} defer={true}
encodeSpecialCharacters={true} encodeSpecialCharacters={true}
@@ -40,20 +42,24 @@ exports[`App router component component no network failure snapshot 1`] = `
</title> </title>
</HelmetWrapper> </HelmetWrapper>
<div> <div>
<LearnerDashboardHeader /> <AppWrapper>
<main> <LearnerDashboardHeader />
<Dashboard /> <main>
</main> <ExperimentProvider>
<Dashboard />
</ExperimentProvider>
</main>
</AppWrapper>
<Footer <Footer
logo="fakeLogo.png" logo="fakeLogo.png"
/> />
<ZendeskFab /> <ZendeskFab />
</div> </div>
</BrowserRouter> </Fragment>
`; `;
exports[`App router component component refresh failure snapshot 1`] = ` exports[`App router component component refresh failure snapshot 1`] = `
<BrowserRouter> <Fragment>
<HelmetWrapper <HelmetWrapper
defer={true} defer={true}
encodeSpecialCharacters={true} encodeSpecialCharacters={true}
@@ -63,20 +69,22 @@ exports[`App router component component refresh failure snapshot 1`] = `
</title> </title>
</HelmetWrapper> </HelmetWrapper>
<div> <div>
<LearnerDashboardHeader /> <AppWrapper>
<main> <LearnerDashboardHeader />
<Alert <main>
variant="danger" <Alert
> variant="danger"
<ErrorPage >
message="If you experience repeated failures, please email support at test-support-url" <ErrorPage
/> message="If you experience repeated failures, please email support at test-support-url"
</Alert> />
</main> </Alert>
</main>
</AppWrapper>
<Footer <Footer
logo="fakeLogo.png" logo="fakeLogo.png"
/> />
<ZendeskFab /> <ZendeskFab />
</div> </div>
</BrowserRouter> </Fragment>
`; `;

View File

@@ -13,18 +13,28 @@ exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
"redux": "store", "redux": "store",
} }
} }
wrapWithRouter={true}
> >
<NoticesWrapper> <NoticesWrapper>
<Switch> <Routes>
<PageRoute <Route
element={
<PageWrap>
<App />
</PageWrap>
}
path="/" path="/"
>
<App />
</PageRoute>
<Redirect
to="/"
/> />
</Switch> <Route
element={
<Navigate
replace={true}
to="/"
/>
}
path="*"
/>
</Routes>
</NoticesWrapper> </NoticesWrapper>
</AppProvider> </AppProvider>
`; `;

View File

@@ -16,6 +16,7 @@ const configuration = {
SUPPORT_URL: process.env.SUPPORT_URL || null, SUPPORT_URL: process.env.SUPPORT_URL || null,
ENABLE_NOTICES: process.env.ENABLE_NOTICES || null, ENABLE_NOTICES: process.env.ENABLE_NOTICES || null,
CAREER_LINK_URL: process.env.CAREER_LINK_URL || null, CAREER_LINK_URL: process.env.CAREER_LINK_URL || null,
LOGO_URL: process.env.LOGO_URL,
}; };
const features = {}; const features = {};

View File

@@ -0,0 +1,29 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks';
import messages from './messages';
export const useCertificatePreviewData = () => {
const { formatMessage } = useIntl();
const selectedCardId = reduxHooks.useCertificatePreviewData().cardId;
const { courseId } = reduxHooks.useCardCourseRunData(selectedCardId) || {};
const courseTitle = courseId;
const header = formatMessage(messages.previewTitle, { courseTitle });
const closePreviewModal = reduxHooks.useUpdateCertificatePreviewModalCallback(null);
const getCertificatePreviewUrl = () => `${getConfig().LMS_BASE_URL}/certificates/upsell/course/${courseId}`;
return {
showModal: selectedCardId != null,
header,
closePreviewModal,
getCertificatePreviewUrl,
};
};
export default useCertificatePreviewData;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
ModalDialog,
} from '@edx/paragon';
import { UpgradeButton } from '../CourseCard/components/CourseCardActions/UpgradeButton';
import useCertificatePreviewData from './hooks';
export const CertificatePreviewModal = ({
cardId,
}) => {
const {
showModal,
header,
closePreviewModal,
getCertificatePreviewUrl,
} = useCertificatePreviewData();
return (
<ModalDialog
isOpen={showModal}
onClose={closePreviewModal}
hasCloseButton
isFullscreenOnMobile
size="lg"
className="p-4 px-4.5"
title={header}
>
<h3>{header}</h3>
<div>
<iframe
title={header}
src={getCertificatePreviewUrl()}
width={725}
height={400}
/>
</div>
<UpgradeButton
cardId={cardId}
/>
</ModalDialog>
);
};
CertificatePreviewModal.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default CertificatePreviewModal;

View File

@@ -0,0 +1,12 @@
/* eslint-disable quotes */
import { StrictDict } from 'utils';
export const messages = StrictDict({
previewTitle: {
id: 'learner-dash.certificatePreview.title',
description: 'The title of the email settings modal',
defaultMessage: 'Your certificate preview for {courseTitle} ',
},
});
export default messages;

View File

@@ -12,11 +12,13 @@ import messages from './messages';
export const BeginCourseButton = ({ cardId }) => { export const BeginCourseButton = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { homeUrl } = reduxHooks.useCardCourseRunData(cardId); const { homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId);
const { disableBeginCourse } = useActionDisabledState(cardId); const { disableBeginCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent( const handleClick = reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked, track.course.enterCourseClicked,
cardId, cardId,
homeUrl, homeUrl + execEdTrackingParam,
); );
return ( return (
<ActionButton <ActionButton

View File

@@ -14,17 +14,22 @@ jest.mock('tracking', () => ({
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { reduxHooks: {
useCardCourseRunData: jest.fn(() => ({ homeUrl: 'home-url' })), useCardCourseRunData: jest.fn(),
useTrackCourseEvent: jest.fn( useCardExecEdTrackingParam: jest.fn(),
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }), useTrackCourseEvent: jest.fn(),
),
}, },
})); }));
jest.mock('../hooks', () => jest.fn(() => ({ disableBeginCourse: false }))); jest.mock('../hooks', () => jest.fn(() => ({ disableBeginCourse: false })));
jest.mock('./ActionButton', () => 'ActionButton'); jest.mock('./ActionButton', () => 'ActionButton');
let wrapper; let wrapper;
const { homeUrl } = reduxHooks.useCardCourseRunData(); 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, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
);
describe('BeginCourseButton', () => { describe('BeginCourseButton', () => {
const props = { const props = {
@@ -33,27 +38,50 @@ describe('BeginCourseButton', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('snapshot', () => {
test('renders default button when learner has access to the course', () => {
wrapper = shallow(<BeginCourseButton {...props} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
props.cardId,
homeUrl,
));
});
});
describe('behavior', () => { describe('behavior', () => {
it('initializes course run data with cardId', () => { it('initializes course run data with cardId', () => {
wrapper = shallow(<BeginCourseButton {...props} />); wrapper = shallow(<BeginCourseButton {...props} />);
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId); expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
}); });
test('disabled states', () => { it('loads exec education path param', () => {
useActionDisabledState.mockReturnValueOnce({ disableBeginCourse: true });
wrapper = shallow(<BeginCourseButton {...props} />); wrapper = shallow(<BeginCourseButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true); expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
});
it('loads disabled states for begin action from action hooks', () => {
wrapper = shallow(<BeginCourseButton {...props} />);
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
});
});
describe('snapshot', () => {
describe('disabled', () => {
beforeEach(() => {
useActionDisabledState.mockReturnValueOnce({ disableBeginCourse: true });
wrapper = shallow(<BeginCourseButton {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be disabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
});
describe('enabled', () => {
beforeEach(() => {
wrapper = shallow(<BeginCourseButton {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be enabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
});
it('should track enter course clicked event on click, with exec ed param', () => {
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
props.cardId,
homeUrl + execEdPath(props.cardId),
));
});
}); });
}); });
}); });

View File

@@ -12,11 +12,13 @@ import messages from './messages';
export const ResumeButton = ({ cardId }) => { export const ResumeButton = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { resumeUrl } = reduxHooks.useCardCourseRunData(cardId); const { resumeUrl } = reduxHooks.useCardCourseRunData(cardId);
const execEdTrackingParam = reduxHooks.useCardExecEdTrackingParam(cardId);
const { disableResumeCourse } = useActionDisabledState(cardId); const { disableResumeCourse } = useActionDisabledState(cardId);
const handleClick = reduxHooks.useTrackCourseEvent( const handleClick = reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked, track.course.enterCourseClicked,
cardId, cardId,
resumeUrl, resumeUrl + execEdTrackingParam,
); );
return ( return (
<ActionButton <ActionButton

View File

@@ -6,52 +6,80 @@ import track from 'tracking';
import useActionDisabledState from '../hooks'; import useActionDisabledState from '../hooks';
import ResumeButton from './ResumeButton'; import ResumeButton from './ResumeButton';
jest.mock('tracking', () => ({
course: {
enterCourseClicked: jest.fn().mockName('segment.enterCourseClicked'),
},
}));
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { reduxHooks: {
useCardCourseRunData: jest.fn(() => ({ resumeUrl: 'resumeUrl' })), useCardCourseRunData: jest.fn(),
useTrackCourseEvent: (eventName, cardId, url) => jest useCardExecEdTrackingParam: jest.fn(),
.fn() useTrackCourseEvent: jest.fn(),
.mockName(`useTrackCourseEvent('${eventName}', '${cardId}', '${url}')`),
}, },
})); }));
jest.mock('../hooks', () => jest.fn(() => ({ disableResumeCourse: false }))); jest.mock('../hooks', () => jest.fn(() => ({ disableResumeCourse: false })));
jest.mock('tracking', () => ({
course: {
enterCourseClicked: 'enterCourseClicked',
},
}));
jest.mock('./ActionButton', () => 'ActionButton'); jest.mock('./ActionButton', () => 'ActionButton');
const { resumeUrl } = reduxHooks.useCardCourseRunData(); 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, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
);
let wrapper;
describe('ResumeButton', () => { describe('ResumeButton', () => {
const props = { const props = {
cardId: 'cardId', cardId: 'cardId',
}; };
describe('snapshot', () => { describe('behavior', () => {
test('renders default button when learner has access to the course', () => { it('initializes course run data with cardId', () => {
const wrapper = shallow(<ResumeButton {...props} />); wrapper = shallow(<ResumeButton {...props} />);
expect(wrapper).toMatchSnapshot(); expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(props.cardId);
expect(wrapper.prop(htmlProps.disabled)).toEqual(false); });
expect(wrapper.prop(htmlProps.onClick).getMockName()).toContain( it('loads exec education path param', () => {
'useTrackCourseEvent', wrapper = shallow(<ResumeButton {...props} />);
track.course.enterCourseClicked, expect(reduxHooks.useCardExecEdTrackingParam).toHaveBeenCalledWith(props.cardId);
props.cardId, });
resumeUrl, it('loads disabled states for resume action from action hooks', () => {
); wrapper = shallow(<ResumeButton {...props} />);
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
}); });
}); });
describe('behavior', () => { describe('snapshot', () => {
it('initializes course run data based on cardId', () => { describe('disabled', () => {
shallow(<ResumeButton {...props} />); beforeEach(() => {
expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith( useActionDisabledState.mockReturnValueOnce({ disableResumeCourse: true });
props.cardId, wrapper = shallow(<ResumeButton {...props} />);
); });
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be disabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(true);
});
}); });
test('disabled states', () => { describe('enabled', () => {
useActionDisabledState.mockReturnValueOnce({ disableResumeCourse: true }); beforeEach(() => {
const wrapper = shallow(<ResumeButton {...props} />); wrapper = shallow(<ResumeButton {...props} />);
expect(wrapper.prop(htmlProps.disabled)).toEqual(true); });
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('should be enabled', () => {
expect(wrapper.prop(htmlProps.disabled)).toEqual(false);
});
it('should track enter course clicked event on click, with exec ed param', () => {
expect(wrapper.prop(htmlProps.onClick)).toEqual(reduxHooks.useTrackCourseEvent(
track.course.enterCourseClicked,
props.cardId,
resumeUrl + execEdPath(props.cardId),
));
});
}); });
}); });
}); });

View File

@@ -1,6 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BeginCourseButton snapshot renders default button when learner has access to the course 1`] = ` exports[`BeginCourseButton snapshot disabled snapshot 1`] = `
<ActionButton
as="a"
disabled={true}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "home-urlexec-ed-tracking-path=cardId",
},
}
}
>
Begin Course
</ActionButton>
`;
exports[`BeginCourseButton snapshot enabled snapshot 1`] = `
<ActionButton <ActionButton
as="a" as="a"
disabled={false} disabled={false}
@@ -10,7 +29,7 @@ exports[`BeginCourseButton snapshot renders default button when learner has acce
"trackCourseEvent": Object { "trackCourseEvent": Object {
"cardId": "cardId", "cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked], "eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "home-url", "upgradeUrl": "home-urlexec-ed-tracking-path=cardId",
}, },
} }
} }

View File

@@ -1,11 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ResumeButton snapshot renders default button when learner has access to the course 1`] = ` exports[`ResumeButton snapshot disabled snapshot 1`] = `
<ActionButton <ActionButton
as="a" as="a"
disabled={false} disabled={true}
href="#" href="#"
onClick={[MockFunction useTrackCourseEvent('enterCourseClicked', 'cardId', 'resumeUrl')]} onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "resume-urlexec-ed-tracking-path=cardId",
},
}
}
>
Resume
</ActionButton>
`;
exports[`ResumeButton snapshot enabled snapshot 1`] = `
<ActionButton
as="a"
disabled={false}
href="#"
onClick={
Object {
"trackCourseEvent": Object {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"upgradeUrl": "resume-urlexec-ed-tracking-path=cardId",
},
}
}
> >
Resume Resume
</ActionButton> </ActionButton>

View File

@@ -1,54 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseCardActions snapshot show begin course button when verified and not entitlement and has started 1`] = `
<ActionRow
data-test-id="CourseCardActions"
>
<BeginCourseButton
cardId="cardId"
/>
</ActionRow>
`;
exports[`CourseCardActions snapshot show resume button when verified and not entitlement and has started 1`] = `
<ActionRow
data-test-id="CourseCardActions"
>
<ResumeButton
cardId="cardId"
/>
</ActionRow>
`;
exports[`CourseCardActions snapshot show select session button when not verified and entitlement 1`] = `
<ActionRow
data-test-id="CourseCardActions"
>
<SelectSessionButton
cardId="cardId"
/>
</ActionRow>
`;
exports[`CourseCardActions snapshot show upgrade button when not verified and not entitlement 1`] = `
<ActionRow
data-test-id="CourseCardActions"
>
<UpgradeButton
cardId="cardId"
/>
<BeginCourseButton
cardId="cardId"
/>
</ActionRow>
`;
exports[`CourseCardActions snapshot show view course button when not verified and entitlement and fulfilled 1`] = `
<ActionRow
data-test-id="CourseCardActions"
>
<ViewCourseButton
cardId="cardId"
/>
</ActionRow>
`;

View File

@@ -13,21 +13,27 @@ import ViewCourseButton from './ViewCourseButton';
export const CourseCardActions = ({ cardId }) => { export const CourseCardActions = ({ cardId }) => {
const { isEntitlement, isFulfilled } = reduxHooks.useCardEntitlementData(cardId); const { isEntitlement, isFulfilled } = reduxHooks.useCardEntitlementData(cardId);
const { isVerified, hasStarted } = reduxHooks.useCardEnrollmentData(cardId); const {
isVerified,
hasStarted,
isExecEd2UCourse,
} = reduxHooks.useCardEnrollmentData(cardId);
const { isArchived } = reduxHooks.useCardCourseRunData(cardId); const { isArchived } = reduxHooks.useCardCourseRunData(cardId);
let PrimaryButton;
if (isEntitlement) {
PrimaryButton = isFulfilled ? ViewCourseButton : SelectSessionButton;
} else if (isArchived) {
PrimaryButton = ViewCourseButton;
} else {
PrimaryButton = hasStarted ? ResumeButton : BeginCourseButton;
}
return ( return (
<ActionRow data-test-id="CourseCardActions"> <ActionRow data-test-id="CourseCardActions">
{!(isEntitlement || isVerified) && <UpgradeButton cardId={cardId} />} {!(isEntitlement || isVerified || isExecEd2UCourse) && <UpgradeButton cardId={cardId} />}
<PrimaryButton cardId={cardId} /> {isEntitlement && (isFulfilled
? <ViewCourseButton cardId={cardId} />
: <SelectSessionButton cardId={cardId} />
)}
{(isArchived && !isEntitlement) && (
<ViewCourseButton cardId={cardId} />
)}
{!(isArchived || isEntitlement) && (hasStarted
? <ResumeButton cardId={cardId} />
: <BeginCourseButton cardId={cardId} />
)}
</ActionRow> </ActionRow>
); );
}; };

View File

@@ -1,7 +1,13 @@
import { shallow } from 'enzyme'; import { shallow } from '@edx/react-unit-test-utils';
import { reduxHooks } from 'hooks'; import { reduxHooks } from 'hooks';
import UpgradeButton from './UpgradeButton';
import SelectSessionButton from './SelectSessionButton';
import BeginCourseButton from './BeginCourseButton';
import ResumeButton from './ResumeButton';
import ViewCourseButton from './ViewCourseButton';
import CourseCardActions from '.'; import CourseCardActions from '.';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
@@ -9,6 +15,7 @@ jest.mock('hooks', () => ({
useCardCourseRunData: jest.fn(), useCardCourseRunData: jest.fn(),
useCardEnrollmentData: jest.fn(), useCardEnrollmentData: jest.fn(),
useCardEntitlementData: jest.fn(), useCardEntitlementData: jest.fn(),
useMasqueradeData: jest.fn(),
}, },
})); }));
@@ -18,87 +25,92 @@ jest.mock('./ViewCourseButton', () => 'ViewCourseButton');
jest.mock('./BeginCourseButton', () => 'BeginCourseButton'); jest.mock('./BeginCourseButton', () => 'BeginCourseButton');
jest.mock('./ResumeButton', () => 'ResumeButton'); jest.mock('./ResumeButton', () => 'ResumeButton');
const cardId = 'test-card-id';
const props = { cardId };
let el;
describe('CourseCardActions', () => { describe('CourseCardActions', () => {
const props = { const mockHooks = ({
cardId: 'cardId', isEntitlement = false,
}; isExecEd2UCourse = false,
const createWrapper = ({ isFulfilled = false,
isEntitlement, isFulfilled, isArchived, isVerified, hasStarted, isArchived = false,
}) => { isVerified = false,
hasStarted = false,
isMasquerading = false,
} = {}) => {
reduxHooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isFulfilled }); reduxHooks.useCardEntitlementData.mockReturnValueOnce({ isEntitlement, isFulfilled });
reduxHooks.useCardCourseRunData.mockReturnValueOnce({ isArchived }); reduxHooks.useCardCourseRunData.mockReturnValueOnce({ isArchived });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isVerified, hasStarted }); reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isExecEd2UCourse, isVerified, hasStarted });
return shallow(<CourseCardActions {...props} />); reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading });
}; };
describe('snapshot', () => { const render = () => {
test('show upgrade button when not verified and not entitlement', () => { el = shallow(<CourseCardActions {...props} />);
const wrapper = createWrapper({ };
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: false, hasStarted: false, describe('behavior', () => {
}); it('initializes redux hooks', () => {
expect(wrapper).toMatchSnapshot(); mockHooks();
}); render();
test('show select session button when not verified and entitlement', () => { expect(reduxHooks.useCardEntitlementData).toHaveBeenCalledWith(cardId);
const wrapper = createWrapper({ expect(reduxHooks.useCardEnrollmentData).toHaveBeenCalledWith(cardId);
isEntitlement: true, isFulfilled: false, isArchived: false, isVerified: false, hasStarted: false, expect(reduxHooks.useCardCourseRunData).toHaveBeenCalledWith(cardId);
});
expect(wrapper).toMatchSnapshot();
});
test('show begin course button when verified and not entitlement and has started', () => {
const wrapper = createWrapper({
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: true, hasStarted: false,
});
expect(wrapper).toMatchSnapshot();
});
test('show resume button when verified and not entitlement and has started', () => {
const wrapper = createWrapper({
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: true, hasStarted: true,
});
expect(wrapper).toMatchSnapshot();
});
test('show view course button when not verified and entitlement and fulfilled', () => {
const wrapper = createWrapper({
isEntitlement: true, isFulfilled: true, isArchived: false, isVerified: false, hasStarted: false,
});
expect(wrapper).toMatchSnapshot();
}); });
}); });
describe('output', () => {
describe('behavior', () => { describe('Exec Ed course', () => {
it('show upgrade button when not verified and not entitlement', () => { it('does not render upgrade button', () => {
const wrapper = createWrapper({ mockHooks({ isExecEd2UCourse: true });
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: false, hasStarted: false, render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
}); });
expect(wrapper.find('UpgradeButton')).toHaveLength(1);
}); });
it('show select session button when not verified and entitlement', () => { describe('entitlement course', () => {
const wrapper = createWrapper({ it('does not render upgrade button', () => {
isEntitlement: true, isFulfilled: false, isArchived: false, isVerified: false, hasStarted: false, mockHooks({ isEntitlement: true });
render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
});
it('renders ViewCourseButton if fulfilled', () => {
mockHooks({ isEntitlement: true, isFulfilled: true });
render();
expect(el.instance.findByType(ViewCourseButton)[0].props.cardId).toEqual(cardId);
});
it('renders SelectSessionButton if not fulfilled', () => {
mockHooks({ isEntitlement: true });
render();
expect(el.instance.findByType(SelectSessionButton)[0].props.cardId).toEqual(cardId);
}); });
expect(wrapper.find('SelectSessionButton')).toHaveLength(1);
}); });
it('show begin course button when verified and not entitlement and has started', () => { describe('verified course', () => {
const wrapper = createWrapper({ it('does not render upgrade button', () => {
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: true, hasStarted: false, mockHooks({ isVerified: true });
render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
}); });
expect(wrapper.find('BeginCourseButton')).toHaveLength(1);
}); });
it('show resume button when verified and not entitlement and has started', () => { describe('not entielement, verified, or exec ed', () => {
const wrapper = createWrapper({ it('renders UpgradeButton and ViewCourseButton for archived courses', () => {
isEntitlement: false, isFulfilled: false, isArchived: false, isVerified: true, hasStarted: true, mockHooks({ isArchived: true });
render();
expect(el.instance.findByType(UpgradeButton)[0].props.cardId).toEqual(cardId);
expect(el.instance.findByType(ViewCourseButton)[0].props.cardId).toEqual(cardId);
}); });
expect(wrapper.find('ResumeButton')).toHaveLength(1); describe('unstarted courses', () => {
}); it('renders UpgradeButton and BeginCourseButton', () => {
it('show view course button when not verified and entitlement and fulfilled', () => { mockHooks();
const wrapper = createWrapper({ render();
isEntitlement: true, isFulfilled: true, isArchived: false, isVerified: false, hasStarted: false, expect(el.instance.findByType(UpgradeButton)[0].props.cardId).toEqual(cardId);
expect(el.instance.findByType(BeginCourseButton)[0].props.cardId).toEqual(cardId);
});
}); });
expect(wrapper.find('ViewCourseButton')).toHaveLength(1); describe('active courses (started, and not archived)', () => {
}); it('renders UpgradeButton and ResumeButton', () => {
it('show view course button when not verified and entitlement and fulfilled and archived', () => { mockHooks({ hasStarted: true });
const wrapper = createWrapper({ render();
isEntitlement: true, isFulfilled: true, isArchived: true, isVerified: false, hasStarted: false, expect(el.instance.findByType(UpgradeButton)[0].props.cardId).toEqual(cardId);
expect(el.instance.findByType(ResumeButton)[0].props.cardId).toEqual(cardId);
});
}); });
expect(wrapper.find('ViewCourseButton')).toHaveLength(1);
}); });
}); });
}); });

View File

@@ -47,6 +47,7 @@ export const useCardDetailsData = ({ cardId }) => {
} = reduxHooks.useCardEntitlementData(cardId); } = reduxHooks.useCardEntitlementData(cardId);
const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId); const openSessionModal = reduxHooks.useUpdateSelectSessionModalCallback(cardId);
const openCertificatePreview = reduxHooks.useUpdateCertificatePreviewModalCallback(cardId);
return { return {
providerName: providerName || formatMessage(messages.unknownProviderName), providerName: providerName || formatMessage(messages.unknownProviderName),
@@ -57,6 +58,7 @@ export const useCardDetailsData = ({ cardId }) => {
openSessionModal, openSessionModal,
courseNumber, courseNumber,
changeOrLeaveSessionMessage: formatMessage(messages.changeOrLeaveSessionButton), changeOrLeaveSessionMessage: formatMessage(messages.changeOrLeaveSessionButton),
openCertificatePreview,
}; };
}; };

View File

@@ -16,23 +16,35 @@ export const CourseCardDetails = ({ cardId }) => {
openSessionModal, openSessionModal,
courseNumber, courseNumber,
changeOrLeaveSessionMessage, changeOrLeaveSessionMessage,
openCertificatePreview,
} = useCardDetailsData({ cardId }); } = useCardDetailsData({ cardId });
return ( return (
<span className="small" data-testid="CourseCardDetails"> <div>
{providerName} {courseNumber} <span className="small" data-testid="CourseCardDetails">
{!(isEntitlement && !isFulfilled) && accessMessage && ( {providerName} {courseNumber}
`${accessMessage}` {!(isEntitlement && !isFulfilled) && accessMessage && (
)} `${accessMessage}`
{isEntitlement && isFulfilled && canChange ? ( )}
<> {isEntitlement && isFulfilled && canChange ? (
{' • '} <>
<Button variant="link" size="inline" className="m-0 p-0" onClick={openSessionModal}> {' • '}
{changeOrLeaveSessionMessage} <Button variant="link" size="inline" className="m-0 p-0" onClick={openSessionModal}>
</Button> {changeOrLeaveSessionMessage}
</> </Button>
) : null} </>
</span> ) : null}
</span>
<Button
variant="link"
size="inline"
className="float-right"
data-testid="certificate-preview"
onClick={openCertificatePreview}
>
Preview Your Certificate
</Button>
</div>
); );
}; };

View File

@@ -0,0 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import * as ReactShare from 'react-share';
import { StrictDict } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import messages from './messages';
export const testIds = StrictDict({
emailSettingsModalToggle: 'emailSettingsModalToggle',
});
export const SocialShareMenu = ({ cardId, emailSettings }) => {
const { formatMessage } = useIntl();
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;
}
return (
<>
{isEmailEnabled && (
<Dropdown.Item
disabled={isMasquerading}
onClick={emailSettings.show}
data-testid={testIds.emailSettingsModalToggle}
>
{formatMessage(messages.emailSettings)}
</Dropdown.Item>
)}
{facebook.isEnabled && (
<ReactShare.FacebookShareButton
url={facebook.shareUrl}
onClick={handleFacebookShare}
title={formatMessage(messages.shareQuote, {
courseName,
socialBrand: facebook.socialBrand,
})}
resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item"
>
{formatMessage(messages.shareToFacebook)}
</ReactShare.FacebookShareButton>
)}
{twitter.isEnabled && (
<ReactShare.TwitterShareButton
url={twitter.shareUrl}
onClick={handleTwitterShare}
title={formatMessage(messages.shareQuote, {
courseName,
socialBrand: twitter.socialBrand,
})}
resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item"
>
{formatMessage(messages.shareToTwitter)}
</ReactShare.TwitterShareButton>
)}
</>
);
};
SocialShareMenu.propTypes = {
cardId: PropTypes.string.isRequired,
emailSettings: PropTypes.shape({
show: PropTypes.func,
}).isRequired,
};
export default SocialShareMenu;

View File

@@ -0,0 +1,235 @@
import { when } from 'jest-when';
import * as ReactShare from 'react-share';
import { useIntl } from '@edx/frontend-platform/i18n';
import { formatMessage, shallow } from '@edx/react-unit-test-utils';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import { useEmailSettings } from './hooks';
import SocialShareMenu, { testIds } from './SocialShareMenu';
import messages from './messages';
jest.mock('react-share', () => ({
FacebookShareButton: () => 'FacebookShareButton',
TwitterShareButton: () => 'TwitterShareButton',
}));
jest.mock('tracking', () => ({
socialShare: 'test-social-share-key',
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
useIntl: jest.fn().mockReturnValue({
formatMessage: jest.requireActual('@edx/react-unit-test-utils').formatMessage,
}),
}));
jest.mock('hooks', () => ({
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(),
}));
const props = {
cardId: 'test-card-id',
emailSettings: { show: jest.fn() },
};
const mockHook = (fn, returnValue, options = {}) => {
if (options.isCardHook) {
when(fn).calledWith(props.cardId).mockReturnValueOnce(returnValue);
} else {
when(fn).calledWith().mockReturnValueOnce(returnValue);
}
};
const courseName = 'test-course-name';
const socialShare = {
facebook: {
isEnabled: true,
shareUrl: 'facebook-share-url',
socialBrand: 'facebook-social-brand',
},
twitter: {
isEnabled: true,
shareUrl: 'twitter-share-url',
socialBrand: 'twitter-social-brand',
},
};
const mockHooks = (returnVals = {}) => {
mockHook(
reduxHooks.useCardEnrollmentData,
{
isEmailEnabled: !!returnVals.isEmailEnabled,
isExecEd2UCourse: !!returnVals.isExecEd2UCourse,
},
{ isCardHook: true },
);
mockHook(reduxHooks.useCardCourseData, { courseName }, { isCardHook: true });
mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading });
mockHook(
reduxHooks.useCardSocialSettingsData,
{
facebook: { ...socialShare.facebook, isEnabled: !!returnVals.facebook?.isEnabled },
twitter: { ...socialShare.twitter, isEnabled: !!returnVals.twitter?.isEnabled },
},
{ isCardHook: true },
);
};
let el;
const render = () => {
el = shallow(<SocialShareMenu {...props} />);
};
describe('SocialShareMenu', () => {
describe('behavior', () => {
beforeEach(() => {
mockHooks();
render();
});
it('initializes intl hook', () => {
expect(useIntl).toHaveBeenCalledWith();
});
it('initializes local hooks', () => {
when(useEmailSettings).expectCalledWith();
});
it('initializes redux hook data ', () => {
when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId);
when(reduxHooks.useCardCourseData).expectCalledWith(props.cardId);
when(reduxHooks.useCardSocialSettingsData).expectCalledWith(props.cardId);
when(reduxHooks.useMasqueradeData).expectCalledWith();
when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'twitter');
when(reduxHooks.useTrackCourseEvent).expectCalledWith(track.socialShare, props.cardId, 'facebook');
});
});
describe('render', () => {
it('renders null if exec ed course', () => {
mockHooks({ isExecEd2UCourse: true });
render();
expect(el.isEmptyRender()).toEqual(true);
});
const testEmailSettingsDropdown = (isMasquerading = false) => {
describe('email settings dropdown', () => {
const loadToggle = () => el.instance.findByTestId(testIds.emailSettingsModalToggle)[0];
it('renders', () => {
expect(el.instance.findByTestId(testIds.emailSettingsModalToggle).length).toEqual(1);
});
if (isMasquerading) {
it('is disabled', () => {
expect(loadToggle().props.disabled).toEqual(true);
});
} else {
it('is enabled', () => {
expect(loadToggle().props.disabled).toEqual(false);
});
}
test('show email settings modal on click', () => {
expect(loadToggle().props.onClick).toEqual(props.emailSettings.show);
});
});
};
const testFacebookShareButton = () => {
test('renders facebook share button with courseName and brand', () => {
const button = el.instance.findByType(ReactShare.FacebookShareButton)[0];
expect(button.props.url).toEqual(socialShare.facebook.shareUrl);
expect(button.props.onClick).toEqual(
reduxHooks.useTrackCourseEvent(track.socialShare, props.cardId, 'facebook'),
);
expect(button.props.title).toEqual(formatMessage(messages.shareQuote, {
courseName,
socialBrand: socialShare.facebook.socialBrand,
}));
});
};
const testTwitterShareButton = () => {
test('renders twitter share button with courseName and brand', () => {
const button = el.instance.findByType(ReactShare.TwitterShareButton)[0];
expect(button.props.url).toEqual(socialShare.twitter.shareUrl);
expect(button.props.onClick).toEqual(
reduxHooks.useTrackCourseEvent(track.socialShare, props.cardId, 'twitter'),
);
expect(button.props.title).toEqual(formatMessage(messages.shareQuote, {
courseName,
socialBrand: socialShare.twitter.socialBrand,
}));
});
};
describe('all enabled', () => {
beforeEach(() => {
mockHooks({
facebook: { isEnabled: true },
twitter: { isEnabled: true },
isEmailEnabled: true,
});
render();
});
describe('email settings dropdown', () => {
const loadToggle = () => el.instance.findByTestId(testIds.emailSettingsModalToggle)[0];
it('renders', () => {
expect(el.instance.findByTestId(testIds.emailSettingsModalToggle).length).toEqual(1);
});
it('is enabled', () => {
expect(loadToggle().props.disabled).toEqual(false);
});
test('show email settings modal on click', () => {
expect(loadToggle().props.onClick).toEqual(props.emailSettings.show);
});
});
testEmailSettingsDropdown();
testFacebookShareButton();
testTwitterShareButton();
});
describe('only email enabled', () => {
beforeEach(() => {
mockHooks({ isEmailEnabled: true });
render();
});
testEmailSettingsDropdown();
it('does not render facebook or twitter controls', () => {
expect(el.instance.findByType(ReactShare.FacebookShareButton).length).toEqual(0);
expect(el.instance.findByType(ReactShare.TwitterShareButton).length).toEqual(0);
});
describe('masquerading', () => {
beforeEach(() => {
mockHooks({ isEmailEnabled: true, isMasquerading: true });
render();
});
testEmailSettingsDropdown(true);
});
});
describe('only facebook enabled', () => {
beforeEach(() => {
mockHooks({ facebook: { isEnabled: true } });
render();
});
testFacebookShareButton();
it('does not render email or twitter controls', () => {
expect(el.instance.findByTestId(testIds.emailSettingsModalToggle).length).toEqual(0);
expect(el.instance.findByType(ReactShare.TwitterShareButton).length).toEqual(0);
});
});
describe('only twitter enabled', () => {
beforeEach(() => {
mockHooks({ twitter: { isEnabled: true } });
render();
});
testTwitterShareButton();
it('does not render email or facebook controls', () => {
expect(el.instance.findByTestId(testIds.emailSettingsModalToggle).length).toEqual(0);
expect(el.instance.findByType(ReactShare.FacebookShareButton).length).toEqual(0);
});
});
});
});

View File

@@ -1,8 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1`] = ` exports[`CourseCardMenu render show dropdown hide unenroll item and disable email snapshot 1`] = `
<Fragment> <Fragment>
<Dropdown> <Dropdown
onToggle={[MockFunction hooks.handleToggleDropdown]}
>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
iconAs="Icon"
id="course-actions-dropdown-test-card-id"
src={[MockFunction icons.MoreVert]}
variant="primary"
/>
<Dropdown.Menu>
<SocialShareMenu
cardId="test-card-id"
emailSettings={
Object {
"hide": [MockFunction emailSettingHide],
"isVisible": false,
"show": [MockFunction emailSettingShow],
}
}
/>
</Dropdown.Menu>
</Dropdown>
<UnenrollConfirmModal
cardId="test-card-id"
closeModal={[MockFunction unenrollHide]}
show={false}
/>
</Fragment>
`;
exports[`CourseCardMenu render show dropdown show unenroll and enable email snapshot 1`] = `
<Fragment>
<Dropdown
onToggle={[MockFunction hooks.handleToggleDropdown]}
>
<Dropdown.Toggle <Dropdown.Toggle
alt="Course actions dropdown" alt="Course actions dropdown"
as="IconButton" as="IconButton"
@@ -19,31 +55,16 @@ exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1
> >
Unenroll Unenroll
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item <SocialShareMenu
data-testid="emailSettingsModalToggle" cardId="test-card-id"
disabled={false} emailSettings={
onClick={[MockFunction emailSettingShow]} Object {
> "hide": [MockFunction emailSettingHide],
Email settings "isVisible": false,
</Dropdown.Item> "show": [MockFunction emailSettingShow],
<FacebookShareButton }
className="pgn__dropdown-item dropdown-item" }
onClick={[MockFunction facebookShareClick]} />
resetButtonStyle={false}
title="I'm taking test-course-name online with facebook-social-brand. Check it out!"
url="facebook-share-url"
>
Share to Facebook
</FacebookShareButton>
<TwitterShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction twitterShareClick]}
resetButtonStyle={false}
title="I'm taking test-course-name online with twitter-social-brand. Check it out!"
url="twitter-share-url"
>
Share to Twitter
</TwitterShareButton>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
<UnenrollConfirmModal <UnenrollConfirmModal
@@ -58,83 +79,3 @@ exports[`CourseCardMenu enrolled, share enabled, email setting enable snapshot 1
/> />
</Fragment> </Fragment>
`; `;
exports[`CourseCardMenu masquerading snapshot 1`] = `
<Fragment>
<Dropdown>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
iconAs="Icon"
id="course-actions-dropdown-test-card-id"
src={[MockFunction icons.MoreVert]}
variant="primary"
/>
<Dropdown.Menu>
<Dropdown.Item
data-testid="unenrollModalToggle"
disabled={true}
onClick={[MockFunction unenrollShow]}
>
Unenroll
</Dropdown.Item>
<Dropdown.Item
data-testid="emailSettingsModalToggle"
disabled={true}
onClick={[MockFunction emailSettingShow]}
>
Email settings
</Dropdown.Item>
<FacebookShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction facebookShareClick]}
resetButtonStyle={false}
title="I'm taking test-course-name online with facebook-social-brand. Check it out!"
url="facebook-share-url"
>
Share to Facebook
</FacebookShareButton>
<TwitterShareButton
className="pgn__dropdown-item dropdown-item"
onClick={[MockFunction twitterShareClick]}
resetButtonStyle={false}
title="I'm taking test-course-name online with twitter-social-brand. Check it out!"
url="twitter-share-url"
>
Share to Twitter
</TwitterShareButton>
</Dropdown.Menu>
</Dropdown>
<UnenrollConfirmModal
cardId="test-card-id"
closeModal={[MockFunction unenrollHide]}
show={false}
/>
<EmailSettingsModal
cardId="test-card-id"
closeModal={[MockFunction emailSettingHide]}
show={false}
/>
</Fragment>
`;
exports[`CourseCardMenu not enrolled, share disabled, email setting disabled snapshot 1`] = `
<Fragment>
<Dropdown>
<Dropdown.Toggle
alt="Course actions dropdown"
as="IconButton"
iconAs="Icon"
id="course-actions-dropdown-test-card-id"
src={[MockFunction icons.MoreVert]}
variant="primary"
/>
<Dropdown.Menu />
</Dropdown>
<UnenrollConfirmModal
cardId="test-card-id"
closeModal={[MockFunction unenrollHide]}
show={false}
/>
</Fragment>
`;

View File

@@ -1,18 +1,15 @@
import React from 'react'; import { useKeyedState, StrictDict } from '@edx/react-unit-test-utils';
import { StrictDict } from 'utils';
import track from 'tracking'; import track from 'tracking';
import { reduxHooks } from 'hooks'; import { reduxHooks } from 'hooks';
import * as module from './hooks'; export const stateKeys = StrictDict({
isUnenrollConfirmVisible: 'isUnenrollConfirmVisible',
export const state = StrictDict({ isEmailSettingsVisible: 'isEmailSettingsVisible',
isUnenrollConfirmVisible: (val) => React.useState(val), // eslint-disable-line
isEmailSettingsVisible: (val) => React.useState(val), // eslint-disable-line
}); });
export const useUnenrollData = () => { export const useUnenrollData = () => {
const [isVisible, setIsVisible] = module.state.isUnenrollConfirmVisible(false); const [isVisible, setIsVisible] = useKeyedState(stateKeys.isUnenrollConfirmVisible, false);
return { return {
show: () => setIsVisible(true), show: () => setIsVisible(true),
hide: () => setIsVisible(false), hide: () => setIsVisible(false),
@@ -21,7 +18,7 @@ export const useUnenrollData = () => {
}; };
export const useEmailSettings = () => { export const useEmailSettings = () => {
const [isVisible, setIsVisible] = module.state.isEmailSettingsVisible(false); const [isVisible, setIsVisible] = useKeyedState(stateKeys.isEmailSettingsVisible, false);
return { return {
show: () => setIsVisible(true), show: () => setIsVisible(true),
hide: () => setIsVisible(false), hide: () => setIsVisible(false),
@@ -30,9 +27,30 @@ export const useEmailSettings = () => {
}; };
export const useHandleToggleDropdown = (cardId) => { export const useHandleToggleDropdown = (cardId) => {
const eventName = track.course.courseOptionsDropdownClicked; const trackCourseEvent = reduxHooks.useTrackCourseEvent(
const trackCourseEvent = reduxHooks.useTrackCourseEvent(eventName, cardId); track.course.courseOptionsDropdownClicked,
cardId,
);
return (isOpen) => { return (isOpen) => {
if (isOpen) { trackCourseEvent(); } if (isOpen) { trackCourseEvent(); }
}; };
}; };
export const useOptionVisibility = (cardId) => {
const { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
const { isEarned } = reduxHooks.useCardCertificateData(cardId);
const shouldShowUnenrollItem = isEnrolled && !isEarned;
const shouldShowDropdown = (
shouldShowUnenrollItem
|| isEmailEnabled
|| facebook.isEnabled
|| twitter.isEnabled
);
return {
shouldShowUnenrollItem,
shouldShowDropdown,
};
};

View File

@@ -1,4 +1,5 @@
import { MockUseState } from 'testUtils'; import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { reduxHooks } from 'hooks'; import { reduxHooks } from 'hooks';
import track from 'tracking'; import track from 'tracking';
@@ -6,71 +7,77 @@ import * as hooks from './hooks';
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { reduxHooks: {
useCardCertificateData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useTrackCourseEvent: jest.fn(), useTrackCourseEvent: jest.fn(),
}, },
})); }));
const trackCourseEvent = jest.fn(); const trackCourseEvent = jest.fn();
reduxHooks.useTrackCourseEvent.mockReturnValue(trackCourseEvent); reduxHooks.useTrackCourseEvent.mockReturnValue(trackCourseEvent);
const state = new MockUseState(hooks);
const cardId = 'test-card-id'; const cardId = 'test-card-id';
let out; let out;
describe('CourseCardMenu hooks', () => { const state = mockUseKeyedState(hooks.stateKeys);
describe('state values', () => {
state.testGetter(state.keys.isUnenrollConfirmVisible);
state.testGetter(state.keys.isEmailSettingsVisible);
});
describe('CourseCardMenu hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
describe('useUnenrollData', () => { describe('useUnenrollData', () => {
beforeEach(() => { beforeEach(() => {
state.mock(); state.mockVals({ isUnenrollConfirmVisible: true });
out = hooks.useUnenrollData(); out = hooks.useUnenrollData();
}); });
afterEach(state.restore); describe('behavior', () => {
it('initializes isUnenrollConfirmVisible state to false', () => {
test('default state', () => { state.expectInitializedWith(state.keys.isUnenrollConfirmVisible, false);
expect(out.isVisible).toEqual(state.stateVals.isUnenrollConfirmVisible); });
}); });
describe('output', () => {
test('show', () => { test('state is loaded from current state value', () => {
out.show(); expect(out.isVisible).toEqual(true);
state.expectSetStateCalledWith(state.keys.isUnenrollConfirmVisible, true); });
}); test('show sets state value to true', () => {
out.show();
test('hide', () => { expect(state.setState.isUnenrollConfirmVisible).toHaveBeenCalledWith(true);
out.hide(); });
state.expectSetStateCalledWith(state.keys.isUnenrollConfirmVisible, false); test('hide sets state value to false', () => {
out.hide();
expect(state.setState.isUnenrollConfirmVisible).toHaveBeenCalledWith(false);
});
}); });
}); });
describe('useEmailSettings', () => { describe('useEmailSettings', () => {
beforeEach(() => { beforeEach(() => {
state.mock(); state.mockVals({ isEmailSettingsVisible: true });
out = hooks.useEmailSettings(); out = hooks.useEmailSettings();
}); });
afterEach(state.restore); describe('behavior', () => {
it('initializes isEmailSettingsVisible state to false', () => {
test('default state', () => { state.expectInitializedWith(state.keys.isEmailSettingsVisible, false);
expect(out.isVisible).toEqual(state.stateVals.isEmailSettingsVisible); });
}); });
describe('output', () => {
test('show', () => { test('state is loaded from current state value', () => {
out.show(); expect(out.isVisible).toEqual(state.values.isEmailSettingsVisible);
state.expectSetStateCalledWith(state.keys.isEmailSettingsVisible, true); });
}); test('show sets state value to true', () => {
out.show();
test('hide', () => { expect(state.setState.isEmailSettingsVisible).toHaveBeenCalledWith(true);
out.hide(); });
state.expectSetStateCalledWith(state.keys.isEmailSettingsVisible, false); test('hide sets state value to false', () => {
out.hide();
expect(state.setState.isEmailSettingsVisible).toHaveBeenCalledWith(false);
});
}); });
}); });
describe('useHandleToggleDropdown', () => { describe('useHandleToggleDropdown', () => {
beforeEach(() => { beforeEach(() => { out = hooks.useHandleToggleDropdown(cardId); });
out = hooks.useHandleToggleDropdown(cardId);
});
describe('behavior', () => { describe('behavior', () => {
it('initializes course event tracker with event name and card ID', () => { it('initializes course event tracker with event name and card ID', () => {
expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith( expect(reduxHooks.useTrackCourseEvent).toHaveBeenCalledWith(
@@ -88,4 +95,59 @@ describe('CourseCardMenu hooks', () => {
}); });
}); });
}); });
describe('useOptionVisibility', () => {
const mockReduxHooks = (returnVals = {}) => {
reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({
facebook: { isEnabled: !!returnVals.facebook?.isEnabled },
twitter: { isEnabled: !!returnVals.twitter?.isEnabled },
});
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
isEnrolled: !!returnVals.isEnrolled,
isEmailEnabled: !!returnVals.isEmailEnabled,
});
reduxHooks.useCardCertificateData.mockReturnValueOnce({
isEarned: !!returnVals.isEarned,
});
};
describe('shouldShowUnenrollItem', () => {
it('returns true if enrolled and not earned', () => {
mockReduxHooks({ isEnrolled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(true);
});
it('returns false if not enrolled', () => {
mockReduxHooks();
expect(hooks.useOptionVisibility(cardId).shouldShowUnenrollItem).toEqual(false);
});
it('returns false if enrolled but also earned', () => {
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', () => {
mockReduxHooks();
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false);
});
it('returns false if enrolled but already earned, and both email and socials are disabled', () => {
mockReduxHooks({ isEnrolled: true, isEarned: true });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(false);
});
it('returns true if either social is enabled', () => {
mockReduxHooks({ facebook: { isEnabled: true } });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
mockReduxHooks({ twitter: { isEnabled: true } });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
});
it('returns true if email is enabled', () => {
mockReduxHooks({ isEmailEnabled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
});
it('returns true if enrolled and not earned', () => {
mockReduxHooks({ isEnrolled: true });
expect(hooks.useOptionVisibility(cardId).shouldShowDropdown).toEqual(true);
});
});
});
}); });

View File

@@ -1,44 +1,43 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import * as ReactShare from 'react-share';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown, Icon, IconButton } from '@edx/paragon'; import { Dropdown, Icon, IconButton } from '@edx/paragon';
import { MoreVert } from '@edx/paragon/icons'; import { MoreVert } from '@edx/paragon/icons';
import { StrictDict } from '@edx/react-unit-test-utils';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import EmailSettingsModal from 'containers/EmailSettingsModal'; import EmailSettingsModal from 'containers/EmailSettingsModal';
import CertificatePreviewModal from 'containers/CertificatePreviewModal';
import UnenrollConfirmModal from 'containers/UnenrollConfirmModal'; import UnenrollConfirmModal from 'containers/UnenrollConfirmModal';
import { reduxHooks } from 'hooks';
import SocialShareMenu from './SocialShareMenu';
import { import {
useEmailSettings, useEmailSettings,
useUnenrollData, useUnenrollData,
useHandleToggleDropdown, useHandleToggleDropdown,
useOptionVisibility,
} from './hooks'; } from './hooks';
import messages from './messages'; import messages from './messages';
export const testIds = StrictDict({
unenrollModalToggle: 'unenrollModalToggle',
});
export const CourseCardMenu = ({ cardId }) => { export const CourseCardMenu = ({ cardId }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { courseName } = reduxHooks.useCardCourseData(cardId); const emailSettings = useEmailSettings();
const { isEnrolled, isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
const { twitter, facebook } = reduxHooks.useCardSocialSettingsData(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const handleTwitterShare = reduxHooks.useTrackCourseEvent(
track.socialShare,
cardId,
'twitter',
);
const handleFacebookShare = reduxHooks.useTrackCourseEvent(
track.socialShare,
cardId,
'facebook',
);
const emailSettingsModal = useEmailSettings();
const unenrollModal = useUnenrollData(); const unenrollModal = useUnenrollData();
const handleToggleDropdown = useHandleToggleDropdown(cardId); const handleToggleDropdown = useHandleToggleDropdown(cardId);
const { shouldShowUnenrollItem, shouldShowDropdown } = useOptionVisibility(cardId);
const { isMasquerading } = reduxHooks.useMasqueradeData();
const { isEmailEnabled } = reduxHooks.useCardEnrollmentData(cardId);
const showCertificatePreviewModal = reduxHooks.useShowCertificatePreviewModal(cardId);
if (!shouldShowDropdown) {
return null;
}
return ( return (
<> <>
@@ -52,52 +51,16 @@ export const CourseCardMenu = ({ cardId }) => {
alt={formatMessage(messages.dropdownAlt)} alt={formatMessage(messages.dropdownAlt)}
/> />
<Dropdown.Menu> <Dropdown.Menu>
{isEnrolled && ( {shouldShowUnenrollItem && (
<Dropdown.Item <Dropdown.Item
disabled={isMasquerading} disabled={isMasquerading}
onClick={unenrollModal.show} onClick={unenrollModal.show}
data-testid="unenrollModalToggle" data-testid={testIds.unenrollModalToggle}
> >
{formatMessage(messages.unenroll)} {formatMessage(messages.unenroll)}
</Dropdown.Item> </Dropdown.Item>
)} )}
{isEmailEnabled && ( <SocialShareMenu cardId={cardId} emailSettings={emailSettings} />
<Dropdown.Item
disabled={isMasquerading}
onClick={emailSettingsModal.show}
data-testid="emailSettingsModalToggle"
>
{formatMessage(messages.emailSettings)}
</Dropdown.Item>
)}
{facebook.isEnabled && (
<ReactShare.FacebookShareButton
url={facebook.shareUrl}
onClick={handleFacebookShare}
title={formatMessage(messages.shareQuote, {
courseName,
socialBrand: facebook.socialBrand,
})}
resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item"
>
{formatMessage(messages.shareToFacebook)}
</ReactShare.FacebookShareButton>
)}
{twitter.isEnabled && (
<ReactShare.TwitterShareButton
url={twitter.shareUrl}
onClick={handleTwitterShare}
title={formatMessage(messages.shareQuote, {
courseName,
socialBrand: twitter.socialBrand,
})}
resetButtonStyle={false}
className="pgn__dropdown-item dropdown-item"
>
{formatMessage(messages.shareToTwitter)}
</ReactShare.TwitterShareButton>
)}
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
<UnenrollConfirmModal <UnenrollConfirmModal
@@ -107,8 +70,13 @@ export const CourseCardMenu = ({ cardId }) => {
/> />
{isEmailEnabled && ( {isEmailEnabled && (
<EmailSettingsModal <EmailSettingsModal
show={emailSettingsModal.isVisible} show={emailSettings.isVisible}
closeModal={emailSettingsModal.hide} closeModal={emailSettings.hide}
cardId={cardId}
/>
)}
{showCertificatePreviewModal && (
<CertificatePreviewModal
cardId={cardId} cardId={cardId}
/> />
)} )}

View File

@@ -1,141 +1,213 @@
import { shallow } from 'enzyme'; import { when } from 'jest-when';
import { Dropdown } from '@edx/paragon';
import { shallow } from '@edx/react-unit-test-utils';
import { useIntl } from '@edx/frontend-platform/i18n';
import EmailSettingsModal from 'containers/EmailSettingsModal';
import UnenrollConfirmModal from 'containers/UnenrollConfirmModal';
import { reduxHooks } from 'hooks'; import { reduxHooks } from 'hooks';
import { useEmailSettings, useUnenrollData } from './hooks'; import SocialShareMenu from './SocialShareMenu';
import CourseCardMenu from '.'; import * as hooks from './hooks';
import CourseCardMenu, { testIds } from '.';
jest.mock('react-share', () => ({ jest.mock('@edx/frontend-platform/i18n', () => ({
FacebookShareButton: () => 'FacebookShareButton', useIntl: jest.fn().mockReturnValue({
TwitterShareButton: () => 'TwitterShareButton', formatMessage: jest.requireActual('@edx/react-unit-test-utils').formatMessage,
}),
})); }));
jest.mock('hooks', () => ({ jest.mock('hooks', () => ({
reduxHooks: { reduxHooks: { useMasqueradeData: jest.fn(), useCardEnrollmentData: jest.fn() },
useCardCourseData: jest.fn(),
useCardEnrollmentData: jest.fn(),
useCardSocialSettingsData: jest.fn(),
useMasqueradeData: jest.fn(),
useTrackCourseEvent: (_, __, site) => jest.fn().mockName(`${site}ShareClick`),
},
})); }));
jest.mock('./SocialShareMenu', () => 'SocialShareMenu');
jest.mock('./hooks', () => ({ jest.mock('./hooks', () => ({
useEmailSettings: jest.fn(), useEmailSettings: jest.fn(),
useUnenrollData: jest.fn(), useUnenrollData: jest.fn(),
useHandleToggleDropdown: jest.fn(), useHandleToggleDropdown: jest.fn(),
useOptionVisibility: jest.fn(),
})); }));
const props = { const props = {
cardId: 'test-card-id', cardId: 'test-card-id',
}; };
const defaultEmailSettingsModal = {
const emailSettings = {
isVisible: false, isVisible: false,
show: jest.fn().mockName('emailSettingShow'), show: jest.fn().mockName('emailSettingShow'),
hide: jest.fn().mockName('emailSettingHide'), hide: jest.fn().mockName('emailSettingHide'),
}; };
const defaultUnenrollModal = {
const unenrollData = {
isVisible: false, isVisible: false,
show: jest.fn().mockName('unenrollShow'), show: jest.fn().mockName('unenrollShow'),
hide: jest.fn().mockName('unenrollHide'), hide: jest.fn().mockName('unenrollHide'),
}; };
const defaultSocialShare = {
facebook: {
isEnabled: true,
shareUrl: 'facebook-share-url',
socialBrand: 'facebook-social-brand',
},
twitter: {
isEnabled: true,
shareUrl: 'twitter-share-url',
socialBrand: 'twitter-social-brand',
},
};
const courseName = 'test-course-name';
let wrapper;
let el; let el;
const mockHook = (fn, returnValue, options = {}) => {
if (options.isCardHook) {
when(fn).calledWith(props.cardId).mockReturnValueOnce(returnValue);
} else {
when(fn).calledWith().mockReturnValueOnce(returnValue);
}
};
const handleToggleDropdown = jest.fn().mockName('hooks.handleToggleDropdown');
const mockHooks = (returnVals = {}) => {
mockHook(
hooks.useEmailSettings,
returnVals.emailSettings ? returnVals.emailSettings : emailSettings,
);
mockHook(
hooks.useUnenrollData,
returnVals.unenrollData ? returnVals.unenrollData : unenrollData,
);
mockHook(hooks.useHandleToggleDropdown, handleToggleDropdown, { isCardHook: true });
mockHook(
hooks.useOptionVisibility,
{
shouldShowUnenrollItem: !!returnVals.shouldShowUnenrollItem,
shouldShowDropdown: !!returnVals.shouldShowDropdown,
},
{ isCardHook: true },
);
mockHook(reduxHooks.useMasqueradeData, { isMasquerading: !!returnVals.isMasquerading });
mockHook(
reduxHooks.useCardEnrollmentData,
{ isEmailEnabled: !!returnVals.isEmailEnabled },
{ isCardHook: true },
);
};
const render = () => {
el = shallow(<CourseCardMenu {...props} />);
};
describe('CourseCardMenu', () => { describe('CourseCardMenu', () => {
beforeEach(() => { describe('behavior', () => {
useEmailSettings.mockReturnValue(defaultEmailSettingsModal);
useUnenrollData.mockReturnValue(defaultUnenrollModal);
reduxHooks.useCardSocialSettingsData.mockReturnValue(defaultSocialShare);
reduxHooks.useCardCourseData.mockReturnValue({ courseName });
reduxHooks.useCardEnrollmentData.mockReturnValue({ isEnrolled: true, isEmailEnabled: true });
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: false });
});
describe('enrolled, share enabled, email setting enable', () => {
beforeEach(() => { beforeEach(() => {
wrapper = shallow(<CourseCardMenu {...props} />); mockHooks();
render();
}); });
test('snapshot', () => { it('initializes intl hook', () => {
expect(wrapper).toMatchSnapshot(); expect(useIntl).toHaveBeenCalledWith();
}); });
it('renders share buttons', () => { it('initializes local hooks', () => {
el = wrapper.find('FacebookShareButton'); when(hooks.useEmailSettings).expectCalledWith();
expect(el.length).toEqual(1); when(hooks.useUnenrollData).expectCalledWith();
expect(el.prop('url')).toEqual('facebook-share-url'); when(hooks.useHandleToggleDropdown).expectCalledWith(props.cardId);
el = wrapper.find('TwitterShareButton'); when(hooks.useOptionVisibility).expectCalledWith(props.cardId);
expect(el.length).toEqual(1);
expect(el.prop('url')).toEqual('twitter-share-url');
}); });
it('renders enabled unenroll modal toggle', () => { it('initializes redux hook data ', () => {
el = wrapper.find({ 'data-testid': 'unenrollModalToggle' }); when(reduxHooks.useMasqueradeData).expectCalledWith();
expect(el.props().disabled).toEqual(false); when(reduxHooks.useCardEnrollmentData).expectCalledWith(props.cardId);
});
it('renders enabled email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.props().disabled).toEqual(false);
});
it('renders enabled email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.props().disabled).toEqual(false);
}); });
}); });
describe('not enrolled, share disabled, email setting disabled', () => { describe('render', () => {
beforeEach(() => { it('renders null if showDropdown is false', () => {
reduxHooks.useCardSocialSettingsData.mockReturnValueOnce({ mockHooks();
...defaultSocialShare, render();
twitter: { ...defaultSocialShare.twitter, isEnabled: false }, expect(el.isEmptyRender()).toEqual(true);
facebook: { ...defaultSocialShare.facebook, isEnabled: false }, });
const testHandleToggle = () => {
it('displays Dropdown with onToggle=handleToggleDropdown', () => {
expect(el.instance.findByType(Dropdown)[0].props.onToggle).toEqual(handleToggleDropdown);
});
};
const testUnenrollConfirmModal = () => {
it('displays UnenrollConfirmModal with cardId and unenrollModal data', () => {
const modal = el.instance.findByType(UnenrollConfirmModal)[0];
expect(modal.props.show).toEqual(unenrollData.isVisible);
expect(modal.props.closeModal).toEqual(unenrollData.hide);
expect(modal.props.cardId).toEqual(props.cardId);
});
};
const testSocialShareMenu = () => {
it('displays SocialShareMenu with cardID and emailSettings', () => {
const menu = el.instance.findByType(SocialShareMenu)[0];
expect(menu.props.cardId).toEqual(props.cardId);
expect(menu.props.emailSettings).toEqual(emailSettings);
});
};
describe('show dropdown', () => {
describe('hide unenroll item and disable email', () => {
beforeEach(() => {
mockHooks({ shouldShowDropdown: true });
render();
});
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
testHandleToggle();
testSocialShareMenu();
it('does not render unenroll modal toggle', () => {
expect(el.instance.findByTestId(testIds.unenrollModalToggle).length).toEqual(0);
});
it('does not render EmailSettingsModal', () => {
expect(el.instance.findByType(EmailSettingsModal).length).toEqual(0);
});
testUnenrollConfirmModal();
});
describe('show unenroll and enable email', () => {
const hookProps = {
shouldShowDropdown: true,
isEmailEnabled: true,
shouldShowUnenrollItem: true,
};
beforeEach(() => {
mockHooks(hookProps);
render();
});
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
testHandleToggle();
testSocialShareMenu();
describe('unenroll modal toggle', () => {
let toggle;
describe('not masquerading', () => {
beforeEach(() => {
mockHooks(hookProps);
render();
[toggle] = el.instance.findByTestId(testIds.unenrollModalToggle);
});
it('renders unenroll modal toggle', () => {
expect(el.instance.findByTestId(testIds.unenrollModalToggle).length).toEqual(1);
});
test('onClick from unenroll modal hook', () => {
expect(toggle.props.onClick).toEqual(unenrollData.show);
});
test('disabled', () => {
expect(toggle.props.disabled).toEqual(false);
});
});
describe('masquerading', () => {
beforeEach(() => {
mockHooks({ ...hookProps, isMasquerading: true });
render();
[toggle] = el.instance.findByTestId(testIds.unenrollModalToggle);
});
it('renders', () => {
expect(el.instance.findByTestId(testIds.unenrollModalToggle).length).toEqual(1);
});
test('onClick from unenroll modal hook', () => {
expect(toggle.props.onClick).toEqual(unenrollData.show);
});
test('disabled', () => {
expect(toggle.props.disabled).toEqual(true);
});
});
});
testUnenrollConfirmModal();
it('displays EmaiSettingsModal with cardId and emailSettingsModal data', () => {
const modal = el.instance.findByType(EmailSettingsModal)[0];
expect(modal.props.show).toEqual(emailSettings.isVisible);
expect(modal.props.closeModal).toEqual(emailSettings.hide);
expect(modal.props.cardId).toEqual(props.cardId);
});
}); });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({ isEnrolled: false, isEmailEnabled: false });
wrapper = shallow(<CourseCardMenu {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('does not renders share buttons', () => {
expect(wrapper.find('FacebookShareButton').length).toEqual(0);
expect(wrapper.find('TwitterShareButton').length).toEqual(0);
});
it('does not render unenroll modal toggle', () => {
el = wrapper.find({ 'data-testid': 'unenrollModalToggle' });
expect(el.length).toEqual(0);
});
it('does not render email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.length).toEqual(0);
});
});
describe('masquerading', () => {
beforeEach(() => {
reduxHooks.useMasqueradeData.mockReturnValue({ isMasquerading: true });
wrapper = shallow(<CourseCardMenu {...props} />);
});
test('snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
it('renders share buttons', () => {
expect(wrapper.find('FacebookShareButton').length).toEqual(1);
el = wrapper.find('TwitterShareButton');
expect(el.length).toEqual(1);
expect(el.prop('url')).toEqual('twitter-share-url');
});
it('renders disabled unenroll modal toggle', () => {
el = wrapper.find({ 'data-testid': 'unenrollModalToggle' });
expect(el.props().disabled).toEqual(true);
});
it('renders disabled email settings modal toggle', () => {
el = wrapper.find({ 'data-testid': 'emailSettingsModalToggle' });
expect(el.props().disabled).toEqual(true);
}); });
}); });
}); });

View File

@@ -63,143 +63,124 @@ describe('useActionDisabledState', () => {
}); });
}; };
const runHook = () => hooks.useActionDisabledState(cardId);
describe('disableBeginCourse', () => { describe('disableBeginCourse', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableBeginCourse).toBe(expected);
};
it('disable when homeUrl is invalid', () => { it('disable when homeUrl is invalid', () => {
mockHooksData({ homeUrl: null }); testDisabled({ homeUrl: null }, true);
const { disableBeginCourse } = hooks.useActionDisabledState(cardId);
expect(disableBeginCourse).toBe(true);
}); });
it('disable when isMasquerading is true', () => { it('disable when isMasquerading is true', () => {
mockHooksData({ isMasquerading: true }); testDisabled({ isMasquerading: true }, true);
const { disableBeginCourse } = hooks.useActionDisabledState(cardId);
expect(disableBeginCourse).toBe(true);
}); });
it('disable when hasAccess is false', () => { it('disable when hasAccess is false', () => {
mockHooksData({ hasAccess: false }); testDisabled({ hasAccess: false }, true);
const { disableBeginCourse } = hooks.useActionDisabledState(cardId);
expect(disableBeginCourse).toBe(true);
}); });
it('disable when isAudit is true and isAuditAccessExpired is true', () => { it('disable when isAudit is true and isAuditAccessExpired is true', () => {
mockHooksData({ isAudit: true, isAuditAccessExpired: true }); testDisabled({ isAudit: true, isAuditAccessExpired: true }, true);
const { disableBeginCourse } = hooks.useActionDisabledState(cardId);
expect(disableBeginCourse).toBe(true);
}); });
it('enable when all conditions are met', () => { it('enable when all conditions are met', () => {
mockHooksData({ hasAccess: true }); testDisabled({ hasAccess: true }, false);
const { disableBeginCourse } = hooks.useActionDisabledState(cardId);
expect(disableBeginCourse).toBe(false);
}); });
}); });
describe('disableResumeCourse', () => { describe('disableResumeCourse', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableResumeCourse).toBe(expected);
};
it('disable when resumeUrl is invalid', () => { it('disable when resumeUrl is invalid', () => {
mockHooksData({ resumeUrl: null }); testDisabled({ resumeUrl: null }, true);
const { disableResumeCourse } = hooks.useActionDisabledState(cardId);
expect(disableResumeCourse).toBe(true);
}); });
it('disable when isMasquerading is true', () => { it('disable when isMasquerading is true', () => {
mockHooksData({ isMasquerading: true }); testDisabled({ isMasquerading: true }, true);
const { disableResumeCourse } = hooks.useActionDisabledState(cardId);
expect(disableResumeCourse).toBe(true);
}); });
it('disable when hasAccess is false', () => { it('disable when hasAccess is false', () => {
mockHooksData({ hasAccess: false }); testDisabled({ hasAccess: false }, true);
const { disableResumeCourse } = hooks.useActionDisabledState(cardId);
expect(disableResumeCourse).toBe(true);
}); });
it('disable when isAudit is true and isAuditAccessExpired is true', () => { it('disable when isAudit is true and isAuditAccessExpired is true', () => {
mockHooksData({ isAudit: true, isAuditAccessExpired: true }); testDisabled({ isAudit: true, isAuditAccessExpired: true }, true);
const { disableResumeCourse } = hooks.useActionDisabledState(cardId);
expect(disableResumeCourse).toBe(true);
}); });
it('enable when all conditions are met', () => { it('enable when all conditions are met', () => {
mockHooksData({ hasAccess: true }); testDisabled({ hasAccess: true }, false);
const { disableResumeCourse } = hooks.useActionDisabledState(cardId);
expect(disableResumeCourse).toBe(false);
}); });
}); });
describe('disableViewCourse', () => { describe('disableViewCourse', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableViewCourse).toBe(expected);
};
it('disable when hasAccess is false', () => { it('disable when hasAccess is false', () => {
mockHooksData({ hasAccess: false }); testDisabled({ hasAccess: false }, true);
const { disableViewCourse } = hooks.useActionDisabledState(cardId);
expect(disableViewCourse).toBe(true);
}); });
it('disable when isAudit is true and isAuditAccessExpired is true', () => { it('disable when isAudit is true and isAuditAccessExpired is true', () => {
mockHooksData({ isAudit: true, isAuditAccessExpired: true }); testDisabled({ isAudit: true, isAuditAccessExpired: true }, true);
const { disableViewCourse } = hooks.useActionDisabledState(cardId);
expect(disableViewCourse).toBe(true);
}); });
it('enable when all conditions are met', () => { it('enable when all conditions are met', () => {
mockHooksData({ hasAccess: true }); testDisabled({ hasAccess: true }, false);
const { disableViewCourse } = hooks.useActionDisabledState(cardId);
expect(disableViewCourse).toBe(false);
}); });
}); });
describe('disableUpgradeCourse', () => { describe('disableUpgradeCourse', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableUpgradeCourse).toBe(expected);
};
it('disable when upgradeUrl is invalid', () => { it('disable when upgradeUrl is invalid', () => {
mockHooksData({ upgradeUrl: null }); testDisabled({ upgradeUrl: null }, true);
const { disableUpgradeCourse } = hooks.useActionDisabledState(cardId);
expect(disableUpgradeCourse).toBe(true);
}); });
it('disable when isMasquerading is true and canUpgrade is false', () => { it('disable when isMasquerading is true and canUpgrade is false', () => {
mockHooksData({ isMasquerading: true, canUpgrade: false }); testDisabled({ isMasquerading: true, canUpgrade: false }, true);
const { disableUpgradeCourse } = hooks.useActionDisabledState(cardId);
expect(disableUpgradeCourse).toBe(true);
}); });
it('enable when all conditions are met', () => { it('enable when all conditions are met', () => {
mockHooksData({ canUpgrade: true }); testDisabled({ canUpgrade: true }, false);
const { disableUpgradeCourse } = hooks.useActionDisabledState(cardId);
expect(disableUpgradeCourse).toBe(false);
}); });
}); });
describe('disableSelectSession', () => { describe('disableSelectSession', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableSelectSession).toBe(expected);
};
it('disable when isEntitlement is false', () => { it('disable when isEntitlement is false', () => {
mockHooksData({ isEntitlement: false }); testDisabled({ isEntitlement: false }, true);
const { disableSelectSession } = hooks.useActionDisabledState(cardId);
expect(disableSelectSession).toBe(true);
}); });
it('disable when isMasquerading is true', () => { it('disable when isMasquerading is true', () => {
mockHooksData({ isMasquerading: true }); testDisabled({ isMasquerading: true }, true);
const { disableSelectSession } = hooks.useActionDisabledState(cardId);
expect(disableSelectSession).toBe(true);
}); });
it('disable when hasAccess is false', () => { it('disable when hasAccess is false', () => {
mockHooksData({ hasAccess: false }); testDisabled({ hasAccess: false }, true);
const { disableSelectSession } = hooks.useActionDisabledState(cardId);
expect(disableSelectSession).toBe(true);
}); });
it('disable when canChange is false', () => { it('disable when canChange is false', () => {
mockHooksData({ canChange: false }); testDisabled({ canChange: false }, true);
const { disableSelectSession } = hooks.useActionDisabledState(cardId);
expect(disableSelectSession).toBe(true);
}); });
it('disable when hasSessions is false', () => { it('disable when hasSessions is false', () => {
mockHooksData({ hasSessions: false }); testDisabled({ hasSessions: false }, true);
const { disableSelectSession } = hooks.useActionDisabledState(cardId);
expect(disableSelectSession).toBe(true);
}); });
it('enable when all conditions are met', () => { it('enable when all conditions are met', () => {
mockHooksData({ testDisabled(
isEntitlement: true, hasAccess: true, canChange: true, hasSessions: true, {
}); isEntitlement: true,
const { disableSelectSession } = hooks.useActionDisabledState(cardId); hasAccess: true,
expect(disableSelectSession).toBe(false); canChange: true,
hasSessions: true,
},
false,
);
}); });
}); });
describe('disableCourseTitle', () => { describe('disableCourseTitle', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableCourseTitle).toBe(expected);
};
it('disable when isEntitlement is true and isFulfilled is false', () => { it('disable when isEntitlement is true and isFulfilled is false', () => {
mockHooksData({ isEntitlement: true, isFulfilled: false }); testDisabled({ isEntitlement: true, isFulfilled: false }, true);
const { disableCourseTitle } = hooks.useActionDisabledState(cardId);
expect(disableCourseTitle).toBe(true);
}); });
it('disable when disableViewCourse is true', () => { it('disable when disableViewCourse is true', () => {
mockHooksData({ hasAccess: false }); testDisabled({ hasAccess: false }, true);
const { disableCourseTitle } = hooks.useActionDisabledState(cardId);
expect(disableCourseTitle).toBe(true);
}); });
it('enable when all conditions are met', () => { it('enable when all conditions are met', () => {
mockHooksData({ isEntitlement: true, isFulfilled: true, hasAccess: true }); testDisabled({ isEntitlement: true, isFulfilled: true, hasAccess: true }, false);
const { disableCourseTitle } = hooks.useActionDisabledState(cardId);
expect(disableCourseTitle).toBe(false);
}); });
}); });
}); });

View File

@@ -3,12 +3,19 @@ import PropTypes from 'prop-types';
import { Container, Col, Row } from '@edx/paragon'; import { Container, Col, Row } from '@edx/paragon';
import WidgetFooter from 'containers/WidgetContainers/WidgetFooter';
import hooks from './hooks'; import hooks from './hooks';
export const columnConfig = { export const columnConfig = {
courseList: { courseList: {
lg: { span: 12, offset: 0 }, withSidebar: {
xl: { span: 8, offset: 0 }, lg: { span: 12, offset: 0 },
xl: { span: 8, offset: 0 },
},
noSidebar: {
lg: { span: 12, offset: 0 },
xl: { span: 12, offset: 0 },
},
}, },
sidebar: { sidebar: {
lg: { span: 12, offset: 0 }, lg: { span: 12, offset: 0 },
@@ -16,18 +23,31 @@ export const columnConfig = {
}, },
}; };
export const DashboardLayout = ({ children, sidebar }) => { export const DashboardLayout = ({ children, sidebar: Sidebar }) => {
const isCollapsed = hooks.useIsDashboardCollapsed(); const {
isCollapsed,
sidebarShowing,
setSidebarShowing,
} = hooks.useDashboardLayoutData();
const courseListColumnProps = sidebarShowing
? columnConfig.courseList.withSidebar
: columnConfig.courseList.noSidebar;
return ( return (
<Container fluid size="xl"> <Container fluid size="xl">
<Row> <Row>
<Col {...columnConfig.courseList} className="course-list-column"> <Col {...courseListColumnProps} className="course-list-column">
{children} {children}
</Col> </Col>
<Col {...columnConfig.sidebar} className="sidebar-column"> <Col {...columnConfig.sidebar} className="sidebar-column">
{!isCollapsed && (<h2 className="course-list-title">&nbsp;</h2>)} {!isCollapsed && (<h2 className="course-list-title">&nbsp;</h2>)}
{sidebar} <Sidebar setSidebarShowing={setSidebarShowing} />
</Col>
</Row>
<Row>
<Col>
<WidgetFooter />
</Col> </Col>
</Row> </Row>
</Container> </Container>
@@ -35,7 +55,7 @@ export const DashboardLayout = ({ children, sidebar }) => {
}; };
DashboardLayout.propTypes = { DashboardLayout.propTypes = {
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
sidebar: PropTypes.node.isRequired, sidebar: PropTypes.func.isRequired,
}; };
export default DashboardLayout; export default DashboardLayout;

View File

@@ -1,60 +1,125 @@
import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Col, Row } from '@edx/paragon'; import { Col, Row } from '@edx/paragon';
import WidgetFooter from 'containers/WidgetContainers/WidgetFooter';
import hooks from './hooks'; import hooks from './hooks';
import DashboardLayout, { columnConfig } from './DashboardLayout'; import DashboardLayout, { columnConfig } from './DashboardLayout';
jest.mock('./hooks', () => ({ jest.mock('./hooks', () => ({
useIsDashboardCollapsed: jest.fn(() => true), useDashboardLayoutData: jest.fn(),
})); }));
const hookProps = {
isCollapsed: true,
sidebarShowing: false,
setSidebarShowing: jest.fn().mockName('hooks.setSidebarShowing'),
};
hooks.useDashboardLayoutData.mockReturnValue(hookProps);
const props = {
sidebar: jest.fn(() => 'test-sidebar-content'),
};
const children = 'test-children';
let el;
describe('DashboardLayout', () => { describe('DashboardLayout', () => {
const children = 'test-children'; beforeEach(() => {
const props = { jest.clearAllMocks();
sidebar: 'test-sidebar-content', el = shallow(<DashboardLayout {...props}>{children}</DashboardLayout>);
}; });
const render = () => shallow(<DashboardLayout sidebar={props.sidebar}>{children}</DashboardLayout>);
const testColumns = () => { const testColumns = () => {
it('loads courseList and sidebar column layout', () => { it('loads courseList and sidebar column layout', () => {
const columns = render().find(Row).find(Col); const columns = el.find(Row).find(Col);
Object.keys(columnConfig.courseList).forEach(size => {
expect(columns.at(0).props()[size]).toEqual(columnConfig.courseList[size]);
});
Object.keys(columnConfig.sidebar).forEach(size => { Object.keys(columnConfig.sidebar).forEach(size => {
expect(columns.at(1).props()[size]).toEqual(columnConfig.sidebar[size]); expect(columns.at(1).props()[size]).toEqual(columnConfig.sidebar[size]);
}); });
}); });
it('displays children in first column', () => { it('displays children in first column', () => {
const columns = render().find(Row).find(Col); const columns = el.find(Row).find(Col);
expect(columns.at(0).contains(children)).toEqual(true); expect(columns.at(0).contains(children)).toEqual(true);
}); });
it('displays sidebar prop in second column', () => { it('displays sidebar prop in second column', () => {
const columns = render().find(Row).find(Col); const columns = el.find(Row).find(Col);
expect(columns.at(1).contains(props.sidebar)).toEqual(true); expect(columns.at(1).find(props.sidebar)).toHaveLength(1);
});
it('displays a footer in the second row', () => {
const columns = el.find(Row).at(1).find(Col);
expect(columns.at(0).containsMatchingElement(<WidgetFooter />)).toBeTruthy();
});
};
const testSidebarLayout = () => {
it('displays widthSidebar width for course list column', () => {
const columns = el.find(Row).find(Col);
Object.keys(columnConfig.courseList.withSidebar).forEach(size => {
expect(columns.at(0).props()[size]).toEqual(columnConfig.courseList.withSidebar[size]);
});
});
};
const testNoSidebarLayout = () => {
it('displays noSidebar width for course list column', () => {
const columns = el.find(Row).find(Col);
Object.keys(columnConfig.courseList.noSidebar).forEach(size => {
expect(columns.at(0).props()[size]).toEqual(columnConfig.courseList.noSidebar[size]);
});
}); });
}; };
const testSnapshot = () => { const testSnapshot = () => {
test('snapshot', () => { test('snapshot', () => {
expect(render()).toMatchSnapshot(); expect(el).toMatchSnapshot();
}); });
}; };
describe('collapsed', () => { describe('collapsed', () => {
testColumns(); describe('sidebar showing', () => {
testSnapshot(); beforeEach(() => {
hooks.useDashboardLayoutData.mockReturnValueOnce({ ...hookProps, sidebarShowing: true });
});
testColumns();
testSnapshot();
testSidebarLayout();
});
describe('sidebar not showing', () => {
testColumns();
testSnapshot();
testNoSidebarLayout();
});
it('does not show spacer component above widget sidebar', () => { it('does not show spacer component above widget sidebar', () => {
const columns = render().find(Col); const columns = el.find(Col);
expect(columns.at(1).find('h2').length).toEqual(0); expect(columns.at(1).find('h2').length).toEqual(0);
}); });
}); });
describe('not collapsed', () => { describe('not collapsed', () => {
beforeEach(() => { hooks.useIsDashboardCollapsed.mockReturnValueOnce(false); }); const testWidgetSpacing = () => {
testColumns(); it('shows a blank (nbsp) h2 spacer component above widget sidebar', () => {
testSnapshot(); const columns = el.find(Col);
it('shows a blank (nbsp) h2 spacer component above widget sidebar', () => { // nonbreaking space equivalent
const columns = render().find(Col); expect(columns.at(1).find('h2').text()).toEqual('\xA0');
// nonbreaking space equivalent });
expect(columns.at(1).find('h2').text()).toEqual('\xA0'); };
describe('sidebar showing', () => {
beforeEach(() => {
hooks.useDashboardLayoutData.mockReturnValueOnce({
...hookProps,
isCollapsed: false,
sidebarShowing: true,
});
});
testColumns();
testSnapshot();
testSidebarLayout();
testWidgetSpacing();
});
describe('sidebar not showing', () => {
beforeEach(() => {
hooks.useDashboardLayoutData.mockReturnValueOnce({ ...hookProps, isCollapsed: false });
});
testColumns();
testSnapshot();
testNoSidebarLayout();
testWidgetSpacing();
}); });
}); });
}); });

View File

@@ -1,6 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DashboardLayout collapsed snapshot 1`] = ` exports[`DashboardLayout collapsed sidebar not showing snapshot 1`] = `
<Container
fluid={true}
size="xl"
>
<Row>
<Col
className="course-list-column"
lg={
Object {
"offset": 0,
"span": 12,
}
}
xl={
Object {
"offset": 0,
"span": 12,
}
}
>
test-children
</Col>
<Col
className="sidebar-column"
lg={
Object {
"offset": 0,
"span": 12,
}
}
xl={
Object {
"offset": 0,
"span": 4,
}
}
>
<mockConstructor
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
/>
</Col>
</Row>
<Row>
<Col>
<WidgetFooter />
</Col>
</Row>
</Container>
`;
exports[`DashboardLayout collapsed sidebar showing snapshot 1`] = `
<Container <Container
fluid={true} fluid={true}
size="xl" size="xl"
@@ -38,13 +89,76 @@ exports[`DashboardLayout collapsed snapshot 1`] = `
} }
} }
> >
test-sidebar-content <mockConstructor
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
/>
</Col>
</Row>
<Row>
<Col>
<WidgetFooter />
</Col> </Col>
</Row> </Row>
</Container> </Container>
`; `;
exports[`DashboardLayout not collapsed snapshot 1`] = ` exports[`DashboardLayout not collapsed sidebar not showing snapshot 1`] = `
<Container
fluid={true}
size="xl"
>
<Row>
<Col
className="course-list-column"
lg={
Object {
"offset": 0,
"span": 12,
}
}
xl={
Object {
"offset": 0,
"span": 12,
}
}
>
test-children
</Col>
<Col
className="sidebar-column"
lg={
Object {
"offset": 0,
"span": 12,
}
}
xl={
Object {
"offset": 0,
"span": 4,
}
}
>
<h2
className="course-list-title"
>
 
</h2>
<mockConstructor
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
/>
</Col>
</Row>
<Row>
<Col>
<WidgetFooter />
</Col>
</Row>
</Container>
`;
exports[`DashboardLayout not collapsed sidebar showing snapshot 1`] = `
<Container <Container
fluid={true} fluid={true}
size="xl" size="xl"
@@ -87,7 +201,14 @@ exports[`DashboardLayout not collapsed snapshot 1`] = `
> >
   
</h2> </h2>
test-sidebar-content <mockConstructor
setSidebarShowing={[MockFunction hooks.setSidebarShowing]}
/>
</Col>
</Row>
<Row>
<Col>
<WidgetFooter />
</Col> </Col>
</Row> </Row>
</Container> </Container>

View File

@@ -15,7 +15,7 @@ exports[`Dashboard snapshots courses loaded, show select session modal, no avail
id="dashboard-content" id="dashboard-content"
> >
<DashboardLayout <DashboardLayout
sidebar={<LoadedWidgetSidebar />} sidebar="LoadedWidgetSidebar"
> >
<CourseList /> <CourseList />
</DashboardLayout> </DashboardLayout>
@@ -56,7 +56,7 @@ exports[`Dashboard snapshots there are no courses, there ARE available dashboard
id="dashboard-content" id="dashboard-content"
> >
<DashboardLayout <DashboardLayout
sidebar={<NoCoursesWidgetSidebar />} sidebar="NoCoursesWidgetSidebar"
> >
<CourseList /> <CourseList />
</DashboardLayout> </DashboardLayout>

View File

@@ -2,13 +2,14 @@ import React from 'react';
import { useWindowSize, breakpoints } from '@edx/paragon'; import { useWindowSize, breakpoints } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { apiHooks } from 'hooks'; import { apiHooks } from 'hooks';
import { StrictDict } from 'utils';
import appMessages from 'messages'; import appMessages from 'messages';
import * as module from './hooks';
export const useIsDashboardCollapsed = () => { export const state = StrictDict({
const { width } = useWindowSize(); sidebarShowing: (val) => React.useState(val), // eslint-disable-line
return width < breakpoints.large.maxWidth; });
};
export const useInitializeDashboard = () => { export const useInitializeDashboard = () => {
const initialize = apiHooks.useInitializeApp(); const initialize = apiHooks.useInitializeApp();
@@ -23,8 +24,18 @@ export const useDashboardMessages = () => {
}; };
}; };
export const useDashboardLayoutData = () => {
const { width } = useWindowSize();
const [sidebarShowing, setSidebarShowing] = module.state.sidebarShowing(false);
return {
isDashboardCollapsed: width < breakpoints.large.maxWidth,
sidebarShowing,
setSidebarShowing,
};
};
export default { export default {
useIsDashboardCollapsed, useDashboardLayoutData,
useInitializeDashboard, useInitializeDashboard,
useDashboardMessages, useDashboardMessages,
}; };

View File

@@ -4,6 +4,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { useWindowSize, breakpoints } from '@edx/paragon'; import { useWindowSize, breakpoints } from '@edx/paragon';
import { apiHooks } from 'hooks'; import { apiHooks } from 'hooks';
import { MockUseState } from 'testUtils';
import appMessages from 'messages'; import appMessages from 'messages';
import * as hooks from './hooks'; import * as hooks from './hooks';
@@ -19,8 +20,12 @@ jest.mock('hooks', () => ({
}, },
})); }));
const state = new MockUseState(hooks);
const initializeApp = jest.fn(); const initializeApp = jest.fn();
apiHooks.useInitializeApp.mockReturnValue(initializeApp); apiHooks.useInitializeApp.mockReturnValue(initializeApp);
useWindowSize.mockReturnValue({ width: 20 });
breakpoints.large = { maxWidth: 30 };
describe('CourseCard hooks', () => { describe('CourseCard hooks', () => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
@@ -28,15 +33,32 @@ describe('CourseCard hooks', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('useIsDashboardCollapsed', () => { describe('state fields', () => {
it('returns true iff windowSize width is below the xl breakpoint', () => { state.testGetter(state.keys.sidebarShowing);
useWindowSize.mockReturnValueOnce({ width: 20 }); });
breakpoints.large = { maxWidth: 30 };
expect(hooks.useIsDashboardCollapsed()).toEqual(true); describe('useDashboardLayoutData', () => {
useWindowSize.mockReturnValueOnce({ width: 40 }); beforeEach(() => { state.mock(); });
expect(hooks.useIsDashboardCollapsed()).toEqual(false); describe('behavior', () => {
useWindowSize.mockReturnValueOnce({ width: 40 }); it('initializes sidebarShowing to default false value', () => {
expect(hooks.useIsDashboardCollapsed()).toEqual(false); hooks.useDashboardLayoutData();
state.expectInitializedWith(state.keys.sidebarShowing, false);
});
});
describe('output', () => {
describe('isDashboardCollapsed', () => {
it('returns true iff windowSize width is below the xl breakpoint', () => {
expect(hooks.useDashboardLayoutData().isDashboardCollapsed).toEqual(true);
useWindowSize.mockReturnValueOnce({ width: 40 });
expect(hooks.useDashboardLayoutData().isDashboardCollapsed).toEqual(false);
});
});
it('forwards sidebarShowing and setSidebarShowing from state hook', () => {
const hook = hooks.useDashboardLayoutData();
const { sidebarShowing, setSidebarShowing } = hook;
expect(sidebarShowing).toEqual(state.stateVals.sidebarShowing);
expect(setSidebarShowing).toEqual(state.setState.sidebarShowing);
});
}); });
}); });
describe('useInitializeDashboard', () => { describe('useInitializeDashboard', () => {

View File

@@ -21,6 +21,7 @@ export const Dashboard = () => {
const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards(); const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards();
const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize); const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
const showSelectSessionModal = reduxHooks.useShowSelectSessionModal(); const showSelectSessionModal = reduxHooks.useShowSelectSessionModal();
return ( return (
<div id="dashboard-container" className="d-flex flex-column p-2 pt-0"> <div id="dashboard-container" className="d-flex flex-column p-2 pt-0">
<h1 className="sr-only">{pageTitle}</h1> <h1 className="sr-only">{pageTitle}</h1>
@@ -34,7 +35,7 @@ export const Dashboard = () => {
{initIsPending {initIsPending
? (<LoadingView />) ? (<LoadingView />)
: ( : (
<DashboardLayout sidebar={hasCourses ? <LoadedSidebar /> : <NoCoursesSidebar />}> <DashboardLayout sidebar={hasCourses ? LoadedSidebar : NoCoursesSidebar}>
<CourseList /> <CourseList />
</DashboardLayout> </DashboardLayout>
)} )}

View File

@@ -116,7 +116,7 @@ describe('Dashboard', () => {
showSelectSessionModal: true, showSelectSessionModal: true,
}, },
content: ['LoadedView', ( content: ['LoadedView', (
<DashboardLayout sidebar={<LoadedWidgetSidebar />}><CourseList /></DashboardLayout> <DashboardLayout sidebar={LoadedWidgetSidebar}><CourseList /></DashboardLayout>
)], )],
showEnterpriseModal: false, showEnterpriseModal: false,
showSelectSessionModal: true, showSelectSessionModal: true,
@@ -132,7 +132,7 @@ describe('Dashboard', () => {
showSelectSessionModal: false, showSelectSessionModal: false,
}, },
content: ['Dashboard layout with no courses sidebar and content', ( content: ['Dashboard layout with no courses sidebar and content', (
<DashboardLayout sidebar={<NoCoursesWidgetSidebar />}><CourseList /></DashboardLayout> <DashboardLayout sidebar={NoCoursesWidgetSidebar}><CourseList /></DashboardLayout>
)], )],
showEnterpriseModal: true, showEnterpriseModal: true,
showSelectSessionModal: false, showSelectSessionModal: false,

View File

@@ -3,6 +3,7 @@ import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks'; import { reduxHooks } from 'hooks';
import { configuration } from '../../config';
import messages from './messages'; import messages from './messages';
@@ -14,7 +15,7 @@ export const BrandLogo = () => {
<a href={dashboard?.url || '/'} className="mx-auto"> <a href={dashboard?.url || '/'} className="mx-auto">
<img <img
className="logo py-3" className="logo py-3"
src="https://edx-cdn.org/v3/prod/logo.svg" src={configuration.LOGO_URL}
alt={formatMessage(messages.logoAltText)} alt={formatMessage(messages.logoAltText)}
/> />
</a> </a>

View File

@@ -4,14 +4,14 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext } from '@edx/frontend-platform/react';
import { Button, Badge } from '@edx/paragon'; import { Button, Badge } from '@edx/paragon';
import WidgetNavbar from 'containers/WidgetContainers/WidgetNavbar';
import urls from 'data/services/lms/urls'; import urls from 'data/services/lms/urls';
import { reduxHooks } from 'hooks'; import { reduxHooks } from 'hooks';
import { COLLAPSED_NAVBAR } from 'widgets/RecommendationsPaintedDoorBtn/constants';
import { findCoursesNavDropdownClicked } from '../hooks'; import { findCoursesNavDropdownClicked } from '../hooks';
import messages from '../messages'; import messages from '../messages';
export const CollapseMenuBody = ({ isOpen }) => { export const CollapseMenuBody = ({ isOpen }) => {
@@ -40,6 +40,7 @@ export const CollapseMenuBody = ({ isOpen }) => {
> >
{formatMessage(messages.discoverNew)} {formatMessage(messages.discoverNew)}
</Button> </Button>
<WidgetNavbar placement={COLLAPSED_NAVBAR} />
<Button as="a" href={getConfig().SUPPORT_URL} variant="inverse-primary"> <Button as="a" href={getConfig().SUPPORT_URL} variant="inverse-primary">
{formatMessage(messages.help)} {formatMessage(messages.help)}
</Button> </Button>

View File

@@ -26,6 +26,9 @@ exports[`CollapseMenuBody render 1`] = `
> >
Discover New Discover New
</Button> </Button>
<WidgetNavbar
placement="collapsedNavbar"
/>
<Button <Button
as="a" as="a"
href="http://localhost:18000/support" href="http://localhost:18000/support"
@@ -92,6 +95,9 @@ exports[`CollapseMenuBody render unauthenticated 1`] = `
> >
Discover New Discover New
</Button> </Button>
<WidgetNavbar
placement="collapsedNavbar"
/>
<Button <Button
as="a" as="a"
href="http://localhost:18000/support" href="http://localhost:18000/support"

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Menu, Close } from '@edx/paragon/icons'; import { MenuIcon, Close } from '@edx/paragon/icons';
import { IconButton, Icon } from '@edx/paragon'; import { IconButton, Icon } from '@edx/paragon';
import { useLearnerDashboardHeaderData, useIsCollapsed } from '../hooks'; import { useLearnerDashboardHeaderData, useIsCollapsed } from '../hooks';
@@ -23,7 +23,7 @@ export const CollapsedHeader = () => {
<IconButton <IconButton
invertColors invertColors
isActive isActive
src={isOpen ? Close : Menu} src={isOpen ? Close : MenuIcon}
iconAs={Icon} iconAs={Icon}
alt={ alt={
isOpen isOpen

View File

@@ -33,6 +33,9 @@ exports[`ExpandedHeader render 1`] = `
> >
Discover New Discover New
</Button> </Button>
<WidgetNavbar
placement="expendedNavbar"
/>
<span <span
className="flex-grow-1" className="flex-grow-1"
/> />

View File

@@ -4,11 +4,12 @@ import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon'; import { Button } from '@edx/paragon';
import WidgetNavbar from 'containers/WidgetContainers/WidgetNavbar';
import urls from 'data/services/lms/urls'; import urls from 'data/services/lms/urls';
import { reduxHooks } from 'hooks'; import { reduxHooks } from 'hooks';
import { EXPANDED_NAVBAR } from 'widgets/RecommendationsPaintedDoorBtn/constants';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import { useIsCollapsed, findCoursesNavClicked } from '../hooks'; import { useIsCollapsed, findCoursesNavClicked } from '../hooks';
import messages from '../messages'; import messages from '../messages';
import BrandLogo from '../BrandLogo'; import BrandLogo from '../BrandLogo';
@@ -51,6 +52,7 @@ export const ExpandedHeader = () => {
> >
{formatMessage(messages.discoverNew)} {formatMessage(messages.discoverNew)}
</Button> </Button>
<WidgetNavbar placement={EXPANDED_NAVBAR} />
<span className="flex-grow-1" /> <span className="flex-grow-1" />
<Button <Button
as="a" as="a"

View File

@@ -8,7 +8,7 @@ exports[`BrandLogo dashboard defined 1`] = `
<img <img
alt="edX, Inc. Dashboard" alt="edX, Inc. Dashboard"
className="logo py-3" className="logo py-3"
src="https://edx-cdn.org/v3/prod/logo.svg" src="https://edx-cdn.org/v3/default/logo.svg"
/> />
</a> </a>
`; `;
@@ -21,7 +21,7 @@ exports[`BrandLogo dashboard undefined 1`] = `
<img <img
alt="edX, Inc. Dashboard" alt="edX, Inc. Dashboard"
className="logo py-3" className="logo py-3"
src="https://edx-cdn.org/v3/prod/logo.svg" src="https://edx-cdn.org/v3/default/logo.svg"
/> />
</a> </a>
`; `;

View File

@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AppWrapper WidgetContainer component output no experiments are active snapshot 1`] = `
<div>
This is some
<b>
test
</b>
<i>
content
</i>
</div>
`;
exports[`AppWrapper WidgetContainer component output painted door experiment is active (08/23) snapshot 1`] = `
<PaintedDoorExperimentProvider>
<div>
This is some
<b>
test
</b>
<i>
content
</i>
</div>
</PaintedDoorExperimentProvider>
`;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import PaintedDoorExperimentProvider from 'widgets/RecommendationsPaintedDoorBtn/PaintedDoorExperimentContext';
export const AppWrapper = ({
children,
}) => {
console.log(`process.env.EXPERIMENT_08_23_VAN_PAINTED_DOOR = ${Boolean(process.env.EXPERIMENT_08_23_VAN_PAINTED_DOOR)}`);
return (
<PaintedDoorExperimentProvider>
{children}
</PaintedDoorExperimentProvider>
);
};
AppWrapper.propTypes = {
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node),
]).isRequired,
};
export default AppWrapper;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import PaintedDoorExperimentProvider from 'widgets/RecommendationsPaintedDoorBtn/PaintedDoorExperimentContext';
import AppWrapper from '.';
jest.mock(
'widgets/RecommendationsPaintedDoorBtn/PaintedDoorExperimentContext',
() => 'PaintedDoorExperimentProvider',
);
let el;
const children = (<div>This is some <b>test</b> <i>content</i></div>);
const render = () => {
el = shallow(<AppWrapper>{children}</AppWrapper>);
};
const mockAndRenderForBlock = (newVal) => {
const oldVal = process.env;
beforeEach(() => {
process.env = { ...oldVal, ...newVal };
render();
});
afterEach(() => {
process.env = oldVal;
render();
});
};
describe('AppWrapper WidgetContainer component', () => {
describe('output', () => {
describe('painted door experiment is active (08/23)', () => {
mockAndRenderForBlock({ EXPERIMENT_08_23_VAN_PAINTED_DOOR: true });
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
it('renders children wrapped in PaintedDoorExperimentProvider', () => {
const control = el.instance.findByType(PaintedDoorExperimentProvider)[0];
expect(el.instance).toEqual(control);
});
});
describe('no experiments are active', () => {
mockAndRenderForBlock({ EXPERIMENT_08_23_VAN_PAINTED_DOOR: false });
test('snapshot', () => {
expect(el.snapshot).toMatchSnapshot();
});
it('renders children wrapped in PaintedDoorExperimentProvider', () => {
expect(el.instance.matches(shallow(children))).toEqual(true);
});
});
});
});

View File

@@ -5,7 +5,7 @@ exports[`WidgetSidebar snapshots default 1`] = `
className="widget-sidebar" className="widget-sidebar"
> >
<div <div
className="d-flex" className="d-flex flex-column"
> >
<RecommendationsPanel /> <RecommendationsPanel />
</div> </div>

View File

@@ -1,13 +1,29 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import RecommendationsPanel from 'widgets/RecommendationsPanel'; import RecommendationsPanel from 'widgets/RecommendationsPanel';
import hooks from 'widgets/ProductRecommendations/hooks';
export const WidgetSidebar = () => ( export const WidgetSidebar = ({ setSidebarShowing }) => {
<div className="widget-sidebar"> const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
<div className="d-flex">
<RecommendationsPanel /> if (!inRecommendationsVariant && isExperimentActive) {
</div> setSidebarShowing(true);
</div>
); return (
<div className="widget-sidebar">
<div className="d-flex flex-column">
<RecommendationsPanel />
</div>
</div>
);
}
return null;
};
WidgetSidebar.propTypes = {
setSidebarShowing: PropTypes.func.isRequired,
};
export default WidgetSidebar; export default WidgetSidebar;

View File

@@ -1,14 +1,49 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import hooks from 'widgets/ProductRecommendations/hooks';
import { mockFooterRecommendationsHook } from 'widgets/ProductRecommendations/testData';
import WidgetSidebar from '.'; import WidgetSidebar from '.';
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget'); jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
jest.mock('widgets/ProductRecommendations/hooks', () => ({
useShowRecommendationsFooter: jest.fn(),
}));
describe('WidgetSidebar', () => { describe('WidgetSidebar', () => {
beforeEach(() => jest.resetAllMocks());
const props = {
setSidebarShowing: jest.fn(),
};
describe('snapshots', () => { describe('snapshots', () => {
test('default', () => { test('default', () => {
const wrapper = shallow(<WidgetSidebar />); hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.activeControl,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).toHaveBeenCalledWith(true);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
}); });
test('is hidden when the has the default values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.default,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
test('is hidden when the has the treatment values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.activeTreatment,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
}); });

View File

@@ -1,13 +1,29 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import RecommendationsPanel from 'widgets/RecommendationsPanel'; import RecommendationsPanel from 'widgets/RecommendationsPanel';
import hooks from 'widgets/ProductRecommendations/hooks';
export const WidgetSidebar = () => ( export const WidgetSidebar = ({ setSidebarShowing }) => {
<div className="widget-sidebar px-2"> const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
<div className="d-flex">
<RecommendationsPanel /> if (!inRecommendationsVariant && isExperimentActive) {
</div> setSidebarShowing(true);
</div>
); return (
<div className="widget-sidebar px-2">
<div className="d-flex">
<RecommendationsPanel />
</div>
</div>
);
}
return null;
};
WidgetSidebar.propTypes = {
setSidebarShowing: PropTypes.func.isRequired,
};
export default WidgetSidebar; export default WidgetSidebar;

View File

@@ -1,14 +1,49 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import hooks from 'widgets/ProductRecommendations/hooks';
import { mockFooterRecommendationsHook } from 'widgets/ProductRecommendations/testData';
import WidgetSidebar from '.'; import WidgetSidebar from '.';
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget'); jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
jest.mock('widgets/ProductRecommendations/hooks', () => ({
useShowRecommendationsFooter: jest.fn(),
}));
describe('WidgetSidebar', () => { describe('WidgetSidebar', () => {
beforeEach(() => jest.resetAllMocks());
const props = {
setSidebarShowing: jest.fn(),
};
describe('snapshots', () => { describe('snapshots', () => {
test('default', () => { test('default', () => {
const wrapper = shallow(<WidgetSidebar />); hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.activeControl,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).toHaveBeenCalledWith(true);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
}); });
test('is hidden when the has the default values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.default,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
test('is hidden when the has the treatment values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.activeTreatment,
);
const wrapper = shallow(<WidgetSidebar {...props} />);
expect(props.setSidebarShowing).not.toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
}); });

View File

@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`WidgetFooter snapshots default 1`] = `
<div
className="widget-footer"
>
<ProductRecommendations />
</div>
`;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import ProductRecommendations from 'widgets/ProductRecommendations';
import hooks from 'widgets/ProductRecommendations/hooks';
export const WidgetFooter = () => {
hooks.useActivateRecommendationsExperiment();
const { inRecommendationsVariant, isExperimentActive } = hooks.useShowRecommendationsFooter();
if (inRecommendationsVariant && isExperimentActive) {
return (
<div className="widget-footer">
<ProductRecommendations />
</div>
);
}
return null;
};
export default WidgetFooter;

View File

@@ -0,0 +1,45 @@
import { shallow } from 'enzyme';
import hooks from 'widgets/ProductRecommendations/hooks';
import { mockFooterRecommendationsHook } from 'widgets/ProductRecommendations/testData';
import WidgetFooter from '.';
jest.mock('widgets/LookingForChallengeWidget', () => 'LookingForChallengeWidget');
jest.mock('widgets/ProductRecommendations/hooks', () => ({
useActivateRecommendationsExperiment: jest.fn(),
useShowRecommendationsFooter: jest.fn(),
}));
describe('WidgetFooter', () => {
describe('snapshots', () => {
test('default', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.activeTreatment,
);
const wrapper = shallow(<WidgetFooter />);
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
expect(wrapper).toMatchSnapshot();
});
});
test('is hidden when the experiment has the default values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.default,
);
const wrapper = shallow(<WidgetFooter />);
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
test('is hidden when the experiment has the control values', () => {
hooks.useShowRecommendationsFooter.mockReturnValueOnce(
mockFooterRecommendationsHook.activeControl,
);
const wrapper = shallow(<WidgetFooter />);
expect(hooks.useActivateRecommendationsExperiment).toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
});

View File

@@ -0,0 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`WidgetNavbar snapshots default 1`] = `
<RecommendationsPaintedDoorBtn
experimentVariation=""
placement="expendedNavbar"
/>
`;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import RecommendationsPaintedDoorBtn from 'widgets/RecommendationsPaintedDoorBtn';
import { COLLAPSED_NAVBAR, EXPANDED_NAVBAR } from 'widgets/RecommendationsPaintedDoorBtn/constants';
import {
usePaintedDoorExperimentContext,
} from 'widgets/RecommendationsPaintedDoorBtn/PaintedDoorExperimentContext';
export const WidgetNavbar = ({ placement }) => {
const {
experimentVariation,
isPaintedDoorNavbarBtnVariation,
experimentLoading,
} = usePaintedDoorExperimentContext();
if (!experimentLoading && isPaintedDoorNavbarBtnVariation) {
return (
<RecommendationsPaintedDoorBtn placement={placement} experimentVariation={experimentVariation} />
);
}
return null;
};
WidgetNavbar.propTypes = {
placement: PropTypes.oneOf([COLLAPSED_NAVBAR, EXPANDED_NAVBAR]).isRequired,
};
export default WidgetNavbar;

View File

@@ -0,0 +1,65 @@
import { shallow } from 'enzyme';
import {
usePaintedDoorExperimentContext,
} from '../../../widgets/RecommendationsPaintedDoorBtn/PaintedDoorExperimentContext';
import WidgetNavbar from './index';
import { EXPANDED_NAVBAR } from '../../../widgets/RecommendationsPaintedDoorBtn/constants';
import RecommendationsPaintedDoorBtn from '../../../widgets/RecommendationsPaintedDoorBtn';
jest.mock('widgets/RecommendationsPaintedDoorBtn/PaintedDoorExperimentContext', () => ({
usePaintedDoorExperimentContext: jest.fn(),
}));
describe('WidgetNavbar', () => {
let mockExperimentContext = {
experimentVariation: '',
isPaintedDoorNavbarBtnVariation: true,
experimentLoading: false,
};
const props = {
placement: EXPANDED_NAVBAR,
};
describe('snapshots', () => {
test('default', () => {
usePaintedDoorExperimentContext.mockReturnValueOnce(mockExperimentContext);
const wrapper = shallow(<WidgetNavbar {...props} />);
expect(usePaintedDoorExperimentContext).toHaveBeenCalled();
expect(wrapper).toMatchSnapshot();
});
});
test('renders button if user in navbar variation', () => {
usePaintedDoorExperimentContext.mockReturnValueOnce(mockExperimentContext);
const wrapper = shallow(<WidgetNavbar {...props} />);
expect(usePaintedDoorExperimentContext).toHaveBeenCalled();
expect(wrapper.type()).toBe(RecommendationsPaintedDoorBtn);
});
test('renders nothing if user in not in navbar variation', () => {
mockExperimentContext = {
...mockExperimentContext,
isPaintedDoorNavbarBtnVariation: false,
};
usePaintedDoorExperimentContext.mockReturnValueOnce(mockExperimentContext);
const wrapper = shallow(<WidgetNavbar {...props} />);
expect(usePaintedDoorExperimentContext).toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
test('renders nothing if experiment is loading', () => {
mockExperimentContext = {
...mockExperimentContext,
isPaintedDoorNavbarBtnVariation: false,
experimentLoading: true,
};
usePaintedDoorExperimentContext.mockReturnValueOnce(mockExperimentContext);
const wrapper = shallow(<WidgetNavbar {...props} />);
expect(usePaintedDoorExperimentContext).toHaveBeenCalled();
expect(wrapper.type()).toBeNull();
});
});

View File

@@ -0,0 +1,17 @@
// Constants related to courses
export const COURSE_MODES = {
VERIFIED: 'verified',
PROFESSIONAL: 'professional',
NO_ID_PROFESSIONAL: 'no-id-professional',
AUDIT: 'audit',
HONOR: 'honor',
EXECUTIVE_EDUCATION: 'executive-education',
PAID_EXECUTIVE_EDUCATION: 'paid-executive-education',
UNPAID_EXECUTIVE_EDUCATION: 'unpaid-executive-education',
};
export const EXECUTIVE_EDUCATION_COURSE_MODES = [
COURSE_MODES.EXECUTIVE_EDUCATION,
COURSE_MODES.PAID_EXECUTIVE_EDUCATION,
COURSE_MODES.UNPAID_EXECUTIVE_EDUCATION,
];

View File

@@ -12,6 +12,7 @@ const initialState = {
suggestedCourses: [], suggestedCourses: [],
filterState: {}, filterState: {},
selectSessionModal: {}, selectSessionModal: {},
certificatePreviewModal: {},
}; };
export const cardId = (val) => `card-${val}`; export const cardId = (val) => `card-${val}`;
@@ -48,6 +49,10 @@ const app = createSlice({
...state, ...state,
selectSessionModal: { cardId: payload }, selectSessionModal: { cardId: payload },
}), }),
updateCertificatePreviewModal: (state, { payload }) => ({
...state,
certificatePreviewModal: { cardId: payload },
}),
setPageNumber: (state, { payload }) => ({ ...state, pageNumber: payload }), setPageNumber: (state, { payload }) => ({ ...state, pageNumber: payload }),
}, },
}); });

View File

@@ -19,9 +19,15 @@ export const showSelectSessionModal = createSelector(
(data) => data.cardId != null, (data) => data.cardId != null,
); );
export const showCertificatePreviewModal = createSelector(
[simpleSelectors.certificatePreviewModal],
(data) => data.cardId != null,
);
export default StrictDict({ export default StrictDict({
numCourses, numCourses,
hasCourses, hasCourses,
hasAvailableDashboards, hasAvailableDashboards,
showSelectSessionModal, showSelectSessionModal,
showCertificatePreviewModal,
}); });

View File

@@ -1,5 +1,6 @@
import { StrictDict } from 'utils'; import { StrictDict } from 'utils';
import { baseAppUrl } from 'data/services/lms/urls'; import { baseAppUrl } from 'data/services/lms/urls';
import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';
import * as module from './courseCard'; import * as module from './courseCard';
import * as simpleSelectors from './simpleSelectors'; import * as simpleSelectors from './simpleSelectors';
@@ -15,17 +16,14 @@ export const loadDateVal = (date) => (date ? new Date(date) : null);
export const courseCard = StrictDict({ export const courseCard = StrictDict({
certificate: mkCardSelector( certificate: mkCardSelector(
cardSimpleSelectors.certificate, cardSimpleSelectors.certificate,
(certificate) => { (certificate) => (certificate === null ? {} : ({
const availableDate = new Date(certificate.availableDate); availableDate: new Date(certificate.availableDate),
const isAvailable = availableDate <= new Date(); certPreviewUrl: baseAppUrl(certificate.certPreviewUrl),
return { isDownloadable: certificate.isDownloadable,
availableDate, isEarnedButUnavailable: certificate.isEarned && new Date(certificate.availableDate) > new Date(),
certPreviewUrl: baseAppUrl(certificate.certPreviewUrl), isRestricted: certificate.isRestricted,
isDownloadable: certificate.isDownloadable, isEarned: certificate.isEarned,
isEarnedButUnavailable: certificate.isEarned && !isAvailable, })),
isRestricted: certificate.isRestricted,
};
},
), ),
course: mkCardSelector( course: mkCardSelector(
cardSimpleSelectors.course, cardSimpleSelectors.course,
@@ -100,6 +98,8 @@ export const courseCard = StrictDict({
isEmailEnabled: enrollment.isEmailEnabled, isEmailEnabled: enrollment.isEmailEnabled,
hasOptedOutOfEmail: enrollment.hasOptedOutOfEmail, hasOptedOutOfEmail: enrollment.hasOptedOutOfEmail,
mode: enrollment.mode,
isExecEd2UCourse: EXECUTIVE_EDUCATION_COURSE_MODES.includes(enrollment.mode),
}; };
}, },
), ),

View File

@@ -1,5 +1,6 @@
import { keyStore } from 'utils'; import { keyStore } from 'utils';
import { baseAppUrl } from 'data/services/lms/urls'; import { baseAppUrl } from 'data/services/lms/urls';
import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';
import simpleSelectors from './simpleSelectors'; import simpleSelectors from './simpleSelectors';
import * as module from './courseCard'; import * as module from './courseCard';
@@ -79,6 +80,9 @@ describe('courseCard selectors module', () => {
it('returns a card selector based on certificate cardSimpleSelector', () => { it('returns a card selector based on certificate cardSimpleSelector', () => {
expect(simpleSelector).toEqual(cardSimpleSelectors.certificate); expect(simpleSelector).toEqual(cardSimpleSelectors.certificate);
}); });
it('returns {} object if null certificate received', () => {
expect(selector(null)).toEqual({});
});
it('passes availableDate, converted to a date', () => { it('passes availableDate, converted to a date', () => {
expect(selected.availableDate).toMatchObject(new Date(testData.availableDate)); expect(selected.availableDate).toMatchObject(new Date(testData.availableDate));
}); });
@@ -162,6 +166,9 @@ describe('courseCard selectors module', () => {
it('returns a card selector based on courseRun cardSimpleSelector', () => { it('returns a card selector based on courseRun cardSimpleSelector', () => {
expect(simpleSelector).toEqual(cardSimpleSelectors.courseRun); expect(simpleSelector).toEqual(cardSimpleSelectors.courseRun);
}); });
it('returns {} object if null courseRun received', () => {
expect(selector(null)).toEqual({});
});
it('passes [endDate, startDate], converted to dates', () => { it('passes [endDate, startDate], converted to dates', () => {
expect(selected.endDate).toEqual(new Date(testData.endDate)); expect(selected.endDate).toEqual(new Date(testData.endDate));
expect(selected.startDate).toEqual(new Date(testData.startDate)); expect(selected.startDate).toEqual(new Date(testData.startDate));
@@ -222,23 +229,25 @@ describe('courseCard selectors module', () => {
}); });
}); });
describe('enrollment selector', () => { describe('enrollment selector', () => {
const defaultData = {
coursewareAccess: {
isStaff: false,
hasUnmetPrereqs: false,
isTooEarly: false,
},
isEnrolled: 'test-is-enrolled',
lastEnrolled: 'test-last-enrolled',
hasStarted: 'test-has-started',
accessExpirationDate: '3000-10-20',
canUpgrade: 'test-can-upgrade',
isAudit: 'test-is-audit',
isAuditAccessExpired: 'test-is-audit-access-expired',
isVerified: 'test-is-verified',
isEmailEnabled: 'test-is-email-enabled',
mode: 'default',
};
beforeEach(() => { beforeEach(() => {
loadSelector(courseCard.enrollment, { loadSelector(courseCard.enrollment, defaultData);
coursewareAccess: {
isStaff: false,
hasUnmetPrereqs: false,
isTooEarly: false,
},
isEnrolled: 'test-is-enrolled',
lastEnrolled: 'test-last-enrolled',
hasStarted: 'test-has-started',
accessExpirationDate: '3000-10-20',
canUpgrade: 'test-can-upgrade',
isAudit: 'test-is-audit',
isAuditAccessExpired: 'test-is-audit-access-expired',
isVerified: 'test-is-verified',
isEmailEnabled: 'test-is-email-enabled',
});
}); });
it('returns a card selector based on enrollment cardSimpleSelector', () => { it('returns a card selector based on enrollment cardSimpleSelector', () => {
expect(simpleSelector).toEqual(cardSimpleSelectors.enrollment); expect(simpleSelector).toEqual(cardSimpleSelectors.enrollment);
@@ -274,6 +283,13 @@ describe('courseCard selectors module', () => {
it('passes isEmailEnabled', () => { it('passes isEmailEnabled', () => {
expect(selected.isEmailEnabled).toEqual(testData.isEmailEnabled); expect(selected.isEmailEnabled).toEqual(testData.isEmailEnabled);
}); });
it('returns isExecEd2UCourse: false if mode is not in EXECUTIVE_EDUCATION_COURSE_MODES', () => {
expect(selected.isExecEd2UCourse).toEqual(false);
});
it('returns isExecEd2UCourse: true if mode is in EXECUTIVE_EDUCATION_COURSE_MODES', () => {
loadSelector(courseCard.enrollment, { ...defaultData, mode: EXECUTIVE_EDUCATION_COURSE_MODES[0] });
expect(selected.isExecEd2UCourse).toEqual(true);
});
}); });
describe('entitlement selector', () => { describe('entitlement selector', () => {
beforeEach(() => { beforeEach(() => {

View File

@@ -12,8 +12,9 @@ export const simpleSelectors = StrictDict({
platformSettings: mkSimpleSelector(app => app.platformSettings), platformSettings: mkSimpleSelector(app => app.platformSettings),
suggestedCourses: mkSimpleSelector(app => app.suggestedCourses), suggestedCourses: mkSimpleSelector(app => app.suggestedCourses),
emailConfirmation: mkSimpleSelector(app => app.emailConfirmation), emailConfirmation: mkSimpleSelector(app => app.emailConfirmation),
enterpriseDashboard: mkSimpleSelector(app => app.enterpriseDashboard), enterpriseDashboard: mkSimpleSelector(app => app.enterpriseDashboard || {}),
selectSessionModal: mkSimpleSelector(app => app.selectSessionModal), selectSessionModal: mkSimpleSelector(app => app.selectSessionModal),
certificatePreviewModal: mkSimpleSelector(app => app.certificatePreviewModal),
pageNumber: mkSimpleSelector(app => app.pageNumber), pageNumber: mkSimpleSelector(app => app.pageNumber),
socialShareSettings: mkSimpleSelector(app => app.socialShareSettings), socialShareSettings: mkSimpleSelector(app => app.socialShareSettings),
}); });

View File

@@ -35,6 +35,12 @@ describe('app simple selectors', () => {
expect(preSelectors).toEqual([appSelector]); expect(preSelectors).toEqual([appSelector]);
expect(cb(testState.app)).toEqual(testString); expect(cb(testState.app)).toEqual(testString);
}); });
test('enterpriseDashboard returns empty object if data returns null', () => {
testState = { app: { enterpriseDashboard: null } };
const { preSelectors, cb } = simpleSelectors.enterpriseDashboard;
expect(preSelectors).toEqual([appSelector]);
expect(cb(testState.app)).toEqual({});
});
describe('cardSimpleSelectors', () => { describe('cardSimpleSelectors', () => {
keys = keyStore(cardSimpleSelectors); keys = keyStore(cardSimpleSelectors);
test.each([ test.each([

View File

@@ -13,6 +13,8 @@ export const useEmailConfirmationData = () => useSelector(selectors.emailConfirm
export const useEnterpriseDashboardData = () => useSelector(selectors.enterpriseDashboard); export const useEnterpriseDashboardData = () => useSelector(selectors.enterpriseDashboard);
export const usePlatformSettingsData = () => useSelector(selectors.platformSettings); export const usePlatformSettingsData = () => useSelector(selectors.platformSettings);
export const useSelectSessionModalData = () => useSelector(selectors.selectSessionModal); export const useSelectSessionModalData = () => useSelector(selectors.selectSessionModal);
export const useCertificatePreviewData = () => useSelector(selectors.certificatePreviewModal);
export const useSocialShareSettings = () => useSelector(selectors.socialShareSettings); export const useSocialShareSettings = () => useSelector(selectors.socialShareSettings);
/** global-level meta-selectors **/ /** global-level meta-selectors **/
@@ -22,6 +24,7 @@ export const useCurrentCourseList = (opts) => useSelector(
state => selectors.currentList(state, opts), state => selectors.currentList(state, opts),
); );
export const useShowSelectSessionModal = () => useSelector(selectors.showSelectSessionModal); export const useShowSelectSessionModal = () => useSelector(selectors.showSelectSessionModal);
export const useShowCertificatePreviewModal = () => useSelector(selectors.showCertificatePreviewModal);
// eslint-disable-next-line // eslint-disable-next-line
export const useCourseCardData = (selector) => (cardId) => useSelector( export const useCourseCardData = (selector) => (cardId) => useSelector(
@@ -55,12 +58,23 @@ export const useCardSocialSettingsData = (cardId) => {
return { facebook: loadSettings(facebook), twitter: loadSettings(twitter) }; return { facebook: loadSettings(facebook), twitter: loadSettings(twitter) };
}; };
export const useCardExecEdTrackingParam = (cardId) => {
const { isExecEd2UCourse } = module.useCardEnrollmentData(cardId);
const { authOrgId } = module.useEnterpriseDashboardData(cardId);
return isExecEd2UCourse ? `?org_id=${authOrgId}` : '';
};
/** Events **/ /** Events **/
export const useUpdateSelectSessionModalCallback = (cardId) => { export const useUpdateSelectSessionModalCallback = (cardId) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
return () => dispatch(actions.updateSelectSessionModal(cardId)); return () => dispatch(actions.updateSelectSessionModal(cardId));
}; };
export const useUpdateCertificatePreviewModalCallback = (cardId) => {
const dispatch = useDispatch();
return () => dispatch(actions.updateCertificatePreviewModal(cardId));
};
export const useTrackCourseEvent = (tracker, cardId, ...args) => { export const useTrackCourseEvent = (tracker, cardId, ...args) => {
const { courseId } = module.useCardCourseRunData(cardId); const { courseId } = module.useCardCourseRunData(cardId);
return (e) => tracker(courseId, ...args)(e); return (e) => tracker(courseId, ...args)(e);

View File

@@ -761,10 +761,13 @@ export const compileCourseRunData = ({ courseName, ...data }, index) => {
credit: {}, credit: {},
...data, ...data,
certificate: genCertificateData(data.certificate), certificate: genCertificateData(data.certificate),
enrollment: genEnrollmentData({ lastEnrolled, ...data.enrollment }), enrollment: genEnrollmentData({
lastEnrolled,
...getOption(emailOptions, index),
...data.enrollment,
}),
courseRun: genCourseRunData({ courseRun: genCourseRunData({
...data.courseRun, ...data.courseRun,
...getOption(emailOptions, index),
courseId, courseId,
}), }),
course: { course: {

View File

@@ -1,4 +1,5 @@
import { messages as footerMessages } from '@edx/frontend-component-footer'; import { messages as footerMessages } from '@edx/frontend-component-footer';
import { messages as paragonMessages } from '@edx/paragon';
import arMessages from './messages/ar.json'; import arMessages from './messages/ar.json';
// no need to import en messages-- they are in the defaultMessage field // no need to import en messages-- they are in the defaultMessage field
@@ -20,4 +21,5 @@ const appMessages = {
export default [ export default [
footerMessages, footerMessages,
appMessages, appMessages,
paragonMessages,
]; ];

View File

@@ -14,25 +14,12 @@
"leanerDashboard.confirmEmailModalHeader": "قم بتأكيد بريدك الإلكتروني", "leanerDashboard.confirmEmailModalHeader": "قم بتأكيد بريدك الإلكتروني",
"leanerDashboard.confirmEmailModalBody": "لقد أرسلنا إليك بريدًا إلكترونيًا للتحقق من حسابك. يرجى التحقق من بريدك الوارد والنقر على الزر الأحمر الكبير للتأكيد ومتابعة التعلم.", "leanerDashboard.confirmEmailModalBody": "لقد أرسلنا إليك بريدًا إلكترونيًا للتحقق من حسابك. يرجى التحقق من بريدك الوارد والنقر على الزر الأحمر الكبير للتأكيد ومتابعة التعلم.",
"leanerDashboard.confirmEmailImageAlt": "ايقونة زر تأكيد البريد الإلكتروني", "leanerDashboard.confirmEmailImageAlt": "ايقونة زر تأكيد البريد الإلكتروني",
"leanerDashboard.menu.dashboard.label": "لوحة التحكم",
"leanerDashboard.help.label": "المساعدة",
"leanerDashboard.menu.profile.label": "الملف الشخصي",
"leanerDashboard.menu.viewPrograms.label": "عرض البرامج",
"leanerDashboard.menu.account.label": "الحساب",
"leanerDashboard.menu.orderHistory.label": "المشتريات السابقة",
"leanerDashboard.menu.signOut.label": "تسجيل الخروج",
"greeting.morning": "صباح الخير!",
"greeting.afternoon": "مساء الخير!",
"greeting.evening": "مساء الخير!",
"leanerDashboard.switchToProgram": "انتقل إلى لائحة البرامج",
"leanerDashboard.exploreCourses": "استكشف المساقات",
"leanerDashboard.courseSearchAlt": "البحث عن مساق",
"learnerVariantDashboard.menu.dashboard.label": "لوحة التحكم", "learnerVariantDashboard.menu.dashboard.label": "لوحة التحكم",
"learnerVariantDashboard.help.label": "المساعدة", "learnerVariantDashboard.help.label": "المساعدة",
"learnerVariantDashboard.menu.profile.label": "الملف الشخصي", "learnerVariantDashboard.menu.profile.label": "الملف الشخصي",
"learnerVariantDashboard.menu.viewPrograms.label": "عرض البرامج", "learnerVariantDashboard.menu.viewPrograms.label": "عرض البرامج",
"learnerVariantDashboard.menu.account.label": "الحساب", "learnerVariantDashboard.menu.account.label": "الحساب",
"learnerVariantDashboard.menu.orderHistory.label": "المشتريات السابقة", "learnerVariantDashboard.menu.orderHistory.label": "Order History",
"learnerVariantDashboard.menu.signOut.label": "تسجيل الخروج", "learnerVariantDashboard.menu.signOut.label": "تسجيل الخروج",
"learnerVariantDashboard.course": "المساقات", "learnerVariantDashboard.course": "المساقات",
"learnerVariantDashboard.program": "البرامج", "learnerVariantDashboard.program": "البرامج",
@@ -40,6 +27,8 @@
"learnerVariantDashboard.logoAltText": ". لوحة القيادة", "learnerVariantDashboard.logoAltText": ". لوحة القيادة",
"learnerVariantDashboard.collapseMenuOpenAltText": "القائمة", "learnerVariantDashboard.collapseMenuOpenAltText": "القائمة",
"learnerVariantDashboard.collapseMenuClosedAltText": "إغلاق", "learnerVariantDashboard.collapseMenuClosedAltText": "إغلاق",
"leanerDashboard.menu.career.label": "Career",
"header.menu.new.label": "New",
"MasqueradeBar.ViewAs": "عرض كـ :", "MasqueradeBar.ViewAs": "عرض كـ :",
"MasqueradeBar.ViewingAs": "يتم عرض كـ:", "MasqueradeBar.ViewingAs": "يتم عرض كـ:",
"MasqueradeBar.SubmitButton": "تأكيد", "MasqueradeBar.SubmitButton": "تأكيد",
@@ -48,6 +37,20 @@
"MasqueradeBar.UnknownError": "حدث خطأ غير معروف", "MasqueradeBar.UnknownError": "حدث خطأ غير معروف",
"WidgetSidebar.lookingForChallengePrompt": "هل تبحث عن تحد جديد؟", "WidgetSidebar.lookingForChallengePrompt": "هل تبحث عن تحد جديد؟",
"WidgetSidebar.findCoursesButton": "ابحث عن مساق {arrow}", "WidgetSidebar.findCoursesButton": "ابحث عن مساق {arrow}",
"ProductRecommendations.recommendationsHeading": "You might also like",
"ProductRecommendations.executiveEducationHeading": "Executive Education",
"ProductRecommendations.executiveEducationDescription": "Short Courses to develop leadership skills",
"ProductRecommendations.bootcampHeading": "Boot Camp",
"ProductRecommendations.bootcampDescription": "Intensive, hands-on, project based training",
"ProductRecommendations.courseHeading": "Courses",
"ProductRecommendations.courseDescription": "Find new interests and advance your career",
"RecommendationsPanel.recommendationsFeatureText": "Personalized recommendations feature is not yet available. We are working hard on bringing it to your learner home in the near future.",
"RecommendationsPanel.recommendationsAlertText": "Would you like to be alerted when it becomes available?",
"RecommendationsPanel.recommendationsModalHeading": "Thank you for your interest!",
"RecommendationsPanel.modalSkipButton": "Skip for now",
"RecommendationsPanel.modalCountMeButton": "Count me in!",
"learnerVariantDashboard.recommendedForYou": "Recommended For You",
"RecommendationsPanel.seeAllRecommendationsButton": "See All Recommendations",
"RecommendationsPanel.recommendationsHeading": "توصيات خاصة لك", "RecommendationsPanel.recommendationsHeading": "توصيات خاصة لك",
"RecommendationsPanel.popularCoursesHeading": "المساقات الشائعة", "RecommendationsPanel.popularCoursesHeading": "المساقات الشائعة",
"RecommendationsPanel.exploreCoursesButton": "استكشف المساقات" "RecommendationsPanel.exploreCoursesButton": "استكشف المساقات"

View File

@@ -7,26 +7,13 @@
"leanerDashboard.enterpriseDialogHeader": "Tienes acceso al panel {label}", "leanerDashboard.enterpriseDialogHeader": "Tienes acceso al panel {label}",
"leanerDashboard.enterpriseDialogBody": "Para acceder a los cursos disponibles para usted a través de {label}, visite el panel {label} ahora.", "leanerDashboard.enterpriseDialogBody": "Para acceder a los cursos disponibles para usted a través de {label}, visite el panel {label} ahora.",
"leanerDashboard.enterpriseDialogDismissButton": "Despedir", "leanerDashboard.enterpriseDialogDismissButton": "Despedir",
"leanerDashboard.enterpriseDialogConfirmButton": "Go to dashboard", "leanerDashboard.enterpriseDialogConfirmButton": "Ir al panel principal",
"leanerDashboard.confirmEmailBanner": "Confirmar ahora", "leanerDashboard.confirmEmailBanner": "Confirmar ahora",
"leanerDashboard.confirmEmailTextReminderBanner": "¡Recuerda confirmar tu correo electrónico para que puedas seguir aprendiendo en edX! {confirmNowButton}.", "leanerDashboard.confirmEmailTextReminderBanner": "¡Recuerda confirmar tu correo electrónico para que puedas seguir aprendiendo en edX! {confirmNowButton}.",
"leanerDashboard.verifiedConfirmEmailButton": "He confirmado mi correo electrónico.", "leanerDashboard.verifiedConfirmEmailButton": "He confirmado mi correo electrónico.",
"leanerDashboard.confirmEmailModalHeader": "confirme su email", "leanerDashboard.confirmEmailModalHeader": "confirme su email",
"leanerDashboard.confirmEmailModalBody": "Te hemos enviado un correo electrónico para verificar tu cuenta. Revise su bandeja de entrada y haga clic en el botón rojo grande para confirmar y seguir aprendiendo.", "leanerDashboard.confirmEmailModalBody": "Te hemos enviado un correo electrónico para verificar tu cuenta. Revise su bandeja de entrada y haga clic en el botón rojo grande para confirmar y seguir aprendiendo.",
"leanerDashboard.confirmEmailImageAlt": "confirmar fondo de correo electrónico", "leanerDashboard.confirmEmailImageAlt": "confirmar fondo de correo electrónico",
"leanerDashboard.menu.dashboard.label": "Panel de Control",
"leanerDashboard.help.label": "Ayuda",
"leanerDashboard.menu.profile.label": "Perfil",
"leanerDashboard.menu.viewPrograms.label": "Ver programas",
"leanerDashboard.menu.account.label": "Cuenta",
"leanerDashboard.menu.orderHistory.label": "Historial de órdenes",
"leanerDashboard.menu.signOut.label": "Cerrar sesión",
"greeting.morning": "¡Buen día!",
"greeting.afternoon": "¡Buenas tardes!",
"greeting.evening": "¡Buenas noches!",
"leanerDashboard.switchToProgram": "Cambiar a Programas",
"leanerDashboard.exploreCourses": "Explorar cursos",
"leanerDashboard.courseSearchAlt": "Búsqueda de cursos",
"learnerVariantDashboard.menu.dashboard.label": "Panel de Control", "learnerVariantDashboard.menu.dashboard.label": "Panel de Control",
"learnerVariantDashboard.help.label": "Ayuda", "learnerVariantDashboard.help.label": "Ayuda",
"learnerVariantDashboard.menu.profile.label": "Perfil", "learnerVariantDashboard.menu.profile.label": "Perfil",
@@ -40,6 +27,8 @@
"learnerVariantDashboard.logoAltText": "Tablero de edX, Inc.", "learnerVariantDashboard.logoAltText": "Tablero de edX, Inc.",
"learnerVariantDashboard.collapseMenuOpenAltText": "Menú", "learnerVariantDashboard.collapseMenuOpenAltText": "Menú",
"learnerVariantDashboard.collapseMenuClosedAltText": "Cerrar", "learnerVariantDashboard.collapseMenuClosedAltText": "Cerrar",
"leanerDashboard.menu.career.label": "Carrera",
"header.menu.new.label": "Nuevo",
"MasqueradeBar.ViewAs": "Ver como:", "MasqueradeBar.ViewAs": "Ver como:",
"MasqueradeBar.ViewingAs": "Viendo como:", "MasqueradeBar.ViewingAs": "Viendo como:",
"MasqueradeBar.SubmitButton": "Enviar", "MasqueradeBar.SubmitButton": "Enviar",
@@ -48,6 +37,20 @@
"MasqueradeBar.UnknownError": "Un error desconocido ocurrió", "MasqueradeBar.UnknownError": "Un error desconocido ocurrió",
"WidgetSidebar.lookingForChallengePrompt": "¿Buscando un nuevo reto?", "WidgetSidebar.lookingForChallengePrompt": "¿Buscando un nuevo reto?",
"WidgetSidebar.findCoursesButton": "Encuentra un curso {arrow}", "WidgetSidebar.findCoursesButton": "Encuentra un curso {arrow}",
"ProductRecommendations.recommendationsHeading": "También podría ser de interés",
"ProductRecommendations.executiveEducationHeading": "Formación Ejecutiva",
"ProductRecommendations.executiveEducationDescription": "Cursos cortos para desarrollar habilidades de liderazgo",
"ProductRecommendations.bootcampHeading": "Capacitación intensiva de corta duración",
"ProductRecommendations.bootcampDescription": "Capacitación intensiva, práctica y basada en proyectos",
"ProductRecommendations.courseHeading": "Cursos",
"ProductRecommendations.courseDescription": "Encontrar nuevos intereses y avanzar en la carrera",
"RecommendationsPanel.recommendationsFeatureText": "La función de recomendaciones personalizadas aún no está disponible. Estamos trabajando arduamente para llevarlo a casa de su alumno en un futuro próximo.",
"RecommendationsPanel.recommendationsAlertText": "¿Le gustaría recibir una alerta cuando esté disponible?",
"RecommendationsPanel.recommendationsModalHeading": "¡Gracias por su interés!",
"RecommendationsPanel.modalSkipButton": "Saltar por ahora ",
"RecommendationsPanel.modalCountMeButton": "¡Cuente conmigo!",
"learnerVariantDashboard.recommendedForYou": "Recomendado para usted",
"RecommendationsPanel.seeAllRecommendationsButton": "Ver todas las recomendaciones",
"RecommendationsPanel.recommendationsHeading": "Recomendaciones para ti", "RecommendationsPanel.recommendationsHeading": "Recomendaciones para ti",
"RecommendationsPanel.popularCoursesHeading": "Cursos populares", "RecommendationsPanel.popularCoursesHeading": "Cursos populares",
"RecommendationsPanel.exploreCoursesButton": "Explorar cursos" "RecommendationsPanel.exploreCoursesButton": "Explorar cursos"

View File

@@ -14,25 +14,12 @@
"leanerDashboard.confirmEmailModalHeader": "Confirmer votre courriel", "leanerDashboard.confirmEmailModalHeader": "Confirmer votre courriel",
"leanerDashboard.confirmEmailModalBody": "Nous vous avons envoyé un courriel pour vérifier votre compte. Veuillez vérifier votre boîte de réception et cliquer sur le gros bouton rouge pour confirmer et continuer à apprendre.", "leanerDashboard.confirmEmailModalBody": "Nous vous avons envoyé un courriel pour vérifier votre compte. Veuillez vérifier votre boîte de réception et cliquer sur le gros bouton rouge pour confirmer et continuer à apprendre.",
"leanerDashboard.confirmEmailImageAlt": "confirmer l'arrière-plan du courriel", "leanerDashboard.confirmEmailImageAlt": "confirmer l'arrière-plan du courriel",
"leanerDashboard.menu.dashboard.label": "Tableau de bord",
"leanerDashboard.help.label": "Aide",
"leanerDashboard.menu.profile.label": "Profil",
"leanerDashboard.menu.viewPrograms.label": "Voir les programmes",
"leanerDashboard.menu.account.label": "Compte",
"leanerDashboard.menu.orderHistory.label": "Historique des commandes",
"leanerDashboard.menu.signOut.label": "Se déconnecter",
"greeting.morning": "Bonjour!",
"greeting.afternoon": "Bon après-midi!",
"greeting.evening": "Bonne soirée!",
"leanerDashboard.switchToProgram": "Passer aux programmes",
"leanerDashboard.exploreCourses": "Explorer les cours",
"leanerDashboard.courseSearchAlt": "Recherche de cours",
"learnerVariantDashboard.menu.dashboard.label": "Tableau de bord", "learnerVariantDashboard.menu.dashboard.label": "Tableau de bord",
"learnerVariantDashboard.help.label": "Aide", "learnerVariantDashboard.help.label": "Aide",
"learnerVariantDashboard.menu.profile.label": "Profil", "learnerVariantDashboard.menu.profile.label": "Profil",
"learnerVariantDashboard.menu.viewPrograms.label": "Voir les programmes", "learnerVariantDashboard.menu.viewPrograms.label": "Voir les programmes",
"learnerVariantDashboard.menu.account.label": "Compte", "learnerVariantDashboard.menu.account.label": "Compte",
"learnerVariantDashboard.menu.orderHistory.label": "Historique des commandes", "learnerVariantDashboard.menu.orderHistory.label": "Order History",
"learnerVariantDashboard.menu.signOut.label": "Se déconnecter", "learnerVariantDashboard.menu.signOut.label": "Se déconnecter",
"learnerVariantDashboard.course": "Cours", "learnerVariantDashboard.course": "Cours",
"learnerVariantDashboard.program": "Programmes", "learnerVariantDashboard.program": "Programmes",
@@ -40,6 +27,8 @@
"learnerVariantDashboard.logoAltText": "Tableau de bord edX, Inc.", "learnerVariantDashboard.logoAltText": "Tableau de bord edX, Inc.",
"learnerVariantDashboard.collapseMenuOpenAltText": "Menu", "learnerVariantDashboard.collapseMenuOpenAltText": "Menu",
"learnerVariantDashboard.collapseMenuClosedAltText": "Fermer", "learnerVariantDashboard.collapseMenuClosedAltText": "Fermer",
"leanerDashboard.menu.career.label": "Career",
"header.menu.new.label": "New",
"MasqueradeBar.ViewAs": "Vue de :", "MasqueradeBar.ViewAs": "Vue de :",
"MasqueradeBar.ViewingAs": "Voir comme : ", "MasqueradeBar.ViewingAs": "Voir comme : ",
"MasqueradeBar.SubmitButton": "Envoyez", "MasqueradeBar.SubmitButton": "Envoyez",
@@ -48,6 +37,20 @@
"MasqueradeBar.UnknownError": "Une erreur d'origine inconnue s'est produite.", "MasqueradeBar.UnknownError": "Une erreur d'origine inconnue s'est produite.",
"WidgetSidebar.lookingForChallengePrompt": "A la recherche d'un nouveau défi?", "WidgetSidebar.lookingForChallengePrompt": "A la recherche d'un nouveau défi?",
"WidgetSidebar.findCoursesButton": "Trouver un cours {arrow}", "WidgetSidebar.findCoursesButton": "Trouver un cours {arrow}",
"ProductRecommendations.recommendationsHeading": "You might also like",
"ProductRecommendations.executiveEducationHeading": "Executive Education",
"ProductRecommendations.executiveEducationDescription": "Short Courses to develop leadership skills",
"ProductRecommendations.bootcampHeading": "Boot Camp",
"ProductRecommendations.bootcampDescription": "Intensive, hands-on, project based training",
"ProductRecommendations.courseHeading": "Courses",
"ProductRecommendations.courseDescription": "Find new interests and advance your career",
"RecommendationsPanel.recommendationsFeatureText": "Personalized recommendations feature is not yet available. We are working hard on bringing it to your learner home in the near future.",
"RecommendationsPanel.recommendationsAlertText": "Would you like to be alerted when it becomes available?",
"RecommendationsPanel.recommendationsModalHeading": "Thank you for your interest!",
"RecommendationsPanel.modalSkipButton": "Skip for now",
"RecommendationsPanel.modalCountMeButton": "Count me in!",
"learnerVariantDashboard.recommendedForYou": "Recommended For You",
"RecommendationsPanel.seeAllRecommendationsButton": "See All Recommendations",
"RecommendationsPanel.recommendationsHeading": "Des recommandations pour vous", "RecommendationsPanel.recommendationsHeading": "Des recommandations pour vous",
"RecommendationsPanel.popularCoursesHeading": "Cours populaires", "RecommendationsPanel.popularCoursesHeading": "Cours populaires",
"RecommendationsPanel.exploreCoursesButton": "Explorer les cours" "RecommendationsPanel.exploreCoursesButton": "Explorer les cours"

View File

@@ -1,6 +1,6 @@
{ {
"dashboard.mycourses": "Mes cours", "dashboard.mycourses": "Mes cours",
"Dashboard.NoCoursesView.lookingForChallengePrompt": "A la recherche d'un nouveau défi ?", "Dashboard.NoCoursesView.lookingForChallengePrompt": "A la recherche d'un nouveau défi?",
"Dashboard.NoCoursesView.exploreCoursesPrompt": "Explorez nos cours pour les ajouter à votre tableau de bord.", "Dashboard.NoCoursesView.exploreCoursesPrompt": "Explorez nos cours pour les ajouter à votre tableau de bord.",
"Dashboard.NoCoursesView.exploreCoursesButton": "Explorer les cours", "Dashboard.NoCoursesView.exploreCoursesButton": "Explorer les cours",
"Dashboard.NoCoursesView.bannerAlt": "Aucune bannière de cours", "Dashboard.NoCoursesView.bannerAlt": "Aucune bannière de cours",
@@ -9,24 +9,11 @@
"leanerDashboard.enterpriseDialogDismissButton": "Rejeter", "leanerDashboard.enterpriseDialogDismissButton": "Rejeter",
"leanerDashboard.enterpriseDialogConfirmButton": "Aller au tableau de bord", "leanerDashboard.enterpriseDialogConfirmButton": "Aller au tableau de bord",
"leanerDashboard.confirmEmailBanner": "Confirmer maintenant", "leanerDashboard.confirmEmailBanner": "Confirmer maintenant",
"leanerDashboard.confirmEmailTextReminderBanner": "N'oubliez pas de confirmer votre courriel afin de pouvoir continuer à apprendre sur edX ! {confirmNowButton}.", "leanerDashboard.confirmEmailTextReminderBanner": "N'oubliez pas de confirmer votre courriel afin de pouvoir continuer à apprendre sur EDUlib! {confirmNowButton}.",
"leanerDashboard.verifiedConfirmEmailButton": "J'ai confirmé mon courriel", "leanerDashboard.verifiedConfirmEmailButton": "J'ai confirmé mon courriel",
"leanerDashboard.confirmEmailModalHeader": "Confirmer votre courriel", "leanerDashboard.confirmEmailModalHeader": "Confirmer votre courriel",
"leanerDashboard.confirmEmailModalBody": "Nous vous avons envoyé un courriel pour vérifier votre compte. Veuillez vérifier votre boîte de réception et cliquer sur le gros bouton rouge pour confirmer et continuer à apprendre.", "leanerDashboard.confirmEmailModalBody": "Nous vous avons envoyé un courriel pour vérifier votre compte. Veuillez vérifier votre boîte de réception et cliquer sur le gros bouton rouge pour confirmer et continuer à apprendre.",
"leanerDashboard.confirmEmailImageAlt": "confirmer l'arrière-plan du courriel", "leanerDashboard.confirmEmailImageAlt": "confirmer l'arrière-plan du courriel",
"leanerDashboard.menu.dashboard.label": "Tableau de bord",
"leanerDashboard.help.label": "Aide",
"leanerDashboard.menu.profile.label": "Profil",
"leanerDashboard.menu.viewPrograms.label": "Voir les programmes",
"leanerDashboard.menu.account.label": "Compte",
"leanerDashboard.menu.orderHistory.label": "Historique des commandes",
"leanerDashboard.menu.signOut.label": "Se déconnecter",
"greeting.morning": "Bonjour!",
"greeting.afternoon": "Bon après-midi!",
"greeting.evening": "Bonne soirée!",
"leanerDashboard.switchToProgram": "Passer aux programmes",
"leanerDashboard.exploreCourses": "Explorer les cours",
"leanerDashboard.courseSearchAlt": "Recherche de cours",
"learnerVariantDashboard.menu.dashboard.label": "Tableau de bord", "learnerVariantDashboard.menu.dashboard.label": "Tableau de bord",
"learnerVariantDashboard.help.label": "Aide", "learnerVariantDashboard.help.label": "Aide",
"learnerVariantDashboard.menu.profile.label": "Profil", "learnerVariantDashboard.menu.profile.label": "Profil",
@@ -37,17 +24,33 @@
"learnerVariantDashboard.course": "Cours", "learnerVariantDashboard.course": "Cours",
"learnerVariantDashboard.program": "Programmes", "learnerVariantDashboard.program": "Programmes",
"learnerVariantDashboard.discoverNew": "Découvrir les nouveautés", "learnerVariantDashboard.discoverNew": "Découvrir les nouveautés",
"learnerVariantDashboard.logoAltText": "Tableau de bord edX, Inc.", "learnerVariantDashboard.logoAltText": "Tableau de bord EDUlib, Inc.",
"learnerVariantDashboard.collapseMenuOpenAltText": "Menu", "learnerVariantDashboard.collapseMenuOpenAltText": "Menu",
"learnerVariantDashboard.collapseMenuClosedAltText": "Fermer", "learnerVariantDashboard.collapseMenuClosedAltText": "Fermer",
"leanerDashboard.menu.career.label": "Carrière",
"header.menu.new.label": "Nouveau",
"MasqueradeBar.ViewAs": "Consulter comme :", "MasqueradeBar.ViewAs": "Consulter comme :",
"MasqueradeBar.ViewingAs": "Affichage en tant que :", "MasqueradeBar.ViewingAs": "Affichage en tant que :",
"MasqueradeBar.SubmitButton": "Soumettre", "MasqueradeBar.SubmitButton": "Soumettre",
"MasqueradeBar.StudentNameInput": "Nom d'utilisateur ou courriel", "MasqueradeBar.StudentNameInput": "Nom d'utilisateur ou courriel",
"MasqueradeBar.NoStudentFound": "Aucun étudiant avec ce nom d'utilisateur ou cette adresse courriel n'a pu être trouvé", "MasqueradeBar.NoStudentFound": "Aucun étudiant avec ce nom d'utilisateur ou cette adresse courriel n'a pu être trouvé",
"MasqueradeBar.UnknownError": "Une erreur inconnue est survenue", "MasqueradeBar.UnknownError": "Une erreur inconnue est survenue",
"WidgetSidebar.lookingForChallengePrompt": "A la recherche d'un nouveau défi ?", "WidgetSidebar.lookingForChallengePrompt": "A la recherche d'un nouveau défi?",
"WidgetSidebar.findCoursesButton": "Trouver un cours {arrow}", "WidgetSidebar.findCoursesButton": "Trouver un cours {arrow}",
"ProductRecommendations.recommendationsHeading": "Vous pourriez aussi aimer",
"ProductRecommendations.executiveEducationHeading": "Formation des cadres",
"ProductRecommendations.executiveEducationDescription": "Cours abrégés pour développer les compétences en leadership",
"ProductRecommendations.bootcampHeading": "Camp d'entraînement",
"ProductRecommendations.bootcampDescription": "Formation intensive, pratique et basée sur des projets",
"ProductRecommendations.courseHeading": "Cours",
"ProductRecommendations.courseDescription": "Trouvez de nouveaux intérêts et faites progresser votre carrière",
"RecommendationsPanel.recommendationsFeatureText": "La fonctionnalité de recommandations personnalisées n'est pas encore disponible. Nous travaillons fort pour le proposer à votre apprenant dans un avenir rapproché.",
"RecommendationsPanel.recommendationsAlertText": "Souhaitez-vous être alerté dès qu'il sera disponible?",
"RecommendationsPanel.recommendationsModalHeading": "Merci pour votre intérêt!",
"RecommendationsPanel.modalSkipButton": "Ignorer pour l'instant",
"RecommendationsPanel.modalCountMeButton": "Comptez sur moi!",
"learnerVariantDashboard.recommendedForYou": "Recommandé pour vous",
"RecommendationsPanel.seeAllRecommendationsButton": "Voir toutes les recommandations",
"RecommendationsPanel.recommendationsHeading": "Des recommandations pour vous", "RecommendationsPanel.recommendationsHeading": "Des recommandations pour vous",
"RecommendationsPanel.popularCoursesHeading": "Cours populaires", "RecommendationsPanel.popularCoursesHeading": "Cours populaires",
"RecommendationsPanel.exploreCoursesButton": "Explorer les cours" "RecommendationsPanel.exploreCoursesButton": "Explorer les cours"

View File

@@ -14,25 +14,12 @@
"leanerDashboard.confirmEmailModalHeader": "Confirm your email", "leanerDashboard.confirmEmailModalHeader": "Confirm your email",
"leanerDashboard.confirmEmailModalBody": "We've sent you an email to verify your acccount. Please check your inbox and click on the big red button to confirm and keep learning.", "leanerDashboard.confirmEmailModalBody": "We've sent you an email to verify your acccount. Please check your inbox and click on the big red button to confirm and keep learning.",
"leanerDashboard.confirmEmailImageAlt": "confirm email background", "leanerDashboard.confirmEmailImageAlt": "confirm email background",
"leanerDashboard.menu.dashboard.label": "Painel de controle",
"leanerDashboard.help.label": "Ajuda",
"leanerDashboard.menu.profile.label": "Perfil",
"leanerDashboard.menu.viewPrograms.label": "View Programs",
"leanerDashboard.menu.account.label": "Conta",
"leanerDashboard.menu.orderHistory.label": "Histórico de pedidos",
"leanerDashboard.menu.signOut.label": "Sair",
"greeting.morning": "Good Morning!",
"greeting.afternoon": "Good Afternoon!",
"greeting.evening": "Good Evening!",
"leanerDashboard.switchToProgram": "Switch to Programs",
"leanerDashboard.exploreCourses": "Explorar cursos",
"leanerDashboard.courseSearchAlt": "Course search",
"learnerVariantDashboard.menu.dashboard.label": "Painel de controle", "learnerVariantDashboard.menu.dashboard.label": "Painel de controle",
"learnerVariantDashboard.help.label": "Ajuda", "learnerVariantDashboard.help.label": "Ajuda",
"learnerVariantDashboard.menu.profile.label": "Perfil", "learnerVariantDashboard.menu.profile.label": "Perfil",
"learnerVariantDashboard.menu.viewPrograms.label": "View Programs", "learnerVariantDashboard.menu.viewPrograms.label": "View Programs",
"learnerVariantDashboard.menu.account.label": "Conta", "learnerVariantDashboard.menu.account.label": "Conta",
"learnerVariantDashboard.menu.orderHistory.label": "Histórico de pedidos", "learnerVariantDashboard.menu.orderHistory.label": "Order History",
"learnerVariantDashboard.menu.signOut.label": "Sair", "learnerVariantDashboard.menu.signOut.label": "Sair",
"learnerVariantDashboard.course": "Cursos", "learnerVariantDashboard.course": "Cursos",
"learnerVariantDashboard.program": "Programas", "learnerVariantDashboard.program": "Programas",
@@ -40,6 +27,8 @@
"learnerVariantDashboard.logoAltText": "edX, Inc. Dashboard", "learnerVariantDashboard.logoAltText": "edX, Inc. Dashboard",
"learnerVariantDashboard.collapseMenuOpenAltText": "Menu", "learnerVariantDashboard.collapseMenuOpenAltText": "Menu",
"learnerVariantDashboard.collapseMenuClosedAltText": "Fechar", "learnerVariantDashboard.collapseMenuClosedAltText": "Fechar",
"leanerDashboard.menu.career.label": "Career",
"header.menu.new.label": "New",
"MasqueradeBar.ViewAs": "Ver como: ", "MasqueradeBar.ViewAs": "Ver como: ",
"MasqueradeBar.ViewingAs": "Viewing as: ", "MasqueradeBar.ViewingAs": "Viewing as: ",
"MasqueradeBar.SubmitButton": "Enviar", "MasqueradeBar.SubmitButton": "Enviar",
@@ -48,6 +37,20 @@
"MasqueradeBar.UnknownError": "An unknown error occurred", "MasqueradeBar.UnknownError": "An unknown error occurred",
"WidgetSidebar.lookingForChallengePrompt": "Looking for a new challenge?", "WidgetSidebar.lookingForChallengePrompt": "Looking for a new challenge?",
"WidgetSidebar.findCoursesButton": "Find a course {arrow}", "WidgetSidebar.findCoursesButton": "Find a course {arrow}",
"ProductRecommendations.recommendationsHeading": "You might also like",
"ProductRecommendations.executiveEducationHeading": "Executive Education",
"ProductRecommendations.executiveEducationDescription": "Short Courses to develop leadership skills",
"ProductRecommendations.bootcampHeading": "Boot Camp",
"ProductRecommendations.bootcampDescription": "Intensive, hands-on, project based training",
"ProductRecommendations.courseHeading": "Courses",
"ProductRecommendations.courseDescription": "Find new interests and advance your career",
"RecommendationsPanel.recommendationsFeatureText": "Personalized recommendations feature is not yet available. We are working hard on bringing it to your learner home in the near future.",
"RecommendationsPanel.recommendationsAlertText": "Would you like to be alerted when it becomes available?",
"RecommendationsPanel.recommendationsModalHeading": "Thank you for your interest!",
"RecommendationsPanel.modalSkipButton": "Skip for now",
"RecommendationsPanel.modalCountMeButton": "Count me in!",
"learnerVariantDashboard.recommendedForYou": "Recommended For You",
"RecommendationsPanel.seeAllRecommendationsButton": "See All Recommendations",
"RecommendationsPanel.recommendationsHeading": "Recommendations for you", "RecommendationsPanel.recommendationsHeading": "Recommendations for you",
"RecommendationsPanel.popularCoursesHeading": "Popular courses", "RecommendationsPanel.popularCoursesHeading": "Popular courses",
"RecommendationsPanel.exploreCoursesButton": "Explorar cursos" "RecommendationsPanel.exploreCoursesButton": "Explorar cursos"

View File

@@ -14,19 +14,6 @@
"leanerDashboard.confirmEmailModalHeader": "Confirm your email", "leanerDashboard.confirmEmailModalHeader": "Confirm your email",
"leanerDashboard.confirmEmailModalBody": "We've sent you an email to verify your acccount. Please check your inbox and click on the big red button to confirm and keep learning.", "leanerDashboard.confirmEmailModalBody": "We've sent you an email to verify your acccount. Please check your inbox and click on the big red button to confirm and keep learning.",
"leanerDashboard.confirmEmailImageAlt": "confirm email background", "leanerDashboard.confirmEmailImageAlt": "confirm email background",
"leanerDashboard.menu.dashboard.label": "Dashboard",
"leanerDashboard.help.label": "Help",
"leanerDashboard.menu.profile.label": "Profile",
"leanerDashboard.menu.viewPrograms.label": "View Programs",
"leanerDashboard.menu.account.label": "Account",
"leanerDashboard.menu.orderHistory.label": "Order History",
"leanerDashboard.menu.signOut.label": "Sign Out",
"greeting.morning": "Good Morning!",
"greeting.afternoon": "Good Afternoon!",
"greeting.evening": "Good Evening!",
"leanerDashboard.switchToProgram": "Switch to Programs",
"leanerDashboard.exploreCourses": "Explore courses",
"leanerDashboard.courseSearchAlt": "Course search",
"learnerVariantDashboard.menu.dashboard.label": "Dashboard", "learnerVariantDashboard.menu.dashboard.label": "Dashboard",
"learnerVariantDashboard.help.label": "Help", "learnerVariantDashboard.help.label": "Help",
"learnerVariantDashboard.menu.profile.label": "Profile", "learnerVariantDashboard.menu.profile.label": "Profile",
@@ -40,6 +27,8 @@
"learnerVariantDashboard.logoAltText": "edX, Inc. Dashboard", "learnerVariantDashboard.logoAltText": "edX, Inc. Dashboard",
"learnerVariantDashboard.collapseMenuOpenAltText": "Menu", "learnerVariantDashboard.collapseMenuOpenAltText": "Menu",
"learnerVariantDashboard.collapseMenuClosedAltText": "Close", "learnerVariantDashboard.collapseMenuClosedAltText": "Close",
"leanerDashboard.menu.career.label": "Career",
"header.menu.new.label": "New",
"MasqueradeBar.ViewAs": "View as: ", "MasqueradeBar.ViewAs": "View as: ",
"MasqueradeBar.ViewingAs": "Viewing as: ", "MasqueradeBar.ViewingAs": "Viewing as: ",
"MasqueradeBar.SubmitButton": "Submit", "MasqueradeBar.SubmitButton": "Submit",
@@ -48,6 +37,20 @@
"MasqueradeBar.UnknownError": "An unknown error occurred", "MasqueradeBar.UnknownError": "An unknown error occurred",
"WidgetSidebar.lookingForChallengePrompt": "Looking for a new challenge?", "WidgetSidebar.lookingForChallengePrompt": "Looking for a new challenge?",
"WidgetSidebar.findCoursesButton": "Find a course {arrow}", "WidgetSidebar.findCoursesButton": "Find a course {arrow}",
"ProductRecommendations.recommendationsHeading": "You might also like",
"ProductRecommendations.executiveEducationHeading": "Executive Education",
"ProductRecommendations.executiveEducationDescription": "Short Courses to develop leadership skills",
"ProductRecommendations.bootcampHeading": "Boot Camp",
"ProductRecommendations.bootcampDescription": "Intensive, hands-on, project based training",
"ProductRecommendations.courseHeading": "Courses",
"ProductRecommendations.courseDescription": "Find new interests and advance your career",
"RecommendationsPanel.recommendationsFeatureText": "Personalized recommendations feature is not yet available. We are working hard on bringing it to your learner home in the near future.",
"RecommendationsPanel.recommendationsAlertText": "Would you like to be alerted when it becomes available?",
"RecommendationsPanel.recommendationsModalHeading": "Thank you for your interest!",
"RecommendationsPanel.modalSkipButton": "Skip for now",
"RecommendationsPanel.modalCountMeButton": "Count me in!",
"learnerVariantDashboard.recommendedForYou": "Recommended For You",
"RecommendationsPanel.seeAllRecommendationsButton": "See All Recommendations",
"RecommendationsPanel.recommendationsHeading": "Recommendations for you", "RecommendationsPanel.recommendationsHeading": "Recommendations for you",
"RecommendationsPanel.popularCoursesHeading": "Popular courses", "RecommendationsPanel.popularCoursesHeading": "Popular courses",
"RecommendationsPanel.exploreCoursesButton": "Explore courses" "RecommendationsPanel.exploreCoursesButton": "Explore courses"

View File

@@ -4,12 +4,14 @@ import 'regenerator-runtime/runtime';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { Switch, Redirect } from 'react-router-dom'; import {
Route, Navigate, Routes,
} from 'react-router-dom';
import { import {
AppProvider, AppProvider,
ErrorPage, ErrorPage,
PageRoute, PageWrap,
} from '@edx/frontend-platform/react'; } from '@edx/frontend-platform/react';
import store from 'data/store'; import store from 'data/store';
import { import {
@@ -31,12 +33,10 @@ subscribe(APP_READY, () => {
ReactDOM.render( ReactDOM.render(
<AppProvider store={store}> <AppProvider store={store}>
<NoticesWrapper> <NoticesWrapper>
<Switch> <Routes>
<PageRoute path="/"> <Route path="/" element={<PageWrap><App /></PageWrap>} />
<App /> <Route path="*" element={<Navigate to="/" replace />} />
</PageRoute> </Routes>
<Redirect to="/" />
</Switch>
</NoticesWrapper> </NoticesWrapper>
</AppProvider>, </AppProvider>,
document.getElementById('root'), document.getElementById('root'),

View File

@@ -3,7 +3,7 @@ import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect'; import '@testing-library/jest-dom/extend-expect';
import Enzyme from 'enzyme'; import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
Enzyme.configure({ adapter: new Adapter() }); Enzyme.configure({ adapter: new Adapter() });
@@ -14,6 +14,7 @@ jest.mock('react', () => ({
useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })), useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
useMemo: jest.fn((cb, prereqs) => cb(prereqs)), useMemo: jest.fn((cb, prereqs) => cb(prereqs)),
useContext: jest.fn(context => context), useContext: jest.fn(context => context),
useState: jest.fn(),
})); }));
jest.mock('reselect', () => ({ jest.mock('reselect', () => ({
@@ -144,6 +145,8 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
Sheet: 'Sheet', Sheet: 'Sheet',
StatefulButton: 'StatefulButton', StatefulButton: 'StatefulButton',
TextFilter: 'TextFilter', TextFilter: 'TextFilter',
Truncate: 'Truncate',
Skeleton: 'Skeleton',
Spinner: 'Spinner', Spinner: 'Spinner',
PageBanner: 'PageBanner', PageBanner: 'PageBanner',
Pagination: 'Pagination', Pagination: 'Pagination',

View File

@@ -42,8 +42,9 @@ jest.unmock('react-redux');
jest.unmock('reselect'); jest.unmock('reselect');
jest.unmock('hooks'); jest.unmock('hooks');
jest.mock('containers/WidgetContainers/LoadedSidebar', () => 'loaded-widget-sidebar'); jest.mock('containers/WidgetContainers/LoadedSidebar', () => jest.fn(() => 'loaded-widget-sidebar'));
jest.mock('containers/WidgetContainers/NoCoursesSidebar', () => 'no-courses-widget-sidebar'); jest.mock('containers/WidgetContainers/NoCoursesSidebar', () => jest.fn(() => 'no-courses-widget-sidebar'));
jest.mock('containers/WidgetContainers/WidgetFooter', () => 'product-recommendations-footer');
jest.mock('components/NoticesWrapper', () => 'notices-wrapper'); jest.mock('components/NoticesWrapper', () => 'notices-wrapper');
jest.mock('@edx/frontend-platform', () => ({ jest.mock('@edx/frontend-platform', () => ({
@@ -60,6 +61,10 @@ jest.mock('@edx/frontend-platform/auth', () => ({
getLoginRedirectUrl: jest.fn(), getLoginRedirectUrl: jest.fn(),
})); }));
jest.mock('ExperimentContext', () => ({
ExperimentProvider: 'div'
}));
jest.mock('@edx/frontend-enterprise-hotjar', () => ({ jest.mock('@edx/frontend-enterprise-hotjar', () => ({
initializeHotjar: jest.fn(), initializeHotjar: jest.fn(),
})); }));

View File

@@ -0,0 +1,110 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProductRecommendations matches snapshot 1`] = `
<LoadedView
crossProductCourses={
Array [
Object {
"courseRunKey": "course-v1:Test+Course+2022T2",
"courseType": "executive-education-2u",
"image": Object {
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
},
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
"owners": Array [
Object {
"key": "HarvardX",
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
"name": "Harvard University",
},
],
"title": "Introduction to Computer Science",
},
Object {
"courseRunKey": "course-v1:Test+Course+2022T2",
"courseType": "bootcamp-2u",
"image": Object {
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
},
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
"owners": Array [
Object {
"key": "HarvardX",
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
"name": "Harvard University",
},
],
"title": "Introduction to Computer Science",
},
]
}
openCourses={
Array [
Object {
"courseRunKey": "course-v1:Test+Course+2022T2",
"courseType": "verified-audit",
"image": Object {
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
},
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
"owners": Array [
Object {
"key": "HarvardX",
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
"name": "Harvard University",
},
],
"title": "Introduction to Computer Science",
},
Object {
"courseRunKey": "course-v1:Test+Course+2022T2",
"courseType": "audit",
"image": Object {
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
},
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
"owners": Array [
Object {
"key": "HarvardX",
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
"name": "Harvard University",
},
],
"title": "Introduction to Computer Science",
},
Object {
"courseRunKey": "course-v1:Test+Course+2022T2",
"courseType": "verified",
"image": Object {
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
},
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
"owners": Array [
Object {
"key": "HarvardX",
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
"name": "Harvard University",
},
],
"title": "Introduction to Computer Science",
},
Object {
"courseRunKey": "course-v1:Test+Course+2022T2",
"courseType": "course",
"image": Object {
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
},
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
"owners": Array [
Object {
"key": "HarvardX",
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
"name": "Harvard University",
},
],
"title": "Introduction to Computer Science",
},
]
}
/>
`;

View File

@@ -0,0 +1,19 @@
import { get, stringifyUrl } from 'data/services/lms/utils';
import urls from 'data/services/lms/urls';
export const crossProductAndAmplitudeRecommendationsUrl = (courseId) => `${urls.getApiUrl()}/edx_recommendations/learner_dashboard/cross_product/${courseId}/`;
export const amplitudeRecommendationsUrl = () => `${urls.getApiUrl()}/edx_recommendations/learner_dashboard/amplitude/v2/`;
export const recommendationsContextUrl = () => `${urls.getApiUrl()}/edx_recommendations/learner_dashboard/recommendations_context/`;
const fetchRecommendationsContext = () => get(stringifyUrl(recommendationsContextUrl()));
const fetchCrossProductRecommendations = (courseId) => (
get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl(courseId)))
);
const fetchAmplitudeRecommendations = () => get(stringifyUrl(amplitudeRecommendationsUrl()));
export default {
fetchCrossProductRecommendations,
fetchAmplitudeRecommendations,
fetchRecommendationsContext,
};

View File

@@ -0,0 +1,34 @@
import { get, stringifyUrl } from 'data/services/lms/utils';
import api, { crossProductAndAmplitudeRecommendationsUrl, amplitudeRecommendationsUrl, recommendationsContextUrl } from './api';
jest.mock('data/services/lms/utils', () => ({
stringifyUrl: (...args) => ({ stringifyUrl: args }),
get: (...args) => ({ get: args }),
}));
describe('productRecommendationCourses api', () => {
describe('fetchCrossProductRecommendations', () => {
it('calls get with the correct recommendation courses URL', () => {
expect(api.fetchCrossProductRecommendations('CourseRunKey')).toEqual(
get(stringifyUrl(crossProductAndAmplitudeRecommendationsUrl('CourseRunKey'))),
);
});
});
describe('fetchAmplitudeRecommendations', () => {
it('calls get with the correct recommendation courses URL', () => {
expect(api.fetchAmplitudeRecommendations()).toEqual(
get(stringifyUrl(amplitudeRecommendationsUrl())),
);
});
});
describe('fetchRecommendationsContext', () => {
it('calls get with the correct recommendation courses URL', () => {
expect(api.fetchRecommendationsContext()).toEqual(
get(stringifyUrl(recommendationsContextUrl())),
);
});
});
});

View File

@@ -0,0 +1,46 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { Container } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import { courseShape, courseTypeToProductTypeMap } from '../utils';
import ProductCardContainer from './ProductCardContainer';
const LoadedView = ({ crossProductCourses, openCourses }) => {
const { formatMessage } = useIntl();
const includesCrossProductTypes = crossProductCourses.length === 2;
const finalProductList = useMemo(() => {
if (includesCrossProductTypes) {
const openCourseList = openCourses.slice(0, 2);
return crossProductCourses.concat(openCourseList);
}
return openCourses;
}, [crossProductCourses, openCourses, includesCrossProductTypes]);
const courseTypes = [...new Set(finalProductList.map((item) => courseTypeToProductTypeMap[item.courseType]))];
return (
<Container
size="lg"
className="recommendations-container pt-sm-5 pt-4.5 pb-2 pb-sm-4.5"
>
<h2>
{formatMessage(messages.recommendationsHeading)}
</h2>
<ProductCardContainer finalProductList={finalProductList} courseTypes={courseTypes} />
</Container>
);
};
LoadedView.propTypes = {
crossProductCourses: PropTypes.arrayOf(
PropTypes.shape(courseShape),
).isRequired,
openCourses: PropTypes.arrayOf(
PropTypes.shape(courseShape),
).isRequired,
};
export default LoadedView;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mockCrossProductCourses, mockOpenCourses } from '../testData';
import LoadedView from './LoadedView';
describe('ProductRecommendations LoadedView', () => {
it('matches snapshot', () => {
expect(
shallow(
<LoadedView
crossProductCourses={mockCrossProductCourses}
openCourses={mockOpenCourses}
/>,
),
).toMatchSnapshot();
});
describe('with less than 2 cross product courses', () => {
it('passes in one course type and 4 open courses to the ProductCardContainer props', () => {
const wrapper = shallow(
<LoadedView
crossProductCourses={[mockCrossProductCourses[0]]}
openCourses={mockOpenCourses}
/>,
);
const productCardContainerProps = wrapper.find('ProductCardContainer').props();
expect(productCardContainerProps.courseTypes.length).toEqual(1);
expect(productCardContainerProps.courseTypes[0]).toEqual('Course');
expect(productCardContainerProps.finalProductList).toEqual(mockOpenCourses);
});
});
});

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { Skeleton } from '@edx/paragon';
export const LoadingView = () => (
<Skeleton height={100} />
);
export default LoadingView;

View File

@@ -0,0 +1,10 @@
import React from 'react';
import { shallow } from 'enzyme';
import LoadingView from './LoadingView';
describe('ProductRecommendations LoadingView', () => {
it('matches snapshot', () => {
expect(shallow(<LoadingView />)).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,98 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Badge,
Card,
Truncate,
Hyperlink,
} from '@edx/paragon';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { trackProductCardClicked, trackCourseCardClicked } from '../optimizelyExperiment';
import { productCardClicked, discoveryCardClicked } from '../track';
import { bootCamp, executiveEducation } from '../constants';
const ProductCard = ({
title,
subtitle,
headerImage,
courseRunKey,
schoolLogo,
courseType,
url,
}) => {
const handleClick = (type, link) => {
const crossProductTypes = [executiveEducation, bootCamp];
const userId = getAuthenticatedUser().userId.toString();
if (crossProductTypes.includes(type)) {
trackProductCardClicked(userId);
productCardClicked(courseRunKey, title, type, link);
} else {
trackCourseCardClicked(userId);
discoveryCardClicked(courseRunKey, title, link);
}
};
const getRedirectUrl = (link) => {
const urlObj = new URL(link);
const hasQueryStringParameters = urlObj.search !== '';
if (hasQueryStringParameters) {
return `${link}&linked_from=recommender`;
}
return `${link}?linked_from=recommender`;
};
const redirectUrl = getRedirectUrl(url);
return (
<Card
className="base-card d-flex text-decoration-none"
as={Hyperlink}
destination={redirectUrl}
onClick={() => {
handleClick(courseType, redirectUrl);
}}
isClickable
>
<Card.ImageCap
src={headerImage}
srcAlt={`header image for ${title}`}
logoSrc={schoolLogo}
logoAlt={`logo for ${subtitle}`}
/>
<Card.Header
className="mt-2"
title={(
<Truncate lines={3} ellipsis="…" className="product-card-title font-weight-bold">
{title}
</Truncate>
)}
subtitle={(
<Truncate lines={1} className="product-card-subtitle font-weight-normal">
{subtitle}
</Truncate>
)}
/>
<Card.Section>
<div className="product-badge position-absolute">
<Badge className="bg-light-500 text-dark-500">{courseType}</Badge>
</div>
</Card.Section>
</Card>
);
};
ProductCard.propTypes = {
title: PropTypes.string.isRequired,
subtitle: PropTypes.string.isRequired,
headerImage: PropTypes.string.isRequired,
courseRunKey: PropTypes.string.isRequired,
schoolLogo: PropTypes.string.isRequired,
courseType: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
};
export default ProductCard;

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mockCrossProductCourses, mockOpenCourses, mockFallbackOpenCourse } from '../testData';
import { trackProductCardClicked, trackCourseCardClicked } from '../optimizelyExperiment';
import { productCardClicked, discoveryCardClicked } from '../track';
import ProductCard from './ProductCard';
import { courseTypeToProductTypeMap } from '../utils';
jest.mock('../optimizelyExperiment', () => ({
trackProductCardClicked: jest.fn(),
trackCourseCardClicked: jest.fn(),
}));
jest.mock('../track', () => ({
productCardClicked: jest.fn(),
discoveryCardClicked: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({ userId: '1' })),
}));
describe('ProductRecommendations ProductCard', () => {
const getProps = (course) => {
const {
title,
owners: [{ name: subtitle }],
image: { src: headerImage },
owners: [{ logoImageUrl: schoolLogo }],
} = course;
return {
title,
subtitle,
headerImage,
schoolLogo,
courseType: courseTypeToProductTypeMap[course.courseType],
courseRunKey: course.courseRunKey,
url: course.marketingUrl,
};
};
const crossProductProps = getProps(mockCrossProductCourses[0]);
const openCourseProps = getProps(mockOpenCourses[0]);
const fallbackOpenCourseProps = getProps(mockFallbackOpenCourse[0]);
it('matches snapshot', () => {
expect(shallow(<ProductCard {...crossProductProps} />)).toMatchSnapshot();
});
it('has the query string parameter attached to a fallback recommendations url', () => {
const wrapper = shallow(<ProductCard {...fallbackOpenCourseProps} />);
const cardUrl = wrapper.find('Card').props().destination;
expect(cardUrl).toEqual('https://www.edx.org/course/some-course?linked_from=recommender');
});
it('send outs experiment events related to open courses when clicked', () => {
const wrapper = shallow(<ProductCard {...openCourseProps} />);
const { courseRunKey, title, url } = openCourseProps;
wrapper.simulate('click');
expect(trackCourseCardClicked).toHaveBeenCalledWith('1');
expect(discoveryCardClicked).toHaveBeenCalledWith(courseRunKey, title, `${url}&linked_from=recommender`);
});
it('send outs experiment events related to cross product courses when clicked', () => {
const wrapper = shallow(<ProductCard {...crossProductProps} />);
const {
courseRunKey,
title,
courseType,
url,
} = crossProductProps;
wrapper.simulate('click');
expect(trackProductCardClicked).toHaveBeenCalledWith('1');
expect(productCardClicked).toHaveBeenCalledWith(courseRunKey, title, courseType, `${url}&linked_from=recommender`);
});
});

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { courseShape, courseTypeToProductTypeMap } from '../utils';
import { course } from '../constants';
import ProductCard from './ProductCard';
import ProductCardHeader from './ProductCardHeader';
const ProductCardContainer = ({ finalProductList, courseTypes }) => (
<div className="product-card-container d-flex">
{finalProductList
&& courseTypes.map((type) => (
<div key={type}>
<ProductCardHeader courseType={type} />
<div
className={classNames('d-flex', {
'course-subcontainer': type === course,
})}
>
{finalProductList
.filter((courseObj) => courseTypeToProductTypeMap[courseObj.courseType] === type)
.map((item) => (
<ProductCard
key={item.title}
url={item.marketingUrl}
title={item.title}
subtitle={item.owners[0].name}
headerImage={item.image.src}
courseRunKey={item.courseRunKey}
schoolLogo={item.owners[0].logoImageUrl}
courseType={type}
/>
))}
</div>
</div>
))}
</div>
);
ProductCardContainer.propTypes = {
finalProductList: PropTypes.arrayOf(
PropTypes.shape(courseShape),
).isRequired,
courseTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
};
export default ProductCardContainer;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mockCrossProductCourses, mockOpenCourses } from '../testData';
import ProductCardContainer from './ProductCardContainer';
import { executiveEducation, bootCamp, course } from '../constants';
describe('ProductRecommendations ProductCardContainer', () => {
const props = {
finalProductList: [...mockCrossProductCourses, ...mockOpenCourses],
courseTypes: [executiveEducation, bootCamp, course],
};
it('matches snapshot', () => {
expect(shallow(<ProductCardContainer {...props} />)).toMatchSnapshot();
});
describe('with finalCourseList containing cross product and open courses', () => {
it('renders 3 ProductCardHeaders with the 3 different course types', () => {
const wrapper = shallow(<ProductCardContainer {...props} />);
const productCardHeaders = wrapper.find('ProductCardHeader');
expect(productCardHeaders.length).toEqual(3);
productCardHeaders.forEach((header, index) => {
expect(header.props().courseType).toEqual(props.courseTypes[index]);
});
});
});
describe('with finalCourseList containing only open courses', () => {
it('renders 1 ProductHeader with the one course type', () => {
const openCoursesProps = {
finalProductList: [...mockOpenCourses, ...mockOpenCourses],
courseTypes: ['Course'],
};
const wrapper = shallow(<ProductCardContainer {...openCoursesProps} />);
const productCardHeaders = wrapper.find('ProductCardHeader');
expect(productCardHeaders.length).toEqual(1);
expect(productCardHeaders.at(0).props().courseType).toEqual(openCoursesProps.courseTypes[0]);
});
});
});

View File

@@ -0,0 +1,76 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { Icon, Hyperlink } from '@edx/paragon';
import { ChevronRight } from '@edx/paragon/icons';
import { trackProductHeaderClicked } from '../optimizelyExperiment';
import { recommendationsHeaderClicked } from '../track';
import { executiveEducation, bootCamp } from '../constants';
import messages from '../messages';
const ProductCardHeader = ({ courseType }) => {
const getProductTypeDetail = (type) => {
switch (type) {
case executiveEducation:
return {
heading: messages.executiveEducationHeading,
description: messages.executiveEducationDescription,
url: '/executive-education?linked_from=recommender',
};
case bootCamp:
return {
heading: messages.bootcampHeading,
description: messages.bootcampDescription,
url: '/boot-camps?linked_from=recommender',
};
default: {
return {
heading: messages.courseHeading,
description: messages.courseDescription,
url: '/search?tab=course',
};
}
}
};
const handleClick = (type, url) => {
const userId = getAuthenticatedUser().userId.toString();
trackProductHeaderClicked(userId);
recommendationsHeaderClicked(type, url);
};
const { formatMessage } = useIntl();
const productTypeDetail = getProductTypeDetail(courseType);
const headerUrl = `${process.env.MARKETING_SITE_BASE_URL}${productTypeDetail.url}`;
return (
<div>
<Hyperlink
destination={headerUrl}
className="base-card-link text-decoration-none"
onClick={() => {
handleClick(courseType, headerUrl);
}}
>
<div className="d-flex align-items-center border-bottom">
<h3 className="h3 mb-2 text-left">
{formatMessage(productTypeDetail.heading)}
</h3>
<Icon src={ChevronRight} className="text-primary-500 ml-2.5" />
</div>
</Hyperlink>
<p className="text-gray-500 x-small mt-2 mb-2">
{formatMessage(productTypeDetail.description)}
</p>
</div>
);
};
ProductCardHeader.propTypes = {
courseType: PropTypes.string.isRequired,
};
export default ProductCardHeader;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { shallow } from 'enzyme';
import ProductCardHeader from './ProductCardHeader';
import { executiveEducation, bootCamp } from '../constants';
import { trackProductHeaderClicked } from '../optimizelyExperiment';
import { recommendationsHeaderClicked } from '../track';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(() => ({ userId: '1' })),
}));
jest.mock('../optimizelyExperiment', () => ({
trackProductHeaderClicked: jest.fn(),
}));
jest.mock('../track', () => ({
recommendationsHeaderClicked: jest.fn(),
}));
describe('ProductRecommendations ProductCardHeader', () => {
const coursesType = 'Courses';
it('matches snapshot', () => {
expect(shallow(<ProductCardHeader courseType={executiveEducation} />)).toMatchSnapshot();
});
describe('with bootcamp courseType prop', () => {
it('renders a bootcamp header', () => {
const wrapper = shallow(<ProductCardHeader courseType={bootCamp} />);
expect(wrapper.find('h3').text()).toEqual(bootCamp);
});
});
describe('with courses courseType prop', () => {
it('renders a courses header', () => {
const wrapper = shallow(<ProductCardHeader courseType={coursesType} />);
expect(wrapper.find('h3').text()).toEqual(coursesType);
});
});
it('send outs experiment events when clicked', () => {
const wrapper = shallow(<ProductCardHeader courseType={executiveEducation} />);
const hyperLink = wrapper.find('Hyperlink');
const execEdLink = 'http://localhost:18000/executive-education?linked_from=recommender';
hyperLink.simulate('click');
expect(trackProductHeaderClicked).toHaveBeenCalledWith('1');
expect(recommendationsHeaderClicked).toHaveBeenCalledWith(executiveEducation, execEdLink);
});
});

View File

@@ -0,0 +1,89 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProductRecommendations LoadedView matches snapshot 1`] = `
<Container
className="recommendations-container pt-sm-5 pt-4.5 pb-2 pb-sm-4.5"
size="lg"
>
<h2>
You might also like
</h2>
<ProductCardContainer
courseTypes={
Array [
"Executive Education",
"Boot Camp",
"Course",
]
}
finalProductList={
Array [
Object {
"courseRunKey": "course-v1:Test+Course+2022T2",
"courseType": "executive-education-2u",
"image": Object {
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
},
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
"owners": Array [
Object {
"key": "HarvardX",
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
"name": "Harvard University",
},
],
"title": "Introduction to Computer Science",
},
Object {
"courseRunKey": "course-v1:Test+Course+2022T2",
"courseType": "bootcamp-2u",
"image": Object {
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
},
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
"owners": Array [
Object {
"key": "HarvardX",
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
"name": "Harvard University",
},
],
"title": "Introduction to Computer Science",
},
Object {
"courseRunKey": "course-v1:Test+Course+2022T2",
"courseType": "verified-audit",
"image": Object {
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
},
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
"owners": Array [
Object {
"key": "HarvardX",
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
"name": "Harvard University",
},
],
"title": "Introduction to Computer Science",
},
Object {
"courseRunKey": "course-v1:Test+Course+2022T2",
"courseType": "audit",
"image": Object {
"src": "https://www.image-2.com/ed79a49b-64c1-48d2-afdc-054bf921e38d-6a76ceb47dea.small.jpg",
},
"marketingUrl": "https://www.edx.org/course/some-course?utm_source=source",
"owners": Array [
Object {
"key": "HarvardX",
"logoImageUrl": "http://www.image.com/ef72daf3-c9a1-4c00-ba37-b3514392bdcf-8839c516815a.png",
"name": "Harvard University",
},
],
"title": "Introduction to Computer Science",
},
]
}
/>
</Container>
`;

View File

@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProductRecommendations LoadingView matches snapshot 1`] = `
<Skeleton
height={100}
/>
`;

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