diff --git a/.env b/.env index 3f51a01c..1d032bda 100644 --- a/.env +++ b/.env @@ -21,3 +21,5 @@ USER_INFO_COOKIE_NAME='' SUPPORT_URL_LEARNER_RECORDS='' APP_ID='' MFE_CONFIG_API_URL='' +ENABLE_VERIFIABLE_CREDENTIALS='' +SUPPORT_URL_VERIFIABLE_CREDENTIALS='' diff --git a/.env.development b/.env.development index f8fce662..9f657f2c 100644 --- a/.env.development +++ b/.env.development @@ -23,3 +23,5 @@ SUPPORT_URL_LEARNER_RECORDS='https://support.edx.org/hc/en-us/sections/360001216 USE_LR_MFE='true' APP_ID='' MFE_CONFIG_API_URL='' +ENABLE_VERIFIABLE_CREDENTIALS='true' +SUPPORT_URL_VERIFIABLE_CREDENTIALS='' diff --git a/.env.test b/.env.test index 35d4bea4..ca930616 100644 --- a/.env.test +++ b/.env.test @@ -18,3 +18,5 @@ SEGMENT_KEY='' SITE_NAME=localhost USER_INFO_COOKIE_NAME='edx-user-info' SUPPORT_URL_LEARNER_RECORDS='' +ENABLE_VERIFIABLE_CREDENTIALS='true' +SUPPORT_URL_VERIFIABLE_CREDENTIALS='' diff --git a/README.rst b/README.rst index b2d023f8..f23c3c53 100644 --- a/README.rst +++ b/README.rst @@ -7,11 +7,18 @@ frontend-app-learner-record Purpose ******* -The Learner Record provides information about the enrolled programs for a user. +The Learner Record provides information about the enrolled programs for a user. It contains views for a learners current status in a program, their current grade, and the ability to share any earned credentials either publically or with institutions. +Verifiable Credentials +====================== + +Optionally, this micro-frontend allows `verifiable credentials`_ creation for already achieved Open edX credentials (currently, program certificates only). + This is the Learner Record micro-frontend, currently under development by `edX `_. +.. _verifiable credentials: https://en.wikipedia.org/wiki/Verifiable_credentials + Getting Started *************** @@ -52,7 +59,7 @@ Every time you develop something in this repo # Start the Learner Record MFE npm start - + # Using your favorite editor, edit the code to make your change. vim ... @@ -81,6 +88,18 @@ This MFE has 2 flags of its own: * ``SUPPORT_URL_LEARNER_RECORDS`` -- A link to a help/support center for learners who run into problems whilst trying to share their records * ``USE_LR_MFE`` -- A toggle that when on, uses the MFE to host shared records instead of the the old UI inside of credentials +Verifiable Credentials +...................... + +An optional feature. It is behind a feature flag. +The feature introduces a couple of enviroment variables: + +* ``ENABLE_VERIFIABLE_CREDENTIALS`` -- Toggles the Verifiable Credentials feature (used by the Credentials IDA and this micro-frontend) +* ``SUPPORT_URL_VERIFIABLE_CREDENTIALS`` -- A link to a help/support center for learners who run into problems whilst trying to create verifiable credentials + +The Verifiable Credentials UI is a functional addition to the corresponding backend app (it will use a REST API from the Credentials IDA located at `credentials/apps/verifiable_credentials/rest_api`. + + Project Structure ----------------- diff --git a/package-lock.json b/package-lock.json index b80a3b0d..0e265203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "lodash": "4.17.21", "prop-types": "15.8.1", "react": "16.14.0", + "react-device-detect": "^2.2.3", "react-dom": "16.14.0", "react-helmet-async": "^1.3.0", "react-redux": "7.2.9", @@ -45,6 +46,7 @@ "glob": "7.2.3", "husky": "7.0.4", "jest": "27.5.1", + "resize-observer-polyfill": "^1.5.1", "rosie": "2.1.0" } }, @@ -21118,6 +21120,18 @@ "node": ">=8" } }, + "node_modules/react-device-detect": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.2.3.tgz", + "integrity": "sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==", + "dependencies": { + "ua-parser-js": "^1.0.33" + }, + "peerDependencies": { + "react": ">= 0.14.0", + "react-dom": ">= 0.14.0" + } + }, "node_modules/react-dom": { "version": "16.14.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", @@ -21917,6 +21931,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "dev": true + }, "node_modules/resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -24218,6 +24238,24 @@ "node": ">=4.2.0" } }, + "node_modules/ua-parser-js": { + "version": "1.0.33", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.33.tgz", + "integrity": "sha512-RqshF7TPTE0XLYAqmjlu5cLLuGdKrNu9O1KLA/qp39QtbZwuzwv1dT46DZSopoUMsYgXpB3Cv8a03FI8b74oFQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -41755,6 +41793,14 @@ } } }, + "react-device-detect": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.2.3.tgz", + "integrity": "sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==", + "requires": { + "ua-parser-js": "^1.0.33" + } + }, "react-dom": { "version": "16.14.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", @@ -42366,6 +42412,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "dev": true + }, "resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -44176,6 +44228,11 @@ "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "devOptional": true }, + "ua-parser-js": { + "version": "1.0.33", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.33.tgz", + "integrity": "sha512-RqshF7TPTE0XLYAqmjlu5cLLuGdKrNu9O1KLA/qp39QtbZwuzwv1dT46DZSopoUMsYgXpB3Cv8a03FI8b74oFQ==" + }, "unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", diff --git a/package.json b/package.json index 936ee9a5..084b7009 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "lodash": "4.17.21", "prop-types": "15.8.1", "react": "16.14.0", + "react-device-detect": "^2.2.3", "react-dom": "16.14.0", "react-helmet-async": "^1.3.0", "react-redux": "7.2.9", @@ -69,6 +70,7 @@ "glob": "7.2.3", "husky": "7.0.4", "jest": "27.5.1", + "resize-observer-polyfill": "^1.5.1", "rosie": "2.1.0" } } diff --git a/src/assets/images/appStore.png b/src/assets/images/appStore.png new file mode 100644 index 00000000..4003d099 Binary files /dev/null and b/src/assets/images/appStore.png differ diff --git a/src/assets/images/googleplay.png b/src/assets/images/googleplay.png new file mode 100644 index 00000000..b91c1139 Binary files /dev/null and b/src/assets/images/googleplay.png differ diff --git a/src/components/NavigationBar/NavigationBar.jsx b/src/components/NavigationBar/NavigationBar.jsx new file mode 100644 index 00000000..2a41dec3 --- /dev/null +++ b/src/components/NavigationBar/NavigationBar.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Tabs, Tab } from '@edx/paragon'; +import { getConfig } from '@edx/frontend-platform'; +import { ROUTES } from '../../constants'; + +import messages from './messages'; + +function NavigationBar({ intl }) { + const NavigationTabs = [ + { + id: 'learnerRecords', + path: ROUTES.PROGRAM_RECORDS, + }, + ]; + + if (getConfig().ENABLE_VERIFIABLE_CREDENTIALS) { + NavigationTabs.push({ + id: 'verifiableCredentials', + path: ROUTES.VERIFIABLE_CREDENTIALS, + }); + } + + const history = useHistory(); + const location = useLocation(); + + return NavigationTabs.length > 1 ? ( + history.push(path)} + > + {NavigationTabs.map(tab => ( + + ))} + + ) : null; +} + +NavigationBar.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(NavigationBar); diff --git a/src/components/NavigationBar/index.js b/src/components/NavigationBar/index.js new file mode 100644 index 00000000..43641a0f --- /dev/null +++ b/src/components/NavigationBar/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-restricted-exports +export { default } from './NavigationBar'; diff --git a/src/components/NavigationBar/messages.js b/src/components/NavigationBar/messages.js new file mode 100644 index 00000000..ed1a5443 --- /dev/null +++ b/src/components/NavigationBar/messages.js @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + learnerRecords: { + id: 'learnerRecords', + defaultMessage: 'My Learner Records', + description: 'A message of Learner Records navigation tab', + }, + verifiableCredentials: { + id: 'verifiableCredentials', + defaultMessage: 'Verifiable Credentials', + description: 'A message of Verifiable Credentials navigation tab', + }, +}); + +export default messages; diff --git a/src/components/NavigationBar/test/NavigationBar.test.jsx b/src/components/NavigationBar/test/NavigationBar.test.jsx new file mode 100644 index 00000000..2088834f --- /dev/null +++ b/src/components/NavigationBar/test/NavigationBar.test.jsx @@ -0,0 +1,48 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import { mergeConfig } from '@edx/frontend-platform'; +import { + render, screen, cleanup, initializeMockApp, fireEvent, +} from '../../../setupTest'; +import NavigationBar from '..'; + +const mockHistoryPush = jest.fn(); +global.ResizeObserver = require('resize-observer-polyfill'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush, + }), +})); + +describe('navigation-bar', () => { + beforeAll(async () => { + await initializeMockApp(); + }); + beforeEach(() => { + mergeConfig({ ENABLE_VERIFIABLE_CREDENTIALS: 'true' }); + return jest.resetModules; + }); + afterEach(cleanup); + + it('not renders the component with disabled the Verifiable Credentials functionality', () => { + mergeConfig({ ENABLE_VERIFIABLE_CREDENTIALS: false }); + const { container } = render(); + expect(container.innerHTML).toHaveLength(0); + }); + + it('renders the component with enabled the Verifiable Credentials functionality', () => { + render(); + expect(screen.getByText('My Learner Records')).toBeTruthy(); + expect(screen.getByText('Verifiable Credentials')).toBeTruthy(); + }); + + it('redirects the appropriate route on tab click', () => { + render(); + fireEvent.click(screen.getByText('Verifiable Credentials')); + expect(mockHistoryPush).toHaveBeenCalledWith('/verifiable-credentials'); + }); +}); diff --git a/src/components/ProgramCertificate/ProgramCertificate.jsx b/src/components/ProgramCertificate/ProgramCertificate.jsx new file mode 100644 index 00000000..ea4fae86 --- /dev/null +++ b/src/components/ProgramCertificate/ProgramCertificate.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + FormattedDate, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; +import { Hyperlink, DropdownButton, Dropdown } from '@edx/paragon'; +import messages from './messages'; + +function ProgramCertificate({ + intl, + program_title: programTitle, + program_org: programOrg, + modified_date: modifiedDate, + uuid, + handleCreate, + storages = [], +}) { + const showSingleAction = storages.length === 1; + + const renderCreationButtons = () => ( +
+ {showSingleAction && ( + handleCreate(uuid, storages[0].id)} + > + {intl.formatMessage(messages.certificateCardActionLabel)} + + )} + {!showSingleAction && ( + + {storages.map(({ id, name }) => ( + handleCreate(uuid, id)}> + {name} + + ))} + + )} +
+ ); + + return ( +
+
+
+
+

+ {intl.formatMessage(messages.certificateCardName)} +

+

{programTitle}

+
+

+ {intl.formatMessage(messages.certificateCardOrgLabel)} +

+

+ {programOrg + || intl.formatMessage(messages.certificateCardNoOrgText)} +

+

+ {intl.formatMessage(messages.certificateCardDateLabel, { + date: , + })} +

+ {renderCreationButtons()} +
+
+
+ ); +} + +ProgramCertificate.propTypes = { + intl: intlShape.isRequired, + program_title: PropTypes.string.isRequired, + program_org: PropTypes.string.isRequired, + modified_date: PropTypes.string.isRequired, + uuid: PropTypes.string.isRequired, + handleCreate: PropTypes.func.isRequired, + storages: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }), + ).isRequired, +}; + +export default injectIntl(ProgramCertificate); diff --git a/src/components/ProgramCertificate/index.js b/src/components/ProgramCertificate/index.js new file mode 100644 index 00000000..1245c0c5 --- /dev/null +++ b/src/components/ProgramCertificate/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-restricted-exports +export { default } from './ProgramCertificate'; diff --git a/src/components/ProgramCertificate/messages.js b/src/components/ProgramCertificate/messages.js new file mode 100644 index 00000000..ca61de46 --- /dev/null +++ b/src/components/ProgramCertificate/messages.js @@ -0,0 +1,36 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + certificateCardName: { + id: 'certificate.card.name', + defaultMessage: 'Program Certificate', + description: 'A title text of the available program certificate item.', + }, + certificateCardOrgLabel: { + id: 'certificate.card.organization.label', + defaultMessage: 'From', + description: '', + }, + certificateCardNoOrgText: { + id: 'certificate.card.noOrg.text', + defaultMessage: 'No organization', + description: '', + }, + certificateCardDateLabel: { + id: 'certificate.card.date.label', + defaultMessage: 'Awarded on {date}', + description: '', + }, + certificateCardActionLabel: { + id: 'certificate.card.action.label', + defaultMessage: 'Create', + description: 'A text on single action button', + }, + certificateCardMultiActionLabel: { + id: 'certificate.card.multiAction.label', + defaultMessage: 'Create with', + description: 'A text on a dropdown with multiple action options', + }, +}); + +export default messages; diff --git a/src/components/ProgramCertificate/test/ProgramCertificate.test.jsx b/src/components/ProgramCertificate/test/ProgramCertificate.test.jsx new file mode 100644 index 00000000..d3284759 --- /dev/null +++ b/src/components/ProgramCertificate/test/ProgramCertificate.test.jsx @@ -0,0 +1,56 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import { + render, screen, cleanup, initializeMockApp, fireEvent, +} from '../../../setupTest'; +import ProgramCertificate from '..'; + +describe('program-certificate', () => { + beforeAll(async () => { + await initializeMockApp(); + }); + beforeEach(() => jest.resetModules); + afterEach(cleanup); + + const props = { + program_title: 'Program name', + program_org: 'Test org', + modified_date: '2023-02-02', + storages: [{ id: 'storageId', name: 'storageName' }], + handleCreate: jest.fn(), + }; + + it('renders the component', () => { + render(); + expect(screen.getByText('Program Certificate')).toBeTruthy(); + }); + + it('it should display a program name', () => { + render(); + expect(screen.getByText(props.program_title)).toBeTruthy(); + }); + + it('it should display a program organization', () => { + render(); + expect(screen.getByText(props.program_org)).toBeTruthy(); + }); + + it('it should display a program organization', () => { + render(); + expect(screen.getByText('Awarded on 2/2/2023')).toBeTruthy(); + }); + + it('it should display a default org name if it wasn\'t set', () => { + render(); + expect(screen.getByText('No organization')).toBeTruthy(); + }); + + it('renders modal by clicking on a create button', () => { + render(); + fireEvent.click(screen.getByText('Create')); + expect(screen.findByTitle('Verifiable credential')).toBeTruthy(); + expect(screen.findByLabelText('Close')).toBeTruthy(); + }); +}); diff --git a/src/components/ProgramCertificateModal/ProgramCertificateModal.jsx b/src/components/ProgramCertificateModal/ProgramCertificateModal.jsx new file mode 100644 index 00000000..c7836cfc --- /dev/null +++ b/src/components/ProgramCertificateModal/ProgramCertificateModal.jsx @@ -0,0 +1,169 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { BrowserView, MobileView, isBrowser } from 'react-device-detect'; +import { + ActionRow, Button, Row, StandardModal, +} from '@edx/paragon'; +import { Info } from '@edx/paragon/icons'; + +import messages from './messages'; +import appStoreImg from '../../assets/images/appStore.png'; +import googlePlayImg from '../../assets/images/googleplay.png'; + +function ProgramCertificateModal({ + intl, isOpen, close, data, +}) { + const { + deeplink, + qrcode, + app_link_android: appLinkAndroid, + app_link_ios: appLinkIos, + error, + } = data; + + if (error) { + return ( + + + {error} + + ); + } + + return ( + + + + + + + ) : null + } + > + <> + + +
+
+ {intl.formatMessage(messages.certificateModalQrCodeLabel)} +
+
+
+

