Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [FC-0006] add Verifiable Credentials optional feature #151

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
GlugovGrGlib marked this conversation as resolved.
Show resolved Hide resolved
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)}>

Check warning on line 34 in src/components/ProgramCertificate/ProgramCertificate.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/ProgramCertificate/ProgramCertificate.jsx#L34

Added line #L34 was not covered by tests
{storages.map(({ id, name }) => (
<Dropdown.Item key={id} onClick={() => handleCreate(uuid, id)}>

Check warning on line 36 in src/components/ProgramCertificate/ProgramCertificate.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/ProgramCertificate/ProgramCertificate.jsx#L36

Added line #L36 was not covered by tests
{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
jsnwesson marked this conversation as resolved.
Show resolved Hide resolved
|| 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