Skip to content

Commit

Permalink
feat: [FC-0006] add Verifiable Credentials optional feature (#151)
Browse files Browse the repository at this point in the history
* feat: [FC-0006] add Verifiable Credentials optional feature

---------

Co-authored-by: Jason Wesson <[email protected]>
  • Loading branch information
wowkalucky and jsnwesson committed Jul 5, 2023
1 parent ef037a5 commit f8e71a3
Show file tree
Hide file tree
Showing 31 changed files with 7,909 additions and 8,110 deletions.
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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=''
2 changes: 2 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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=''
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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=''
23 changes: 21 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.edx.org>`_.

.. _verifiable credentials: https://en.wikipedia.org/wiki/Verifiable_credentials

Getting Started
***************

Expand Down Expand Up @@ -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 ...
Expand Down Expand Up @@ -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 two environment 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 while trying to create their verifiable credential

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

Expand Down
14,901 changes: 6,800 additions & 8,101 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
Binary file added src/assets/images/appStore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/images/googleplay.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 50 additions & 0 deletions src/components/NavigationBar/NavigationBar.jsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<Tabs
className="mt-1 mb-5"
defaultActiveKey={location.pathname}
onSelect={path => history.push(path)}
>
{NavigationTabs.map(tab => (
<Tab
key={tab.id}
eventKey={tab.path}
title={intl.formatMessage(messages[tab.id])}
/>
))}
</Tabs>
) : null;
}

NavigationBar.propTypes = {
intl: intlShape.isRequired,
};

export default injectIntl(NavigationBar);
2 changes: 2 additions & 0 deletions src/components/NavigationBar/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './NavigationBar';
16 changes: 16 additions & 0 deletions src/components/NavigationBar/messages.js
Original file line number Diff line number Diff line change
@@ -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;
48 changes: 48 additions & 0 deletions src/components/NavigationBar/test/NavigationBar.test.jsx
Original file line number Diff line number Diff line change
@@ -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('does not render the component with Verifiable Credentials functionality when flag is disabled', () => {
mergeConfig({ ENABLE_VERIFIABLE_CREDENTIALS: false });
const { container } = render(<NavigationBar />);
expect(container.innerHTML).toHaveLength(0);
});

it('renders the component with enabled the Verifiable Credentials functionality', () => {
render(<NavigationBar />);
expect(screen.getByText('My Learner Records')).toBeTruthy();
expect(screen.getByText('Verifiable Credentials')).toBeTruthy();
});

it('redirects the appropriate route on tab click', () => {
render(<NavigationBar />);
fireEvent.click(screen.getByText('Verifiable Credentials'));
expect(mockHistoryPush).toHaveBeenCalledWith('/verifiable-credentials');
});
});
89 changes: 89 additions & 0 deletions src/components/ProgramCertificate/ProgramCertificate.jsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<div>
{showSingleAction && (
<Hyperlink
className="btn btn-outline-primary"
onClick={() => handleCreate(uuid, storages[0].id)}
>
{intl.formatMessage(messages.certificateCardActionLabel)}
</Hyperlink>
)}
{!showSingleAction && (
<DropdownButton id="dropdown-storages" title={intl.formatMessage(messages.certificateCardMultiActionLabel)}>
{storages.map(({ id, name }) => (
<Dropdown.Item key={id} onClick={() => handleCreate(uuid, id)}>
{name}
</Dropdown.Item>
))}
</DropdownButton>
)}
</div>
);

return (
<div className="col-12 col-sm-6 col-md-4 d-flex align-items-stretch">
<div className="card mb-4 certificate flex-grow-1">
<div className="card-body d-flex flex-column">
<div className="card-title">
<p className="small mb-0">
{intl.formatMessage(messages.certificateCardName)}
</p>
<h4 className="certificate-title">{programTitle}</h4>
</div>
<p className="small mb-0">
{intl.formatMessage(messages.certificateCardOrgLabel)}
</p>
<p className="h6 mb-4">
{programOrg
|| intl.formatMessage(messages.certificateCardNoOrgText)}
</p>
<p className="small mb-2">
{intl.formatMessage(messages.certificateCardDateLabel, {
date: <FormattedDate value={new Date(modifiedDate)} />,
})}
</p>
{renderCreationButtons()}
</div>
</div>
</div>
);
}

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);
2 changes: 2 additions & 0 deletions src/components/ProgramCertificate/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './ProgramCertificate';
36 changes: 36 additions & 0 deletions src/components/ProgramCertificate/messages.js
Original file line number Diff line number Diff line change
@@ -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;
47 changes: 47 additions & 0 deletions src/components/ProgramCertificate/test/ProgramCertificate.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @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(<ProgramCertificate {...props} />);

expect(screen.getByText('Program Certificate')).toBeTruthy();
expect(screen.getByText(props.program_title)).toBeTruthy();
expect(screen.getByText(props.program_org)).toBeTruthy();
expect(screen.getByText('Awarded on 2/2/2023')).toBeTruthy();
});

it('should display "No organization" if Program Organization wasn\'t set', () => {
render(<ProgramCertificate {...props} program_org="" />);

expect(screen.getByText('No organization')).toBeTruthy();
});

it('renders modal by clicking on a create button', () => {
render(<ProgramCertificate {...props} />);
fireEvent.click(screen.getByText('Create'));

expect(screen.findByTitle('Verifiable credential')).toBeTruthy();
expect(screen.findByLabelText('Close')).toBeTruthy();
});
});
Loading

0 comments on commit f8e71a3

Please sign in to comment.