Compare commits

..

4 Commits

Author SHA1 Message Date
Adolfo R. Brandes
021f52b303 feat: remove upgrade button
This removes the upgrade button as default content in CourseCardActionSlot.

This is, for the moment, a Sumac-specific change.  The button is still
present in master, pending proper removal (including the component
definitions from the rest of the repository) prior to the U release by
the maintainers.  See:

https://github.com/openedx/frontend-app-learner-dashboard/issues/438
2024-12-06 15:55:31 -03:00
Adolfo R. Brandes
8e0e217402 fix: Use browserslist-config
We were installing browserslist-config but not declaring it.  This had
the effect that webpack - and likely others - were not using it.
2024-12-06 11:08:23 -03:00
Diana Olarte
261448dee9 fix: display programs tab only if it is configured
fix: apply feedback
2024-12-02 07:50:28 -08:00
Diana Olarte
5ef5ed954c fix: display SUPPORT_URL only if the url is configured 2024-12-02 07:50:28 -08:00
92 changed files with 9788 additions and 5789 deletions

1
.env
View File

@@ -32,6 +32,7 @@ ENTERPRISE_MARKETING_UTM_SOURCE=''
ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
LEARNING_BASE_URL=''
ZENDESK_KEY=''
HOTJAR_APP_ID=''
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''

View File

@@ -38,6 +38,7 @@ ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
LEARNING_BASE_URL='http://localhost:2000'
SESSION_COOKIE_DOMAIN='localhost'
ZENDESK_KEY=''
HOTJAR_APP_ID=''
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''

View File

@@ -37,6 +37,7 @@ ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
LEARNING_BASE_URL='http://localhost:2000'
ZENDESK_KEY='test-zendesk-key'
HOTJAR_APP_ID='hot-jar-app-id'
HOTJAR_VERSION='6'
HOTJAR_DEBUG=''

View File

@@ -11,6 +11,9 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20]
steps:
- name: Checkout
@@ -19,7 +22,7 @@ jobs:
- name: Setup Nodejs
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm ci
@@ -37,7 +40,24 @@ jobs:
run: npm run build
- name: Run Coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
- name: Send failure notification
if: ${{ failure() }}
uses: dawidd6/action-send-mail@v3
with:
server_address: email-smtp.us-east-1.amazonaws.com
server_port: 465
username: ${{ secrets.EDX_SMTP_USERNAME }}
password: ${{ secrets.EDX_SMTP_PASSWORD }}
subject: CI workflow failed in ${{github.repository}}
to: masters-grades@edx.org,aperture@2u-internal.opsgenie.net
from: github-actions <github-actions@edx.org>
nodemailerlog: true
nodemailerdebug: true
body: CI workflow in ${{github.repository}} failed!
For details see "github.com/${{ github.repository }}/actions/runs/${{ github.run_id
}}"

35
.github/workflows/npm-publish.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Release CI
on:
push:
tags:
- "*"
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci
- name: Create Build
run: npm run build
- name: Release Package
env:
GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.SEMANTIC_RELEASE_NPM_TOKEN }}
run: npm semantic-release

4
.husky/pre-push Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint

27
.releaserc Normal file
View File

@@ -0,0 +1,27 @@
{
"branch": "master",
"tagFormat": "v${version}",
"verifyConditions": [
"@semantic-release/npm",
{
"path": "@semantic-release/github",
"assets": {
"path": "dist/*"
}
}
],
"analyzeCommits": "@semantic-release/commit-analyzer",
"generateNotes": "@semantic-release/release-notes-generator",
"prepare": "@semantic-release/npm",
"publish": [
"@semantic-release/npm",
{
"path": "@semantic-release/github",
"assets": {
"path": "dist/*"
}
}
],
"success": [],
"fail": []
}

View File

@@ -17,7 +17,6 @@ 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