+ {intl.formatMessage(messages.certificateModalInstructionTitle)} +

+
    +
  1. + {intl.formatMessage( + messages.certificateModalInstructionStep1, + )} +
  2. +
  3. + {intl.formatMessage( + messages.certificateModalInstructionStep2, + )} +
  4. +
  5. + {intl.formatMessage( + messages.certificateModalInstructionStep3, + )} +
  6. +
  7. + {intl.formatMessage( + messages.certificateModalInstructionStep4, + )} +
  8. +
+
+
+
+ +

{intl.formatMessage(messages.certificateModalMobileTitle)}

+ + +

+

    +
  1. + {intl.formatMessage(messages.certificateModalInstructionStep1)} +
  2. +
  3. + {intl.formatMessage(messages.certificateModalInstructionStep2)} +
  4. +
  5. + {intl.formatMessage(messages.certificateModalInstructionStep3)} +
  6. +
  7. + {intl.formatMessage(messages.certificateModalInstructionStep4)} +
  8. +
+ + +
+ +
+ ); +} + +ProgramCertificateModal.propTypes = { + intl: intlShape.isRequired, + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + data: PropTypes.shape.isRequired, +}; + +export default injectIntl(ProgramCertificateModal); diff --git a/src/components/ProgramCertificateModal/index.js b/src/components/ProgramCertificateModal/index.js new file mode 100644 index 00000000..2b394357 --- /dev/null +++ b/src/components/ProgramCertificateModal/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-restricted-exports +export { default } from './ProgramCertificateModal'; diff --git a/src/components/ProgramCertificateModal/messages.js b/src/components/ProgramCertificateModal/messages.js new file mode 100644 index 00000000..b4efcc85 --- /dev/null +++ b/src/components/ProgramCertificateModal/messages.js @@ -0,0 +1,81 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + certificateModalTitle: { + id: 'credentials.modal.title', + defaultMessage: 'Verifiable credential', + description: 'Title of a dialog.', + }, + certificateModalCloseBtn: { + id: 'credentials.modal.close.button', + defaultMessage: 'Close modal window', + description: 'Label on button to close a dialog.', + }, + certificateModalCloseMobileBtn: { + id: 'credentials.modal.close.mobile.button', + defaultMessage: 'Cancel', + description: 'Label on button to close a dialog.', + }, + certificateModalMobileTitle: { + id: 'credentials.modal.mobile.title', + defaultMessage: 'To download a verifiable credential to your mobile wallet application, please follow the instructions below.', + description: 'Text for a mobile dialog of the program certificate.', + }, + certificateModalAppStoreBtn: { + id: 'credentials.modal.instruction.appStore.button', + defaultMessage: 'Download the mobile app from the Apple App Store', + description: 'The label for the link to download the apple version of the app.', + }, + certificateModalGooglePlayBtn: { + id: 'credentials.modal.instruction.googlePlay.button', + defaultMessage: 'Download the mobile app from the Google Play', + description: 'The label for the link to download the google version of the app.', + }, + certificateModalInstructionTitle: { + id: 'credentials.modal.instruction.title', + defaultMessage: 'Download and install the app on your smartphone.', + description: 'Title text of the instructions.', + }, + certificateModalInstructionStep1: { + id: 'credentials.modal.instruction.step1', + defaultMessage: 'Sign up for the app to identify yourself.', + description: 'Text of step of the instructions.', + }, + certificateModalInstructionStep2: { + id: 'credentials.modal.instruction.step2', + defaultMessage: 'Open the application and select the option scan the QR code. Scan the provided code.', + description: 'Text of step of the instructions.', + }, + certificateModalInstructionStep3: { + id: 'credentials.modal.instruction.step3', + defaultMessage: 'Follow this instructions below to get verifiable credential:', + description: 'Text of step of the instructions.', + }, + certificateModalInstructionStep4: { + id: 'credentials.modal.instruction.step4', + defaultMessage: 'Once you have successfully done - close modal.', + description: 'Text of step of the instructions.', + }, + certificateModalDeeplinkBtn: { + id: 'credentials.modal.deeplink', + defaultMessage: 'Download Credential', + description: 'The label for the link to download credential.', + }, + certificateModalLoading: { + id: 'credentials.modal.loading', + defaultMessage: 'Loading...', + description: 'Message when data is being loaded', + }, + certificateModalQrCodeLabel: { + id: 'credentials.modal.qrCode.label', + defaultMessage: 'QR code of the credential certificate', + description: 'The label for QR code image', + }, + credentialsModalError: { + id: 'credentials.modal.error', + defaultMessage: 'An error occurred attempting to retrieve your program certificate. Please try again later.', + description: 'An error message indicating there is a problem retrieving the user\'s program certificate data', + }, +}); + +export default messages; diff --git a/src/components/ProgramCertificateModal/test/ProgramCertificateModal.test.jsx b/src/components/ProgramCertificateModal/test/ProgramCertificateModal.test.jsx new file mode 100644 index 00000000..02b1dde2 --- /dev/null +++ b/src/components/ProgramCertificateModal/test/ProgramCertificateModal.test.jsx @@ -0,0 +1,33 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import ProgramCertificateModal from '..'; +import { + render, screen, cleanup, initializeMockApp, +} from '../../../setupTest'; + +const props = { + isOpen: true, + close: jest.fn(), + data: { + deeplink: 'https://example1.com', + qrcode: 'data:image/png;base64,...', + app_link_android: 'https://example2.com', + app_link_ios: 'https://example3.com', + }, +}; + +describe('program-certificate-modal', () => { + beforeAll(async () => { + await initializeMockApp(); + }); + beforeEach(() => jest.resetModules); + afterEach(cleanup); + + it('renders the component', () => { + render(); + expect(screen.getByText('Verifiable credential')).toBeTruthy(); + expect(screen.getByText('Close modal window')).toBeTruthy(); + }); +}); diff --git a/src/components/ProgramCertificatesList/ProgramCertificatesList.jsx b/src/components/ProgramCertificatesList/ProgramCertificatesList.jsx new file mode 100644 index 00000000..937cb58a --- /dev/null +++ b/src/components/ProgramCertificatesList/ProgramCertificatesList.jsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState } from 'react'; + +import { ChevronLeft, Info } from '@edx/paragon/icons'; +import { + Alert, Hyperlink, Row, useToggle, +} from '@edx/paragon'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform/config'; +import { logError } from '@edx/frontend-platform/logging'; + +import ProgramCertificate from '../ProgramCertificate'; +import NavigationBar from '../NavigationBar'; +import { + getProgramCertificates, + getAvailableStorages, + initVerifiableCredentialIssuance, +} from './data/service'; +import messages from './messages'; +import ProgramCertificateModal from '../ProgramCertificateModal'; + +function ProgramCertificatesList({ intl }) { + const [certificatesAreLoaded, setCertificatesAreLoaded] = useState(false); + const [dataLoadingIssue, setDataLoadingIssue] = useState(''); + const [certificates, setCertificates] = useState([]); + + const [storagesIsLoaded, setStoragesIsLoaded] = useState(false); + const [storages, setStorages] = useState([]); + + const [modalIsOpen, openModal, closeModal] = useToggle(false); + + const [ + verfifiableCredentialIssuanceData, + setVerifiableCredentialIssuanceData, + ] = useState({}); + + useEffect(() => { + getProgramCertificates() + .then((data) => { + setCertificates(data.program_credentials); + setCertificatesAreLoaded(true); + }) + .catch((error) => { + const errorMessage = intl.formatMessage( + messages.errorProgramCertificatesLoading, + ); + setDataLoadingIssue(errorMessage); + logError(errorMessage + error.message); + }); + }, [intl]); + + useEffect(() => { + getAvailableStorages() + .then((data) => { + setStorages(data); + setStoragesIsLoaded(true); + }) + .catch((error) => { + const errorMessage = intl.formatMessage( + messages.errorAvailableStoragesLoading, + ); + setDataLoadingIssue(errorMessage); + logError(errorMessage + error.message); + }); + }, [intl]); + + const handleCreate = (uuid, storageId) => { + initVerifiableCredentialIssuance({ uuid, storageId }) + .then((data) => { + setVerifiableCredentialIssuanceData(data); + if (data.redirect) { + window.location = data.deeplink; + } else { + openModal(); + } + }) + .catch((error) => { + const errorMessage = intl.formatMessage(messages.errorIssuanceInit); + setVerifiableCredentialIssuanceData({ error: errorMessage }); + openModal(); + logError(errorMessage + error.message); + }); + }; + + const renderProfile = () => { + const { username } = getAuthenticatedUser(); + return ( + + + {intl.formatMessage(messages.credentialsProfileLink)} + + ); + }; + + const renderCredentialsServiceIssueAlert = ({ + message = intl.formatMessage(messages.credentialsListError), + }) => ( +
+ + + {message} + +
+ ); + + const renderEmpty = () => ( +

+ {intl.formatMessage(messages.credentialsListEmpty)} +

+ ); + + const renderProgramCertificates = () => ( +
+

{intl.formatMessage(messages.credentialsDescription)}

+ + {certificates.map((certificate) => ( + + ))} + +
+ ); + + const renderData = () => { + if (dataLoadingIssue) { + return renderCredentialsServiceIssueAlert({ + message: dataLoadingIssue, + }); + } + if (!certificates.length) { + return renderEmpty(); + } + if (!certificatesAreLoaded || !storagesIsLoaded) { + return null; + } + return renderProgramCertificates(); + }; + + const renderHelp = () => ( +
+

+ {intl.formatMessage(messages.credentialsHelpHeader)} +

+ {intl.formatMessage(messages.credentialsHelpDescription)} + + {intl.formatMessage(messages.credentialsHelpLink)} + +
+ ); + + return ( +
+ {renderProfile()} + +

+ {intl.formatMessage(messages.credentialsHeader)} +

+ {renderData()} + {renderHelp()} + +
+ ); +} + +ProgramCertificatesList.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(ProgramCertificatesList); diff --git a/src/components/ProgramCertificatesList/data/service.js b/src/components/ProgramCertificatesList/data/service.js new file mode 100644 index 00000000..51b3d87d --- /dev/null +++ b/src/components/ProgramCertificatesList/data/service.js @@ -0,0 +1,46 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform/config'; + +export async function getProgramCertificates() { + const url = `${ + getConfig().CREDENTIALS_BASE_URL + }/verifiable_credentials/api/v1/program_credentials/`; + let data = {}; + try { + ({ data } = await getAuthenticatedHttpClient().get(url, { + withCredentials: true, + })); + } catch (error) { + // We are catching and suppressing errors here on purpose. If an error occurs during the + // getProgramCertificates call we will pass back an empty `data` object. Downstream we make + // the assumption that if the ProgramCertificates object is empty that there was an issue or + // error communicating with the service/API. + } + return data; +} + +export async function getAvailableStorages() { + const url = `${ + getConfig().CREDENTIALS_BASE_URL + }/verifiable_credentials/api/v1/storages/`; + let data = []; + ({ data } = await getAuthenticatedHttpClient().get(url, { + withCredentials: true, + })); + return data; +} + +export async function initVerifiableCredentialIssuance({ uuid, storageId }) { + const url = `${ + getConfig().CREDENTIALS_BASE_URL + }/verifiable_credentials/api/v1/credentials/init/`; + const requestData = { + credential_uuid: uuid, + storage_id: storageId, + }; + let data = {}; + ({ data } = await getAuthenticatedHttpClient().post(url, requestData, { + withCredentials: true, + })); + return data; +} diff --git a/src/components/ProgramCertificatesList/index.js b/src/components/ProgramCertificatesList/index.js new file mode 100644 index 00000000..a71ed01d --- /dev/null +++ b/src/components/ProgramCertificatesList/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-restricted-exports +export { default } from './ProgramCertificatesList'; diff --git a/src/components/ProgramCertificatesList/messages.js b/src/components/ProgramCertificatesList/messages.js new file mode 100644 index 00000000..48e130d4 --- /dev/null +++ b/src/components/ProgramCertificatesList/messages.js @@ -0,0 +1,61 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + credentialsProfileLink: { + id: 'credentials.profile.link', + defaultMessage: 'Back to My Profile', + description: 'Link text that redirects logged-in user to their profile page', + }, + credentialsListEmpty: { + id: 'credentials.list.empty', + defaultMessage: 'No certificate available. Finish you first program to get a certificate.', + description: 'A message indicating the user has no program certificates to display on the Verifiable Credentials page', + }, + credentialsListError: { + id: 'credentials.list.error', + defaultMessage: 'An error occurred attempting to retrieve your program certificates. Please try again later.', + description: 'An error message indicating there is a problem retrieving the user\'s program certificates', + }, + credentialsHeader: { + id: 'credentials.header', + defaultMessage: 'Verifiable Credentials', + description: 'Header for the Verifiable Credentials page', + }, + credentialsDescription: { + id: 'credentials.description', + defaultMessage: 'A certificate for a program will appear in the list once you have earned all course certificates in a program.', + description: 'Description of program credentials for the Verifiable Credentials page', + }, + credentialsHelpHeader: { + id: 'credentials.help.header', + defaultMessage: 'Questions about Verifiable Credentials?', + description: 'Header for the help section of Verifiable Credentials page', + }, + credentialsHelpDescription: { + id: 'credentials.help.description', + defaultMessage: 'To learn more about Verifiable Credentials you can ', + description: 'Text description for the help section of Verifiable Credentials page', + }, + credentialsHelpLink: { + id: 'credentials.help.link', + defaultMessage: 'read in our verifiable credentials help area.', + description: 'Text containing link that redirects user to support page', + }, + errorProgramCertificatesLoading: { + id: 'credentials.error.fetch.certificates', + defaultMessage: 'Could not fetch program certificates', + description: 'API data fetching error when program certificates cannot be loaded', + }, + errorAvailableStoragesLoading: { + id: 'credentials.error.fetch.storages', + defaultMessage: 'Could not fetch available storages', + description: 'API data fetching error when storages configuration cannot be loaded', + }, + errorIssuanceInit: { + id: 'credentials.error.issuance.init', + defaultMessage: 'Could not initiate issuance line', + description: 'Verifiable credential issuance init API request has failed', + }, +}); + +export default messages; diff --git a/src/components/ProgramCertificatesList/test/ProgramCertificatesList.test.jsx b/src/components/ProgramCertificatesList/test/ProgramCertificatesList.test.jsx new file mode 100644 index 00000000..6797fb49 --- /dev/null +++ b/src/components/ProgramCertificatesList/test/ProgramCertificatesList.test.jsx @@ -0,0 +1,79 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import { Factory } from 'rosie'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform/config'; +import { + render, screen, cleanup, initializeMockApp, act, +} from '../../../setupTest'; +import ProgramCertificatesList from '..'; +import { getProgramCredentialsFactory, getAvailableStoragesFactory } from './__factories__/programCertificatesList.factory'; + +describe('program-certificates-list', () => { + beforeAll(async () => { + await initializeMockApp(); + }); + beforeEach(() => jest.resetModules); + afterEach(cleanup); + + it('renders the component', () => { + render(); + expect(screen.getByText('Verifiable Credentials')).toBeTruthy(); + }); + + it('it should display a link to the user\'s Profile', () => { + render(); + expect(screen.getByText('Back to My Profile')).toBeTruthy(); + }); + + it('it should have a help section', () => { + render(); + expect(screen.getByText('Questions about Verifiable Credentials?')).toBeTruthy(); + }); +}); + +describe('program-certificates-data', () => { + beforeAll(async () => { + await initializeMockApp(); + }); + afterEach(() => { + cleanup(); + Factory.resetAll(); + }); + + it('should display certificates when data is present', async () => { + await act(async () => { + const axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(`${getConfig().CREDENTIALS_BASE_URL}/verifiable_credentials/api/v1/program_credentials/`) + .reply(200, getProgramCredentialsFactory.build()); + axiosMock + .onGet(`${getConfig().CREDENTIALS_BASE_URL}/verifiable_credentials/api/v1/storages/`) + .reply(200, getAvailableStoragesFactory.buildList(1)); + render(); + }); + + expect(await screen.findByText('Verifiable Credentials')).toBeTruthy(); + expect(await screen.findByText('A certificate for a program will appear in the list once you ' + + 'have earned all course certificates in a program.')).toBeTruthy(); + expect(await screen.findByText('Programm title 1')).toBeTruthy(); + expect(await screen.findByText('Programm org 1')).toBeTruthy(); + }); + + it('should display no certificates when no enrolled_programs are present', async () => { + await act(async () => { + const axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(`${getConfig().CREDENTIALS_BASE_URL}/verifiable_credentials/api/v1/program_credentials/`) + .reply(200, { program_credentials: [] }); + axiosMock + .onGet(`${getConfig().CREDENTIALS_BASE_URL}/verifiable_credentials/api/v1/storages/`) + .reply(200, []); + render(); + }); + expect(await screen.findByText('No certificate available. Finish you first program to get a certificate.')).toBeTruthy(); + }); +}); diff --git a/src/components/ProgramCertificatesList/test/__factories__/programCertificatesList.factory.js b/src/components/ProgramCertificatesList/test/__factories__/programCertificatesList.factory.js new file mode 100644 index 00000000..e19b8022 --- /dev/null +++ b/src/components/ProgramCertificatesList/test/__factories__/programCertificatesList.factory.js @@ -0,0 +1,33 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies + +export const getProgramCredentialsFactory = Factory.define('program_credentials') + .attr('program_credentials', [ + { + uuid: '12345', + status: 'awarded', + username: 'honor', + download_url: null, + credential_id: 1, + program_uuid: '54321', + program_title: 'Programm title 1', + program_org: 'Programm org 1', + modified_date: '2022-10-08', + }, + { + uuid: '67890', + status: 'awarded', + username: 'honor', + download_url: null, + credential_id: 1, + program_uuid: '09876', + program_title: 'Programm title 2', + program_org: '', + modified_date: '2023-02-02', + }, + ]); + +export const getAvailableStoragesFactory = Factory.define('storages') + .attrs({ + id: 'test_storage', + name: 'Test Storage Name', + }); diff --git a/src/components/ProgramRecordsList/ProgramRecordsList.jsx b/src/components/ProgramRecordsList/ProgramRecordsList.jsx index 5470347a..9da2ba4a 100644 --- a/src/components/ProgramRecordsList/ProgramRecordsList.jsx +++ b/src/components/ProgramRecordsList/ProgramRecordsList.jsx @@ -10,6 +10,8 @@ import { getConfig } from '@edx/frontend-platform/config'; import { logError } from '@edx/frontend-platform/logging'; import _ from 'lodash'; +import NavigationBar from '../NavigationBar/NavigationBar'; + import getProgramRecords from './data/service'; function ProgramRecordsList() { @@ -70,7 +72,7 @@ function ProgramRecordsList() { ); const renderEmpty = () => ( -

+

+

{renderProfile()} +

{ ReactDOM.render( @@ -29,19 +31,27 @@ subscribe(APP_READY, () => { + {getConfig().ENABLE_VERIFIABLE_CREDENTIALS && ( + + + + )} { mergeConfig({ SUPPORT_URL_LEARNER_RECORDS: process.env.SUPPORT_URL_LEARNER_RECORDS || '', - USE_LR_MFE: process.env.USE_LR_MFE || '', + USE_LR_MFE: process.env.USE_LR_MFE || false, + ENABLE_VERIFIABLE_CREDENTIALS: process.env.ENABLE_VERIFIABLE_CREDENTIALS || false, + SUPPORT_URL_VERIFIABLE_CREDENTIALS: process.env.SUPPORT_URL_VERIFIABLE_CREDENTIALS || '', }, 'LearnerRecordConfig'); }, },