Compare commits

..

70 Commits

Author SHA1 Message Date
Jason Wesson
3663efb283 feat!: remove custom header settings 2025-06-03 18:56:32 +00:00
renovate[bot]
9ef5840700 fix(deps): update dependency @edx/frontend-component-footer to v14.8.0 (#632)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 08:44:31 +00:00
renovate[bot]
3c8c92ab92 fix(deps): update dependency @edx/frontend-platform to v8.3.8 (#631)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 05:44:31 +00:00
renovate[bot]
7488fe55f0 chore(deps): update dependency @openedx/frontend-build to v14.6.0 (#610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jason Wesson <jsnwesson@gmail.com>
2025-05-28 20:40:21 +00:00
renovate[bot]
fe6c726306 fix(deps): update dependency core-js to v3.42.0 (#630)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-26 12:30:22 +00:00
renovate[bot]
8c3d62c12b fix(deps): update dependency @openedx/paragon to v22.18.1 (#629)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-26 05:35:25 +00:00
diana-villalvazo-wgu
66794acf17 feat: add advertised start date on course card (#619) 2025-05-23 15:51:37 -04:00
Maxwell Frank
2f3f3bcd8b fix: email banner bug (#628) 2025-05-23 13:49:01 -04:00
diana-villalvazo-wgu
dcab4f1b75 feat: add NON_BROWSABLE_COURSES to MFE config (#620) 2025-05-21 13:21:31 -04:00
renovate[bot]
66fdd79bdf fix(deps): update dependency @edx/frontend-platform to v8.3.7 (#623)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 13:48:56 +00:00
renovate[bot]
0ea9f6d193 fix(deps): update dependency @reduxjs/toolkit to v2.8.2 (#622)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 13:33:24 +00:00
renovate[bot]
fee6a26366 fix(deps): update dependency @edx/frontend-component-footer to v14.7.2 (#621)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 06:54:58 +00:00
Justin Hynes
1301fddbc9 Audit usage of header elements in Learner Dashboard (#618) 2025-05-16 12:57:24 -04:00
diana-villalvazo-wgu
5dcd596ac9 chore: change heading for confirm email 2025-05-16 09:49:14 -06:00
diana-villalvazo-wgu
a5522faa42 test: update related tests 2025-05-16 09:27:30 -06:00
diana-villalvazo-wgu
3542c38472 fix: incorrect usage of header elements 2025-05-16 09:27:30 -06:00
Victor Navarro
14c03d8461 fix: add missing translation for notices not found (#612) 2025-05-15 11:57:21 -04:00
diana-villalvazo-wgu
5562d8a339 chore: Clean up learnerDashboardHeader files (#614) 2025-05-13 12:23:20 -03:00
diana-villalvazo-wgu
a9194261c8 chore: Replace query-string with URLSearchParams (#613)
Co-authored-by: diana-villalvazo-wgu <dianaximena.villalva@wgu.edu>
2025-05-13 12:12:06 -03:00
renovate[bot]
11a7512fea fix(deps): update dependency @edx/openedx-atlas to ^0.7.0 (#616)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-12 11:15:48 +00:00
renovate[bot]
be620a80fa fix(deps): update dependency @edx/frontend-platform to v8.3.6 (#615)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-12 07:56:10 +00:00
Edward Zarecor
1d9eb08e59 E0d/i18n fixes (#600)
Co-authored-by: I18n Fix Bot <i18n-fix@example.com>
Co-authored-by: Deborah Kaplan <deborahgu@users.noreply.github.com>
2025-05-09 20:14:57 +00:00
renovate[bot]
05e9626e57 fix(deps): update dependency @edx/frontend-component-footer to v14.7.1 (#611)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 05:38:36 +00:00
Brian Smith
fe386e31ee feat: import FooterSlot from component package instead of slot package (#604) 2025-04-24 12:25:32 -04:00
Brian Smith
cb1de82f0a feat: standardize slot ids (#608) 2025-04-24 07:47:52 -04:00
renovate[bot]
2337843d54 fix(deps): update dependency @edx/frontend-component-header to v6.4.0 (#607)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-23 20:17:47 +00:00
renovate[bot]
70da0d38ed fix(deps): update dependency @edx/frontend-component-header to v6.3.0 (#606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-21 10:26:20 +00:00
renovate[bot]
154a2583f6 fix(deps): update dependency @edx/frontend-component-footer to v14.6.0 (#605)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-21 07:27:36 +00:00
renovate[bot]
633050739e fix(deps): update dependency @edx/frontend-component-footer to v14.4.0 (#602)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 12:28:07 +00:00
renovate[bot]
61d24d29f1 chore(deps): update dependency @openedx/frontend-build to v14.5.0 (#601)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 07:11:40 +00:00
Ivo Branco
a210f23c9f fix: unenroll reasons translation
The unenroll reasons select wasn't being translated.
2025-04-09 10:17:21 -04:00
renovate[bot]
b16908842e fix(deps): update dependency @edx/frontend-platform to v8.3.4 (#596)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Deborah Kaplan <deborahgu@users.noreply.github.com>
2025-04-08 09:47:48 -04:00
renovate[bot]
b80cab7a66 chore(deps): update dependency @openedx/frontend-build to v14.4.2 (#595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 09:42:55 -04:00
Brian Smith
6b8cd1f780 feat: upgrade to react 18 (#593) 2025-04-04 10:24:46 -04:00
Régis Behmo
78c5d73900 chore: remove husky 🪓🐶 (#594) 2025-04-03 09:17:58 -04:00
renovate[bot]
eb3fc9412d fix(deps): update dependency @openedx/paragon to v22.17.0 (#592)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 10:52:17 +00:00
renovate[bot]
3caf6fd67a chore(deps): update dependency @openedx/frontend-build to v14.4.1 (#591)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 06:45:28 +00:00
Brian Smith
d48cb3d9fc chore(deps): update @openedx dependencies to versions that support React 18 (#590) 2025-03-27 16:16:48 -04:00
Maxwell Frank
d3b4a7fc84 feat: remove upgrade refs course banner (#585) 2025-03-26 09:48:14 -04:00
Maxwell Frank
9e63777c5c fix: CourseBanner slot readme (#589) 2025-03-25 11:36:10 -04:00
renovate[bot]
cf2f3acc51 fix(deps): update dependency @openedx/frontend-slot-footer to v1.1.0 (#588)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 06:45:05 +00:00
Maxwell Frank
54f8bc86e3 fix: rename course banner slot (#586) 2025-03-21 11:08:32 -04:00
renovate[bot]
10961010ba fix(deps): update dependency @openedx/frontend-plugin-framework to v1.6.0 (#584)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-17 05:59:50 +00:00
Kira Miller
3c1b749395 fix: changing slot id 2025-03-11 08:39:12 -06:00
Kira Miller
845ee09bf2 fix: PR requests 2025-03-11 08:39:12 -06:00
Kira Miller
1efec09f44 fix: PR requests 2025-03-11 08:39:12 -06:00
Kira Miller
aa1cae5200 fix: pr requests 2025-03-11 08:39:12 -06:00
Kira Miller
77ab48c59f fix: renaming slot 2025-03-11 08:39:12 -06:00
Kira Miller
5d2b33abd3 feat: adding new plugin slot for an enterprise modal 2025-03-11 08:39:12 -06:00
renovate[bot]
dd4f61eec3 fix(deps): update dependency @edx/frontend-enterprise-hotjar to v7.2.0 (#581)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-10 12:27:57 +00:00
renovate[bot]
8f7580ec30 chore(deps): update dependency @openedx/frontend-build to v14.3.2 (#580)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-10 05:34:46 +00:00
Ankush Chudiwal
fbf24e42d3 fix: broken skip link in Learner Dashboard (#522)
Co-authored-by: Deborah Kaplan <deborahgu@users.noreply.github.com>
2025-03-03 14:52:33 -05:00
Jason Wesson
e764e9c502 Revert "feat: adding new plugin slot for an enterprise modal"
This reverts commit f110a0ade8.
2025-03-03 12:40:17 -06:00
Jason Wesson
13721f2770 Revert "fix: renaming slot"
This reverts commit 6a43918b56.
2025-03-03 12:40:17 -06:00
Jason Wesson
960647ce9f Revert "fix: pr requests"
This reverts commit 86fd29309a.
2025-03-03 12:40:17 -06:00
Jason Wesson
44c797854f Revert "fix: PR requests"
This reverts commit 57d09af61d.
2025-03-03 12:40:17 -06:00
Jason Wesson
3ea088e411 Revert "fix: PR requests"
This reverts commit 76783133da.
2025-03-03 12:40:17 -06:00
Jason Wesson
4a18c890c3 Revert "fix: changing slot id"
This reverts commit b26d4632c9.
2025-03-03 12:40:17 -06:00
renovate[bot]
e1c1c51704 chore(deps): update dependency @openedx/frontend-build to v14.3.1 (#578)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 10:39:48 +00:00
renovate[bot]
f83f3a1850 fix(deps): update dependency @openedx/paragon to v22.15.3 (#577)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 05:37:49 +00:00
Kira Miller
b26d4632c9 fix: changing slot id 2025-02-27 11:28:33 -06:00
Kira Miller
76783133da fix: PR requests 2025-02-27 11:28:33 -06:00
Kira Miller
57d09af61d fix: PR requests 2025-02-27 11:28:33 -06:00
Kira Miller
86fd29309a fix: pr requests 2025-02-27 11:28:33 -06:00
Kira Miller
6a43918b56 fix: renaming slot 2025-02-27 11:28:33 -06:00
Kira Miller
f110a0ade8 feat: adding new plugin slot for an enterprise modal 2025-02-27 11:28:33 -06:00
Feanil Patel
93bd883a01 Update catalog-info file for release data (#570) 2025-02-26 09:07:11 -05:00
salman2013
61375c9e95 chore: update catalog-info-file for release data and remove openedx.yaml file 2025-02-24 15:36:50 -05:00
renovate[bot]
c2f4be5063 fix(deps): update dependency @openedx/paragon to v22.15.2 (#574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 11:48:06 +00:00
renovate[bot]
14bde7fc3f fix(deps): update dependency @edx/frontend-component-header to v5.8.3 (#573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 07:03:02 +00:00
87 changed files with 5736 additions and 4153 deletions

1
.env
View File

@@ -41,3 +41,4 @@ ENABLE_NOTICES=''
CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=false
ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false

View File

@@ -47,3 +47,4 @@ ENABLE_NOTICES=''
CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=false
ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false

View File

@@ -46,3 +46,4 @@ ENABLE_NOTICES=''
CAREER_LINK_URL=''
ENABLE_EDX_PERSONAL_DASHBOARD=true
ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false

View File

@@ -1 +0,0 @@
npm run lint

View File

@@ -17,6 +17,7 @@ metadata:
openedx.org/arch-interest-groups: ""
# This can be multiple comma-separated projects.
openedx.org/add-to-projects: "openedx:23"
openedx.org/release: "master"
spec:
type: 'service'
lifecycle: 'production'

View File

@@ -1,9 +0,0 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
tags:
- frontend-app
- masters
oeps:
oep-2: true # Repository metadata
openedx-release: {ref: master}

8227
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,8 +20,7 @@
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"quality": "npm run lint-fix && npm run test",
"watch-tests": "jest --watch",
"snapshot": "fedx-scripts jest --updateSnapshot",
"prepare": "husky"
"snapshot": "fedx-scripts jest --updateSnapshot"
},
"author": "edX",
"license": "AGPL-3.0",
@@ -31,31 +30,30 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-header": "^5.6.0",
"@edx/frontend-enterprise-hotjar": "7.1.0",
"@edx/frontend-platform": "8.1.5",
"@edx/openedx-atlas": "^0.6.0",
"@edx/react-unit-test-utils": "3.0.0",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-enterprise-hotjar": "7.2.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0",
"@edx/react-unit-test-utils": "^4.0.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.2.0",
"@openedx/frontend-plugin-framework": "^1.2.0",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "^22.2.2",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^22.16.0",
"@redux-devtools/extension": "3.3.0",
"@reduxjs/toolkit": "^2.0.0",
"classnames": "^2.3.1",
"core-js": "3.40.0",
"core-js": "3.42.0",
"filesize": "^10.0.0",
"font-awesome": "4.7.0",
"history": "5.3.0",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"prop-types": "15.8.1",
"query-string": "7.1.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-intl": "6.8.9",
"react-redux": "^7.2.4",
@@ -72,18 +70,17 @@
"devDependencies": {
"@edx/browserslist-config": "^1.3.0",
"@edx/reactifex": "^2.1.1",
"@openedx/frontend-build": "14.2.2",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.0",
"@openedx/frontend-build": "^14.3.3",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"copy-webpack-plugin": "^12.0.0",
"husky": "^9.0.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-expect-message": "^1.1.3",
"jest-when": "^3.6.0",
"react-dev-utils": "^12.0.0",
"react-test-renderer": "^17.0.2",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "^1.5.4"
}
}

View File

@@ -6,7 +6,7 @@ import { logError } from '@edx/frontend-platform/logging';
import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { ErrorPage, AppContext } from '@edx/frontend-platform/react';
import FooterSlot from '@openedx/frontend-slot-footer';
import { FooterSlot } from '@edx/frontend-component-footer';
import { Alert } from '@openedx/paragon';
import { RequestKeys } from 'data/constants/requests';
@@ -80,7 +80,7 @@ export const App = () => {
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<main id="main">
{hasNetworkFailure
? (
<Alert variant="danger">

View File

@@ -13,7 +13,7 @@ import AppWrapper from 'containers/WidgetContainers/AppWrapper';
import { App } from './App';
import messages from './messages';
jest.mock('@edx/frontend-component-footer', () => ({ FooterSlot: 'Footer' }));
jest.mock('@edx/frontend-component-footer', () => ({ FooterSlot: 'FooterSlot' }));
jest.mock('containers/Dashboard', () => 'Dashboard');
jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader');
@@ -74,11 +74,9 @@ describe('App router component', () => {
it('loads dashboard', () => {
const main = el.instance.findByType('main')[0];
expect(main.children.length).toEqual(1);
const dashboard = main.children[0];
const dashboard = main.children[0].el;
expect(dashboard.type).toEqual('Dashboard');
expect(
dashboard.matches(shallow(<Dashboard />)),
).toEqual(true);
expect(dashboard).toEqual(shallow(<Dashboard />));
});
});
describe('no network failure with optimizely url', () => {
@@ -91,11 +89,9 @@ describe('App router component', () => {
it('loads dashboard', () => {
const main = el.instance.findByType('main')[0];
expect(main.children.length).toEqual(1);
const dashboard = main.children[0];
const dashboard = main.children[0].el;
expect(dashboard.type).toEqual('Dashboard');
expect(
dashboard.matches(shallow(<Dashboard />)),
).toEqual(true);
expect(dashboard).toEqual(shallow(<Dashboard />));
});
});
describe('no network failure with optimizely project id', () => {
@@ -108,11 +104,9 @@ describe('App router component', () => {
it('loads dashboard', () => {
const main = el.instance.findByType('main')[0];
expect(main.children.length).toEqual(1);
const dashboard = main.children[0];
const dashboard = main.children[0].el;
expect(dashboard.type).toEqual('Dashboard');
expect(
dashboard.matches(shallow(<Dashboard />)),
).toEqual(true);
expect(dashboard).toEqual(shallow(<Dashboard />));
});
});
describe('initialize failure', () => {

View File

@@ -17,7 +17,9 @@ exports[`App router component component initialize failure snapshot 1`] = `
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<main
id="main"
>
<Alert
variant="danger"
>
@@ -49,7 +51,9 @@ exports[`App router component component no network failure snapshot 1`] = `
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<main
id="main"
>
<Dashboard />
</main>
</AppWrapper>
@@ -75,7 +79,9 @@ exports[`App router component component no network failure with optimizely proje
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<main
id="main"
>
<Dashboard />
</main>
</AppWrapper>
@@ -101,7 +107,9 @@ exports[`App router component component no network failure with optimizely url s
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<main
id="main"
>
<Dashboard />
</main>
</AppWrapper>
@@ -127,7 +135,9 @@ exports[`App router component component refresh failure snapshot 1`] = `
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main>
<main
id="main"
>
<Alert
variant="danger"
>

View File

@@ -1,40 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app registry subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element 1`] = `
<ErrorPage
message="test-error-message"
/>
<UNDEFINED>
<ErrorPage
message="test-error-message"
/>
</UNDEFINED>
`;
exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
<AppProvider
store={
{
"redux": "store",
<UNDEFINED>
<AppProvider
store={
{
"redux": "store",
}
}
}
wrapWithRouter={true}
>
<NoticesWrapper>
<Routes>
<Route
element={
<PageWrap>
<App />
</PageWrap>
}
path="/"
/>
<Route
element={
<Navigate
replace={true}
to="/"
/>
}
path="*"
/>
</Routes>
</NoticesWrapper>
</AppProvider>
>
<NoticesWrapper>
<Routes>
<Route
element={
<PageWrap>
<App />
</PageWrap>
}
path="/"
/>
<Route
element={
<Navigate
replace={true}
to="/"
/>
}
path="*"
/>
</Routes>
</NoticesWrapper>
</AppProvider>
</UNDEFINED>
`;

View File

@@ -3,16 +3,15 @@ import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-
import { logError, logInfo } from '@edx/frontend-platform/logging';
export const noticesUrl = `${getConfig().LMS_BASE_URL}/notices/api/v1/unacknowledged`;
export const error404Message = 'This probably happened because the notices plugin is not installed on platform.';
export const getNotices = ({ onLoad }) => {
export const getNotices = ({ onLoad, notFoundMessage }) => {
const authenticatedUser = getAuthenticatedUser();
const handleError = async (e) => {
// Error probably means that notices is not installed, which is fine.
const { customAttributes: { httpErrorStatus } } = e;
if (httpErrorStatus === 404) {
logInfo(`${e}. ${error404Message}`);
logInfo(`${e}. ${notFoundMessage}`);
} else {
logError(e);
}

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from 'react-intl';
import { StrictDict } from 'utils';
import { getNotices } from './api';
import * as module from './hooks';
import messages from './messages';
/**
* This component uses the platform-plugin-notices plugin to function.
@@ -17,6 +19,8 @@ export const state = StrictDict({
export const useNoticesWrapperData = () => {
const [isRedirected, setIsRedirected] = module.state.isRedirected();
const { formatMessage } = useIntl();
React.useEffect(() => {
if (getConfig().ENABLE_NOTICES) {
getNotices({
@@ -26,9 +30,10 @@ export const useNoticesWrapperData = () => {
window.location.replace(`${data.data.results[0]}?next=${window.location.href}`);
}
},
notFoundMessage: formatMessage(messages.error404Message),
});
}
}, [setIsRedirected]);
}, [setIsRedirected, formatMessage]);
return { isRedirected };
};

View File

@@ -8,6 +8,12 @@ import * as hooks from './hooks';
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
jest.mock('./api', () => ({ getNotices: jest.fn() }));
const mockFormatMessage = jest.fn(message => message.defaultMessage || 'translated-string');
jest.mock('react-intl', () => ({
useIntl: () => ({
formatMessage: mockFormatMessage,
}),
}));
getConfig.mockReturnValue({ ENABLE_NOTICES: true });
const state = new MockUseState(hooks);
@@ -34,7 +40,7 @@ describe('NoticesWrapper hooks', () => {
getConfig.mockReturnValueOnce({ ENABLE_NOTICES: false });
hooks.useNoticesWrapperData();
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs).toEqual([state.setState.isRedirected]);
expect(prereqs).toEqual([state.setState.isRedirected, mockFormatMessage]);
cb();
expect(getNotices).not.toHaveBeenCalled();
});
@@ -43,7 +49,7 @@ describe('NoticesWrapper hooks', () => {
hooks.useNoticesWrapperData();
expect(React.useEffect).toHaveBeenCalled();
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs).toEqual([state.setState.isRedirected]);
expect(prereqs).toEqual([state.setState.isRedirected, mockFormatMessage]);
cb();
expect(getNotices).toHaveBeenCalled();
const { onLoad } = getNotices.mock.calls[0][0];
@@ -59,7 +65,7 @@ describe('NoticesWrapper hooks', () => {
window.location = { replace: jest.fn(), href: 'test-old-href' };
hooks.useNoticesWrapperData();
const [cb, prereqs] = React.useEffect.mock.calls[0];
expect(prereqs).toEqual([state.setState.isRedirected]);
expect(prereqs).toEqual([state.setState.isRedirected, mockFormatMessage]);
cb();
expect(getNotices).toHaveBeenCalled();
const { onLoad } = getNotices.mock.calls[0][0];

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
error404Message: {
id: 'learner-dash.notices.error404Message',
defaultMessage: 'This probably happened because the notices plugin is not installed on platform.',
description: 'Error message when notices API returns 404',
},
});
export default messages;

View File

@@ -19,6 +19,7 @@ const configuration = {
ENABLE_EDX_PERSONAL_DASHBOARD: process.env.ENABLE_EDX_PERSONAL_DASHBOARD === 'true',
SEARCH_CATALOG_URL: process.env.SEARCH_CATALOG_URL || null,
ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS === 'true',
NON_BROWSABLE_COURSES: process.env.NON_BROWSABLE_COURSES === 'true',
};
const features = {};

View File

@@ -12,7 +12,6 @@ export const CourseBanner = ({ cardId }) => {
const {
isVerified,
isAuditAccessExpired,
canUpgrade,
coursewareAccess = {},
} = reduxHooks.useCardEnrollmentData(cardId);
const courseRun = reduxHooks.useCardCourseRunData(cardId);
@@ -26,13 +25,7 @@ export const CourseBanner = ({ cardId }) => {
return (
<>
{isAuditAccessExpired
&& (canUpgrade ? (
<Banner>
{formatMessage(messages.auditAccessExpired)}
{' '}
{formatMessage(messages.upgradeToAccess)}
</Banner>
) : (
&& (
<Banner>
{formatMessage(messages.auditAccessExpired)}
{' '}
@@ -40,17 +33,7 @@ export const CourseBanner = ({ cardId }) => {
{formatMessage(messages.findAnotherCourse)}
</Hyperlink>
</Banner>
))}
{courseRun.isActive && !canUpgrade && (
<Banner>
{formatMessage(messages.upgradeDeadlinePassed)}
{' '}
<Hyperlink isInline destination={courseRun.marketingUrl || ''}>
{formatMessage(messages.exploreCourseDetails)}
</Hyperlink>
</Banner>
)}
)}
{(!isStaff && isTooEarly && courseRun.startDate) && (
<Banner>
@@ -59,6 +42,7 @@ export const CourseBanner = ({ cardId }) => {
})}
</Banner>
)}
{(!isStaff && hasUnmetPrerequisites) && (
<Banner>{formatMessage(messages.prerequisitesNotMet)}</Banner>
)}

View File

@@ -25,7 +25,6 @@ let el;
const enrollmentData = {
isVerified: false,
canUpgrade: false,
isAuditAccessExpired: false,
coursewareAccess: {
hasUnmetPrerequisites: false,
@@ -65,51 +64,18 @@ describe('CourseBanner', () => {
render({ enrollment: { isVerified: true } });
expect(el.isEmptyRender()).toEqual(true);
});
describe('audit access expired, can upgrade', () => {
beforeEach(() => {
render({ enrollment: { isAuditAccessExpired: true, canUpgrade: true } });
});
test('snapshot: (auditAccessExpired, upgradeToAccess)', () => {
expect(el.snapshot).toMatchSnapshot();
});
test('messages: (auditAccessExpired, upgradeToAccess)', () => {
expect(el.instance.children[0].children[0].el).toContain(messages.auditAccessExpired.defaultMessage);
expect(el.instance.children[0].children[2].el).toContain(messages.upgradeToAccess.defaultMessage);
});
});
describe('audit access expired, cannot upgrade', () => {
describe('audit access expired', () => {
beforeEach(() => {
render({ enrollment: { isAuditAccessExpired: true } });
});
test('snapshot: (auditAccessExpired, findAnotherCourse hyperlink)', () => {
expect(el.snapshot).toMatchSnapshot();
});
test('messages: (auditAccessExpired, upgradeToAccess)', () => {
test('messages: auditAccessExpired', () => {
expect(el.instance.children[0].children[0].el).toContain(messages.auditAccessExpired.defaultMessage);
expect(el.instance.findByType(Hyperlink)[0].children[0].el).toEqual(messages.findAnotherCourse.defaultMessage);
});
});
describe('course run active and cannot upgrade', () => {
beforeEach(() => {
render({ courseRun: { isActive: true } });
});
test('snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => {
expect(el.snapshot).toMatchSnapshot();
});
test('messages: (upgradseDeadlinePassed, exploreCourseDetails hyperlink)', () => {
expect(el.instance.children[0].children[0].el).toContain(messages.upgradeDeadlinePassed.defaultMessage);
const link = el.instance.findByType(Hyperlink);
expect(link[0].children[0].el).toEqual(messages.exploreCourseDetails.defaultMessage);
expect(link[0].props.destination).toEqual(courseRunData.marketingUrl);
});
});
test('no display if audit access not expired and (course is not active or can upgrade)', () => {
render();
// isEmptyRender() isn't true because the minimal is <Fragment />
expect(el.instance.children).toEqual([]);
render({ enrollment: { canUpgrade: true }, courseRun: { isActive: true } });
expect(el.instance.children).toEqual([]);
});
describe('unmet prerequisites', () => {
beforeEach(() => {
render({ enrollment: { coursewareAccess: { hasUnmetPrerequisites: true } } });

View File

@@ -1,16 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseBanner audit access expired, can upgrade snapshot: (auditAccessExpired, upgradeToAccess) 1`] = `
<Fragment>
<Banner>
Your audit access to this course has expired.
Upgrade now to access your course again.
</Banner>
</Fragment>
`;
exports[`CourseBanner audit access expired, cannot upgrade snapshot: (auditAccessExpired, findAnotherCourse hyperlink) 1`] = `
exports[`CourseBanner audit access expired snapshot: (auditAccessExpired, findAnotherCourse hyperlink) 1`] = `
<Fragment>
<Banner>
Your audit access to this course has expired.
@@ -25,21 +15,6 @@ exports[`CourseBanner audit access expired, cannot upgrade snapshot: (auditAcces
</Fragment>
`;
exports[`CourseBanner course run active and cannot upgrade snapshot: (upgradseDeadlinePassed, exploreCourseDetails hyperlink) 1`] = `
<Fragment>
<Banner>
Your upgrade deadline for this course has passed. To upgrade, enroll in a session that is farther in the future.
<Hyperlink
destination="marketing-url"
isInline={true}
>
Explore course details.
</Hyperlink>
</Banner>
</Fragment>
`;
exports[`CourseBanner snapshot: stacking banners 1`] = `<Fragment />`;
exports[`CourseBanner staff snapshot: isStaff 1`] = `<Fragment />`;

View File

@@ -6,26 +6,11 @@ const messages = defineMessages({
description: 'Audit access expiration banner message',
defaultMessage: 'Your audit access to this course has expired.',
},
upgradeToAccess: {
id: 'learner-dash.courseCard.banners.upgradeToAccess',
description: 'Upgrade prompt for audit-expired learners that can still upgrade',
defaultMessage: 'Upgrade now to access your course again.',
},
findAnotherCourse: {
id: 'learner-dash.courseCard.banners.findAnotherCourse',
description: 'Action prompt taking learners to course exploration',
defaultMessage: 'Find another course',
},
upgradeDeadlinePassed: {
id: 'learner-dash.courseCard.banners.upgradeDeadlinePassed',
description: 'Audit upgrade deadline passed banner message',
defaultMessage: 'Your upgrade deadline for this course has passed. To upgrade, enroll in a session that is farther in the future.',
},
exploreCourseDetails: {
id: 'learner-dash.courseCard.banners.exploreCourseDetails',
description: 'Action prompt taking learners to course details page',
defaultMessage: 'Explore course details.',
},
certRestricted: {
id: 'learner-dash.courseCard.banners.certificateRestricted',
description: 'Restricted certificate warning message',

View File

@@ -10,8 +10,8 @@ export const useAccessMessage = ({ cardId }) => {
const courseRun = reduxHooks.useCardCourseRunData(cardId);
const formatDate = utilHooks.useFormatDate();
if (!courseRun.isStarted) {
if (!courseRun.startDate) { return null; }
const startDate = formatDate(courseRun.startDate);
if (!courseRun.startDate && !courseRun.advertisedStart) { return null; }
const startDate = courseRun.advertisedStart ? courseRun.advertisedStart : formatDate(courseRun.startDate);
return formatMessage(messages.courseStarts, { startDate });
}
if (enrollment.isEnrolled) {

View File

@@ -9,9 +9,11 @@ exports[`NoCoursesView snapshot 1`] = `
alt="No Courses view banner"
src="icon/mock/path"
/>
<h1>
<h3
className="h1"
>
Looking for a new challenge?
</h1>
</h3>
<p>
Explore our courses to add them to your dashboard.
</p>

View File

@@ -19,9 +19,9 @@ export const NoCoursesView = () => {
className="d-flex align-items-center justify-content-center mb-4.5"
>
<Image src={emptyCourseSVG} alt={formatMessage(messages.bannerAlt)} />
<h1>
<h3 className="h1">
{formatMessage(messages.lookingForChallengePrompt)}
</h1>
</h3>
<p>
{formatMessage(messages.exploreCoursesPrompt)}
</p>

View File

@@ -1,7 +1,5 @@
import React from 'react';
import queryString from 'query-string';
import { ListPageSize, SortKeys } from 'data/constants/app';
import { reduxHooks } from 'hooks';
import { StrictDict } from 'utils';
@@ -27,12 +25,13 @@ export const useCourseListData = () => {
const [sortBy, setSortBy] = module.state.sortBy(SortKeys.enrolled);
const querySearch = queryString.parse(window.location.search, { parseNumbers: true });
const querySearch = new URLSearchParams(window.location.search);
const disablePagination = querySearch.get('disable_pagination');
const { numPages, visibleList } = reduxHooks.useCurrentCourseList({
sortBy,
filters,
pageSize: querySearch?.disable_pagination === 1 ? 0 : ListPageSize,
pageSize: Number(disablePagination) === 1 ? 0 : ListPageSize,
});
const handleRemoveFilter = (filter) => () => removeFilter(filter);

View File

@@ -1,5 +1,3 @@
import queryString from 'query-string';
import { MockUseState } from 'testUtils';
import { reduxHooks } from 'hooks';
import { ListPageSize, SortKeys } from 'data/constants/app';
@@ -15,8 +13,10 @@ jest.mock('hooks', () => ({
},
}));
jest.mock('query-string', () => ({
parse: jest.fn(() => ({})),
const mockGet = jest.fn(() => ({}));
global.URLSearchParams = jest.fn().mockImplementation(() => ({
get: mockGet,
}));
const state = new MockUseState(hooks);
@@ -67,7 +67,7 @@ describe('CourseList hooks', () => {
it('loads current course list with page size 0 if/when there is query param disable_pagination=1', () => {
state.mock();
state.mockVal(state.keys.sortBy, testSortBy);
queryString.parse.mockReturnValueOnce({ disable_pagination: 1 });
mockGet.mockReturnValueOnce('1');
out = hooks.useCourseListData();
expect(reduxHooks.useCurrentCourseList).toHaveBeenCalledWith({
sortBy: testSortBy,

View File

@@ -40,8 +40,7 @@ export const DashboardLayout = ({ children }) => {
<Col {...courseListColumnProps} className="course-list-column">
{children}
</Col>
<Col {...columnConfig.sidebar} className="sidebar-column">
{!isCollapsed && (<h2 className="course-list-title">&nbsp;</h2>)}
<Col {...columnConfig.sidebar} className={['sidebar-column', !isCollapsed && 'not-collapsed']}>
<WidgetSidebarSlot />
</Col>
</Row>

View File

@@ -84,10 +84,9 @@ describe('DashboardLayout', () => {
describe('not collapsed', () => {
const testWidgetSpacing = () => {
it('shows a blank (nbsp) h2 spacer component above widget sidebar', () => {
it('shows not-collapsed class on widget sidebar', () => {
const columns = el.instance.findByType(Col);
// nonbreaking space equivalent
expect(columns[1].findByType('h2')[0].children[0].el).toEqual('\xA0');
expect(columns[1].props.className).toContain('not-collapsed');
});
};
describe('sidebar showing', () => {

View File

@@ -24,7 +24,12 @@ exports[`DashboardLayout collapsed sidebar not showing snapshot 1`] = `
test-children
</Col>
<Col
className="sidebar-column"
className={
[
"sidebar-column",
false,
]
}
lg={
{
"offset": 0,
@@ -68,7 +73,12 @@ exports[`DashboardLayout collapsed sidebar showing snapshot 1`] = `
test-children
</Col>
<Col
className="sidebar-column"
className={
[
"sidebar-column",
false,
]
}
lg={
{
"offset": 0,
@@ -112,7 +122,12 @@ exports[`DashboardLayout not collapsed sidebar not showing snapshot 1`] = `
test-children
</Col>
<Col
className="sidebar-column"
className={
[
"sidebar-column",
"not-collapsed",
]
}
lg={
{
"offset": 0,
@@ -126,11 +141,6 @@ exports[`DashboardLayout not collapsed sidebar not showing snapshot 1`] = `
}
}
>
<h2
className="course-list-title"
>
 
</h2>
<WidgetSidebarSlot />
</Col>
</Row>
@@ -161,7 +171,12 @@ exports[`DashboardLayout not collapsed sidebar showing snapshot 1`] = `
test-children
</Col>
<Col
className="sidebar-column"
className={
[
"sidebar-column",
"not-collapsed",
]
}
lg={
{
"offset": 0,
@@ -175,11 +190,6 @@ exports[`DashboardLayout not collapsed sidebar showing snapshot 1`] = `
}
}
>
<h2
className="course-list-title"
>
 
</h2>
<WidgetSidebarSlot />
</Col>
</Row>

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Dashboard snapshots courses loaded, show select session modal, no available dashboards snapshot 1`] = `
exports[`Dashboard snapshots courses loaded, show select session modal snapshot 1`] = `
<div
className="d-flex flex-column p-2 pt-0"
id="dashboard-container"
@@ -11,6 +11,7 @@ exports[`Dashboard snapshots courses loaded, show select session modal, no avail
test-page-title
</h1>
<Fragment>
<DashboardModalSlot />
<SelectSessionModal />
</Fragment>
<div
@@ -43,7 +44,7 @@ exports[`Dashboard snapshots courses still loading snapshot 1`] = `
</div>
`;
exports[`Dashboard snapshots there are no courses, there ARE available dashboards snapshot 1`] = `
exports[`Dashboard snapshots there are no courses snapshot 1`] = `
<div
className="d-flex flex-column p-2 pt-0"
id="dashboard-container"
@@ -54,7 +55,7 @@ exports[`Dashboard snapshots there are no courses, there ARE available dashboard
test-page-title
</h1>
<Fragment>
<EnterpriseDashboardModal />
<DashboardModalSlot />
</Fragment>
<div
data-testid="dashboard-content"

View File

@@ -2,9 +2,9 @@ import React from 'react';
import { reduxHooks } from 'hooks';
import { RequestKeys } from 'data/constants/requests';
import EnterpriseDashboardModal from 'containers/EnterpriseDashboardModal';
import SelectSessionModal from 'containers/SelectSessionModal';
import CoursesPanel from 'containers/CoursesPanel';
import DashboardModalSlot from 'plugin-slots/DashboardModalSlot';
import LoadingView from './LoadingView';
import DashboardLayout from './DashboardLayout';
@@ -15,7 +15,6 @@ export const Dashboard = () => {
hooks.useInitializeDashboard();
const { pageTitle } = hooks.useDashboardMessages();
const hasCourses = reduxHooks.useHasCourses();
const hasAvailableDashboards = reduxHooks.useHasAvailableDashboards();
const initIsPending = reduxHooks.useRequestIsPending(RequestKeys.initialize);
const showSelectSessionModal = reduxHooks.useShowSelectSessionModal();
@@ -24,7 +23,7 @@ export const Dashboard = () => {
<h1 className="sr-only">{pageTitle}</h1>
{!initIsPending && (
<>
{hasAvailableDashboards && <EnterpriseDashboardModal />}
<DashboardModalSlot />
{(hasCourses && showSelectSessionModal) && <SelectSessionModal />}
</>
)}

View File

@@ -6,6 +6,14 @@
.sidebar-column {
padding: 0 map-get($spacers, 3) 0 map-get($spacers, 1);
&.not-collapsed {
padding-top: map-get($spacers, 2);
& >:first-child {
margin-top: map-get($spacers, 5\.5);
}
}
}
@include media-breakpoint-down(lg) {

View File

@@ -2,7 +2,6 @@ import { shallow } from '@edx/react-unit-test-utils';
import { reduxHooks } from 'hooks';
import EnterpriseDashboardModal from 'containers/EnterpriseDashboardModal';
import SelectSessionModal from 'containers/SelectSessionModal';
import CoursesPanel from 'containers/CoursesPanel';
@@ -14,13 +13,12 @@ import Dashboard from '.';
jest.mock('hooks', () => ({
reduxHooks: {
useHasCourses: jest.fn(),
useHasAvailableDashboards: jest.fn(),
useShowSelectSessionModal: jest.fn(),
useRequestIsPending: jest.fn(),
},
}));
jest.mock('containers/EnterpriseDashboardModal', () => 'EnterpriseDashboardModal');
jest.mock('plugin-slots/DashboardModalSlot', () => 'DashboardModalSlot');
jest.mock('containers/CoursesPanel', () => 'CoursesPanel');
jest.mock('./LoadingView', () => 'LoadingView');
jest.mock('./DashboardLayout', () => 'DashboardLayout');
@@ -38,12 +36,10 @@ describe('Dashboard', () => {
});
const createWrapper = ({
hasCourses,
hasAvailableDashboards,
initIsPending,
showSelectSessionModal,
}) => {
reduxHooks.useHasCourses.mockReturnValueOnce(hasCourses);
reduxHooks.useHasAvailableDashboards.mockReturnValueOnce(hasAvailableDashboards);
reduxHooks.useRequestIsPending.mockReturnValueOnce(initIsPending);
reduxHooks.useShowSelectSessionModal.mockReturnValueOnce(showSelectSessionModal);
return shallow(<Dashboard />);
@@ -71,7 +67,6 @@ describe('Dashboard', () => {
const testView = ({
props,
content: [contentName, contentEl],
showEnterpriseModal,
showSelectSessionModal,
}) => {
beforeEach(() => { wrapper = createWrapper(props); });
@@ -80,10 +75,6 @@ describe('Dashboard', () => {
it(`renders ${contentName}`, () => {
testContent(contentEl);
});
it(`${renderString(showEnterpriseModal)} dashbaord modal`, () => {
expect(wrapper.instance.findByType(EnterpriseDashboardModal).length)
.toEqual(showEnterpriseModal ? 1 : 0);
});
it(`${renderString(showSelectSessionModal)} select session modal`, () => {
expect(wrapper.instance.findByType(SelectSessionModal).length).toEqual(showSelectSessionModal ? 1 : 0);
});
@@ -92,44 +83,38 @@ describe('Dashboard', () => {
testView({
props: {
hasCourses: false,
hasAvailableDashboards: false,
initIsPending: true,
showSelectSessionModal: false,
},
content: ['LoadingView', <LoadingView />],
showEnterpriseModal: false,
showSelectSessionModal: false,
});
});
describe('courses loaded, show select session modal, no available dashboards', () => {
describe('courses loaded, show select session modal', () => {
testView({
props: {
hasCourses: true,
hasAvailableDashboards: false,
initIsPending: false,
showSelectSessionModal: true,
},
content: ['LoadedView', (
<DashboardLayout><CoursesPanel /></DashboardLayout>
)],
showEnterpriseModal: false,
showSelectSessionModal: true,
});
});
describe('there are no courses, there ARE available dashboards', () => {
describe('there are no courses', () => {
testView({
props: {
hasCourses: false,
hasAvailableDashboards: true,
initIsPending: false,
showSelectSessionModal: false,
},
content: ['Dashboard layout with no courses sidebar and content', (
<DashboardLayout><CoursesPanel /></DashboardLayout>
)],
showEnterpriseModal: true,
showSelectSessionModal: false,
});
});

View File

@@ -1,42 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EnterpriseDashboard empty snapshot 1`] = `null`;
exports[`EnterpriseDashboard snapshot 1`] = `
<ModalDialog
hasCloseButton={false}
onClose={[MockFunction useEnterpriseDashboardHook.handleEscape]}
title=""
>
<div
className="bg-white p-3 rounded shadow"
style={
{
"textAlign": "start",
}
}
>
<h4>
You have access to the edX, Inc. dashboard
</h4>
<p>
To access the courses available to you through edX, Inc., visit the edX, Inc. dashboard now.
</p>
<ActionRow>
<Button
onClick={[MockFunction useEnterpriseDashboardHook.handleClose]}
variant="tertiary"
>
Dismiss
</Button>
<Button
href="/edx-dashboard"
onClick={[MockFunction useEnterpriseDashboardHook.handleCTAClick]}
type="a"
>
Go to dashboard
</Button>
</ActionRow>
</div>
</ModalDialog>
`;

View File

@@ -1,48 +0,0 @@
import React from 'react';
import { StrictDict } from 'utils';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import * as module from './hooks';
export const state = StrictDict({
showModal: (val) => React.useState(val), // eslint-disable-line
});
const { modalOpened, modalClosed, modalCTAClicked } = track.enterpriseDashboard;
export const useEnterpriseDashboardHook = () => {
const [showModal, setShowModal] = module.state.showModal(true);
const dashboard = reduxHooks.useEnterpriseDashboardData();
const trackOpened = modalOpened(dashboard.enterpriseUUID);
const trackClose = modalClosed(dashboard.enterpriseUUID, 'Cancel button');
const trackEscape = modalClosed(dashboard.enterpriseUUID, 'Escape');
const handleCTAClick = modalCTAClicked(dashboard.enterpriseUUID, dashboard.url);
const handleClose = () => {
trackClose();
setShowModal(false);
};
const handleEscape = () => {
trackEscape();
setShowModal(false);
};
React.useEffect(() => {
if (dashboard && dashboard.label) {
trackOpened();
}
}, []); // eslint-disable-line
return {
showModal,
handleCTAClick,
handleClose,
handleEscape,
dashboard,
};
};
export default useEnterpriseDashboardHook;

View File

@@ -1,75 +0,0 @@
import { MockUseState } from 'testUtils';
import { reduxHooks } from 'hooks';
import track from 'tracking';
import * as hooks from './hooks';
jest.mock('hooks', () => ({
reduxHooks: {
useEnterpriseDashboardData: jest.fn(),
},
}));
jest.mock('tracking', () => {
const modalOpenedEvent = jest.fn();
const modalClosedEvent = jest.fn();
const modalCTAClickedEvent = jest.fn();
return {
__esModule: true,
default: {
enterpriseDashboard: {
modalOpenedEvent,
modalClosedEvent,
modalCTAClickedEvent,
modalOpened: jest.fn(() => modalOpenedEvent),
modalClosed: jest.fn(() => modalClosedEvent),
modalCTAClicked: jest.fn(() => modalCTAClickedEvent),
},
},
};
});
const state = new MockUseState(hooks);
const enterpriseDashboardData = { label: 'edX, Inc.', url: '/edx-dashboard' };
describe('EnterpriseDashboard hooks', () => {
reduxHooks.useEnterpriseDashboardData.mockReturnValue({ ...enterpriseDashboardData });
describe('state values', () => {
state.testGetter(state.keys.showModal);
});
describe('behavior', () => {
let out;
beforeEach(() => {
state.mock();
out = hooks.useEnterpriseDashboardHook();
});
afterEach(state.restore);
test('useEnterpriseDashboardHook to return dashboard data from redux hooks', () => {
expect(out.dashboard).toMatchObject(enterpriseDashboardData);
});
test('modal initializes to shown when rendered and closes on click', () => {
state.expectInitializedWith(state.keys.showModal, true);
out.handleClose();
expect(state.values.showModal).toEqual(false);
});
test('modal initializes to shown when rendered and closes on escape', () => {
state.expectInitializedWith(state.keys.showModal, true);
out.handleEscape();
expect(state.values.showModal).toEqual(false);
});
test('CTA click tracks modalCTAClicked', () => {
out.handleCTAClick();
expect(track.enterpriseDashboard.modalCTAClicked).toHaveBeenCalledWith(
enterpriseDashboardData.enterpriseUUID,
enterpriseDashboardData.url,
);
});
});
});

View File

@@ -1,60 +0,0 @@
import React from 'react';
// import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ModalDialog, ActionRow, Button,
} from '@openedx/paragon';
import messages from './messages';
import useEnterpriseDashboardHook from './hooks';
export const EnterpriseDashboardModal = () => {
const { formatMessage } = useIntl();
const {
showModal,
handleClose,
handleCTAClick,
handleEscape,
dashboard,
} = useEnterpriseDashboardHook();
if (!dashboard || !dashboard.label) {
return null;
}
return (
<ModalDialog
isOpen={showModal}
onClose={handleEscape}
hasCloseButton={false}
title=""
>
<div
className="bg-white p-3 rounded shadow"
style={{ textAlign: 'start' }}
>
<h4>
{formatMessage(messages.enterpriseDialogHeader, {
label: dashboard.label,
})}
</h4>
<p>
{formatMessage(messages.enterpriseDialogBody, {
label: dashboard.label,
})}
</p>
<ActionRow>
<Button variant="tertiary" onClick={handleClose}>
{formatMessage(messages.enterpriseDialogDismissButton)}
</Button>
<Button type="a" href={dashboard.url} onClick={handleCTAClick}>
{formatMessage(messages.enterpriseDialogConfirmButton)}
</Button>
</ActionRow>
</div>
</ModalDialog>
);
};
EnterpriseDashboardModal.propTypes = {};
export default EnterpriseDashboardModal;

View File

@@ -1,29 +0,0 @@
import { shallow } from '@edx/react-unit-test-utils';
import EnterpriseDashboard from '.';
import useEnterpriseDashboardHook from './hooks';
jest.mock('./hooks', () => ({
__esModule: true,
default: jest.fn(),
}));
describe('EnterpriseDashboard', () => {
test('snapshot', () => {
const hookData = {
dashboard: { label: 'edX, Inc.', url: '/edx-dashboard' },
showDialog: false,
handleClose: jest.fn().mockName('useEnterpriseDashboardHook.handleClose'),
handleCTAClick: jest.fn().mockName('useEnterpriseDashboardHook.handleCTAClick'),
handleEscape: jest.fn().mockName('useEnterpriseDashboardHook.handleEscape'),
};
useEnterpriseDashboardHook.mockReturnValueOnce({ ...hookData });
const el = shallow(<EnterpriseDashboard />);
expect(el.snapshot).toMatchSnapshot();
});
test('empty snapshot', () => {
useEnterpriseDashboardHook.mockReturnValueOnce({});
const el = shallow(<EnterpriseDashboard />);
expect(el.snapshot).toMatchSnapshot();
});
});

View File

@@ -1,26 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
enterpriseDialogHeader: {
id: 'leanerDashboard.enterpriseDialogHeader',
defaultMessage: 'You have access to the {label} dashboard',
description: 'title for enterpise dashboard dialog',
},
enterpriseDialogBody: {
id: 'leanerDashboard.enterpriseDialogBody',
defaultMessage: 'To access the courses available to you through {label}, visit the {label} dashboard now.',
description: 'Body text for enterpise dashboard dialog',
},
enterpriseDialogDismissButton: {
id: 'leanerDashboard.enterpriseDialogDismissButton',
defaultMessage: 'Dismiss',
description: 'Dismiss button to cancel visiting dashboard',
},
enterpriseDialogConfirmButton: {
id: 'leanerDashboard.enterpriseDialogConfirmButton',
defaultMessage: 'Go to dashboard',
description: 'Confirm button to go to the dashboard url',
},
});
export default messages;

View File

@@ -1,27 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { reduxHooks } from 'hooks';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
export const BrandLogo = () => {
const { formatMessage } = useIntl();
const dashboard = reduxHooks.useEnterpriseDashboardData();
return (
<a href={dashboard?.url || '/'} className="mx-auto">
<img
className="logo py-3"
src={getConfig().LOGO_URL}
alt={formatMessage(messages.logoAltText)}
/>
</a>
);
};
BrandLogo.propTypes = {};
export default BrandLogo;

View File

@@ -1,28 +0,0 @@
import { shallow } from '@edx/react-unit-test-utils';
import { reduxHooks } from 'hooks';
import BrandLogo from './BrandLogo';
jest.mock('hooks', () => ({
reduxHooks: {
useEnterpriseDashboardData: jest.fn(),
},
}));
describe('BrandLogo', () => {
test('dashboard defined', () => {
reduxHooks.useEnterpriseDashboardData.mockReturnValueOnce({
url: 'url',
});
const wrapper = shallow(<BrandLogo />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('a')[0].props.href).toEqual('url');
});
test('dashboard undefined', () => {
reduxHooks.useEnterpriseDashboardData.mockReturnValueOnce(null);
const wrapper = shallow(<BrandLogo />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('a')[0].props.href).toEqual('/');
});
});

View File

@@ -55,11 +55,11 @@ exports[`ConfirmEmailBanner snapshot Show on unverified 1`] = `
onClose={[MockFunction closeConfirmModal]}
title=""
>
<h1
className="text-center p-3"
<h2
className="text-center p-3 h1"
>
Confirm your email
</h1>
</h2>
<p
className="text-center"
>

View File

@@ -64,7 +64,7 @@ export const ConfirmEmailBanner = () => {
</Button>
)}
>
<h1 className="text-center p-3">{formatMessage(messages.confirmEmailModalHeader)}</h1>
<h2 className="text-center p-3 h1">{formatMessage(messages.confirmEmailModalHeader)}</h2>
<p className="text-center">{formatMessage(messages.confirmEmailModalBody)}</p>
</MarketingModal>
</>

View File

@@ -1,76 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import urls from 'data/services/lms/urls';
import messages from './messages';
const getLearnerHeaderMenu = (
formatMessage,
courseSearchUrl,
authenticatedUser,
exploreCoursesClick,
) => ({
mainMenu: [
{
type: 'item',
href: '/',
content: formatMessage(messages.course),
isActive: true,
},
...(getConfig().ENABLE_PROGRAMS ? [{
type: 'item',
href: `${urls.programsUrl()}`,
content: formatMessage(messages.program),
}] : []),
{
type: 'item',
href: `${urls.baseAppUrl(courseSearchUrl)}`,
content: formatMessage(messages.discoverNew),
onClick: (e) => {
exploreCoursesClick(e);
},
},
],
secondaryMenu: [
...(getConfig().SUPPORT_URL ? [{
type: 'item',
href: `${getConfig().SUPPORT_URL}`,
content: formatMessage(messages.help),
}] : []),
],
userMenu: [
{
heading: '',
items: [
{
type: 'item',
href: `${getConfig().ACCOUNT_PROFILE_URL}/u/${authenticatedUser?.username}`,
content: formatMessage(messages.profile),
},
{
type: 'item',
href: `${getConfig().ACCOUNT_SETTINGS_URL}`,
content: formatMessage(messages.account),
},
...(getConfig().ORDER_HISTORY_URL ? [{
type: 'item',
href: getConfig().ORDER_HISTORY_URL,
content: formatMessage(messages.orderHistory),
}] : []),
],
},
{
heading: '',
items: [
{
type: 'item',
href: `${getConfig().LOGOUT_URL}`,
content: formatMessage(messages.signOut),
},
],
},
],
}
);
export default getLearnerHeaderMenu;

View File

@@ -1,27 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BrandLogo dashboard defined 1`] = `
<a
className="mx-auto"
href="url"
>
<img
alt="edX, Inc. Dashboard"
className="logo py-3"
src="https://edx-cdn.org/v3/default/logo.svg"
/>
</a>
`;
exports[`BrandLogo dashboard undefined 1`] = `
<a
className="mx-auto"
href="/"
>
<img
alt="edX, Inc. Dashboard"
className="logo py-3"
src="https://edx-cdn.org/v3/default/logo.svg"
/>
</a>
`;

View File

@@ -3,59 +3,7 @@
exports[`LearnerDashboardHeader render 1`] = `
<Fragment>
<ConfirmEmailBanner />
<Header
mainMenuItems={
[
{
"content": "Courses",
"href": "/",
"isActive": true,
"type": "item",
},
{
"content": "Discover New",
"href": "http://localhost:18000/course-search-url",
"onClick": [Function],
"type": "item",
},
]
}
secondaryMenuItems={[]}
userMenuItems={
[
{
"heading": "",
"items": [
{
"content": "Profile",
"href": "http://account-profile-url.test/u/undefined",
"type": "item",
},
{
"content": "Account",
"href": "http://account-settings-url.test",
"type": "item",
},
{
"content": "Order History",
"href": "test-url",
"type": "item",
},
],
},
{
"heading": "",
"items": [
{
"content": "Sign Out",
"href": "http://localhost:18000/logout",
"type": "item",
},
],
},
]
}
/>
<Header />
<MasqueradeBar />
</Fragment>
`;

View File

@@ -1,53 +0,0 @@
import React from 'react';
import { useWindowSize, breakpoints } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import track from 'tracking';
import { StrictDict } from 'utils';
import { linkNames } from 'tracking/constants';
import getLearnerHeaderMenu from './LearnerDashboardMenu';
import * as module from './hooks';
export const state = StrictDict({
isOpen: (val) => React.useState(val), // eslint-disable-line
});
export const useIsCollapsed = () => {
const { width } = useWindowSize();
const isCollapsed = React.useMemo(() => (width <= breakpoints.large.minWidth), [width]);
return isCollapsed;
};
export const findCoursesNavClicked = (href) => track.findCourses.findCoursesClicked(href, {
linkName: linkNames.learnerHomeNavExplore,
});
export const findCoursesNavDropdownClicked = (href) => track.findCourses.findCoursesClicked(href, {
linkName: linkNames.learnerHomeNavDropdownExplore,
});
export const useLearnerDashboardHeaderMenu = ({
courseSearchUrl, authenticatedUser, exploreCoursesClick,
}) => {
const { formatMessage } = useIntl();
return getLearnerHeaderMenu(formatMessage, courseSearchUrl, authenticatedUser, exploreCoursesClick);
};
export const useLearnerDashboardHeaderData = () => {
const [isOpen, setIsOpen] = module.state.isOpen(false);
const toggleIsOpen = () => setIsOpen(!isOpen);
return {
isOpen,
toggleIsOpen,
};
};
export default {
useIsCollapsed,
findCoursesNavClicked,
findCoursesNavDropdownClicked,
useLearnerDashboardHeaderData,
useLearnerDashboardHeaderMenu,
};

View File

@@ -1,81 +0,0 @@
import { useWindowSize, breakpoints } from '@openedx/paragon';
import track from 'tracking';
import { linkNames } from 'tracking/constants';
import { MockUseState } from 'testUtils';
import * as hooks from './hooks';
const state = new MockUseState(hooks);
const {
useIsCollapsed,
findCoursesNavClicked,
findCoursesNavDropdownClicked,
useLearnerDashboardHeaderData,
useLearnerDashboardHeaderMenu,
} = hooks;
jest.mock('tracking', () => ({
findCourses: {
findCoursesClicked: jest.fn(),
},
}));
const url = 'http://example.com';
describe('LearnerDashboardHeader hooks', () => {
describe('state values', () => {
state.testGetter(state.keys.isOpen);
});
describe('useIsCollapsed', () => {
test('large screen is not collapsed', () => {
useWindowSize.mockReturnValueOnce({ width: breakpoints.large.minWidth + 1 });
expect(useIsCollapsed()).toEqual(false);
});
test('small screen is collapsed', () => {
useWindowSize.mockReturnValueOnce({ width: breakpoints.large.minWidth - 1 });
expect(useIsCollapsed()).toEqual(true);
});
});
describe('findCoursesNavClicked', () => {
test('calls tracking with nav link name', () => {
findCoursesNavClicked(url);
expect(track.findCourses.findCoursesClicked).toHaveBeenCalledWith(url, {
linkName: linkNames.learnerHomeNavExplore,
});
});
});
describe('getLearnerDashboardHeaderMenu', () => {
test('calls header menu data hook', () => {
const courseSearchUrl = '/courses';
const authenticatedUser = {
username: 'test',
};
const learnerHomeHeaderMenu = useLearnerDashboardHeaderMenu({ courseSearchUrl, authenticatedUser });
expect(learnerHomeHeaderMenu.mainMenu.length).toBe(2);
});
});
describe('findCoursesNavDropdownClicked', () => {
test('calls tracking with dropdown link name', () => {
findCoursesNavDropdownClicked(url);
expect(track.findCourses.findCoursesClicked).toHaveBeenCalledWith(url, {
linkName: linkNames.learnerHomeNavDropdownExplore,
});
});
});
describe('useLearnerDashboardHeaderData', () => {
test('default state', () => {
state.mock();
const out = useLearnerDashboardHeaderData();
state.expectInitializedWith(state.keys.isOpen, false);
out.toggleIsOpen();
expect(state.values.isOpen).toEqual(true);
});
});
});

View File

@@ -1,43 +1,16 @@
import React from 'react';
import MasqueradeBar from 'containers/MasqueradeBar';
import { AppContext } from '@edx/frontend-platform/react';
import Header from '@edx/frontend-component-header';
import { reduxHooks } from 'hooks';
import urls from 'data/services/lms/urls';
import MasqueradeBar from 'containers/MasqueradeBar';
import ConfirmEmailBanner from './ConfirmEmailBanner';
import { useLearnerDashboardHeaderMenu, findCoursesNavClicked } from './hooks';
import './index.scss';
export const LearnerDashboardHeader = () => {
const { authenticatedUser } = React.useContext(AppContext);
const { courseSearchUrl } = reduxHooks.usePlatformSettingsData();
const exploreCoursesClick = () => {
findCoursesNavClicked(urls.baseAppUrl(courseSearchUrl));
};
const learnerHomeHeaderMenu = useLearnerDashboardHeaderMenu({
courseSearchUrl,
authenticatedUser,
exploreCoursesClick,
});
return (
<>
<ConfirmEmailBanner />
<Header
mainMenuItems={learnerHomeHeaderMenu.mainMenu}
secondaryMenuItems={learnerHomeHeaderMenu.secondaryMenu}
userMenuItems={learnerHomeHeaderMenu.userMenu}
/>
<MasqueradeBar />
</>
);
};
export const LearnerDashboardHeader = () => (
<>
<ConfirmEmailBanner />
<Header />
<MasqueradeBar />
</>
);
LearnerDashboardHeader.propTypes = {};

View File

@@ -1,38 +0,0 @@
.dropdown-menu-collapse {
width: 100vw;
position: absolute;
left: 0;
}
.learner-variant-header {
a {
// needed to make the link not resize the header
border-bottom: 2px solid transparent;
}
.course-link {
border-bottom: 2px solid !important;
}
.course-link:hover {
border-bottom: inherit !important;
}
}
.nav-small-menu {
> * {
justify-content: flex-start !important;
border-radius: 0 !important;
border-top: 1px solid #ddd !important;
&::after {
content: '\00BB';
padding-left: 10px;
}
}
}
.logo {
// copy from legacy dashboard
height: 40px;
}

View File

@@ -1,47 +1,18 @@
import { mergeConfig } from '@edx/frontend-platform';
import { shallow } from '@edx/react-unit-test-utils';
import Header from '@edx/frontend-component-header';
import urls from 'data/services/lms/urls';
import LearnerDashboardHeader from '.';
import { findCoursesNavClicked } from './hooks';
jest.mock('hooks', () => ({
reduxHooks: {
usePlatformSettingsData: jest.fn(() => ({
courseSearchUrl: '/course-search-url',
})),
},
}));
jest.mock('./hooks', () => ({
...jest.requireActual('./hooks'),
findCoursesNavClicked: jest.fn(),
}));
jest.mock('containers/MasqueradeBar', () => 'MasqueradeBar');
jest.mock('./ConfirmEmailBanner', () => 'ConfirmEmailBanner');
jest.mock('@edx/frontend-component-header', () => 'Header');
describe('LearnerDashboardHeader', () => {
test('render', () => {
mergeConfig({ ORDER_HISTORY_URL: 'test-url' });
const wrapper = shallow(<LearnerDashboardHeader />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('ConfirmEmailBanner')).toHaveLength(1);
expect(wrapper.instance.findByType('MasqueradeBar')).toHaveLength(1);
expect(wrapper.instance.findByType(Header)).toHaveLength(1);
wrapper.instance.findByType(Header)[0].props.mainMenuItems[1].onClick();
expect(findCoursesNavClicked).toHaveBeenCalledWith(urls.baseAppUrl('/course-search-url'));
expect(wrapper.instance.findByType(Header)[0].props.secondaryMenuItems.length).toBe(0);
});
test('should display Help link if SUPPORT_URL is set', () => {
mergeConfig({ SUPPORT_URL: 'http://localhost:18000/support' });
const wrapper = shallow(<LearnerDashboardHeader />);
expect(wrapper.instance.findByType(Header)[0].props.secondaryMenuItems.length).toBe(1);
});
test('should display Programs link if it is enabled by configuration', () => {
mergeConfig({ ENABLE_PROGRAMS: true });
const wrapper = shallow(<LearnerDashboardHeader />);
expect(wrapper.instance.findByType(Header)[0].props.mainMenuItems.length).toBe(3);
});
});

View File

@@ -1,91 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
dashboard: {
id: 'learnerVariantDashboard.menu.dashboard.label',
defaultMessage: 'Dashboard',
description: 'The text for the user menu Dashboard navigation link.',
},
dashboardPersonal: {
id: 'learnerVariantDashboard.menu.dashboardPersonal.label',
defaultMessage: 'Personal',
description: 'Link to personal dashboard in user menu',
},
dashboardSwitch: {
id: 'learnerVariantDashboard.menu.dashboardSwitch.label',
defaultMessage: 'SWITCH DASHBOARD',
description: 'Switch Dashboard header in the user menu',
},
help: {
id: 'learnerVariantDashboard.help.label',
defaultMessage: 'Help',
description: 'The text for the link to the Help Center',
},
profile: {
id: 'learnerVariantDashboard.menu.profile.label',
defaultMessage: 'Profile',
description: 'The text for the user menu Profile navigation link.',
},
viewPrograms: {
id: 'learnerVariantDashboard.menu.viewPrograms.label',
defaultMessage: 'View Programs',
description: 'The text for the user menu View Programs navigation link.',
},
account: {
id: 'learnerVariantDashboard.menu.account.label',
defaultMessage: 'Account',
description: 'The text for the user menu Account navigation link.',
},
orderHistory: {
id: 'learnerVariantDashboard.menu.orderHistory.label',
defaultMessage: 'Order History',
description: 'The text for the user menu Order History navigation link.',
},
signOut: {
id: 'learnerVariantDashboard.menu.signOut.label',
defaultMessage: 'Sign Out',
description: 'The label for the user menu Sign Out action.',
},
course: {
id: 'learnerVariantDashboard.course',
defaultMessage: 'Courses',
description: 'Header link for switching to dashboard page.',
},
program: {
id: 'learnerVariantDashboard.program',
defaultMessage: 'Programs',
description: 'Header link for switching to program page.',
},
discoverNew: {
id: 'learnerVariantDashboard.discoverNew',
defaultMessage: 'Discover New',
description: 'Header link for switching to discover page.',
},
logoAltText: {
id: 'learnerVariantDashboard.logoAltText',
defaultMessage: 'edX, Inc. Dashboard',
description: 'Alt text for the edX logo.',
},
collapseMenuOpenAltText: {
id: 'learnerVariantDashboard.collapseMenuOpenAltText',
defaultMessage: 'Menu',
description: 'Alt text for the collapse menu icon when the menu is open.',
},
collapseMenuClosedAltText: {
id: 'learnerVariantDashboard.collapseMenuClosedAltText',
defaultMessage: 'Close',
description: 'Alt text for the collapse menu icon when the menu is closed.',
},
career: {
id: 'leanerDashboard.menu.career.label',
defaultMessage: 'Career',
description: 'The text for the user menu Career navigation link.',
},
newAlert: {
id: 'header.menu.new.label',
defaultMessage: 'New',
description: 'The text announcing that an item in the user menu is New',
},
});
export default messages;

View File

@@ -1,5 +1,6 @@
/* eslint-disable quotes */
import { StrictDict } from 'utils';
import { defineMessages } from '@edx/frontend-platform/i18n';
export const reasonKeys = StrictDict({
prereqs: 'prereqs',
@@ -26,7 +27,7 @@ export const order = [
reasonKeys.easy,
];
const messages = StrictDict({
const messages = defineMessages({
[reasonKeys.prereqs]: {
id: 'learner-dash.unenrollConfirm.reasons.prereqs',
description: 'Unenroll reason option - missing prerequisites',

View File

@@ -10,10 +10,7 @@ export const numCourses = createSelector(
(courseData) => Object.keys(courseData).length,
);
export const hasCourses = createSelector([module.numCourses], (num) => num > 0);
export const hasAvailableDashboards = createSelector(
[simpleSelectors.enterpriseDashboard],
(data) => data !== null && data.isLearnerPortalEnabled === true,
);
export const showSelectSessionModal = createSelector(
[simpleSelectors.selectSessionModal],
(data) => data.cardId != null,
@@ -22,6 +19,5 @@ export const showSelectSessionModal = createSelector(
export default StrictDict({
numCourses,
hasCourses,
hasAvailableDashboards,
showSelectSessionModal,
});

View File

@@ -17,15 +17,6 @@ describe('basic app selectors', () => {
expect(cb(0)).toEqual(false);
});
});
describe('hasAvailableDashboards', () => {
it('returns true iff the enterpriseDashboard field is populated and learner portal is enabled', () => {
const { preSelectors, cb } = appSelectors.hasAvailableDashboards;
expect(preSelectors).toEqual([simpleSelectors.enterpriseDashboard]);
expect(cb({ isLearnerPortalEnabled: true })).toEqual(true);
expect(cb({ isLearnerPortalEnabled: false })).toEqual(false);
expect(cb(null)).toEqual(false);
});
});
describe('showSelectSessionModal', () => {
it('returns true if the selectSessionModal cardId is not null', () => {
const { preSelectors, cb } = appSelectors.showSelectSessionModal;

View File

@@ -43,6 +43,7 @@ export const courseCard = StrictDict({
(courseRun) => (courseRun === null ? {} : {
endDate: module.loadDateVal(courseRun.endDate),
startDate: module.loadDateVal(courseRun.startDate),
advertisedStart: courseRun.advertisedStart,
courseId: courseRun.courseId,
isArchived: courseRun.isArchived,

View File

@@ -147,6 +147,7 @@ describe('courseCard selectors module', () => {
loadSelector(courseCard.courseRun, {
endDate: '3000-10-20',
startDate: '2000-10-20',
advertisedStart: 'Mid June',
courseId: 'test-course-id',
isArchived: 'test-is-archived',
@@ -172,6 +173,9 @@ describe('courseCard selectors module', () => {
expect(selected.endDate).toEqual(new Date(testData.endDate));
expect(selected.startDate).toEqual(new Date(testData.startDate));
});
it('passes advertised start date', () => {
expect(selected.advertisedStart).toEqual(testData.advertisedStart);
});
it('passes [courseId, isArchived, isStarted]', () => {
expect(selected.courseId).toEqual(testData.courseId);
expect(selected.isArchived).toEqual(testData.isArchived);

View File

@@ -36,12 +36,6 @@ describe('app simple selectors', () => {
expect(preSelectors).toEqual([appSelector]);
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', () => {
keys = keyStore(cardSimpleSelectors);
test.each([

View File

@@ -18,7 +18,6 @@ export const useSocialShareSettings = () => useSelector(selectors.socialShareSet
/** global-level meta-selectors **/
export const useHasCourses = () => useSelector(selectors.hasCourses);
export const useHasAvailableDashboards = () => useSelector(selectors.hasAvailableDashboards);
export const useCurrentCourseList = (opts) => useSelector(
state => selectors.currentList(state, opts),
);

View File

@@ -47,5 +47,16 @@ describe('requests reducer', () => {
});
});
});
describe('clearRequest', () => {
it('cleanup status and error', () => {
expect(reducer(
testingState,
actions.clearRequest({ requestKey: testKey, error: testValue }),
)).toEqual({
...testingState,
[testKey]: {},
});
});
});
});
});

View File

@@ -1,6 +1,34 @@
import queryString from 'query-string';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
/**
* stringify(query, existingQuery)
* simple wrapper to convert an object to a query string
* @param {object} query - object to convert
* @param {string} existingQuery - existing query string
* @returns {string} - query string
*/
export const stringify = (query, existingQuery = '') => {
const searchParams = new URLSearchParams(existingQuery);
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
searchParams.delete(key);
} else if (Array.isArray(value)) {
searchParams.delete(key);
value.forEach((val) => {
if (val !== undefined && val !== null && val !== '') {
searchParams.append(key, val);
}
});
} else {
searchParams.set(key, value);
}
});
return searchParams.toString();
};
/**
* get(url)
* simple wrapper providing an authenticated Http client get action
@@ -10,21 +38,23 @@ export const get = (...args) => getAuthenticatedHttpClient().get(...args);
/**
* post(url, data)
* simple wrapper providing an authenticated Http client post action
* queryString.stringify is used to convert the object to query string with = and &
* stringify is used to convert the object to query string with = and &
* @param {string} url - target url
* @param {object|string} body - post payload
*/
export const post = (url, body) => getAuthenticatedHttpClient().post(url, queryString.stringify(body));
export const post = (url, body = {}) => getAuthenticatedHttpClient().post(url, stringify(body));
export const client = getAuthenticatedHttpClient;
/**
* stringifyUrl(url, query)
* simple wrapper around queryString.stringifyUrl that sets skip behavior
* simple wrapper to convert a url and query object to a full url
* @param {string} url - base url string
* @param {object} query - query parameters
* @returns {string} - full url
*/
export const stringifyUrl = (url, query) => queryString.stringifyUrl(
{ url, query },
{ skipNull: true, skipEmptyString: true },
);
export const stringifyUrl = (url, query) => {
const [baseUrl, existingQuery = ''] = url.split('?');
const queryString = stringify(query, existingQuery);
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
};

View File

@@ -1,11 +1,6 @@
import queryString from 'query-string';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import * as utils from './utils';
jest.mock('query-string', () => ({
stringifyUrl: jest.fn((url, options) => ({ url, options })),
stringify: jest.fn((data) => data),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
@@ -20,28 +15,25 @@ describe('lms service utils', () => {
});
});
describe('post', () => {
it('forwards arguments to authenticatedHttpClient().post', () => {
it('forwards arguments to authenticatedHttpClient().post, removes undefined attributes and appends array values', () => {
const post = jest.fn((...args) => ({ post: args }));
getAuthenticatedHttpClient.mockReturnValue({ post });
const url = 'some url';
const body = {
some: 'body',
for: 'the',
for: undefined,
test: 'yay',
array: ['one', 'two', undefined],
};
const expectedUrl = utils.post(url, body);
expect(queryString.stringify).toHaveBeenCalledWith(body);
expect(expectedUrl).toEqual(post(url, body));
expect(expectedUrl).toEqual(post(url, 'some=body&test=yay&array=one&array=two'));
});
});
describe('stringifyUrl', () => {
it('forwards url and query to stringifyUrl with options to skip null and ""', () => {
it('forwards url and query to stringifyUrl skipping null and ""', () => {
const url = 'here.com';
const query = { some: 'set', of: 'queryParams' };
const options = { skipNull: true, skipEmptyString: true };
expect(utils.stringifyUrl(url, query)).toEqual(
queryString.stringifyUrl({ url, query }, options),
);
expect(utils.stringifyUrl(url, query)).toEqual('here.com?some=set&of=queryParams');
});
});
});

View File

@@ -2,8 +2,8 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import React from 'react';
import ReactDOM from 'react-dom';
import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import {
Route, Navigate, Routes,
} from 'react-router-dom';
@@ -30,23 +30,29 @@ import App from './App';
import NoticesWrapper from './components/NoticesWrapper';
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider store={store}>
<NoticesWrapper>
<Routes>
<Route path="/" element={<PageWrap><App /></PageWrap>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</NoticesWrapper>
</AppProvider>,
document.getElementById('root'),
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<AppProvider store={store}>
<NoticesWrapper>
<Routes>
<Route path="/" element={<PageWrap><App /></PageWrap>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</NoticesWrapper>
</AppProvider>
</StrictMode>,
);
});
subscribe(APP_INIT_ERROR, (error) => {
ReactDOM.render(
<ErrorPage message={error.message} />,
document.getElementById('root'),
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<ErrorPage message={error.message} />
</StrictMode>,
);
});

View File

@@ -1,5 +1,3 @@
import { render } from 'react-dom';
import {
APP_INIT_ERROR,
APP_READY,
@@ -11,9 +9,20 @@ import {
import { configuration } from './config';
import * as app from '.';
jest.mock('react-dom', () => ({
render: jest.fn(),
}));
// These need to be var not let so they get hoisted
// and can be used by jest.mock (which is also hoisted)
var mockRender; // eslint-disable-line no-var
var mockCreateRoot; // eslint-disable-line no-var
jest.mock('react-dom/client', () => {
mockRender = jest.fn();
mockCreateRoot = jest.fn(() => ({
render: mockRender,
}));
return ({
createRoot: mockCreateRoot,
});
});
jest.mock('@edx/frontend-platform', () => ({
mergeConfig: jest.fn(),
@@ -32,7 +41,9 @@ describe('app registry', () => {
let getElement;
beforeEach(() => {
render.mockClear();
mockCreateRoot.mockClear();
mockRender.mockClear();
getElement = window.document.getElementById;
window.document.getElementById = jest.fn(id => ({ id }));
});
@@ -44,18 +55,16 @@ describe('app registry', () => {
const callArgs = subscribe.mock.calls[0];
expect(callArgs[0]).toEqual(APP_READY);
callArgs[1]();
const [rendered, target] = render.mock.calls[0];
const [rendered] = mockRender.mock.calls[0];
expect(rendered).toMatchSnapshot();
expect(target).toEqual(document.getElementById('root'));
});
test('subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element', () => {
const callArgs = subscribe.mock.calls[1];
expect(callArgs[0]).toEqual(APP_INIT_ERROR);
const error = { message: 'test-error-message' };
callArgs[1](error);
const [rendered, target] = render.mock.calls[0];
const [rendered] = mockRender.mock.calls[0];
expect(rendered).toMatchSnapshot();
expect(target).toEqual(document.getElementById('root'));
});
test('initialize is called with requireAuthenticatedUser', () => {
expect(initialize).toHaveBeenCalledTimes(1);

View File

@@ -1,6 +1,6 @@
# Course Card Action Slot
### Slot ID: `course_banner_slot`
### Slot ID: `org.openedx.frontend.learner_dashboard.course_card_banner.v1`
### Props:
* `cardId`
@@ -19,16 +19,17 @@ The following `env.config.jsx` will render a custom implemenation of a CourseBan
```js
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import { Alert } from '@openedx/paragon';
const config = {
pluginSlots: {
course_banner_slot: {
'org.openedx.frontend.learner_dashboard.course_card_banner.v1': {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_course_banner',
id: 'org.openedx.frontend.learner_dashboard.course_card_banner.v1',
type: DIRECT_PLUGIN,
priority: 60,
RenderWidget: ({ cardId }) => (

View File

@@ -5,7 +5,7 @@ import CourseBanner from 'containers/CourseCard/components/CourseCardBanners/Cou
const CourseBannerSlot = ({ cardId }) => (
<PluginSlot
id="course_banner_slot"
id="org.openedx.frontend.learner_dashboard.course_card_banner.v1"
pluginProps={{
cardId,
}}

View File

@@ -1,6 +1,10 @@
# Course Card Action Slot
### Slot ID: `course_card_action_slot`
### Slot ID: `org.openedx.frontend.learner_dashboard.course_card_action.v1`
### Slot ID Aliases
* `course_card_action_slot`
### Props:
* `cardId`
@@ -20,7 +24,7 @@ import ActionButton from 'containers/CourseCard/components/CourseCardActions/Act
const config = {
pluginSlots: {
course_card_action_slot: {
'org.openedx.frontend.learner_dashboard.course_card_action.v1': {
keepDefault: false,
plugins: [
{

View File

@@ -4,7 +4,8 @@ import { PluginSlot } from '@openedx/frontend-plugin-framework';
const CourseCardActionSlot = ({ cardId }) => (
<PluginSlot
id="course_card_action_slot"
id="org.openedx.frontend.learner_dashboard.course_card_action.v1"
idAliases={['course_card_action_slot']}
pluginProps={{
cardId,
}}

View File

@@ -1,6 +1,9 @@
# Course List Slot
### Slot ID: `course_list_slot`
### Slot ID: `org.openedx.frontend.learner_dashboard.course_list.v1`
### Slot ID Aliases
* `course_list_slot`
## Plugin Props
@@ -25,7 +28,7 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-frame
const config = {
pluginSlots: {
course_list_slot: {
'org.openedx.frontend.learner_dashboard.course_list.v1': {
// Hide the default CourseList component
keepDefault: false,
plugins: [

View File

@@ -4,7 +4,11 @@ import { PluginSlot } from '@openedx/frontend-plugin-framework';
import { CourseList, courseListDataShape } from 'containers/CoursesPanel/CourseList';
export const CourseListSlot = ({ courseListData }) => (
<PluginSlot id="course_list_slot" pluginProps={{ courseListData }}>
<PluginSlot
id="org.openedx.frontend.learner_dashboard.course_list.v1"
idAliases={['course_list_slot']}
pluginProps={{ courseListData }}
>
<CourseList courseListData={courseListData} />
</PluginSlot>
);

View File

@@ -0,0 +1,41 @@
# Dashboard Modal Slot
### Slot ID: `org.openedx.frontend.learner_dashboard.dashboard_modal.v1`
### https://github.com/openedx/frontend-plugin-framework/blob/master/docs/decisions/0003-slot-naming-and-life-cycle.rst#1-naming-format
## Description
This slot is used for the modal on a dashboard.
The following `env.config.jsx` will render the modal.
## Example
Learner dashboard will show modal
![Screenshot of the dashboard modal](./images/dashboard_modal_slot.png)
```js
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import { ModalDialog } from '@openedx/paragon';
const config = {
pluginSlots: {
org.openedx.frontend.learner_dashboard.dashboard_modal.v1: {
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'dashboard_modal',
type: DIRECT_PLUGIN,
priority: 60,
RenderWidget:
<ModalDialog title="Modal that appears on learner dashboard" />,
},
},
],
}
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
const DashboardModalSlot = () => (
<PluginSlot id="org.openedx.frontend.learner_dashboard.dashboard_modal.v1" />
);
export default DashboardModalSlot;

View File

@@ -1,12 +1,15 @@
# Footer Slot
### Slot ID: `footer_slot`
### Slot ID: `org.openedx.frontend.layout.footer.v1`
### Slot ID Aliases
* `footer_slot`
## Description
This slot is used to replace/modify/hide the footer.
The implementation of the `FooterSlot` component lives in [the `frontend-slot-footer` repository](https://github.com/openedx/frontend-slot-footer/).
The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/).
## Example
@@ -23,7 +26,7 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-frame
const config = {
pluginSlots: {
footer_slot: {
'org.openedx.frontend.layout.footer.v1': {
plugins: [
{
// Hide the default footer

View File

@@ -1,6 +1,9 @@
# No Courses View Slot
### Slot ID: `no_courses_view_slot`
### Slot ID: `org.openedx.frontend.learner_dashboard.no_courses_view.v1`
### Slot ID Aliases
* `no_courses_view_slot`
## Description
@@ -21,7 +24,7 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-frame
const config = {
pluginSlots: {
no_courses_view_slot: {
'org.openedx.frontend.learner_dashboard.no_courses_view.v1': {
// Hide the default NoCoursesView component
keepDefault: false,
plugins: [

View File

@@ -4,7 +4,10 @@ import { PluginSlot } from '@openedx/frontend-plugin-framework';
import NoCoursesView from 'containers/CoursesPanel/NoCoursesView';
export const NoCoursesViewSlot = () => (
<PluginSlot id="no_courses_view_slot">
<PluginSlot
id="org.openedx.frontend.learner_dashboard.no_courses_view.v1"
idAliases={['no_courses_view_slot']}
>
<NoCoursesView />
</PluginSlot>
);

View File

@@ -1,7 +1,8 @@
# `frontend-app-learner-dashboard` Plugin Slots
* [`course_card_action_slot`](./CourseCardActionSlot/)
* [`footer_slot`](./FooterSlot/)
* [`widget_sidebar_slot`](./WidgetSidebarSlot/)
* [`course_list_slot`](./CourseListSlot/)
* [`no_courses_view_slot`](./NoCoursesViewSlot/)
* [`org.openedx.frontend.learner_dashboard.course_card_action.v1`](./CourseCardActionSlot/)
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)
* [`org.openedx.frontend.learner_dashboard.widget_sidebar.v1`](./WidgetSidebarSlot/)
* [`org.openedx.frontend.learner_dashboard.course_list.v1`](./CourseListSlot/)
* [`org.openedx.frontend.learner_dashboard.no_courses_view.v1`](./NoCoursesViewSlot/)
* [`org.openedx.frontend.learner_dashboard.dashboard_modal.v1`](./DashboardModalSlot)

View File

@@ -1,6 +1,9 @@
# Widget Sidebar Slot
### Slot ID: `widget_sidebar_slot`
### Slot ID: `org.openedx.frontend.learner_dashboard.widget_sidebar.v1`
### Slot ID Aliases
* `widget_sidebar_slot`
## Description
@@ -21,7 +24,7 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-frame
const config = {
pluginSlots: {
widget_sidebar_slot: {
'org.openedx.frontend.learner_dashboard.widget_sidebar.v1': {
// Hide the default LookingForChallenge component
keepDefault: false,
plugins: [

View File

@@ -2,7 +2,12 @@
exports[`WidgetSidebar snapshots 1`] = `
<PluginSlot
id="widget_sidebar_slot"
id="org.openedx.frontend.learner_dashboard.widget_sidebar.v1"
idAliases={
[
"widget_sidebar_slot",
]
}
>
<LookingForChallengeWidget />
</PluginSlot>

View File

@@ -5,7 +5,10 @@ import LookingForChallengeWidget from 'widgets/LookingForChallengeWidget';
// eslint-disable-next-line arrow-body-style
export const WidgetSidebarSlot = () => (
<PluginSlot id="widget_sidebar_slot">
<PluginSlot
id="org.openedx.frontend.learner_dashboard.widget_sidebar.v1"
idAliases={['widget_sidebar_slot']}
>
<LookingForChallengeWidget />
</PluginSlot>
);

View File

@@ -1,6 +1,5 @@
/* eslint-disable import/no-extraneous-dependencies */
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
jest.mock('react', () => ({
...jest.requireActual('react'),

View File

@@ -20,13 +20,8 @@ export const events = StrictDict({
leaveSession: 'leaveSession',
unenrollReason: 'unenrollReason',
entitlementUnenrollReason: 'entitlementUnenrollReason',
enterpriseDashboardModalOpened: 'enterpriseDashboardModalOpened',
enterpriseDashboardModalCTAClicked: 'enterpriseDashboardModalCTAClicked',
enterpriseDashboardModalClosed: 'enterpriseDashboardModalClosed',
});
const learnerPortal = 'edx.ui.enterprise.lms.dashboard.learner_portal_modal';
export const eventNames = StrictDict({
enterCourseClicked: 'edx.bi.dashboard.enter_course.clicked',
courseImageClicked: 'edx.bi.dashboard.course_image.clicked',
@@ -39,9 +34,6 @@ export const eventNames = StrictDict({
leaveSession: 'course-dashboard.leave-session',
unenrollReason: 'unenrollment_reason.selected',
entitlementUnenrollReason: 'entitlement_unenrollment_reason.selected',
enterpriseDashboardModalOpened: `${learnerPortal}.opened`,
enterpriseDashboardModalCTAClicked: `${learnerPortal}.dashboard_cta.clicked`,
enterpriseDashboardModalClosed: `${learnerPortal}.closed`,
findCoursesClicked: 'edx.bi.dashboard.find_courses_button.clicked',
purchaseCredit: 'edx.bi.credit.clicked_purchase_credit',
filterClicked: 'course-dashboard.filter.clicked',

View File

@@ -1,7 +1,6 @@
import course from './trackers/course';
import credit from './trackers/credit';
import engagement from './trackers/engagement';
import enterpriseDashboard from './trackers/enterpriseDashboard';
import entitlements from './trackers/entitlements';
import socialShare from './trackers/socialShare';
import findCourses from './trackers/findCourses';
@@ -11,7 +10,6 @@ export default {
course,
credit,
engagement,
enterpriseDashboard,
entitlements,
socialShare,
findCourses,

View File

@@ -1,44 +0,0 @@
import { createEventTracker, createLinkTracker } from 'data/services/segment/utils';
import { eventNames } from '../constants';
/** Enterprise Dashboard events**/
/**
* Creates tracking callback for Enterprise Dashboard Modal open event
* @param {string} enterpriseUUID - enterprise identifier
* @return {func} - Callback that tracks the event when fired.
*/
export const modalOpened = (enterpriseUUID) => () => createEventTracker(
eventNames.enterpriseDashboardModalOpened,
{ enterpriseUUID },
);
/**
* Creates tracking callback for Enterprise Dashboard Modal Call-to-action click-event
* @param {string} enterpriseUUID - enterprise identifier
* @param {string} href - destination url
* @return {func} - Callback that tracks the event when fired and then loads the passed href.
*/
export const modalCTAClicked = (enterpriseUUID, href) => createLinkTracker(
createEventTracker(
eventNames.enterpriseDashboardModalCTAClicked,
{ enterpriseUUID },
),
href,
);
/**
* Creates tracking callback for Enterprise Dashboard Modal close event
* @param {string} enterpriseUUID - enterprise identifier
* @param {string} source - close event soruce ("Cancel button" vs "Escape")
* @return {func} - Callback that tracks the event when fired.
*/
export const modalClosed = (enterpriseUUID, source) => createEventTracker(
eventNames.enterpriseDashboardModalClosed,
{ enterpriseUUID, source },
);
export default {
modalOpened,
modalCTAClicked,
modalClosed,
};

View File

@@ -1,47 +0,0 @@
import { createEventTracker } from 'data/services/segment/utils';
import { eventNames } from '../constants';
import * as trackers from './enterpriseDashboard';
jest.mock('data/services/segment/utils', () => ({
createEventTracker: jest.fn(args => ({ createEventTracker: args })),
createLinkTracker: jest.fn((cb, href) => ({ createLinkTracker: { cb, href } })),
}));
const enterpriseUUID = 'test-enterprise-uuid';
const source = 'test-source';
describe('enterpriseDashboard trackers', () => {
describe('modalOpened', () => {
it('creates event tracker for dashboard modal opened event', () => {
expect(trackers.modalOpened(enterpriseUUID, source)()).toEqual(
createEventTracker(
eventNames.enterpriseDashboardModalOpened,
{ enterpriseUUID, source },
),
);
});
});
describe('modalCTAClicked', () => {
const testHref = 'test-href';
it('creates link tracker for dashboard modal cta click event', () => {
const { cb, href } = trackers.modalCTAClicked(enterpriseUUID, testHref).createLinkTracker;
expect(href).toEqual(testHref);
expect(cb).toEqual(
createEventTracker(
eventNames.enterpriseDashboardModalCTAClicked,
{ enterpriseUUID, source },
),
);
});
});
describe('modalClosed', () => {
it('creates event tracker for dashboard modal closed event with close source', () => {
expect(trackers.modalClosed(enterpriseUUID, source)).toEqual(
createEventTracker(
eventNames.enterpriseDashboardModalClosed,
{ enterpriseUUID, source },
),
);
});
});
});

View File

@@ -9,7 +9,7 @@ config.resolve.modules = [
'node_modules',
];
config.module.rules[0].exclude = /node_modules\/(?!(query-string|split-on-first|strict-uri-encode|@edx))/;
config.module.rules[0].exclude = /node_modules\/(?!(split-on-first|strict-uri-encode|@edx))/;
config.plugins.push(
new CopyPlugin({

View File

@@ -9,7 +9,7 @@ config.resolve.modules = [
'node_modules',
];
config.module.rules[0].exclude = /node_modules\/(?!(query-string|split-on-first|strict-uri-encode|@edx))/;
config.module.rules[0].exclude = /node_modules\/(?!(split-on-first|strict-uri-encode|@edx))/;
config.plugins.push(
new CopyPlugin({