@@ -59,6 +59,7 @@ module.exports = {
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: 'Footer',
LEARNING_BASE_URL: 'http://localhost:2000',
SESSION_COOKIE_DOMAIN: 'localhost',
ZENDESK_KEY: '',
HOTJAR_APP_ID: '',
HOTJAR_VERSION: 6,
HOTJAR_DEBUG: '',

9
openedx.yaml Normal file
View File

@@ -0,0 +1,9 @@
# 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}

14022
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,11 +16,11 @@
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
"semantic-release": "semantic-release",
"start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/learner-dashboard/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"quality": "npm run lint-fix && npm run test",
"watch-tests": "jest --watch",
"snapshot": "fedx-scripts jest --updateSnapshot"
"snapshot": "fedx-scripts jest --updateSnapshot",
"prepare": "husky install"
},
"author": "edX",
"license": "AGPL-3.0",
@@ -30,58 +30,70 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@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/frontend-component-header": "^5.6.0",
"@edx/frontend-enterprise-hotjar": "3.0.0",
"@edx/frontend-platform": "8.1.2",
"@edx/openedx-atlas": "^0.6.0",
"@edx/react-unit-test-utils": "^4.0.0",
"@edx/react-unit-test-utils": "3.0.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.2.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^22.16.0",
"@openedx/frontend-plugin-framework": "^1.2.0",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "^22.2.2",
"@redux-beacon/segment": "^1.1.0",
"@redux-devtools/extension": "3.3.0",
"@reduxjs/toolkit": "^2.0.0",
"@reduxjs/toolkit": "^1.6.1",
"@testing-library/user-event": "^13.5.0",
"classnames": "^2.3.1",
"core-js": "3.40.0",
"filesize": "^10.0.0",
"core-js": "3.38.1",
"dompurify": "^2.3.1",
"email-prop-type": "^3.0.1",
"file-saver": "^2.0.5",
"filesize": "^8.0.6",
"font-awesome": "4.7.0",
"history": "5.3.0",
"html-react-parser": "^1.3.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "29.7.0",
"jest-when": "^3.6.0",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"prop-types": "15.8.1",
"query-string": "7.1.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-helmet": "^6.1.0",
"react-intl": "6.8.9",
"react-intl": "6.8.0",
"react-pdf": "^7.0.0",
"react-redux": "^7.2.4",
"react-router-dom": "6.29.0",
"react-router-dom": "6.27.0",
"react-share": "^4.4.0",
"react-zendesk": "^0.1.13",
"redux": "4.2.1",
"redux-beacon": "^2.1.0",
"redux-logger": "3.0.6",
"redux-thunk": "2.4.2",
"regenerator-runtime": "^0.14.0",
"reselect": "^4.0.0",
"universal-cookie": "^4.0.4",
"util": "^0.12.4"
"util": "^0.12.4",
"whatwg-fetch": "^3.6.2"
},
"devDependencies": {
"@edx/browserslist-config": "^1.3.0",
"@edx/reactifex": "^2.1.1",
"@openedx/frontend-build": "^14.3.3",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@openedx/frontend-build": "14.1.5",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.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": "^18.3.1",
"redux-mock-store": "^1.5.4"
"react-test-renderer": "^17.0.2",
"redux-mock-store": "^1.5.4",
"semantic-release": "^20.1.3"
}
}

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 '@edx/frontend-component-footer';
import FooterSlot from '@openedx/frontend-slot-footer';
import { Alert } from '@openedx/paragon';
import { RequestKeys } from 'data/constants/requests';
@@ -80,7 +80,7 @@ export const App = () => {
<div>
<AppWrapper>
<LearnerDashboardHeader />
<main id="main">
<main>
{hasNetworkFailure
? (
<Alert variant="danger">

View File

@@ -13,10 +13,11 @@ import AppWrapper from 'containers/WidgetContainers/AppWrapper';
import { App } from './App';
import messages from './messages';
jest.mock('@edx/frontend-component-footer', () => ({ FooterSlot: 'FooterSlot' }));
jest.mock('@edx/frontend-component-footer', () => ({ FooterSlot: 'Footer' }));
jest.mock('containers/Dashboard', () => 'Dashboard');
jest.mock('containers/LearnerDashboardHeader', () => 'LearnerDashboardHeader');
jest.mock('components/ZendeskFab', () => 'ZendeskFab');
jest.mock('containers/WidgetContainers/AppWrapper', () => 'AppWrapper');
jest.mock('data/redux', () => ({
selectors: 'redux.selectors',

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ZendeskFab snapshot 1`] = `
<Zendesk
cookies={true}
defer={true}
webWidget={
{
"answerBot": {
"avatar": {
"name": {
"*": "edX Support",
},
"url": "https://edx-cdn.org/v3/prod/favicon.ico",
},
"contactOnlyAfterQuery": true,
"suppress": false,
"title": {
"*": "edX Support",
},
},
"chat": {
"departments": {
"enabled": [
"account settings",
"billing and payments",
"certificates",
"deadlines",
"errors and technical issues",
"other",
"proctoring",
],
},
"suppress": false,
},
"contactForm": {
"attachments": true,
"selectTicketForm": {
"*": "Please choose your request type:",
},
"ticketForms": [
{
"fields": [
{
"id": "description",
"prefill": {
"*": "",
},
},
],
"id": 360003368814,
"subject": false,
},
],
},
"contactOptions": {
"enabled": false,
},
"helpCenter": {
"originalArticleButton": true,
},
}
}
/>
`;

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import Zendesk from 'react-zendesk';
import messages from './messages';
const ZendeskFab = () => {
const { formatMessage } = useIntl();
const setting = {
cookies: true,
webWidget: {
contactOptions: {
enabled: false,
},
chat: {
suppress: false,
departments: {
enabled: ['account settings', 'billing and payments', 'certificates', 'deadlines', 'errors and technical issues', 'other', 'proctoring'],
},
},
contactForm: {
ticketForms: [
{
id: 360003368814,
subject: false,
fields: [{ id: 'description', prefill: { '*': '' } }],
},
],
selectTicketForm: {
'*': formatMessage(messages.selectTicketForm),
},
attachments: true,
},
helpCenter: {
originalArticleButton: true,
},
answerBot: {
suppress: false,
contactOnlyAfterQuery: true,
title: { '*': formatMessage(messages.supportTitle) },
avatar: {
url: 'https://edx-cdn.org/v3/prod/favicon.ico',
name: { '*': formatMessage(messages.supportTitle) },
},
},
},
};
return (
<Zendesk defer zendeskKey={getConfig().ZENDESK_KEY} {...setting} />
);
};
export default ZendeskFab;

View File

@@ -0,0 +1,12 @@
import { shallow } from '@edx/react-unit-test-utils';
import ZendeskFab from '.';
jest.mock('react-zendesk', () => 'Zendesk');
describe('ZendeskFab', () => {
test('snapshot', () => {
const wrapper = shallow(<ZendeskFab />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
supportTitle: {
id: 'zendesk.supportTitle',
description: 'Title for the support button',
defaultMessage: 'edX Support',
},
selectTicketForm: {
id: 'zendesk.selectTicketForm',
description: 'Select ticket form',
defaultMessage: 'Please choose your request type:',
},
});
export default messages;

View File

@@ -12,6 +12,7 @@ const configuration = {
// ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
LEARNING_BASE_URL: process.env.LEARNING_BASE_URL,
SESSION_COOKIE_DOMAIN: process.env.SESSION_COOKIE_DOMAIN || '',
ZENDESK_KEY: process.env.ZENDESK_KEY,
SUPPORT_URL: process.env.SUPPORT_URL || null,
ENABLE_NOTICES: process.env.ENABLE_NOTICES || null,
CAREER_LINK_URL: process.env.CAREER_LINK_URL || null,

View File

@@ -27,7 +27,7 @@ reduxHooks.useCardCourseRunData.mockReturnValue({ homeUrl });
const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`;
reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath);
reduxHooks.useTrackCourseEvent.mockImplementation(
(eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }),
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
);
describe('BeginCourseButton', () => {

View File

@@ -26,7 +26,7 @@ reduxHooks.useCardCourseRunData.mockReturnValue({ resumeUrl });
const execEdPath = (cardId) => `exec-ed-tracking-path=${cardId}`;
reduxHooks.useCardExecEdTrackingParam.mockImplementation(execEdPath);
reduxHooks.useTrackCourseEvent.mockImplementation(
(eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }),
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
);
let wrapper;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Locked } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import ActionButton from './ActionButton';
import messages from './messages';
export const UpgradeButton = ({ cardId }) => {
const { formatMessage } = useIntl();
const { upgradeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { disableUpgradeCourse } = useActionDisabledState(cardId);
const trackUpgradeClick = reduxHooks.useTrackCourseEvent(
track.course.upgradeClicked,
cardId,
upgradeUrl,
);
const enabledProps = {
as: 'a',
href: upgradeUrl,
onClick: trackUpgradeClick,
};
return (
<ActionButton
iconBefore={Locked}
variant="outline-primary"
disabled={disableUpgradeCourse}
{...!disableUpgradeCourse && enabledProps}
>
{formatMessage(messages.upgrade)}
</ActionButton>
);
};
UpgradeButton.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default UpgradeButton;

View File

@@ -0,0 +1,49 @@
import { shallow } from '@edx/react-unit-test-utils';
import track from 'tracking';
import { reduxHooks } from 'hooks';
import useActionDisabledState from '../hooks';
import UpgradeButton from './UpgradeButton';
jest.mock('tracking', () => ({
course: {
upgradeClicked: jest.fn().mockName('segment.trackUpgradeClicked'),
},
}));
jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(),
useTrackCourseEvent: jest.fn(
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
),
},
}));
jest.mock('../hooks', () => jest.fn(() => ({ disableUpgradeCourse: false })));
jest.mock('./ActionButton', () => 'ActionButton');
describe('UpgradeButton', () => {
const props = {
cardId: 'cardId',
};
const upgradeUrl = 'upgradeUrl';
reduxHooks.useCardCourseRunData.mockReturnValue({ upgradeUrl });
describe('snapshot', () => {
test('can upgrade', () => {
const wrapper = shallow(<UpgradeButton {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.props.disabled).toEqual(false);
expect(wrapper.instance.props.onClick).toEqual(reduxHooks.useTrackCourseEvent(
track.course.upgradeClicked,
props.cardId,
upgradeUrl,
));
});
test('cannot upgrade', () => {
useActionDisabledState.mockReturnValueOnce({ disableUpgradeCourse: true });
const wrapper = shallow(<UpgradeButton {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.props.disabled).toEqual(true);
});
});
});

View File

@@ -15,7 +15,7 @@ jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseRunData: jest.fn(() => ({ homeUrl: 'homeUrl' })),
useTrackCourseEvent: jest.fn(
(eventName, cardId, url) => ({ trackCourseEvent: { eventName, cardId, url } }),
(eventName, cardId, upgradeUrl) => ({ trackCourseEvent: { eventName, cardId, upgradeUrl } }),
),
},
}));

View File

@@ -10,7 +10,7 @@ exports[`BeginCourseButton snapshot disabled snapshot 1`] = `
"trackCourseEvent": {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"url": "home-urlexec-ed-tracking-path=cardId",
"upgradeUrl": "home-urlexec-ed-tracking-path=cardId",
},
}
}
@@ -29,7 +29,7 @@ exports[`BeginCourseButton snapshot enabled snapshot 1`] = `
"trackCourseEvent": {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"url": "home-urlexec-ed-tracking-path=cardId",
"upgradeUrl": "home-urlexec-ed-tracking-path=cardId",
},
}
}

View File

@@ -10,7 +10,7 @@ exports[`ResumeButton snapshot disabled snapshot 1`] = `
"trackCourseEvent": {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"url": "resume-urlexec-ed-tracking-path=cardId",
"upgradeUrl": "resume-urlexec-ed-tracking-path=cardId",
},
}
}
@@ -29,7 +29,7 @@ exports[`ResumeButton snapshot enabled snapshot 1`] = `
"trackCourseEvent": {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"url": "resume-urlexec-ed-tracking-path=cardId",
"upgradeUrl": "resume-urlexec-ed-tracking-path=cardId",
},
}
}

View File

@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UpgradeButton snapshot can upgrade 1`] = `
<ActionButton
as="a"
disabled={false}
href="upgradeUrl"
iconBefore={[MockFunction icons.Locked]}
onClick={
{
"trackCourseEvent": {
"cardId": "cardId",
"eventName": [MockFunction segment.trackUpgradeClicked],
"upgradeUrl": "upgradeUrl",
},
}
}
variant="outline-primary"
>
Upgrade
</ActionButton>
`;
exports[`UpgradeButton snapshot cannot upgrade 1`] = `
<ActionButton
disabled={true}
iconBefore={[MockFunction icons.Locked]}
variant="outline-primary"
>
Upgrade
</ActionButton>
`;

View File

@@ -10,7 +10,7 @@ exports[`ViewCourseButton learner can view course 1`] = `
"trackCourseEvent": {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"url": "homeUrl",
"upgradeUrl": "homeUrl",
},
}
}
@@ -29,7 +29,7 @@ exports[`ViewCourseButton learner cannot view course 1`] = `
"trackCourseEvent": {
"cardId": "cardId",
"eventName": [MockFunction segment.enterCourseClicked],
"url": "homeUrl",
"upgradeUrl": "homeUrl",
},
}
}

View File

@@ -3,6 +3,7 @@ import { shallow } from '@edx/react-unit-test-utils';
import { reduxHooks } from 'hooks';
import CourseCardActionSlot from 'plugin-slots/CourseCardActionSlot';
import UpgradeButton from './UpgradeButton';
import SelectSessionButton from './SelectSessionButton';
import BeginCourseButton from './BeginCourseButton';
import ResumeButton from './ResumeButton';
@@ -20,6 +21,7 @@ jest.mock('hooks', () => ({
}));
jest.mock('plugin-slots/CourseCardActionSlot', () => 'CustomActionButton');
jest.mock('./UpgradeButton', () => 'UpgradeButton');
jest.mock('./SelectSessionButton', () => 'SelectSessionButton');
jest.mock('./ViewCourseButton', () => 'ViewCourseButton');
jest.mock('./BeginCourseButton', () => 'BeginCourseButton');
@@ -57,7 +59,19 @@ describe('CourseCardActions', () => {
});
});
describe('output', () => {
describe('Exec Ed course', () => {
it('does not render upgrade button', () => {
mockHooks({ isExecEd2UCourse: true });
render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
});
});
describe('entitlement course', () => {
it('does not render upgrade button', () => {
mockHooks({ isEntitlement: true });
render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
});
it('renders ViewCourseButton if fulfilled', () => {
mockHooks({ isEntitlement: true, isFulfilled: true });
render();
@@ -69,15 +83,22 @@ describe('CourseCardActions', () => {
expect(el.instance.findByType(SelectSessionButton)[0].props.cardId).toEqual(cardId);
});
});
describe('verified course', () => {
it('does not render upgrade button', () => {
mockHooks({ isVerified: true });
render();
expect(el.instance.findByType(UpgradeButton).length).toEqual(0);
});
});
describe('not entitlement, verified, or exec ed', () => {
it('renders CourseCardActionSlot and ViewCourseButton for archived courses', () => {
it('renders UpgradeButton and ViewCourseButton for archived courses', () => {
mockHooks({ isArchived: true });
render();
expect(el.instance.findByType(CourseCardActionSlot)[0].props.cardId).toEqual(cardId);
expect(el.instance.findByType(ViewCourseButton)[0].props.cardId).toEqual(cardId);
});
describe('unstarted courses', () => {
it('renders CourseCardActionSlot and BeginCourseButton', () => {
it('renders UpgradeButton and BeginCourseButton', () => {
mockHooks();
render();
expect(el.instance.findByType(CourseCardActionSlot)[0].props.cardId).toEqual(cardId);
@@ -85,7 +106,7 @@ describe('CourseCardActions', () => {
});
});
describe('active courses (started, and not archived)', () => {
it('renders CourseCardActionSlot and ResumeButton', () => {
it('renders UpgradeButton and ResumeButton', () => {
mockHooks({ hasStarted: true });
render();
expect(el.instance.findByType(CourseCardActionSlot)[0].props.cardId).toEqual(cardId);

View File

@@ -1,6 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
upgrade: {
id: 'learner-dash.courseCard.actions.upgrade',
description: 'Course card upgrade button text',
defaultMessage: 'Upgrade',
},
beginCourse: {
id: 'learner-dash.courseCard.actions.beginCourse',
description: 'Course card begin-course button text',

View File

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

View File

@@ -25,6 +25,7 @@ let el;
const enrollmentData = {
isVerified: false,
canUpgrade: false,
isAuditAccessExpired: false,
coursewareAccess: {
hasUnmetPrerequisites: false,
@@ -64,18 +65,51 @@ describe('CourseBanner', () => {
render({ enrollment: { isVerified: true } });
expect(el.isEmptyRender()).toEqual(true);
});
describe('audit access expired', () => {
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', () => {
beforeEach(() => {
render({ enrollment: { isAuditAccessExpired: true } });
});
test('snapshot: (auditAccessExpired, findAnotherCourse hyperlink)', () => {
expect(el.snapshot).toMatchSnapshot();
});
test('messages: auditAccessExpired', () => {
test('messages: (auditAccessExpired, upgradeToAccess)', () => {
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,6 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CourseBanner audit access expired snapshot: (auditAccessExpired, findAnotherCourse hyperlink) 1`] = `
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`] = `
<Fragment>
<Banner>
Your audit access to this course has expired.
@@ -15,6 +25,21 @@ exports[`CourseBanner audit access expired snapshot: (auditAccessExpired, findAn
</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

@@ -8,7 +8,7 @@ exports[`CourseCardBanners render with isEnrolled false 1`] = `
<RelatedProgramsBanner
cardId="test-card-id"
/>
<CourseBannerSlot
<CourseBanner
cardId="test-card-id"
/>
<EntitlementBanner
@@ -25,7 +25,7 @@ exports[`CourseCardBanners renders default CourseCardBanners 1`] = `
<RelatedProgramsBanner
cardId="test-card-id"
/>
<CourseBannerSlot
<CourseBanner
cardId="test-card-id"
/>
<EntitlementBanner

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { reduxHooks } from 'hooks';
import CourseBannerSlot from 'plugin-slots/CourseBannerSlot';
import CourseBanner from './CourseBanner';
import CertificateBanner from './CertificateBanner';
import CreditBanner from './CreditBanner';
import EntitlementBanner from './EntitlementBanner';
@@ -14,7 +14,7 @@ export const CourseCardBanners = ({ cardId }) => {
return (
<div className="course-card-banners" data-testid="CourseCardBanners">
<RelatedProgramsBanner cardId={cardId} />
<CourseBannerSlot cardId={cardId} />
<CourseBanner cardId={cardId} />
<EntitlementBanner cardId={cardId} />
{isEnrolled && <CertificateBanner cardId={cardId} />}
{isEnrolled && <CreditBanner cardId={cardId} />}

View File

@@ -6,11 +6,26 @@ 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

@@ -20,13 +20,11 @@ export const CourseCardImage = ({ cardId, orientation }) => {
const { isVerified } = reduxHooks.useCardEnrollmentData(cardId);
const { disableCourseTitle } = useActionDisabledState(cardId);
const handleImageClicked = reduxHooks.useTrackCourseEvent(courseImageClicked, cardId, homeUrl);
const wrapperClassName = `pgn__card-wrapper-image-cap d-inline-block overflow-visible ${orientation}`;
const wrapperClassName = `pgn__card-wrapper-image-cap overflow-visible ${orientation}`;
const image = (
<>
<img
// w-100 is necessary for images on Safari, otherwise stretches full height of the image
// https://stackoverflow.com/a/44250830
className="pgn__card-image-cap w-100 show"
className="pgn__card-image-cap show"
src={bannerImgSrc}
alt={formatMessage(messages.bannerAlt)}
/>

View File

@@ -18,8 +18,8 @@ jest.mock('hooks', () => ({
useCardCourseData: jest.fn(() => ({ bannerImgSrc: 'banner-img-src' })),
useCardCourseRunData: jest.fn(() => ({ homeUrl })),
useCardEnrollmentData: jest.fn(() => ({ isVerified: true })),
useTrackCourseEvent: jest.fn((eventName, cardId, url) => ({
trackCourseEvent: { eventName, cardId, url },
useTrackCourseEvent: jest.fn((eventName, cardId, upgradeUrl) => ({
trackCourseEvent: { eventName, cardId, upgradeUrl },
})),
},
}));

View File

@@ -17,8 +17,8 @@ jest.mock('hooks', () => ({
reduxHooks: {
useCardCourseData: jest.fn(() => ({ courseName: 'course-name' })),
useCardCourseRunData: jest.fn(() => ({ homeUrl })),
useTrackCourseEvent: jest.fn((eventName, cardId, url) => ({
trackCourseEvent: { eventName, cardId, url },
useTrackCourseEvent: jest.fn((eventName, cardId, upgradeUrl) => ({
trackCourseEvent: { eventName, cardId, upgradeUrl },
})),
},
}));

View File

@@ -2,14 +2,14 @@
exports[`CourseCardImage snapshot renders clickable link course Image 1`] = `
<a
className="pgn__card-wrapper-image-cap d-inline-block overflow-visible orientation"
className="pgn__card-wrapper-image-cap overflow-visible orientation"
href="home-url"
onClick={
{
"trackCourseEvent": {
"cardId": "cardId",
"eventName": [MockFunction segment.courseImageClicked],
"url": "home-url",
"upgradeUrl": "home-url",
},
}
}
@@ -18,7 +18,7 @@ exports[`CourseCardImage snapshot renders clickable link course Image 1`] = `
<Fragment>
<img
alt="Course thumbnail"
className="pgn__card-image-cap w-100 show"
className="pgn__card-image-cap show"
src="banner-img-src"
/>
<span
@@ -43,12 +43,12 @@ exports[`CourseCardImage snapshot renders clickable link course Image 1`] = `
exports[`CourseCardImage snapshot renders disabled link 1`] = `
<div
className="pgn__card-wrapper-image-cap d-inline-block overflow-visible orientation"
className="pgn__card-wrapper-image-cap overflow-visible orientation"
>
<Fragment>
<img
alt="Course thumbnail"
className="pgn__card-image-cap w-100 show"
className="pgn__card-image-cap show"
src="banner-img-src"
/>
<span

View File

@@ -11,7 +11,7 @@ exports[`CourseCardTitle snapshot renders clickable link course title 1`] = `
"trackCourseEvent": {
"cardId": "cardId",
"eventName": [MockFunction segment.courseTitleClicked],
"url": "home-url",
"upgradeUrl": "home-url",
},
}
}

View File

@@ -3,18 +3,19 @@ import { reduxHooks } from 'hooks';
export const useActionDisabledState = (cardId) => {
const { isMasquerading } = reduxHooks.useMasqueradeData();
const {
hasAccess, isAudit, isAuditAccessExpired,
canUpgrade, hasAccess, isAudit, isAuditAccessExpired,
} = reduxHooks.useCardEnrollmentData(cardId);
const {
isEntitlement, isFulfilled, canChange, hasSessions,
} = reduxHooks.useCardEntitlementData(cardId);
const { resumeUrl, homeUrl } = reduxHooks.useCardCourseRunData(cardId);
const { resumeUrl, homeUrl, upgradeUrl } = reduxHooks.useCardCourseRunData(cardId);
const disableBeginCourse = !homeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired));
const disableResumeCourse = !resumeUrl || (isMasquerading || !hasAccess || (isAudit && isAuditAccessExpired));
const disableViewCourse = !hasAccess || (isAudit && isAuditAccessExpired);
const disableSelectSession = !isEntitlement || isMasquerading || !hasAccess || (!canChange || !hasSessions);
const disableUpgradeCourse = !upgradeUrl || (isMasquerading && !canUpgrade);
const disableCourseTitle = (isEntitlement && !isFulfilled) || disableViewCourse;
@@ -22,6 +23,7 @@ export const useActionDisabledState = (cardId) => {
disableBeginCourse,
disableResumeCourse,
disableViewCourse,
disableUpgradeCourse,
disableSelectSession,
disableCourseTitle,
};

View File

@@ -16,6 +16,7 @@ const cardId = 'my-test-course-number';
describe('useActionDisabledState', () => {
const defaultData = {
isMasquerading: false,
canUpgrade: false,
isEntitlement: false,
isFulfilled: false,
canChange: false,
@@ -25,10 +26,12 @@ describe('useActionDisabledState', () => {
isAuditAccessExpired: false,
resumeUrl: 'resume.url',
homeUrl: 'home.url',
upgradeUrl: 'upgrade.url',
};
const mockHooksData = (args) => {
const {
isMasquerading,
canUpgrade,
isEntitlement,
isFulfilled,
canChange,
@@ -38,9 +41,11 @@ describe('useActionDisabledState', () => {
isAuditAccessExpired,
resumeUrl,
homeUrl,
upgradeUrl,
} = { ...defaultData, ...args };
reduxHooks.useMasqueradeData.mockReturnValueOnce({ isMasquerading });
reduxHooks.useCardEnrollmentData.mockReturnValueOnce({
canUpgrade,
hasAccess,
isAudit,
isAuditAccessExpired,
@@ -54,6 +59,7 @@ describe('useActionDisabledState', () => {
reduxHooks.useCardCourseRunData.mockReturnValueOnce({
resumeUrl,
homeUrl,
upgradeUrl,
});
};
@@ -115,6 +121,21 @@ describe('useActionDisabledState', () => {
testDisabled({ hasAccess: true }, false);
});
});
describe('disableUpgradeCourse', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);
expect(runHook().disableUpgradeCourse).toBe(expected);
};
it('disable when upgradeUrl is invalid', () => {
testDisabled({ upgradeUrl: null }, true);
});
it('disable when isMasquerading is true and canUpgrade is false', () => {
testDisabled({ isMasquerading: true, canUpgrade: false }, true);
});
it('enable when all conditions are met', () => {
testDisabled({ canUpgrade: true }, false);
});
});
describe('disableSelectSession', () => {
const testDisabled = (data, expected) => {
mockHooksData(data);

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Dashboard snapshots courses loaded, show select session modal snapshot 1`] = `
exports[`Dashboard snapshots courses loaded, show select session modal, no available dashboards snapshot 1`] = `
<div
className="d-flex flex-column p-2 pt-0"
id="dashboard-container"
@@ -11,7 +11,6 @@ exports[`Dashboard snapshots courses loaded, show select session modal snapshot
test-page-title
</h1>
<Fragment>
<DashboardModalSlot />
<SelectSessionModal />
</Fragment>
<div
@@ -44,7 +43,7 @@ exports[`Dashboard snapshots courses still loading snapshot 1`] = `
</div>
`;
exports[`Dashboard snapshots there are no courses snapshot 1`] = `
exports[`Dashboard snapshots there are no courses, there ARE available dashboards snapshot 1`] = `
<div
className="d-flex flex-column p-2 pt-0"
id="dashboard-container"
@@ -55,7 +54,7 @@ exports[`Dashboard snapshots there are no courses snapshot 1`] = `
test-page-title
</h1>
<Fragment>
<DashboardModalSlot />
<EnterpriseDashboardModal />
</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,6 +15,7 @@ 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();
@@ -23,7 +24,7 @@ export const Dashboard = () => {
<h1 className="sr-only">{pageTitle}</h1>
{!initIsPending && (
<>
<DashboardModalSlot />
{hasAvailableDashboards && <EnterpriseDashboardModal />}
{(hasCourses && showSelectSessionModal) && <SelectSessionModal />}
</>
)}

View File

@@ -2,6 +2,7 @@ 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';
@@ -13,12 +14,13 @@ import Dashboard from '.';
jest.mock('hooks', () => ({
reduxHooks: {
useHasCourses: jest.fn(),
useHasAvailableDashboards: jest.fn(),
useShowSelectSessionModal: jest.fn(),
useRequestIsPending: jest.fn(),
},
}));
jest.mock('plugin-slots/DashboardModalSlot', () => 'DashboardModalSlot');
jest.mock('containers/EnterpriseDashboardModal', () => 'EnterpriseDashboardModal');
jest.mock('containers/CoursesPanel', () => 'CoursesPanel');
jest.mock('./LoadingView', () => 'LoadingView');
jest.mock('./DashboardLayout', () => 'DashboardLayout');
@@ -36,10 +38,12 @@ 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 />);
@@ -67,6 +71,7 @@ describe('Dashboard', () => {
const testView = ({
props,
content: [contentName, contentEl],
showEnterpriseModal,
showSelectSessionModal,
}) => {
beforeEach(() => { wrapper = createWrapper(props); });
@@ -75,6 +80,10 @@ 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);
});
@@ -83,38 +92,44 @@ 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', () => {
describe('courses loaded, show select session modal, no available dashboards', () => {
testView({
props: {
hasCourses: true,
hasAvailableDashboards: false,
initIsPending: false,
showSelectSessionModal: true,
},
content: ['LoadedView', (
<DashboardLayout><CoursesPanel /></DashboardLayout>
)],
showEnterpriseModal: false,
showSelectSessionModal: true,
});
});
describe('there are no courses', () => {
describe('there are no courses, there ARE available dashboards', () => {
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

@@ -0,0 +1,42 @@
// 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

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

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

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

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

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

View File

@@ -10,7 +10,10 @@ 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,
@@ -19,5 +22,6 @@ export const showSelectSessionModal = createSelector(
export default StrictDict({
numCourses,
hasCourses,
hasAvailableDashboards,
showSelectSessionModal,
});

View File

@@ -17,6 +17,15 @@ 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

@@ -52,6 +52,7 @@ export const courseCard = StrictDict({
homeUrl: courseRun.homeUrl,
marketingUrl: courseRun.marketingUrl,
upgradeUrl: courseRun.upgradeUrl,
progressUrl: baseAppUrl(courseRun.progressUrl),
resumeUrl: baseAppUrl(courseRun.resumeUrl), // resume will route this to learning mfe.

View File

@@ -156,6 +156,7 @@ describe('courseCard selectors module', () => {
homeUrl: 'test-home-url',
marketingUrl: 'test-marketing-url',
upgradeUrl: 'test-upgrade-url',
progressUrl: 'test-progress-url',
resumeUrl: 'test-resume-url',
@@ -180,9 +181,10 @@ describe('courseCard selectors module', () => {
it('passes minPassingGrade floored from float to a percentage value', () => {
expect(selected.minPassingGrade).toEqual(93);
});
it('passes [homeUrl, marketingUrl]', () => {
it('passes [homeUrl, marketingUrl, upgradeUrl]', () => {
expect(selected.homeUrl).toEqual(testData.homeUrl);
expect(selected.marketingUrl).toEqual(testData.marketingUrl);
expect(selected.upgradeUrl).toEqual(testData.upgradeUrl);
});
it('passes [progressUrl, unenrollUrl, resumeUrl], converted to baseAppUrl', () => {
expect(selected.progressUrl).toEqual(baseAppUrl(testData.progressUrl));

View File

@@ -36,6 +36,12 @@ 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,6 +18,7 @@ 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

@@ -50,6 +50,12 @@ export const logEvent = ({ eventName, data, courseId }) => post(urls.event(), {
event: JSON.stringify(data),
});
export const logUpgrade = ({ courseId }) => module.logEvent({
eventName: eventNames.upgradeButtonClickedEnrollment,
courseId,
data: { location: 'learner-dashboard' },
});
export const logShare = ({ courseId, site }) => module.logEvent({
eventName: eventNames.shareClicked,
courseId,
@@ -72,6 +78,7 @@ export default {
updateEntitlementEnrollment,
deleteEntitlementEnrollment,
logEvent,
logUpgrade,
logShare,
createCreditRequest,
};

View File

@@ -130,6 +130,13 @@ describe('lms api methods', () => {
beforeEach(() => {
jest.spyOn(api, moduleKeys.logEvent).mockImplementation(logEvent);
});
test('logUpgrade sends enrollment upgrade click event with learner dashboard location', () => {
expect(api.logUpgrade({ courseId })).toEqual(logEvent({
eventName: eventNames.upgradeButtonClickedEnrollment,
courseId,
data: { location: 'learner-dashboard' },
}));
});
test('logShare sends share clicke vent with course id, side and location', () => {
const site = 'test-site';
expect(api.logShare({ courseId, site })).toEqual(logEvent({

View File

@@ -779,6 +779,9 @@ export const compileCourseRunData = ({ courseName, ...data }, index) => {
courseProvider: getOption(providerOptions, index),
programs: getOption(programsOptions, index),
};
if (out.enrollment.canUpgrade) {
out.courseRun.upgradeUrl = 'test-upgrade-url';
}
return out;
};

View File

@@ -2,8 +2,8 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import React from 'react';
import ReactDOM from 'react-dom';
import {
Route, Navigate, Routes,
} from 'react-router-dom';
@@ -30,29 +30,23 @@ import App from './App';
import NoticesWrapper from './components/NoticesWrapper';
subscribe(APP_READY, () => {
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>,
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'),
);
});
subscribe(APP_INIT_ERROR, (error) => {
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<ErrorPage message={error.message} />
</StrictMode>,
ReactDOM.render(
<ErrorPage message={error.message} />,
document.getElementById('root'),
);
});

View File

@@ -1,3 +1,5 @@
import { render } from 'react-dom';
import {
APP_INIT_ERROR,
APP_READY,
@@ -9,20 +11,9 @@ import {
import { configuration } from './config';
import * as app from '.';
// 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('react-dom', () => ({
render: jest.fn(),
}));
jest.mock('@edx/frontend-platform', () => ({
mergeConfig: jest.fn(),
@@ -41,9 +32,7 @@ describe('app registry', () => {
let getElement;
beforeEach(() => {
mockCreateRoot.mockClear();
mockRender.mockClear();
render.mockClear();
getElement = window.document.getElementById;
window.document.getElementById = jest.fn(id => ({ id }));
});
@@ -55,16 +44,18 @@ describe('app registry', () => {
const callArgs = subscribe.mock.calls[0];
expect(callArgs[0]).toEqual(APP_READY);
callArgs[1]();
const [rendered] = mockRender.mock.calls[0];
const [rendered, target] = render.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] = mockRender.mock.calls[0];
const [rendered, target] = render.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,48 +0,0 @@
# Course Card Action Slot
### Slot ID: `org.openedx.frontend.learner_dashboard.course_card_banner.v1`
### Props:
* `cardId`
## Description
This slot is used for replacing or adding content for the `CourseBanner` component. This banner is rendered as a child of the `CourseCard`.
The default CourseBanner looks like this when audit access has expired for the course:
![Screenshot of the default CourseBanner when audit access has expired](./images/course_banner_slot_default.png)
## Example
The following `env.config.jsx` will render a custom implemenation of a CourseBanner under every `CourseCard`.
![Screenshot of custom banner added under CourseCard](./images/course_banner_slot_default.png)
```js
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import { Alert } from '@openedx/paragon';
const config = {
pluginSlots: {
'org.openedx.frontend.learner_dashboard.course_card_banner.v1': {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'org.openedx.frontend.learner_dashboard.course_card_banner.v1',
type: DIRECT_PLUGIN,
priority: 60,
RenderWidget: ({ cardId }) => (
<Alert variant="info" className="mb-0">
Course banner for course with {cardId}
</Alert>
),
},
},
],
},
},
}
export default config;
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -1,23 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import CourseBanner from 'containers/CourseCard/components/CourseCardBanners/CourseBanner';
const CourseBannerSlot = ({ cardId }) => (
<PluginSlot
id="org.openedx.frontend.learner_dashboard.course_card_banner.v1"
pluginProps={{
cardId,
}}
>
<CourseBanner
cardId={cardId}
/>
</PluginSlot>
);
CourseBannerSlot.propTypes = {
cardId: PropTypes.string.isRequired,
};
export default CourseBannerSlot;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,41 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 187 KiB

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
# `frontend-app-learner-dashboard` Plugin Slots
* [`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)
* [`course_card_action_slot`](./CourseCardActionSlot/)
* [`footer_slot`](./FooterSlot/)
* [`widget_sidebar_slot`](./WidgetSidebarSlot/)
* [`course_list_slot`](./CourseListSlot/)
* [`no_courses_view_slot`](./NoCoursesViewSlot/)

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
/* 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

@@ -9,6 +9,7 @@ import {
within,
prettyDOM,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
initialize,
mergeConfig,

View File

@@ -2,6 +2,7 @@ import { StrictDict } from 'utils';
export const categories = StrictDict({
dashboard: 'dashboard',
upgrade: 'upgrade',
userEngagement: 'user-engagement',
searchButton: 'search_button',
credit: 'credit',
@@ -13,6 +14,9 @@ export const events = StrictDict({
courseImageClicked: 'courseImageClicked',
courseTitleClicked: 'courseTitleClicked',
courseOptionsDropdownClicked: 'courseOptionsDropdownClicked',
upgradeButtonClicked: 'upgradeButtonClicked',
upgradeButtonClickedEnrollment: 'upgradeButtonClickedEnrollment',
upgradeButtonClickedUpsell: 'upgradeButtonClickedUpsell',
shareClicked: 'shareClicked',
userSettingsChanged: 'userSettingsChanged',
newSession: 'newSession',
@@ -20,13 +24,21 @@ 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',
courseTitleClicked: 'edx.bi.dashboard.course_title.clicked',
courseOptionsDropdownClicked: 'edx.bi.dashboard.course_options_dropdown.clicked',
upgradeButtonClicked: 'edx.bi.dashboard.upgrade_button.clicked',
upgradeButtonClickedEnrollment: 'edx.course.enrollment.upgrade.clicked',
upgradeButtonClickedUpsell: 'edx.bi.ecommerce.upsell_links_clicked',
shareClicked: 'edx.course.share_clicked',
userSettingsChanged: 'edx.user.settings.changed',
newSession: 'course-dashboard.new-session',
@@ -34,6 +46,9 @@ 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,6 +1,7 @@
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';
@@ -10,6 +11,7 @@ export default {
course,
credit,
engagement,
enterpriseDashboard,
entitlements,
socialShare,
findCourses,

View File

@@ -1,7 +1,15 @@
import api from 'data/services/lms/api';
import { createEventTracker, createLinkTracker } from 'data/services/segment/utils';
import { categories, eventNames } from '../constants';
import * as module from './course';
export const upsellOptions = {
linkName: 'course_dashboard_green',
linkType: 'button',
pageName: 'course_dashboard',
linkCategory: 'green_update',
};
// Utils/Helpers
/**
* Generate a segement event tracker for a given course event.
@@ -23,6 +31,20 @@ export const courseLinkTracker = (eventName) => (courseId, href) => (
createLinkTracker(module.courseEventTracker(eventName, courseId), href)
);
// Upgrade Events
/**
* There are currently multiple tracked api events for the upgrade event, with different targets.
* Goal here is to split out the tracked events for easier testing.
*/
export const upgradeButtonClicked = (courseId) => createEventTracker(
eventNames.upgradeButtonClicked,
{ category: categories.upgrade, label: courseId },
);
export const upgradeButtonClickedUpsell = (courseId) => createEventTracker(
eventNames.upgradeButtonClickedUpsell,
{ ...upsellOptions, courseId },
);
// Non-Link events
export const courseOptionsDropdownClicked = (courseId) => (
module.courseEventTracker(eventNames.courseOptionsDropdownClicked, courseId)
@@ -35,10 +57,19 @@ export const courseTitleClicked = (...args) => (
module.courseLinkTracker(eventNames.courseTitleClicked)(...args));
export const enterCourseClicked = (...args) => (
module.courseLinkTracker(eventNames.enterCourseClicked)(...args));
export const upgradeClicked = (courseId, href) => createLinkTracker(
() => {
module.upgradeButtonClicked(courseId)();
module.upgradeButtonClickedUpsell(courseId)();
api.logUpgrade({ courseId });
},
href,
);
export default {
courseImageClicked,
courseOptionsDropdownClicked,
courseTitleClicked,
enterCourseClicked,
upgradeClicked,
};

View File

@@ -1,8 +1,13 @@
import { keyStore } from 'utils';
import api from 'data/services/lms/api';
import { createEventTracker, createLinkTracker } from 'data/services/segment/utils';
import { categories, eventNames } from '../constants';
import * as trackers from './course';
jest.mock('data/services/lms/api', () => ({
logUpgrade: jest.fn(),
}));
jest.mock('data/services/segment/utils', () => ({
createEventTracker: jest.fn(args => ({ createEventTracker: args })),
createLinkTracker: jest.fn((cb, href) => ({ createLinkTracker: { cb, href } })),
@@ -39,6 +44,26 @@ describe('course trackers', () => {
});
});
});
describe('Upgrade Events', () => {
describe('upgradeButtonClicked', () => {
it('creates an event tracker for upgradeButtonClicked event with category and label', () => {
expect(trackers.upgradeButtonClicked(courseId)).toEqual(createEventTracker(
eventNames.upgradeButtonClicked,
{ category: categories.upgrade, label: courseId },
));
});
});
describe('upgradeButtonClickedUpsell', () => {
it('creates an event tracker for upgradeButtonClickedUpsell eventwith upsellOptions', () => {
expect(trackers.upgradeButtonClickedUpsell(courseId)).toEqual(
createEventTracker(
eventNames.upgradeButtonClickedUpsell,
{ ...trackers.upsellOptions, courseId },
),
);
});
});
});
describe('Non-link events', () => {
describe('courseOptionsDropdownClicked', () => {
it('creates course event tracker for courseOptionsDropdownClicked event', () => {
@@ -76,5 +101,25 @@ describe('course trackers', () => {
);
});
});
describe('upgradeClicked', () => {
it('triggers upgrade actions and api.logUpgrade with courseId', () => {
const upgradeButtonClicked = jest.fn();
const upgradeButtonClickedUpsell = jest.fn();
const trackUpgradeButtonClicked = jest.fn(() => upgradeButtonClicked);
const trackUpgradeButtonClickedUpsell = jest.fn(() => upgradeButtonClickedUpsell);
jest.spyOn(trackers, moduleKeys.upgradeButtonClicked)
.mockImplementationOnce(trackUpgradeButtonClicked);
jest.spyOn(trackers, moduleKeys.upgradeButtonClickedUpsell)
.mockImplementationOnce(trackUpgradeButtonClickedUpsell);
const out = trackers.upgradeClicked(courseId, href).createLinkTracker;
expect(out.href).toEqual(href);
out.cb();
expect(trackUpgradeButtonClicked).toHaveBeenCalledWith(courseId);
expect(trackUpgradeButtonClickedUpsell).toHaveBeenCalledWith(courseId);
expect(upgradeButtonClicked).toHaveBeenCalledWith();
expect(upgradeButtonClickedUpsell).toHaveBeenCalledWith();
expect(api.logUpgrade).toHaveBeenCalledWith({ courseId });
});
});
});
});

View File

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

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