Compare commits

...

36 Commits

Author SHA1 Message Date
Diana Olarte
559c335aa3 feat: allow runtime configuration (#144)
* feat: allow runtime configuration

* test: organize Head test
2022-11-23 10:10:21 -05:00
Leangseu Kim
18a1e7da48 fix: update transifex flag for tx cli 1.4.0 2022-11-23 10:10:21 -05:00
edX requirements bot
35532fed92 fix: update organization references (#142) 2022-10-03 12:56:21 +05:00
Nathan Sprenkle
15952d808a Merge pull request #141 from edx/transifex-bot-update-translations2022-09-18
chore(i18n): update translations
2022-09-19 15:30:58 -04:00
Jenkins
3a928e42bc chore(i18n): update translations 2022-09-18 15:40:16 +00:00
Ben Warzeski
15e756673f fix: remove return from useEffect call (#131)
* fix: remove return from useEffect call

* fix: text renderer tests
2022-07-20 13:29:26 -04:00
Leangseu Kim
cba03d305c chore: update rubric style 2022-07-19 13:32:22 -04:00
Leangseu Kim
956dee9a6d chore: change card body to card section 2022-07-19 11:28:15 -04:00
leangseu-edx
4f7d3aeb57 leangseu edx/header footer dependency (#127)
* chore: update dependency

* fix: update dependency to match deploy package for header and footer

* chore: update linting

* chore: update datatable filter for paragon upgrade
2022-07-15 11:02:44 -04:00
leangseu-edx
d4f1383822 fix: make student response persist break line on display (#125)
* fix: make student response persist break line on display

* chore: scroll bug when selecting text
2022-07-07 11:54:47 -04:00
Nathan Sprenkle
5efd1466bf Merge pull request #121 from edx/nsprenkle/readme
docs: add a readme
2022-06-21 14:00:23 -04:00
nsprenkle
36bd27517c docs: update reamde 2022-06-17 11:14:55 -04:00
nsprenkle
6c884ce215 docs: add a basic readme 2022-06-17 11:09:55 -04:00
Leangseu Kim
8b4f554cf6 fix: use moment to handle date 2022-06-13 15:08:26 -04:00
Leangseu Kim
0b1b079abd fix: patch to name duplicate 2022-06-13 10:14:23 -04:00
Leangseu Kim
b2c52111d7 chore: update text on CTA banner 2022-05-20 12:51:46 -04:00
leangseu-edx
18bc94e2ff chore: add CTA for page (#112)
* chore: add CTA for page

* chore: update hyperlink style
2022-05-19 10:16:26 -04:00
leangseu-edx
0f41df2cf3 feat: add fetch submission files (#110)
chore: remove cache busting
2022-05-12 09:45:46 -04:00
Ben Warzeski
91fbb8978a chore: update integration tests (#109) 2022-05-11 14:14:25 -04:00
leangseu-edx
5aecd88c70 fix: loose end on hook refactor (#111)
* fix: loose end on hook refactor

* chore: update package-lock.json to npm 8
2022-05-11 13:05:01 -04:00
Jawayria
2bf499fb43 Merge pull request #107 from edx/jenkins/version-check-0a90024
feat: Add package-lock file version check
2022-05-06 15:59:20 +05:00
edx-semantic-release
c217c32196 chore(i18n): update translations 2022-05-01 11:45:23 -04:00
Ben Warzeski
5f12c4fb8e chore: renderer test coverage (#103)
* chore: renderer test coverage

* fix: lint

* chore: api tests

* chore: tests for app reducer and StartGradeButton

* chore: lint

* fix: update reducer tests

* chore: more test coverage

* chore: test coverage

* chore: update test for merge conflicts
2022-04-29 14:54:33 -04:00
edX requirements bot
4d7d95e490 feat: Add package-lock file version check 2022-04-29 08:51:09 -04:00
Matthew Carter
0a90024de9 feat: Update Demo Mode banner (#105)
* chore: Update MFE page title

* feat: Demo mode banner includes end date and call to action
2022-04-27 12:55:10 -04:00
edx-semantic-release
91d06e9788 chore(i18n): update translations 2022-04-24 11:45:23 -04:00
Leangseu Kim
74423bf359 feat: prevent download large files 2022-04-21 09:37:11 -04:00
leangseu-edx
7e9eab24b0 header component (#101)
* chore: use LearningHeader instead course header

* chore: remove course header debris
2022-04-20 13:13:03 -04:00
leangseu-edx
91dd10917f fix: cannot select criterion (#100)
* fix: cannot select criterion

* fix: refactor fill grade data

* fix: update tests

Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
2022-04-18 16:43:37 -04:00
edx-semantic-release
b2098be114 chore(i18n): update translations 2022-04-17 11:50:11 -04:00
leangseu-edx
64ac98c310 download filename, error handling and cache busting (#98)
* feat: handle download error and display them

* chore: update test environment for easier single file test

* feat: add cache bursting to the download
2022-04-14 13:52:39 -04:00
Leangseu Kim
8a80e2a70e chore: update package 2022-04-12 10:36:25 -04:00
Matthew Carter
a936d970db Merge pull request #95 from muselesscreator/batch_unlock_api
feat: Batch unlock api
2022-04-11 10:56:21 -04:00
Ben Warzeski
56c6c88638 feat: connect batch unlock to the api 2022-04-07 15:55:49 -04:00
Ben Warzeski
9c42bfbd8a fix: update snapshot 2022-04-07 15:53:09 -04:00
Ben Warzeski
69733f7837 fix: update routing for images 2022-04-07 15:52:50 -04:00
163 changed files with 26509 additions and 24460 deletions

2
.env
View File

@@ -30,3 +30,5 @@ ENTERPRISE_MARKETING_URL=''
ENTERPRISE_MARKETING_UTM_SOURCE=''
ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
APP_ID=''
MFE_CONFIG_API_URL=''

View File

@@ -36,3 +36,5 @@ ENTERPRISE_MARKETING_URL='http://example.com'
ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
APP_ID=''
MFE_CONFIG_API_URL=''

View File

@@ -6,6 +6,7 @@ const config = createConfig('eslint', {
'import/no-named-as-default-member': 'off',
'import/no-self-import': 'off',
'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
'react-hooks/rules-of-hooks': 'off',
},
});

View File

@@ -7,4 +7,4 @@ on:
jobs:
commitlint:
uses: edx/.github/.github/workflows/commitlint.yml@master
uses: openedx/.github/.github/workflows/commitlint.yml@master

View File

@@ -0,0 +1,13 @@
#check package-lock file version
name: Lockfile Version check
on:
push:
branches:
- master
pull_request:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master

View File

@@ -57,7 +57,7 @@ push_translations:
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
# This target is used by CI.
validate-no-uncommitted-package-lock-changes:

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# frontend-app-ora-grading
The ORA Staff Grading App is a microfrontend (MFE) staff grading experience for Open Response Assessments (ORAs). This experience was designed to streamline the grading process and enable richer previews of submission content.
When enabled, ORAs with a staff grading step will link to this new MFE when clicking "Grade Available Responses" from the ORA or link in the instructor dashboard.
## Quickstart
To start the MFE and enable the feature in LMS:
1. Start the MFE with `npm run start`. Take a note of the path/port (defaults to `http://localhost:1993`).
2. Add the route root to `edx-platform` settings: In `edx-platform/lms/envs/private.py` or similar, add `ORA_GRADING_MICROFRONTEND_URL = 'http://localhost:1993'`
3. Enable the feature: In Django Admin go to django-waffle > Flags and add/enable a new flag called `openresponseassessment.enhanced_staff_grader`.
From there, visit the new experience by going to the Instructor Dashboard > Open Responses or an ORA with a Staff Graded Step and click a link to begin grading.
## Resources
See the [ORA Staff Grading](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/open_response_assessments/ORA_Staff_Grading.html#ora-staff-grading) section on ReadTheDocs for usage information.

View File

@@ -12,6 +12,10 @@ module.exports = createConfig('jest', {
coveragePathIgnorePatterns: [
'src/segment.js',
'src/postcss.config.js',
'testUtils', // don't unit test jest mocking tools
'src/data/services/lms/fakeData', // don't unit test mock data
'src/test', // don't unit test integration test utils
],
testTimeout: 120000,
testEnvironment: 'jsdom',
});

42258
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,9 +27,10 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-edx.org@^2.0.3",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-platform": "1.12.4",
"@edx/paragon": "16.14.4",
"@edx/frontend-component-footer": "^11.1.1",
"@edx/frontend-component-header": "^3.1.1",
"@edx/frontend-platform": "^2.5.1",
"@edx/paragon": "^19.9.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
@@ -51,11 +52,12 @@
"history": "5.0.1",
"html-react-parser": "^1.3.0",
"lodash": "^4.17.21",
"node-sass": "^6.0.1",
"moment": "^2.29.3",
"prop-types": "15.7.2",
"query-string": "7.0.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react-helmet": "^6.1.0",
"react-intl": "^5.20.9",
"react-pdf": "^5.5.0",
"react-redux": "^7.2.4",
@@ -73,7 +75,7 @@
"whatwg-fetch": "^3.6.2"
},
"devDependencies": {
"@edx/frontend-build": "9.1.1",
"@edx/frontend-build": "^11.0.2",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.0",
"axios-mock-adapter": "^1.20.0",
@@ -85,10 +87,10 @@
"identity-obj-proxy": "^3.0.0",
"jest": "27.0.6",
"jest-expect-message": "^1.0.2",
"react-dev-utils": "^11.0.4",
"react-test-renderer": "^17.0.2",
"react-dev-utils": "^12.0.1",
"react-test-renderer": "^16.14.0",
"reactifex": "1.1.1",
"redux-mock-store": "^1.5.4",
"semantic-release": "^17.4.5"
"semantic-release": "^19.0.3"
}
}

View File

@@ -1,7 +1,7 @@
<!doctype html>
<html lang="en-us" dir="ltr">
<head>
<title>ORA Enhanced Staff Grader | <%= process.env.SITE_NAME %></title>
<title>ORA staff grading | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />

View File

@@ -4,24 +4,28 @@ import { connect } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import Footer from '@edx/frontend-component-footer';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import { selectors } from 'data/redux';
import DemoWarning from 'containers/DemoWarning';
import CourseHeader from 'containers/CourseHeader';
import CTA from 'containers/CTA';
import ListView from 'containers/ListView';
import './App.scss';
import Head from './components/Head';
export const App = ({ courseMetadata, isEnabled }) => (
<Router>
<div>
<CourseHeader
<Head />
<Header
courseTitle={courseMetadata.title}
courseNumber={courseMetadata.number}
courseOrg={courseMetadata.org}
/>
{!isEnabled && <DemoWarning />}
<CTA />
<main>
<ListView />
</main>

View File

@@ -42,32 +42,6 @@ $input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.
}
}
.course-header {
min-width: 0;
border-bottom: 1px solid black;
.course-title-lockup {
min-width: 0;
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-bottom: 0.1rem;
}
}
.user-dropdown {
.btn {
height: 3rem;
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
padding: 0 0.5rem;
}
}
}
}
#paragon-portal-root {
.pgn__modal-layer {
.pgn__modal-close-container {

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import Footer from '@edx/frontend-component-footer';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import ListView from 'containers/ListView';
@@ -16,11 +17,15 @@ jest.mock('data/redux', () => ({
},
}));
jest.mock('@edx/frontend-component-header', () => ({
LearningHeader: 'Header',
}));
jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('containers/DemoWarning', () => 'DemoWarning');
jest.mock('containers/CTA', () => 'CTA');
jest.mock('containers/ListView', () => 'ListView');
jest.mock('containers/CourseHeader', () => 'CourseHeader');
jest.mock('components/Head', () => 'Head');
const logo = 'fakeLogo.png';
let el;
@@ -57,5 +62,16 @@ describe('App router component', () => {
test('Footer logo drawn from env variable', () => {
expect(router.find(Footer).props().logo).toEqual(logo);
});
test('Header to use courseMetadata props', () => {
const {
courseTitle,
courseNumber,
courseOrg,
} = router.find(Header).props();
expect(courseTitle).toEqual(props.courseMetadata.title);
expect(courseNumber).toEqual(props.courseMetadata.number);
expect(courseOrg).toEqual(props.courseMetadata.org);
});
});
});

View File

@@ -3,12 +3,14 @@
exports[`App router component snapshot: disabled (show demo warning) 1`] = `
<BrowserRouter>
<div>
<CourseHeader
<Head />
<Header
courseNumber="course-number"
courseOrg="course-org"
courseTitle="course-title"
/>
<DemoWarning />
<CTA />
<main>
<ListView />
</main>
@@ -22,11 +24,13 @@ exports[`App router component snapshot: disabled (show demo warning) 1`] = `
exports[`App router component snapshot: enabled 1`] = `
<BrowserRouter>
<div>
<CourseHeader
<Head />
<Header
courseNumber="course-number"
courseOrg="course-org"
courseTitle="course-title"
/>
<CTA />
<main>
<ListView />
</main>

View File

@@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app registry subscribe: APP_INIT_ERROR. snapshot: displays an ErrorPage to root element 1`] = `
<ErrorPage
message="test-error-message"
/>
`;
exports[`app registry subscribe: APP_READY. links App to root element 1`] = `
<IntlProvider
defaultFormats={Object {}}
defaultLocale="en"
fallbackOnEmptyString={true}
formats={Object {}}
locale="en"
messages={Object {}}
onError={[Function]}
onWarn={[Function]}
textComponent={Symbol(react.fragment)}
>
<AppProvider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(Symbol.observable): [Function],
}
}
>
<App />
</AppProvider>
</IntlProvider>
`;

View File

@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { AlertModal, ActionRow, Button } from '@edx/paragon';
import { nullMethod } from 'hooks';
export const ConfirmModal = ({
title,
@@ -15,7 +16,7 @@ export const ConfirmModal = ({
<AlertModal
className="confirm-modal"
title={title}
onClose={() => ({})}
onClose={nullMethod}
isOpen={isOpen}
footerNode={(
<ActionRow>

View File

@@ -6,144 +6,80 @@ import {
Icon, Form, ActionRow, IconButton,
} from '@edx/paragon';
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
import pdfjsWorker from 'react-pdf/node_modules/pdfjs-dist/build/pdf.worker.entry';
import pdfjsWorker from 'react-pdf/dist/esm/pdf.worker.entry';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import { rendererHooks } from './pdfHooks';
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;
/**
* <PDFRenderer />
*/
export class PDFRenderer extends React.Component {
static INITIAL_STATE = {
pageNumber: 1,
numPages: 1,
relativeHeight: 0,
};
export const PDFRenderer = ({
onError,
onSuccess,
url,
}) => {
const {
pageNumber,
numPages,
relativeHeight,
wrapperRef,
onDocumentLoadSuccess,
onLoadPageSuccess,
onDocumentLoadError,
onInputPageChange,
onNextPageButtonClick,
onPrevPageButtonClick,
hasNext,
hasPrev,
} = rendererHooks({ onError, onSuccess });
constructor(props) {
super(props);
this.state = { ...PDFRenderer.INITIAL_STATE };
this.wrapperRef = React.createRef();
this.onDocumentLoadSuccess = this.onDocumentLoadSuccess.bind(this);
this.onDocumentLoadError = this.onDocumentLoadError.bind(this);
this.onLoadPageSuccess = this.onLoadPageSuccess.bind(this);
this.onPrevPageButtonClick = this.onPrevPageButtonClick.bind(this);
this.onNextPageButtonClick = this.onNextPageButtonClick.bind(this);
this.onInputPageChange = this.onInputPageChange.bind(this);
}
onDocumentLoadSuccess = ({ numPages }) => {
this.props.onSuccess();
this.setState({ numPages });
};
onLoadPageSuccess = (page) => {
const pageWidth = page.view[2];
const pageHeight = page.view[3];
const wrapperHeight = this.wrapperRef.current.getBoundingClientRect().width;
const relativeHeight = (wrapperHeight * pageHeight) / pageWidth;
if (relativeHeight !== this.state.relativeHeight) {
this.setState({ relativeHeight });
}
};
onDocumentLoadError = (error) => {
let status;
switch (error.name) {
case 'MissingPDFException':
status = 404;
break;
default:
status = 500;
break;
}
this.props.onError(status);
};
onInputPageChange = ({ target: { value } }) => {
this.setPageNumber(parseInt(value, 10));
}
onPrevPageButtonClick = () => {
this.setPageNumber(this.state.pageNumber - 1);
}
onNextPageButtonClick = () => {
this.setPageNumber(this.state.pageNumber + 1);
}
setPageNumber(pageNumber) {
if (pageNumber > 0 && pageNumber <= this.state.numPages) {
this.setState({ pageNumber });
}
}
get hasNext() {
return this.state.pageNumber < this.state.numPages;
}
get hasPrev() {
return this.state.pageNumber > 1;
}
render() {
return (
<div ref={this.wrapperRef} className="pdf-renderer">
<Document
file={this.props.url}
onLoadSuccess={this.onDocumentLoadSuccess}
onLoadError={this.onDocumentLoadError}
>
{/* <Outline /> */}
<div
className="page-wrapper"
style={{
height: this.state.relativeHeight,
}}
>
<Page
pageNumber={this.state.pageNumber}
onLoadSuccess={this.onLoadPageSuccess}
/>
</div>
</Document>
<ActionRow className="d-flex justify-content-center m-0">
<IconButton
size="inline"
alt="previous pdf page"
iconAs={Icon}
src={ChevronLeft}
disabled={!this.hasPrev}
onClick={this.onPrevPageButtonClick}
return (
<div ref={wrapperRef} className="pdf-renderer">
<Document
file={url}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
>
{/* <Outline /> */}
<div className="page-wrapper" style={{ height: relativeHeight }}>
<Page pageNumber={pageNumber} onLoadSuccess={onLoadPageSuccess} />
</div>
</Document>
<ActionRow className="d-flex justify-content-center m-0">
<IconButton
size="inline"
alt="previous pdf page"
iconAs={Icon}
src={ChevronLeft}
disabled={!hasPrev}
onClick={onPrevPageButtonClick}
/>
<Form.Group className="d-flex align-items-center m-0">
<Form.Label isInline>Page </Form.Label>
<Form.Control
type="number"
min={0}
max={numPages}
value={pageNumber}
onChange={onInputPageChange}
/>
<Form.Group className="d-flex align-items-center m-0">
<Form.Label isInline>Page </Form.Label>
<Form.Control
type="number"
min={0}
max={this.state.numPages}
value={this.state.pageNumber}
onChange={this.onInputPageChange}
/>
<Form.Label isInline> of {this.state.numPages}</Form.Label>
</Form.Group>
<IconButton
size="inline"
alt="next pdf page"
iconAs={Icon}
src={ChevronRight}
disabled={!this.hasNext}
onClick={this.onNextPageButtonClick}
/>
</ActionRow>
</div>
);
}
}
<Form.Label isInline> of {numPages}</Form.Label>
</Form.Group>
<IconButton
size="inline"
alt="next pdf page"
iconAs={Icon}
src={ChevronRight}
disabled={!hasNext}
onClick={onNextPageButtonClick}
/>
</ActionRow>
</div>
);
};
PDFRenderer.defaultProps = {};

View File

@@ -1,221 +1,57 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Document, Page } from 'react-pdf';
import { Form, IconButton } from '@edx/paragon';
import PDFRenderer from './PDFRenderer';
import * as hooks from './pdfHooks';
jest.mock('react-pdf', () => ({
pdfjs: { GlobalWorkerOptions: {} },
Document: () => 'Document',
Page: () => 'Page',
}));
jest.mock('./pdfHooks', () => ({
rendererHooks: jest.fn(),
}));
describe('PDF Renderer Component', () => {
const props = {
url: 'some_url.pdf',
onError: jest.fn().mockName('this.props.onError'),
onSuccess: jest.fn().mockName('this.props.onSuccess'),
};
const hookProps = {
pageNumber: 1,
numPages: 10,
relativeHeight: 200,
wrapperRef: { current: 'hooks.wrapperRef' },
onDocumentLoadSuccess: jest.fn().mockName('hooks.onDocumentLoadSuccess'),
onLoadPageSuccess: jest.fn().mockName('hooks.onLoadPageSuccess'),
onDocumentLoadError: jest.fn().mockName('hooks.onDocumentLoadError'),
onInputPageChange: jest.fn().mockName('hooks.onInputPageChange'),
onNextPageButtonClick: jest.fn().mockName('hooks.onNextPageButtonClick'),
onPrevPageButtonClick: jest.fn().mockName('hooks.onPrevPageButtonClick'),
hasNext: true,
hasPref: false,
};
props.onError = jest.fn().mockName('this.props.onError');
props.onSuccess = jest.fn().mockName('this.props.onSuccess');
let el;
describe('snapshots', () => {
beforeEach(() => {
el = shallow(<PDFRenderer {...props} />);
el.instance().onDocumentLoadSuccess = jest
.fn()
.mockName('onDocumentLoadSuccess');
el.instance().onDocumentLoadError = jest
.fn()
.mockName('onDocumentLoadError');
el.instance().onLoadPageSuccess = jest.fn().mockName('onLoadPageSuccess');
});
test('snapshot', () => {
el.instance().onPrevPageButtonClick = jest
.fn()
.mockName('onPrevPageButtonClick');
el.instance().onNextPageButtonClick = jest
.fn()
.mockName('onNextPageButtonClick');
el.instance().onInputPageChange = jest.fn().mockName('onInputPageChange');
expect(el.instance().render()).toMatchSnapshot();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('Component', () => {
const numPages = 99;
const pageNumber = 234;
beforeEach(() => {
el = shallow(<PDFRenderer {...props} />);
describe('snapshots', () => {
test('first page, prev is disabled', () => {
hooks.rendererHooks.mockReturnValue(hookProps);
expect(shallow(<PDFRenderer {...props} />)).toMatchSnapshot();
});
describe('render', () => {
describe('Top-level document', () => {
let documentEl;
beforeEach(() => { documentEl = el.find(Document); });
it('displays file from props.url', () => {
expect(documentEl.props().file).toEqual(props.url);
});
it('calls this.onDocumentLoadSuccess onLoadSuccess', () => {
expect(documentEl.props().onLoadSuccess).toEqual(el.instance().onDocumentLoadSuccess);
});
it('calls this.onDocumentLoadError onLoadError', () => {
expect(documentEl.props().onLoadError).toEqual(el.instance().onDocumentLoadError);
});
});
describe('Page', () => {
let pageProps;
beforeEach(() => {
el.instance().setState({ pageNumber });
pageProps = el.find(Page).props();
});
it('loads pageNumber from state', () => {
expect(pageProps.pageNumber).toEqual(pageNumber);
});
it('calls onLoadPageSuccess onLoadSuccess', () => {
expect(pageProps.onLoadSuccess).toEqual(el.instance().onLoadPageSuccess);
});
});
describe('pagination ActionRow', () => {
describe('Previous page button', () => {
let hasPrev;
beforeEach(() => {
hasPrev = jest.spyOn(el.instance(), 'hasPrev', 'get').mockReturnValue(false);
});
const btn = () => shallow(el.instance().render()).find(IconButton).at(0).props();
test('disabled iff not this.hasPrev', () => {
expect(btn().disabled).toEqual(true);
hasPrev.mockReturnValue(true);
expect(btn().disabled).toEqual(false);
});
it('calls onPrevPageButtonClick onClick', () => {
expect(btn().onClick).toEqual(el.instance().onPrevPageButtonClick);
});
});
describe('page indicator', () => {
const control = () => el.find(Form.Control).at(0).props();
const labels = () => {
const flat = el.find({ isInline: true });
return [0, 1].map(i => flat.at(i).text());
};
beforeEach(() => { el.instance().setState({ numPages, pageNumber }); });
test('labels: Page <state.pageNumber> of <state.numPages>', () => {
expect(`${labels()[0]}${control().value}${labels()[1]}`).toEqual(
`Page ${pageNumber} of ${numPages}`,
);
});
it('loads max from state.numPages', () => expect(control().max).toEqual(numPages));
it('loads value from state.pageNumber', () => {
expect(control().value).toEqual(pageNumber);
});
it('calls onInputPageChange onChange', () => {
expect(control().onChange).toEqual(el.instance().onInputPageChange);
});
});
describe('Next page button', () => {
let hasNext;
beforeEach(() => {
hasNext = jest.spyOn(el.instance(), 'hasNext', 'get').mockReturnValue(false);
});
const btn = () => shallow(el.instance().render()).find(IconButton).at(1).props();
test('disabled iff not this.hasNext', () => {
expect(btn().disabled).toEqual(true);
hasNext.mockReturnValue(true);
expect(btn().disabled).toEqual(false);
});
it('calls onNextPageButtonClick onClick', () => {
expect(btn().onClick).toEqual(el.instance().onNextPageButtonClick);
});
});
});
});
describe('behavior', () => {
test('initial state', () => {
expect(el.instance().state).toEqual(PDFRenderer.INITIAL_STATE);
});
describe('onDocumentLoadSuccess', () => {
test('loads numPages into state', () => {
el.instance().onDocumentLoadSuccess({ numPages });
expect(el.instance().state.numPages).toEqual(numPages);
});
});
describe('onLoadPageSuccess', () => {
const [pageHeight, pageWidth] = [23, 34];
const page = { view: [1, 2, pageWidth, pageHeight] };
const wrapperWidth = 20;
const expected = (wrapperWidth * pageHeight) / pageWidth;
beforeEach(() => {
el.instance().wrapperRef = {
current: {
getBoundingClientRect: () => ({ width: wrapperWidth }),
},
};
});
it('sets relative height if it has changes', () => {
el.instance().onLoadPageSuccess(page);
expect(el.instance().state.relativeHeight).toEqual(expected);
});
it('does not try to set height if has not changes', () => {
el.instance().setState({ relativeHeight: expected });
el.instance().setState = jest.fn();
el.instance().onLoadPageSuccess(page);
expect(el.instance().setState).not.toHaveBeenCalled();
});
});
describe('setPageNumber inheritors', () => {
beforeEach(() => {
el.instance().setPageNumber = jest.fn();
el.instance().setState({ pageNumber });
});
describe('onInputChange', () => {
it('calls setPageNumber with int value of event target value', () => {
el.instance().onInputPageChange({ target: { value: '23' } });
expect(el.instance().setPageNumber).toHaveBeenCalledWith(23);
});
});
describe('onPrevPageButtonClick', () => {
it('calls setPageNumber with state.pageNumber - 1', () => {
el.instance().onPrevPageButtonClick();
expect(el.instance().setPageNumber).toHaveBeenCalledWith(pageNumber - 1);
});
});
describe('onNextPageButtonClick', () => {
it('calls setPageNumber with state.pageNumber + 1', () => {
el.instance().onNextPageButtonClick();
expect(el.instance().setPageNumber).toHaveBeenCalledWith(pageNumber + 1);
});
});
});
describe('setPageNumber', () => {
it('calls setState with pageNumber iff valid', () => {
el.instance().setState({ numPages });
const setState = jest.spyOn(el.instance(), 'setState');
el.instance().setPageNumber(0);
expect(setState).not.toHaveBeenCalled();
el.instance().setPageNumber(numPages + 1);
expect(setState).not.toHaveBeenCalled();
el.instance().setPageNumber(2);
expect(setState).toHaveBeenCalledWith({ pageNumber: 2 });
});
});
describe('hasNext getter', () => {
it('returns true iff state.pageNumber < state.numPages', () => {
el.instance().setState({ pageNumber: 1, numPages: 1 });
expect(el.instance().hasNext).toEqual(false);
el.instance().setState({ pageNumber: 1, numPages: 2 });
expect(el.instance().hasNext).toEqual(true);
});
});
describe('hasPrev getter', () => {
it('returns true iff state.pageNumber > 1', () => {
el.instance().setState({ pageNumber: 1 });
expect(el.instance().hasPrev).toEqual(false);
el.instance().setState({ pageNumber: 2 });
expect(el.instance().hasPrev).toEqual(true);
});
test('on last page, next is disabled', () => {
hooks.rendererHooks.mockReturnValue({
...hookProps,
pageNumber: hookProps.numPages,
hasNext: false,
hasPrev: true,
});
expect(shallow(<PDFRenderer {...props} />)).toMatchSnapshot();
});
});
});

View File

@@ -1,18 +1,9 @@
import React, { useMemo, useState } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { get } from 'axios';
import { rendererHooks } from './textHooks';
const TXTRenderer = ({ url, onError, onSuccess }) => {
const [content, setContent] = useState('');
useMemo(() => {
get(url)
.then(({ data }) => {
onSuccess();
setContent(data);
})
.catch(({ response }) => onError(response.status));
}, [url]);
const { content } = rendererHooks({ url, onError, onSuccess });
return (
<pre className="txt-renderer">
{content}

View File

@@ -3,23 +3,21 @@ import { shallow } from 'enzyme';
import TXTRenderer from './TXTRenderer';
jest.mock('axios', () => ({
get: jest.fn((...args) => Promise.resolve({ data: `Content of ${args}` })),
}));
jest.mock('./textHooks', () => {
const content = 'test-content';
return {
content,
rendererHooks: (args) => ({ content, rendererHooks: args }),
};
});
describe('TXT Renderer Component', () => {
const props = {
url: 'some_url.txt',
onError: jest.fn().mockName('this.props.onError'),
onSuccess: jest.fn().mockName('this.props.onSuccess'),
};
props.onError = jest.fn().mockName('this.props.onError');
props.onSuccess = jest.fn().mockName('this.props.onSuccess');
let el;
beforeEach(() => {
el = shallow(<TXTRenderer {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
expect(shallow(<TXTRenderer {...props} />)).toMatchSnapshot();
});
});

View File

@@ -1,24 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PDF Renderer Component snapshots snapshot 1`] = `
exports[`PDF Renderer Component snapshots first page, prev is disabled 1`] = `
<div
className="pdf-renderer"
>
<Document
file="some_url.pdf"
onLoadError={[MockFunction onDocumentLoadError]}
onLoadSuccess={[MockFunction onDocumentLoadSuccess]}
onLoadError={[MockFunction hooks.onDocumentLoadError]}
onLoadSuccess={[MockFunction hooks.onDocumentLoadSuccess]}
>
<div
className="page-wrapper"
style={
Object {
"height": 0,
"height": 200,
}
}
>
<Page
onLoadSuccess={[MockFunction onLoadPageSuccess]}
onLoadSuccess={[MockFunction hooks.onLoadPageSuccess]}
pageNumber={1}
/>
</div>
@@ -30,7 +30,7 @@ exports[`PDF Renderer Component snapshots snapshot 1`] = `
alt="previous pdf page"
disabled={true}
iconAs="Icon"
onClick={[MockFunction onPrevPageButtonClick]}
onClick={[MockFunction hooks.onPrevPageButtonClick]}
size="inline"
src={[MockFunction icons.ChevronLeft]}
/>
@@ -43,9 +43,9 @@ exports[`PDF Renderer Component snapshots snapshot 1`] = `
Page
</Form.Label>
<Form.Control
max={1}
max={10}
min={0}
onChange={[MockFunction onInputPageChange]}
onChange={[MockFunction hooks.onInputPageChange]}
type="number"
value={1}
/>
@@ -53,14 +53,82 @@ exports[`PDF Renderer Component snapshots snapshot 1`] = `
isInline={true}
>
of
1
10
</Form.Label>
</Form.Group>
<IconButton
alt="next pdf page"
disabled={true}
disabled={false}
iconAs="Icon"
onClick={[MockFunction onNextPageButtonClick]}
onClick={[MockFunction hooks.onNextPageButtonClick]}
size="inline"
src={[MockFunction icons.ChevronRight]}
/>
</ActionRow>
</div>
`;
exports[`PDF Renderer Component snapshots on last page, next is disabled 1`] = `
<div
className="pdf-renderer"
>
<Document
file="some_url.pdf"
onLoadError={[MockFunction hooks.onDocumentLoadError]}
onLoadSuccess={[MockFunction hooks.onDocumentLoadSuccess]}
>
<div
className="page-wrapper"
style={
Object {
"height": 200,
}
}
>
<Page
onLoadSuccess={[MockFunction hooks.onLoadPageSuccess]}
pageNumber={10}
/>
</div>
</Document>
<ActionRow
className="d-flex justify-content-center m-0"
>
<IconButton
alt="previous pdf page"
disabled={false}
iconAs="Icon"
onClick={[MockFunction hooks.onPrevPageButtonClick]}
size="inline"
src={[MockFunction icons.ChevronLeft]}
/>
<Form.Group
className="d-flex align-items-center m-0"
>
<Form.Label
isInline={true}
>
Page
</Form.Label>
<Form.Control
max={10}
min={0}
onChange={[MockFunction hooks.onInputPageChange]}
type="number"
value={10}
/>
<Form.Label
isInline={true}
>
of
10
</Form.Label>
</Form.Group>
<IconButton
alt="next pdf page"
disabled={true}
iconAs="Icon"
onClick={[MockFunction hooks.onNextPageButtonClick]}
size="inline"
src={[MockFunction icons.ChevronRight]}
/>

View File

@@ -4,6 +4,6 @@ exports[`TXT Renderer Component snapshot 1`] = `
<pre
className="txt-renderer"
>
Content of some_url.txt
test-content
</pre>
`;

View File

@@ -0,0 +1,81 @@
import { useState, useRef } from 'react';
import { pdfjs } from 'react-pdf';
import pdfjsWorker from 'react-pdf/dist/esm/pdf.worker.entry';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import { ErrorStatuses } from 'data/constants/requests';
import { StrictDict } from 'utils';
import * as module from './pdfHooks';
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;
export const errors = StrictDict({
missingPDF: 'MissingPDFException',
});
export const state = StrictDict({
pageNumber: (val) => useState(val),
numPages: (val) => useState(val),
relativeHeight: (val) => useState(val),
});
export const initialState = {
pageNumber: 1,
numPages: 1,
relativeHeight: 1,
};
export const safeSetPageNumber = ({ numPages, rawSetPageNumber }) => (pageNumber) => {
if (pageNumber > 0 && pageNumber <= numPages) {
rawSetPageNumber(pageNumber);
}
};
export const rendererHooks = ({
onError,
onSuccess,
}) => {
const [pageNumber, rawSetPageNumber] = module.state.pageNumber(initialState.pageNumber);
const [numPages, setNumPages] = module.state.numPages(initialState.numPages);
const [relativeHeight, setRelativeHeight] = module.state.relativeHeight(
initialState.relativeHeight,
);
const setPageNumber = module.safeSetPageNumber({ numPages, rawSetPageNumber });
const wrapperRef = useRef();
return {
pageNumber,
numPages,
relativeHeight,
wrapperRef,
onDocumentLoadSuccess: (args) => {
onSuccess();
setNumPages(args.numPages);
},
onLoadPageSuccess: (page) => {
const pageWidth = page.view[2];
const pageHeight = page.view[3];
const wrapperHeight = wrapperRef.current.getBoundingClientRect().width;
const newHeight = (wrapperHeight * pageHeight) / pageWidth;
setRelativeHeight(newHeight);
},
onDocumentLoadError: (error) => {
let status;
if (error.name === errors.missingPDF) {
status = ErrorStatuses.notFound;
} else {
status = ErrorStatuses.serverError;
}
onError(status);
},
onInputPageChange: ({ target: { value } }) => setPageNumber(parseInt(value, 10)),
onPrevPageButtonClick: () => setPageNumber(pageNumber - 1),
onNextPageButtonClick: () => setPageNumber(pageNumber + 1),
hasNext: pageNumber < numPages,
hasPrev: pageNumber > 1,
};
};

View File

@@ -0,0 +1,148 @@
import React from 'react';
import { MockUseState } from 'testUtils';
import { keyStore } from 'utils';
import { ErrorStatuses } from 'data/constants/requests';
import * as hooks from './pdfHooks';
jest.mock('react-pdf', () => ({
pdfjs: { GlobalWorkerOptions: {} },
Document: () => 'Document',
Page: () => 'Page',
}));
const state = new MockUseState(hooks);
const hookKeys = keyStore(hooks);
const testValue = 'my-test-value';
describe('PDF Renderer hooks', () => {
beforeAll(() => {
jest.clearAllMocks();
});
describe('state hooks', () => {
state.testGetter(state.keys.pageNumber);
state.testGetter(state.keys.numPages);
state.testGetter(state.keys.relativeHeight);
});
describe('non-state hooks', () => {
beforeEach(() => state.mock());
afterEach(() => state.restore());
describe('safeSetPageNumber', () => {
it('returns value handler that sets page number if valid', () => {
const rawSetPageNumber = jest.fn();
const numPages = 10;
hooks.safeSetPageNumber({ numPages, rawSetPageNumber })(0);
expect(rawSetPageNumber).not.toHaveBeenCalled();
hooks.safeSetPageNumber({ numPages, rawSetPageNumber })(numPages + 1);
expect(rawSetPageNumber).not.toHaveBeenCalled();
hooks.safeSetPageNumber({ numPages, rawSetPageNumber })(numPages - 1);
expect(rawSetPageNumber).toHaveBeenCalledWith(numPages - 1);
});
});
describe('rendererHooks', () => {
const props = {
url: 'some_url.pdf',
onError: jest.fn().mockName('this.props.onError'),
onSuccess: jest.fn().mockName('this.props.onSuccess'),
};
let setPageNumber;
let hook;
let mockSetPageNumber;
let mockSafeSetPageNumber;
beforeEach(() => {
mockSetPageNumber = jest.fn(val => ({ setPageNumber: { val } }));
mockSafeSetPageNumber = jest.fn(() => mockSetPageNumber);
setPageNumber = jest.spyOn(hooks, hookKeys.safeSetPageNumber)
.mockImplementation(mockSafeSetPageNumber);
hook = hooks.rendererHooks(props);
});
afterAll(() => {
setPageNumber.mockRestore();
});
describe('returned object', () => {
Object.keys(state.keys).forEach(key => {
test(`${key} tied to store and initialized from initialState`, () => {
expect(hook[key]).toEqual(hooks.initialState[key]);
expect(hook[key]).toEqual(state.stateVals[key]);
});
});
});
test('wrapperRef passed as react ref', () => {
expect(hook.wrapperRef.useRef).toEqual(true);
});
describe('onDocumentLoadSuccess', () => {
it('calls onSuccess and sets numPages based on args', () => {
hook.onDocumentLoadSuccess({ numPages: testValue });
expect(props.onSuccess).toHaveBeenCalled();
expect(state.setState.numPages).toHaveBeenCalledWith(testValue);
});
});
describe('onLoadPageSuccess', () => {
it('sets relative height based on page size', () => {
const width = 23;
React.useRef.mockReturnValueOnce({
current: {
getBoundingClientRect: () => ({ width }),
},
});
const [pageWidth, pageHeight] = [20, 30];
const page = { view: [0, 0, pageWidth, pageHeight] };
hook = hooks.rendererHooks(props);
const height = (width * pageHeight) / pageWidth;
hook.onLoadPageSuccess(page);
expect(state.setState.relativeHeight).toHaveBeenCalledWith(height);
});
});
describe('onDocumentLoadError', () => {
it('calls onError with notFound error if error is missingPDF error', () => {
hook.onDocumentLoadError({ name: hooks.errors.missingPDF });
expect(props.onError).toHaveBeenCalledWith(ErrorStatuses.notFound);
});
it('calls onError with serverError by default', () => {
hook.onDocumentLoadError({ name: testValue });
expect(props.onError).toHaveBeenCalledWith(ErrorStatuses.serverError);
});
});
describe('onInputPageChange', () => {
it('calls setPageNumber with int event target value', () => {
hook.onInputPageChange({ target: { value: '2.3' } });
expect(mockSetPageNumber).toHaveBeenCalledWith(2);
});
});
describe('onPrevPageButtonClick', () => {
it('calls setPageNumber with current page number - 1', () => {
hook.onPrevPageButtonClick();
expect(mockSetPageNumber).toHaveBeenCalledWith(hook.pageNumber - 1);
});
});
describe('onNextPageButtonClick', () => {
it('calls setPageNumber with current page number + 1', () => {
hook.onNextPageButtonClick();
expect(mockSetPageNumber).toHaveBeenCalledWith(hook.pageNumber + 1);
});
});
test('hasNext returns true iff pageNumber is less than total number of pages', () => {
state.mockVal(state.keys.numPages, 10);
state.mockVal(state.keys.pageNumber, 9);
hook = hooks.rendererHooks(props);
expect(hook.hasNext).toEqual(true);
state.mockVal(state.keys.pageNumber, 10);
hook = hooks.rendererHooks(props);
expect(hook.hasNext).toEqual(false);
});
test('hasPrev returns true iff pageNumber is greater than 1', () => {
state.mockVal(state.keys.pageNumber, 1);
hook = hooks.rendererHooks(props);
expect(hook.hasPrev).toEqual(false);
state.mockVal(state.keys.pageNumber, 0);
hook = hooks.rendererHooks(props);
expect(hook.hasPrev).toEqual(false);
state.mockVal(state.keys.pageNumber, 2);
hook = hooks.rendererHooks(props);
expect(hook.hasPrev).toEqual(true);
});
});
});
});

View File

@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
import { get } from 'axios';
import { StrictDict } from 'utils';
import * as module from './textHooks';
export const state = StrictDict({
content: (val) => useState(val),
});
export const fetchFile = async ({
setContent,
url,
onError,
onSuccess,
}) => get(url)
.then(({ data }) => {
onSuccess();
setContent(data);
})
.catch((e) => onError(e.response.status));
export const rendererHooks = ({ url, onError, onSuccess }) => {
const [content, setContent] = module.state.content('');
useEffect(() => {
module.fetchFile({
setContent,
url,
onError,
onSuccess,
});
}, [onError, onSuccess, setContent, url]);
return { content };
};

View File

@@ -0,0 +1,95 @@
/* eslint-disable prefer-promise-reject-errors */
import { useEffect } from 'react';
import * as axios from 'axios';
import { keyStore } from 'utils';
import { MockUseState } from 'testUtils';
import * as hooks from './textHooks';
jest.mock('axios', () => ({
get: jest.fn(),
}));
const hookKeys = keyStore(hooks);
const state = new MockUseState(hooks);
let hook;
const testValue = 'test-value';
const props = {
url: 'test-url',
onError: jest.fn(),
onSuccess: jest.fn(),
};
describe('Text file preview hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('state hooks', () => {
state.testGetter(state.keys.content);
});
describe('non-state hooks', () => {
beforeEach(() => {
state.mock();
});
afterEach(() => {
state.restore();
});
describe('rendererHooks', () => {
it('returns content tied to hook state', () => {
hook = hooks.rendererHooks(props);
expect(hook.content).toEqual(state.stateVals.content);
expect(hook.content).toEqual('');
});
describe('initialization behavior', () => {
let cb;
let prereqs;
const loadHook = () => {
hook = hooks.rendererHooks(props);
[[cb, prereqs]] = useEffect.mock.calls;
};
it('calls fetchFile method, predicated on setContent, url, and callbacks', () => {
jest.spyOn(hooks, hookKeys.fetchFile).mockImplementationOnce(() => {});
loadHook();
expect(useEffect).toHaveBeenCalled();
expect(prereqs).toEqual([
props.onError,
props.onSuccess,
state.setState.content,
props.url,
]);
expect(hooks.fetchFile).not.toHaveBeenCalled();
cb();
expect(hooks.fetchFile).toHaveBeenCalledWith({
onError: props.onError,
onSuccess: props.onSuccess,
setContent: state.setState.content,
url: props.url,
});
});
});
});
describe('fetchFile', () => {
describe('onSuccess', () => {
it('calls get', async () => {
const testData = 'test-data';
axios.get.mockReturnValueOnce(Promise.resolve({ data: testData }));
await hooks.fetchFile({ ...props, setContent: state.setState.content });
expect(props.onSuccess).toHaveBeenCalled();
expect(state.setState[state.keys.content]).toHaveBeenCalledWith(testData);
});
});
describe('onError', () => {
it('calls get on the passed url when it changes', async (done) => {
axios.get.mockReturnValueOnce(Promise.reject(
{ response: { status: testValue } },
));
await hooks.fetchFile({ ...props, setContent: state.setState.content });
expect(props.onError).toHaveBeenCalledWith(testValue);
done();
});
});
});
});
});

View File

@@ -12,7 +12,11 @@ import './FileCard.scss';
*/
export const FileCard = ({ file, children }) => (
<Card className="file-card" key={file.name}>
<Collapsible className="file-collapsible" defaultOpen title={<h3 className="file-card-title">{file.name}</h3>}>
<Collapsible
className="file-collapsible"
defaultOpen
title={<h3 className="file-card-title">{file.name}</h3>}
>
<div className="preview-panel">
<FileInfo><FilePopoverContent {...file} /></FileInfo>
{children}

View File

@@ -8,6 +8,7 @@ import {
} from '@edx/paragon';
import { InfoOutline } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { nullMethod } from 'hooks';
import messages from './messages';
/**
@@ -19,7 +20,7 @@ export const FileInfo = ({ onClick, children }) => (
placement="right-end"
flip
overlay={(
<Popover className="overlay-help-popover">
<Popover id="file-popover" className="overlay-help-popover">
<Popover.Content>{children}</Popover.Content>
</Popover>
)}
@@ -36,7 +37,7 @@ export const FileInfo = ({ onClick, children }) => (
);
FileInfo.defaultProps = {
onClick: () => {},
onClick: nullMethod,
};
FileInfo.propTypes = {
onClick: PropTypes.func,

View File

@@ -1,123 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import { StrictDict } from 'utils';
import { FileTypes } from 'data/constants/files';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
PDFRenderer,
ImageRenderer,
TXTRenderer,
} from 'components/FilePreview/BaseRenderers';
import FileCard from './FileCard';
import { ErrorBanner, LoadingBanner } from './Banners';
import messages from './messages';
export const RENDERERS = StrictDict({
[FileTypes.pdf]: PDFRenderer,
[FileTypes.jpg]: ImageRenderer,
[FileTypes.jpeg]: ImageRenderer,
[FileTypes.bmp]: ImageRenderer,
[FileTypes.png]: ImageRenderer,
[FileTypes.txt]: TXTRenderer,
[FileTypes.gif]: ImageRenderer,
[FileTypes.jfif]: ImageRenderer,
[FileTypes.pjpeg]: ImageRenderer,
[FileTypes.pjp]: ImageRenderer,
[FileTypes.svg]: ImageRenderer,
});
export const ERROR_STATUSES = {
404: {
headingMessage: messages.fileNotFoundError,
children: <FormattedMessage {...messages.fileNotFoundError} />,
},
500: {
headingMessage: messages.unknownError,
children: <FormattedMessage {...messages.unknownError} />,
},
};
export const SUPPORTED_TYPES = Object.keys(RENDERERS);
export const getFileType = (fileName) => fileName.split('.').pop()?.toLowerCase();
export const isSupported = (file) => SUPPORTED_TYPES.includes(getFileType(file.name));
import { renderHooks } from './hooks';
/**
* <FileRenderer />
*/
export class FileRenderer extends React.Component {
constructor(props) {
super(props);
this.state = {
errorStatus: null,
isLoading: true,
};
this.onError = this.onError.bind(this);
this.onSuccess = this.onSuccess.bind(this);
this.resetState = this.resetState.bind(this);
}
onError(status) {
this.setState({
errorStatus: status,
isLoading: false,
});
}
onSuccess() {
this.setState({
errorStatus: null,
isLoading: false,
});
}
get error() {
const status = this.state.errorStatus;
return {
...ERROR_STATUSES[status] || ERROR_STATUSES[500],
actions: [
{
id: 'retry',
onClick: this.resetState,
message: messages.retryButton,
},
],
};
}
resetState = () => {
this.setState({
errorStatus: null,
isLoading: true,
});
};
render() {
const { file } = this.props;
const Renderer = RENDERERS[getFileType(file.name)];
return (
<FileCard key={file.downloadUrl} file={file}>
{this.state.isLoading && <LoadingBanner />}
{this.state.errorStatus ? (
<ErrorBanner {...this.error} />
) : (
<Renderer
fileName={file.name}
url={file.downloadUrl}
onError={this.onError}
onSuccess={this.onSuccess}
/>
)}
</FileCard>
);
}
}
export const FileRenderer = ({
file,
intl,
}) => {
const {
Renderer,
isLoading,
errorStatus,
error,
rendererProps,
} = renderHooks({ file, intl });
return (
<FileCard key={file.downloadUrl} file={file}>
{isLoading && <LoadingBanner />}
{errorStatus ? (
<ErrorBanner {...error} />
) : (
<Renderer {...rendererProps} />
)}
</FileCard>
);
};
FileRenderer.defaultProps = {};
FileRenderer.propTypes = {
@@ -125,6 +39,8 @@ FileRenderer.propTypes = {
name: PropTypes.string,
downloadUrl: PropTypes.string,
}).isRequired,
// injected
intl: intlShape.isRequired,
};
export default FileRenderer;
export default injectIntl(FileRenderer);

View File

@@ -1,132 +1,52 @@
import React from 'react';
import { shallow } from 'enzyme';
import { FileTypes } from 'data/constants/files';
import {
ImageRenderer,
PDFRenderer,
TXTRenderer,
} from 'components/FilePreview/BaseRenderers';
import {
FileRenderer,
getFileType,
ERROR_STATUSES,
RENDERERS,
} from './FileRenderer';
import { formatMessage } from 'testUtils';
import { keyStore } from 'utils';
import { ErrorStatuses } from 'data/constants/requests';
import { FileRenderer } from './FileRenderer';
import * as hooks from './hooks';
jest.mock('./FileCard', () => 'FileCard');
jest.mock('components/FilePreview/BaseRenderers', () => ({
PDFRenderer: () => 'PDFRenderer',
ImageRenderer: () => 'ImageRenderer',
TXTRenderer: () => 'TXTRenderer',
}));
jest.mock('./Banners', () => ({
ErrorBanner: () => 'ErrorBanner',
LoadingBanner: () => 'LoadingBanner',
}));
const hookKeys = keyStore(hooks);
const props = {
file: {
downloadUrl: 'file download url',
name: 'filename.txt',
},
intl: { formatMessage },
};
describe('FileRenderer', () => {
describe('component', () => {
const supportedTypes = Object.keys(RENDERERS);
const files = [
...supportedTypes.map((fileType, index) => ({
name: `fake_file_${index}.${fileType}`,
description: `file description ${index}`,
downloadUrl: `/url-path/fake_file_${index}.${fileType}`,
})),
];
const els = files.map((file) => {
const el = shallow(<FileRenderer file={file} />);
el.instance().onError = jest.fn().mockName('this.props.onError');
el.instance().onSuccess = jest.fn().mockName('this.props.onSuccess');
return el;
});
describe('snapshot', () => {
els.forEach((el) => {
const file = el.prop('file');
const fileType = getFileType(file.name);
test(`successful rendering ${fileType}`, () => {
el.setState({ isLoading: false });
expect(el.instance().render()).toMatchSnapshot();
});
});
Object.keys(ERROR_STATUSES).forEach((status) => {
test(`has error ${status}`, () => {
const el = shallow(<FileRenderer file={files[0]} />);
el.instance().setState({
errorStatus: status,
isLoading: false,
});
el.instance().resetState = jest.fn().mockName('this.resetState');
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('component', () => {
describe('uses the correct renderers', () => {
const checkFile = (index, expectedRenderer) => {
const file = files[index];
const el = shallow(<FileRenderer file={file} />);
const renderer = el.find(expectedRenderer);
const { url, fileName } = renderer.props();
expect(renderer).toBeDefined();
expect(url).toEqual(file.downloadUrl);
expect(fileName).toEqual(file.name);
test('isLoading, no Error', () => {
const hookProps = {
Renderer: () => 'Renderer',
isloading: true,
errorStatus: null,
error: null,
rendererProps: { prop: 'hooks.rendererProps' },
};
/**
* The manual process for this is prefer. I want to be more explicit
* of which file correspond to which renderer. If I use RENDERERS dicts,
* this wouldn't be a test.
*/
test(FileTypes.pdf, () => checkFile(0, PDFRenderer));
test(FileTypes.jpg, () => checkFile(1, ImageRenderer));
test(FileTypes.jpeg, () => checkFile(2, ImageRenderer));
test(FileTypes.bmp, () => checkFile(3, ImageRenderer));
test(FileTypes.png, () => checkFile(4, ImageRenderer));
test(FileTypes.txt, () => checkFile(5, TXTRenderer));
test(FileTypes.gif, () => checkFile(6, ImageRenderer));
test(FileTypes.jfif, () => checkFile(7, ImageRenderer));
test(FileTypes.pjpeg, () => checkFile(8, ImageRenderer));
test(FileTypes.pjp, () => checkFile(9, ImageRenderer));
test(FileTypes.svg, () => checkFile(10, ImageRenderer));
jest.spyOn(hooks, hookKeys.renderHooks).mockReturnValueOnce(hookProps);
expect(shallow(<FileRenderer {...props} />)).toMatchSnapshot();
});
test('getter for error', () => {
const el = els[0];
Object.keys(ERROR_STATUSES).forEach((status) => {
el.setState({
isLoading: false,
errorStatus: status,
});
const { actions, ...expectedError } = el.instance().error;
expect(ERROR_STATUSES[status]).toEqual(expectedError);
});
});
});
describe('renderer constraints', () => {
els.forEach((el) => {
const file = el.prop('file');
const fileType = getFileType(file.name);
const RendererComponent = RENDERERS[fileType];
const ActualRendererComponent = jest.requireActual(
'components/FilePreview/BaseRenderers',
)[RendererComponent.name];
test(`${fileType} renderer must have onError and onSuccess props`, () => {
/* eslint-disable react/forbid-foreign-prop-types */
expect(ActualRendererComponent.propTypes.onError).toBeDefined();
expect(ActualRendererComponent.propTypes.onSuccess).toBeDefined();
});
test('is not loading, with error', () => {
const hookProps = {
Renderer: () => 'Renderer',
isloading: false,
errorStatus: ErrorStatuses.serverError,
error: { prop: 'hooks.errorProps' },
rendererProps: { prop: 'hooks.rendererProps' },
};
jest.spyOn(hooks, hookKeys.renderHooks).mockReturnValueOnce(hookProps);
expect(shallow(<FileRenderer {...props} />)).toMatchSnapshot();
});
});
});

View File

@@ -6,6 +6,7 @@ exports[`File Preview Card component snapshot 1`] = `
overlay={
<Popover
className="overlay-help-popover"
id="file-popover"
>
<Popover.Content>
<h1>

View File

@@ -1,292 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FileRenderer component snapshot has error 404 1`] = `
exports[`FileRenderer component snapshot is not loading, with error 1`] = `
<FileCard
file={
Object {
"description": "file description 0",
"downloadUrl": "/url-path/fake_file_0.pdf",
"name": "fake_file_0.pdf",
"downloadUrl": "file download url",
"name": "filename.txt",
}
}
key="file download url"
>
<ErrorBanner
actions={
Array [
Object {
"id": "retry",
"message": Object {
"defaultMessage": "Retry",
"description": "Retry button for error in file renderer",
"id": "ora-grading.ResponseDisplay.FileRenderer.retryButton",
},
"onClick": [MockFunction this.resetState],
},
]
}
headingMessage={
Object {
"defaultMessage": "File not found",
"description": "File not found error message",
"id": "ora-grading.ResponseDisplay.FileRenderer.fileNotFound",
}
}
>
<FormattedMessage
defaultMessage="File not found"
description="File not found error message"
id="ora-grading.ResponseDisplay.FileRenderer.fileNotFound"
/>
</ErrorBanner>
</FileCard>
`;
exports[`FileRenderer component snapshot has error 500 1`] = `
<FileCard
file={
Object {
"description": "file description 0",
"downloadUrl": "/url-path/fake_file_0.pdf",
"name": "fake_file_0.pdf",
}
}
>
<ErrorBanner
actions={
Array [
Object {
"id": "retry",
"message": Object {
"defaultMessage": "Retry",
"description": "Retry button for error in file renderer",
"id": "ora-grading.ResponseDisplay.FileRenderer.retryButton",
},
"onClick": [MockFunction this.resetState],
},
]
}
headingMessage={
Object {
"defaultMessage": "Unknown errors",
"description": "Unknown errors message",
"id": "ora-grading.ResponseDisplay.FileRenderer.unknownError",
}
}
>
<FormattedMessage
defaultMessage="Unknown errors"
description="Unknown errors message"
id="ora-grading.ResponseDisplay.FileRenderer.unknownError"
/>
</ErrorBanner>
</FileCard>
`;
exports[`FileRenderer component snapshot successful rendering bmp 1`] = `
<FileCard
file={
Object {
"description": "file description 3",
"downloadUrl": "/url-path/fake_file_3.bmp",
"name": "fake_file_3.bmp",
}
}
>
<ImageRenderer
fileName="fake_file_3.bmp"
onError={[MockFunction this.props.onError]}
onSuccess={[MockFunction this.props.onSuccess]}
url="/url-path/fake_file_3.bmp"
prop="hooks.errorProps"
/>
</FileCard>
`;
exports[`FileRenderer component snapshot successful rendering gif 1`] = `
exports[`FileRenderer component snapshot isLoading, no Error 1`] = `
<FileCard
file={
Object {
"description": "file description 6",
"downloadUrl": "/url-path/fake_file_6.gif",
"name": "fake_file_6.gif",
"downloadUrl": "file download url",
"name": "filename.txt",
}
}
key="file download url"
>
<ImageRenderer
fileName="fake_file_6.gif"
onError={[MockFunction this.props.onError]}
onSuccess={[MockFunction this.props.onSuccess]}
url="/url-path/fake_file_6.gif"
/>
</FileCard>
`;
exports[`FileRenderer component snapshot successful rendering jfif 1`] = `
<FileCard
file={
Object {
"description": "file description 7",
"downloadUrl": "/url-path/fake_file_7.jfif",
"name": "fake_file_7.jfif",
}
}
>
<ImageRenderer
fileName="fake_file_7.jfif"
onError={[MockFunction this.props.onError]}
onSuccess={[MockFunction this.props.onSuccess]}
url="/url-path/fake_file_7.jfif"
/>
</FileCard>
`;
exports[`FileRenderer component snapshot successful rendering jpeg 1`] = `
<FileCard
file={
Object {
"description": "file description 2",
"downloadUrl": "/url-path/fake_file_2.jpeg",
"name": "fake_file_2.jpeg",
}
}
>
<ImageRenderer
fileName="fake_file_2.jpeg"
onError={[MockFunction this.props.onError]}
onSuccess={[MockFunction this.props.onSuccess]}
url="/url-path/fake_file_2.jpeg"
/>
</FileCard>
`;
exports[`FileRenderer component snapshot successful rendering jpg 1`] = `
<FileCard
file={
Object {
"description": "file description 1",
"downloadUrl": "/url-path/fake_file_1.jpg",
"name": "fake_file_1.jpg",
}
}
>
<ImageRenderer
fileName="fake_file_1.jpg"
onError={[MockFunction this.props.onError]}
onSuccess={[MockFunction this.props.onSuccess]}
url="/url-path/fake_file_1.jpg"
/>
</FileCard>
`;
exports[`FileRenderer component snapshot successful rendering pdf 1`] = `
<FileCard
file={
Object {
"description": "file description 0",
"downloadUrl": "/url-path/fake_file_0.pdf",
"name": "fake_file_0.pdf",
}
}
>
<PDFRenderer
fileName="fake_file_0.pdf"
onError={[MockFunction this.props.onError]}
onSuccess={[MockFunction this.props.onSuccess]}
url="/url-path/fake_file_0.pdf"
/>
</FileCard>
`;
exports[`FileRenderer component snapshot successful rendering pjp 1`] = `
<FileCard
file={
Object {
"description": "file description 9",
"downloadUrl": "/url-path/fake_file_9.pjp",
"name": "fake_file_9.pjp",
}
}
>
<ImageRenderer
fileName="fake_file_9.pjp"
onError={[MockFunction this.props.onError]}
onSuccess={[MockFunction this.props.onSuccess]}
url="/url-path/fake_file_9.pjp"
/>
</FileCard>
`;
exports[`FileRenderer component snapshot successful rendering pjpeg 1`] = `
<FileCard
file={
Object {
"description": "file description 8",
"downloadUrl": "/url-path/fake_file_8.pjpeg",
"name": "fake_file_8.pjpeg",
}
}
>
<ImageRenderer
fileName="fake_file_8.pjpeg"
onError={[MockFunction this.props.onError]}
onSuccess={[MockFunction this.props.onSuccess]}
url="/url-path/fake_file_8.pjpeg"
/>
</FileCard>
`;
exports[`FileRenderer component snapshot successful rendering png 1`] = `
<FileCard
file={
Object {
"description": "file description 4",
"downloadUrl": "/url-path/fake_file_4.png",
"name": "fake_file_4.png",
}
}
>
<ImageRenderer
fileName="fake_file_4.png"
onError={[MockFunction this.props.onError]}
onSuccess={[MockFunction this.props.onSuccess]}
url="/url-path/fake_file_4.png"
/>
</FileCard>
`;
exports[`FileRenderer component snapshot successful rendering svg 1`] = `
<FileCard
file={
Object {
"description": "file description 10",
"downloadUrl": "/url-path/fake_file_10.svg",
"name": "fake_file_10.svg",
}
}
>
<ImageRenderer
fileName="fake_file_10.svg"
onError={[MockFunction this.props.onError]}
onSuccess={[MockFunction this.props.onSuccess]}
url="/url-path/fake_file_10.svg"
/>
</FileCard>
`;
exports[`FileRenderer component snapshot successful rendering txt 1`] = `
<FileCard
file={
Object {
"description": "file description 5",
"downloadUrl": "/url-path/fake_file_5.txt",
"name": "fake_file_5.txt",
}
}
>
<TXTRenderer
fileName="fake_file_5.txt"
onError={[MockFunction this.props.onError]}
onSuccess={[MockFunction this.props.onSuccess]}
url="/url-path/fake_file_5.txt"
<Renderer
prop="hooks.rendererProps"
/>
</FileCard>
`;

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { StrictDict } from 'utils';
import { ErrorStatuses } from 'data/constants/requests';
import { FileTypes } from 'data/constants/files';
import {
PDFRenderer,
ImageRenderer,
TXTRenderer,
} from 'components/FilePreview/BaseRenderers';
import * as module from './hooks';
import messages from './messages';
/**
* Config data
*/
export const RENDERERS = StrictDict({
[FileTypes.pdf]: PDFRenderer,
[FileTypes.jpg]: ImageRenderer,
[FileTypes.jpeg]: ImageRenderer,
[FileTypes.bmp]: ImageRenderer,
[FileTypes.png]: ImageRenderer,
[FileTypes.txt]: TXTRenderer,
[FileTypes.gif]: ImageRenderer,
[FileTypes.jfif]: ImageRenderer,
[FileTypes.pjpeg]: ImageRenderer,
[FileTypes.pjp]: ImageRenderer,
[FileTypes.svg]: ImageRenderer,
});
export const SUPPORTED_TYPES = Object.keys(RENDERERS);
export const ERROR_STATUSES = {
[ErrorStatuses.notFound]: messages.fileNotFoundError,
[ErrorStatuses.serverError]: messages.unknownError,
};
/**
* State hooks
*/
export const state = StrictDict({
errorStatus: (val) => React.useState(val),
isLoading: (val) => React.useState(val),
});
/**
* Util methods and transforms
*/
export const getFileType = (fileName) => fileName.split('.').pop()?.toLowerCase();
export const isSupported = (file) => module.SUPPORTED_TYPES.includes(
module.getFileType(file.name),
);
/**
* component hooks
*/
export const renderHooks = ({
file,
intl,
}) => {
const [errorStatus, setErrorStatus] = module.state.errorStatus(null);
const [isLoading, setIsLoading] = module.state.isLoading(true);
const setState = (newState) => {
setErrorStatus(newState.errorStatus);
setIsLoading(newState.isLoading);
};
const stopLoading = (status = null) => setState({ isLoading: false, errorStatus: status });
const errorMessage = (
module.ERROR_STATUSES[errorStatus] || module.ERROR_STATUSES[ErrorStatuses.serverError]
);
const errorAction = {
id: 'retry',
onClick: () => setState({ errorStatus: null, isLoading: true }),
message: messages.retryButton,
};
const error = {
headerMessage: errorMessage,
children: intl.formatMessage(errorMessage),
actions: [errorAction],
};
const Renderer = module.RENDERERS[module.getFileType(file.name)];
const rendererProps = {
fileName: file.name,
url: file.downloadUrl,
onError: stopLoading,
onSuccess: () => stopLoading(),
};
return {
errorStatus,
isLoading,
error,
Renderer,
rendererProps,
};
};

View File

@@ -0,0 +1,117 @@
import { MockUseState, formatMessage } from 'testUtils';
import { keyStore } from 'utils';
import { ErrorStatuses } from 'data/constants/requests';
import * as hooks from './hooks';
const testValue = 'Test-Value';
const state = new MockUseState(hooks);
const hookKeys = keyStore(hooks);
let hook;
describe('FilePreview hooks', () => {
describe('state hooks', () => {
});
describe('non-state hooks', () => {
beforeEach(() => {
state.mock();
});
afterEach(() => {
state.restore();
});
describe('utility methods', () => {
describe('getFileType', () => {
it('returns file extension if available, in lowercase', () => {
expect(hooks.getFileType('thing.TXT')).toEqual('txt');
expect(hooks.getFileType(testValue)).toEqual(testValue.toLowerCase());
});
});
describe('isSupported', () => {
it('returns true iff the filetype is included in SUPPORTED_TYPES', () => {
let spy = jest.spyOn(hooks, hookKeys.getFileType).mockImplementationOnce(v => v);
expect(hooks.isSupported({ name: hooks.SUPPORTED_TYPES[0] })).toEqual(true);
spy = jest.spyOn(hooks, hookKeys.getFileType).mockImplementationOnce(v => v);
expect(hooks.isSupported({ name: testValue })).toEqual(false);
spy.mockRestore();
});
});
});
describe('component hooks', () => {
describe('renderHooks', () => {
const file = {
name: 'test-file-name.txt',
downloadUrl: 'my-test-download-url.jpg',
};
beforeEach(() => {
hook = hooks.renderHooks({ intl: { formatMessage }, file });
});
describe('returned object', () => {
test('errorStatus and isLoading tied to state, initialized to null and true', () => {
expect(hook.errorStatus).toEqual(state.stateVals.errorStatus);
expect(hook.errorStatus).toEqual(null);
expect(hook.isLoading).toEqual(state.stateVals.isLoading);
expect(hook.isLoading).toEqual(true);
});
describe('error', () => {
it('loads message from current error status, if valid, else from serverError', () => {
expect(hook.error.headerMessage).toEqual(
hooks.ERROR_STATUSES[ErrorStatuses.serverError],
);
expect(hook.error.children).toEqual(
formatMessage(hooks.ERROR_STATUSES[ErrorStatuses.serverError]),
);
state.mockVal(state.keys.errorStatus, ErrorStatuses.notFound);
hook = hooks.renderHooks({ intl: { formatMessage }, file });
expect(hook.error.headerMessage).toEqual(
hooks.ERROR_STATUSES[ErrorStatuses.notFound],
);
expect(hook.error.children).toEqual(
formatMessage(hooks.ERROR_STATUSES[ErrorStatuses.notFound]),
);
});
it('provides a single action', () => {
expect(hook.error.actions.length).toEqual(1);
});
describe('action', () => {
it('sets errorState to null and isLoading to true on click', () => {
hook.error.actions[0].onClick();
expect(state.setState.isLoading).toHaveBeenCalledWith(true);
expect(state.setState.errorStatus).toHaveBeenCalledWith(null);
});
});
});
describe('Renderer', () => {
it('returns configured renderer based on filetype', () => {
hooks.SUPPORTED_TYPES.forEach(type => {
jest.spyOn(hooks, hookKeys.getFileType).mockReturnValueOnce(type);
hook = hooks.renderHooks({ intl: { formatMessage }, file });
expect(hook.Renderer).toEqual(hooks.RENDERERS[type]);
});
});
});
describe('rendererProps', () => {
it('forwards url and fileName from file', () => {
expect(hook.rendererProps.fileName).toEqual(file.name);
expect(hook.rendererProps.url).toEqual(file.downloadUrl);
});
describe('onError', () => {
it('it sets isLoading to false and loads errorStatus', () => {
hook.rendererProps.onError(testValue);
expect(state.setState.isLoading).toHaveBeenCalledWith(false);
expect(state.setState.errorStatus).toHaveBeenCalledWith(testValue);
});
});
describe('onSuccess', () => {
it('it sets isLoading to false and errorStatus to null', () => {
hook.rendererProps.onSuccess(testValue);
expect(state.setState.isLoading).toHaveBeenCalledWith(false);
expect(state.setState.errorStatus).toHaveBeenCalledWith(null);
});
});
});
});
});
});
});
});

View File

@@ -1 +1,2 @@
export { default as FileRenderer, isSupported } from './FileRenderer';
export { default as FileRenderer } from './FileRenderer';
export { isSupported } from './hooks';

View File

@@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Head snapshot 1`] = `
<Helmet>
<title>
ORA staff grading | site-name
</title>
<link
href="favicon-url"
rel="shortcut icon"
type="image/x-icon"
/>
</Helmet>
`;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
function Head() {
const { formatMessage } = useIntl();
return (
<Helmet>
<title>
{formatMessage(messages.PageTitle, { siteName: getConfig().SITE_NAME })}
</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
);
}
export default Head;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { shallow } from 'enzyme';
import Head from '.';
jest.mock('react-helmet', () => ({
Helmet: 'Helmet',
}));
jest.mock('@edx/frontend-platform', () => ({
getConfig: () => ({
SITE_NAME: 'site-name',
FAVICON_URL: 'favicon-url',
}),
}));
describe('Head', () => {
it('snapshot', () => {
const el = shallow(<Head />);
expect(el).toMatchSnapshot();
expect(el.find('title').text()).toContain(getConfig().SITE_NAME);
expect(el.find('link').prop('href')).toEqual(getConfig().FAVICON_URL);
});
});

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
PageTitle: {
id: 'PageTitle',
defaultMessage: 'ORA staff grading | {siteName}',
description: 'Title tag',
},
});
export default messages;

View File

@@ -6,6 +6,7 @@ exports[`Info Popover Component snapshot 1`] = `
overlay={
<Popover
className="overlay-help-popover"
id="info-popover"
>
<Popover.Content>
<div>

View File

@@ -10,6 +10,8 @@ import {
import { InfoOutline } from '@edx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { nullMethod } from 'hooks';
import messages from './messages';
/**
@@ -21,7 +23,7 @@ export const InfoPopover = ({ onClick, children, intl }) => (
placement="right-end"
flip
overlay={(
<Popover className="overlay-help-popover">
<Popover id="info-popover" className="overlay-help-popover">
<Popover.Content>{children}</Popover.Content>
</Popover>
)}
@@ -37,7 +39,7 @@ export const InfoPopover = ({ onClick, children, intl }) => (
);
InfoPopover.defaultProps = {
onClick: () => {},
onClick: nullMethod,
};
InfoPopover.propTypes = {
onClick: PropTypes.func,

View File

@@ -4,21 +4,29 @@ import PropTypes from 'prop-types';
import { Badge } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
import { gradingStatuses as statuses } from 'data/services/lms/constants';
import messages from 'data/services/lms/messages';
export const statusVariants = {
[statuses.ungraded]: 'primary',
[statuses.locked]: 'light',
[statuses.graded]: 'success',
[statuses.inProgress]: 'warning',
};
export const buttonVariants = StrictDict({
primary: 'primary',
light: 'light',
success: 'success',
warning: 'warning',
});
export const statusVariants = StrictDict({
[statuses.ungraded]: buttonVariants.primary,
[statuses.locked]: buttonVariants.light,
[statuses.graded]: buttonVariants.success,
[statuses.inProgress]: buttonVariants.warning,
});
/**
* <StatusBadge />
*/
export const StatusBadge = ({ className, status }) => {
if (statusVariants[status] === undefined) {
if (!Object.keys(statusVariants).includes(status)) {
return null;
}
return (

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { shallow } from 'enzyme';
import { gradingStatuses } from 'data/services/lms/constants';
import { StatusBadge } from './StatusBadge';
const className = 'test-className';
describe('StatusBadge component', () => {
const render = (status) => shallow(<StatusBadge className={className} status={status} />);
describe('behavior', () => {
it('does not render if status does not have configured variant', () => {
const el = render('arbitrary');
expect(el).toMatchSnapshot();
expect(el.isEmptyRender()).toEqual(true);
});
describe('status snapshots: loads badge with configured variant and message.', () => {
test('`ungraded` shows primary button variant and message', () => {
const el = render(gradingStatuses.ungraded);
expect(el).toMatchSnapshot();
});
test('`locked` shows light button variant and message', () => {
const el = render(gradingStatuses.locked);
expect(el).toMatchSnapshot();
});
test('`graded` shows success button variant and message', () => {
const el = render(gradingStatuses.graded);
expect(el).toMatchSnapshot();
});
test('`inProgress` shows warning button variant and message', () => {
const el = render(gradingStatuses.inProgress);
expect(el).toMatchSnapshot();
});
});
});
});

View File

@@ -20,7 +20,7 @@ exports[`ConfirmModal snapshot: closed 1`] = `
</ActionRow>
}
isOpen={false}
onClose={[Function]}
onClose={[MockFunction hooks.nullMethod]}
title="test-title"
>
<p>
@@ -49,7 +49,7 @@ exports[`ConfirmModal snapshot: open 1`] = `
</ActionRow>
}
isOpen={true}
onClose={[Function]}
onClose={[MockFunction hooks.nullMethod]}
title="test-title"
>
<p>

View File

@@ -0,0 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StatusBadge component behavior does not render if status does not have configured variant 1`] = `""`;
exports[`StatusBadge component behavior status snapshots: loads badge with configured variant and message. \`graded\` shows success button variant and message 1`] = `
<Badge
className="test-className"
variant="success"
>
<FormattedMessage
defaultMessage="Grading Completed"
description="Grading status label for graded submission"
id="ora-grading.lms-api.gradingStatusDisplay.graded"
/>
</Badge>
`;
exports[`StatusBadge component behavior status snapshots: loads badge with configured variant and message. \`inProgress\` shows warning button variant and message 1`] = `
<Badge
className="test-className"
variant="warning"
>
<FormattedMessage
defaultMessage="You are currently grading this response"
description="Grading status label for in-progress submission"
id="ora-grading.lms-api.gradingStatusDisplay.inProgress"
/>
</Badge>
`;
exports[`StatusBadge component behavior status snapshots: loads badge with configured variant and message. \`locked\` shows light button variant and message 1`] = `
<Badge
className="test-className"
variant="light"
>
<FormattedMessage
defaultMessage="Currently being graded by someone else"
description="Grading status label for locked submission"
id="ora-grading.lms-api.gradingStatusDisplay.locked"
/>
</Badge>
`;
exports[`StatusBadge component behavior status snapshots: loads badge with configured variant and message. \`ungraded\` shows primary button variant and message 1`] = `
<Badge
className="test-className"
variant="primary"
>
<FormattedMessage
defaultMessage="Ungraded"
description="Grading status label for ungraded submission"
id="ora-grading.lms-api.gradingStatusDisplay.ungraded"
/>
</Badge>
`;

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { shallow } from 'enzyme';
import { CTA } from '.';
describe('CTA component', () => {
test('snapshots', () => {
const el = shallow(<CTA hide />);
expect(el).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CTA component snapshots 1`] = `
<PageBanner>
<span>
<FormattedMessage
defaultMessage="Thanks for using the new ORA staff grading experience. "
description="Thank user for using ora and ask for feed back"
id="ora-grading.CTA.feedbackMessage"
/>
<Hyperlink
destination="https://docs.google.com/forms/d/1Hu1rgJcCHl5_EtDb5Up3hiZ40sSUtkZQfRHJ3fWOvfQ/edit"
isInline={true}
showLaunchIcon={false}
target="_blank"
variant="muted"
>
<FormattedMessage
defaultMessage="Provide some feedback"
description="placeholder for the feedback anchor link"
id="ora-grading.CTA.linkMessage"
/>
</Hyperlink>
<FormattedMessage
defaultMessage=" and let us know what you think!"
description="inform user to provide feedback"
id="ora-grading.CTA.letUsKnowMessage"
/>
</span>
</PageBanner>
`;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { PageBanner, Hyperlink } from '@edx/paragon';
import messages from './messages';
/**
* <CTA />
*/
export const CTA = () => (
<PageBanner>
<span>
<FormattedMessage {...messages.ctaFeedbackMessage} />
<Hyperlink
isInline
variant="muted"
destination="https://docs.google.com/forms/d/1Hu1rgJcCHl5_EtDb5Up3hiZ40sSUtkZQfRHJ3fWOvfQ/edit"
target="_blank"
showLaunchIcon={false}
>
<FormattedMessage {...messages.ctaLinkMessage} />
</Hyperlink>
<FormattedMessage {...messages.ctaLetUsKnowMessage} />
</span>
</PageBanner>
);
export default CTA;

View File

@@ -0,0 +1,23 @@
/* eslint-disable quotes */
import { defineMessages } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
const messages = defineMessages({
ctaFeedbackMessage: {
id: 'ora-grading.CTA.feedbackMessage',
defaultMessage: 'Thanks for using the new ORA staff grading experience. ',
description: 'Thank user for using ora and ask for feed back',
},
ctaLinkMessage: {
id: 'ora-grading.CTA.linkMessage',
defaultMessage: 'Provide some feedback',
description: 'placeholder for the feedback anchor link',
},
ctaLetUsKnowMessage: {
id: 'ora-grading.CTA.letUsKnowMessage',
defaultMessage: ' and let us know what you think!',
description: 'inform user to provide feedback',
},
});
export default StrictDict(messages);

View File

@@ -1,38 +0,0 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import message from './messages';
export const getRegisterUrl = () => {
const { LMS_BASE_URL } = getConfig();
const locationHref = encodeURIComponent(global.location.href);
return `${LMS_BASE_URL}/register?next=${locationHref}`;
};
export const AnonymousUserMenu = ({ intl }) => (
<div>
<Button
className="mr-3"
variant="outline-primary"
href={getRegisterUrl()}
>
{intl.formatMessage(message.registerSentenceCase)}
</Button>
<Button
variant="primary"
href={`${getLoginRedirectUrl(global.location.href)}`}
>
{intl.formatMessage(message.signInSentenceCase)}
</Button>
</div>
);
AnonymousUserMenu.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AnonymousUserMenu);

View File

@@ -1,24 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { AnonymousUserMenu } from './AnonymousUserMenu';
jest.mock('@edx/frontend-platform', () => ({
getConfig: () => ({
LMS_BASE_URL: '<LMS_BASE_URL>',
}),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getLoginRedirectUrl: (url) => `redirect:${url}`,
}));
describe('Header AnonymousUserMenu component', () => {
const props = {
intl: { formatMessage: (msg) => msg.defaultMessage },
};
test('snapshot', () => {
expect(
shallow(<AnonymousUserMenu {...props} />),
).toMatchSnapshot();
});
});

View File

@@ -1,27 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { Dropdown } from '@edx/paragon';
export const UserAvatar = ({ username }) => (
<Dropdown.Toggle variant="outline-primary">
<FontAwesomeIcon
icon={faUserCircle}
className="d-md-none"
size="lg"
/>
<span data-hj-suppress className="d-none d-md-inline">
{username}
</span>
</Dropdown.Toggle>
);
UserAvatar.propTypes = {
username: PropTypes.string.isRequired,
};
UserAvatar.defaultProps = {};
export default UserAvatar;

View File

@@ -1,23 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import UserAvatar from './UserAvatar';
jest.mock('@edx/frontend-platform', () => ({
getConfig: () => ({
LMS_BASE_URL: '<LMS_BASE_URL>',
LOGOUT_URL: '<LOGOUT_URL>',
SUPPORT_URL: '<SUPPORT_URL>',
}),
}));
describe('Header AuthenticatedUserDropdown UserAvatar component', () => {
const props = {
username: 'test-username',
};
test('snapshot', () => {
expect(
shallow(<UserAvatar {...props} />),
).toMatchSnapshot();
});
});

View File

@@ -1,40 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import messages from '../messages';
export class UserMenu extends React.Component {
menuItem(href, message) {
return (
<Dropdown.Item href={href}>
{this.props.intl.formatMessage(message)}
</Dropdown.Item>
);
}
render() {
const { username } = this.props;
const { LMS_BASE_URL, LOGOUT_URL } = getConfig();
return (
<Dropdown.Menu className="dropdown-menu-right">
{this.menuItem(`${LMS_BASE_URL}/dashboard`, messages.dashboard)}
{this.menuItem(`${LMS_BASE_URL}/u/${username}`, messages.profile)}
{this.menuItem(`${LMS_BASE_URL}/account/settings`, messages.account)}
{this.menuItem(LOGOUT_URL, messages.signOut)}
</Dropdown.Menu>
);
}
}
UserMenu.propTypes = {
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
};
UserMenu.defaultProps = {};
export default injectIntl(UserMenu);

View File

@@ -1,24 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { UserMenu } from './UserMenu';
jest.mock('@edx/frontend-platform', () => ({
getConfig: () => ({
LMS_BASE_URL: '<LMS_BASE_URL>',
LOGOUT_URL: '<LOGOUT_URL>',
SUPPORT_URL: '<SUPPORT_URL>',
}),
}));
describe('Header AuthenticatedUserDropdown UserMenu component', () => {
const props = {
intl: { formatMessage: (msg) => msg.defaultMessage },
username: 'test-username',
};
test('snapshot', () => {
expect(
shallow(<UserMenu {...props} />),
).toMatchSnapshot();
});
});

View File

@@ -1,31 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header AuthenticatedUserDropdown UserAvatar component snapshot 1`] = `
<Dropdown.Toggle
variant="outline-primary"
>
<FontAwesomeIcon
className="d-md-none"
icon={
Object {
"icon": Array [
496,
512,
Array [],
"f2bd",
"M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 96c48.6 0 88 39.4 88 88s-39.4 88-88 88-88-39.4-88-88 39.4-88 88-88zm0 344c-58.7 0-111.3-26.6-146.5-68.2 18.8-35.4 55.6-59.8 98.5-59.8 2.4 0 4.8.4 7.1 1.1 13 4.2 26.6 6.9 40.9 6.9 14.3 0 28-2.7 40.9-6.9 2.3-.7 4.7-1.1 7.1-1.1 42.9 0 79.7 24.4 98.5 59.8C359.3 421.4 306.7 448 248 448z",
],
"iconName": "user-circle",
"prefix": "fas",
}
}
size="lg"
/>
<span
className="d-none d-md-inline"
data-hj-suppress={true}
>
test-username
</span>
</Dropdown.Toggle>
`;

View File

@@ -1,28 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header AuthenticatedUserDropdown UserMenu component snapshot 1`] = `
<Dropdown.Menu
className="dropdown-menu-right"
>
<Dropdown.Item
href="<LMS_BASE_URL>/dashboard"
>
Dashboard
</Dropdown.Item>
<Dropdown.Item
href="<LMS_BASE_URL>/u/test-username"
>
Profile
</Dropdown.Item>
<Dropdown.Item
href="<LMS_BASE_URL>/account/settings"
>
Account
</Dropdown.Item>
<Dropdown.Item
href="<LOGOUT_URL>"
>
Sign Out
</Dropdown.Item>
</Dropdown.Menu>
`;

View File

@@ -1,22 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header AuthenticatedUserDropdown component snapshot 1`] = `
<Fragment>
<a
className="text-gray-700 mr-3"
href="<SUPPORT_URL>"
>
Help
</a>
<Dropdown
className="user-dropdown"
>
<UserAvatar
username="test-username"
/>
<UserMenu
username="test-username"
/>
</Dropdown>
</Fragment>
`;

View File

@@ -1,35 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import UserMenu from './UserMenu';
import UserAvatar from './UserAvatar';
import messages from '../messages';
export const AuthenticatedUserDropdown = ({
intl,
username,
}) => (
<>
<a className="text-gray-700 mr-3" href={`${getConfig().SUPPORT_URL}`}>
{intl.formatMessage(messages.help)}
</a>
<Dropdown className="user-dropdown">
<UserAvatar username={username} />
<UserMenu username={username} />
</Dropdown>
</>
);
AuthenticatedUserDropdown.propTypes = {
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
};
AuthenticatedUserDropdown.defaultProps = {};
export default injectIntl(AuthenticatedUserDropdown);

View File

@@ -1,24 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { AuthenticatedUserDropdown } from '.';
jest.mock('@edx/frontend-platform', () => ({
getConfig: () => ({
SUPPORT_URL: '<SUPPORT_URL>',
}),
}));
jest.mock('./UserAvatar', () => 'UserAvatar');
jest.mock('./UserMenu', () => 'UserMenu');
describe('Header AuthenticatedUserDropdown component', () => {
const props = {
intl: { formatMessage: (msg) => msg.defaultMessage },
username: 'test-username',
};
test('snapshot', () => {
expect(
shallow(<AuthenticatedUserDropdown {...props} />),
).toMatchSnapshot();
});
});

View File

@@ -1,32 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
export const CourseLabel = ({
courseOrg,
courseNumber,
courseTitle,
}) => (
<div
className="flex-grow-1 course-title-lockup"
style={{ lineHeight: 1 }}
>
<span className="d-block small m-0">
{courseOrg} {courseNumber}
</span>
<span className="d-block m-0 font-weight-bold course-title">
{courseTitle}
</span>
</div>
);
CourseLabel.propTypes = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
};
CourseLabel.defaultProps = {
courseOrg: null,
courseNumber: null,
courseTitle: null,
};
export default CourseLabel;

View File

@@ -1,18 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import CourseLabel from './CourseLabel';
const courseData = {
courseOrg: 'course-org',
courseNumber: 'course-number',
courseTitle: 'course-title',
};
describe('Header CourseLabel component', () => {
test('snapshot', () => {
expect(
shallow(<CourseLabel {...courseData} />),
).toMatchSnapshot();
});
});

View File

@@ -1,17 +0,0 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
const LinkedLogo = () => (
<a
className="logo"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
>
<img
className="d-block"
src={getConfig().LOGO_URL}
alt={getConfig().SITE_NAME}
/>
</a>
);
export default LinkedLogo;

View File

@@ -1,20 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import LinkedLogo from './LinkedLogo';
jest.mock('@edx/frontend-platform', () => ({
getConfig: () => ({
LMS_BASE_URL: '<getConfig().LMS_BASE_URL>',
LOGO_URL: '<getConfig().LOGO_URL>',
SITE_NAME: '<getConfig().SITE_NAME>',
}),
}));
describe('Header CourseLabel component', () => {
test('snapshot', () => {
expect(
shallow(<LinkedLogo />),
).toMatchSnapshot();
});
});

View File

@@ -1,19 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header AnonymousUserMenu component snapshot 1`] = `
<div>
<Button
className="mr-3"
href="<LMS_BASE_URL>/register?next=http%3A%2F%2Flocalhost%2F"
variant="outline-primary"
>
Register
</Button>
<Button
href="redirect:http://localhost/"
variant="primary"
>
Sign in
</Button>
</div>
`;

View File

@@ -1,25 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header CourseLabel component snapshot 1`] = `
<div
className="flex-grow-1 course-title-lockup"
style={
Object {
"lineHeight": 1,
}
}
>
<span
className="d-block small m-0"
>
course-org
course-number
</span>
<span
className="d-block m-0 font-weight-bold course-title"
>
course-title
</span>
</div>
`;

View File

@@ -1,14 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header CourseLabel component snapshot 1`] = `
<a
className="logo"
href="<getConfig().LMS_BASE_URL>/dashboard"
>
<img
alt="<getConfig().SITE_NAME>"
className="d-block"
src="<getConfig().LOGO_URL>"
/>
</a>
`;

View File

@@ -1,51 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header component snapshot 1`] = `
<header
className="course-header"
>
<a
className="sr-only sr-only-focusable"
href="#main-content"
>
Skip to main content.
</a>
<div
className="container-xl py-2 d-flex align-items-center"
>
<LinkedLogo />
<CourseLabel
courseNumber="course-number"
courseOrg="course-org"
courseTitle="course-title"
/>
<AnonymousUserMenu />
</div>
</header>
`;
exports[`Header component snapshot with authenticatedUser 1`] = `
<header
className="course-header"
>
<a
className="sr-only sr-only-focusable"
href="#main-content"
>
Skip to main content.
</a>
<div
className="container-xl py-2 d-flex align-items-center"
>
<LinkedLogo />
<CourseLabel
courseNumber="course-number"
courseOrg="course-org"
courseTitle="course-title"
/>
<AuthenticatedUserDropdown
username="test"
/>
</div>
</header>
`;

View File

@@ -1,47 +0,0 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import AnonymousUserMenu from './AnonymousUserMenu';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import LinkedLogo from './LinkedLogo';
import CourseLabel from './CourseLabel';
import messages from './messages';
export const Header = ({
courseOrg,
courseNumber,
courseTitle,
intl,
}) => {
const { authenticatedUser } = useContext(AppContext);
return (
<header className="course-header">
<a className="sr-only sr-only-focusable" href="#main-content">
{intl.formatMessage(messages.skipNavLink)}
</a>
<div className="container-xl py-2 d-flex align-items-center">
<LinkedLogo />
<CourseLabel {...{ courseOrg, courseNumber, courseTitle }} />
{authenticatedUser
? (<AuthenticatedUserDropdown username={authenticatedUser.username} />)
: (<AnonymousUserMenu />)}
</div>
</header>
);
};
Header.propTypes = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
intl: intlShape.isRequired,
};
Header.defaultProps = {
courseOrg: null,
courseNumber: null,
courseTitle: null,
};
export default injectIntl(Header);

View File

@@ -1,38 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { AppContext } from '@edx/frontend-platform/react';
import { Header } from '.';
jest.mock('./AnonymousUserMenu', () => 'AnonymousUserMenu');
jest.mock('./AuthenticatedUserDropdown', () => 'AuthenticatedUserDropdown');
jest.mock('./LinkedLogo', () => 'LinkedLogo');
jest.mock('./CourseLabel', () => 'CourseLabel');
jest.mock('@edx/frontend-platform/react', () => ({
AppContext: { authenticatedUser: null },
}));
jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: (context) => context,
}));
const courseData = {
courseOrg: 'course-org',
courseNumber: 'course-number',
courseTitle: 'course-title',
};
describe('Header component', () => {
const props = {
...courseData,
intl: { formatMessage: (msg) => msg.defaultMessage },
};
test('snapshot', () => {
expect(shallow(<Header {...props} />)).toMatchSnapshot();
});
test('snapshot with authenticatedUser', () => {
AppContext.authenticatedUser = { username: 'test' };
expect(shallow(<Header {...props} />)).toMatchSnapshot();
});
});

View File

@@ -1,56 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
courseMaterial: {
id: 'learn.navigation.course.tabs.label',
defaultMessage: 'Course Material',
description: 'The accessible label for course tabs navigation',
},
dashboard: {
id: 'header.menu.dashboard.label',
defaultMessage: 'Dashboard',
description: 'The text for the user menu Dashboard navigation link.',
},
help: {
id: 'header.help.label',
defaultMessage: 'Help',
description: 'The text for the link to the Help Center',
},
profile: {
id: 'header.menu.profile.label',
defaultMessage: 'Profile',
description: 'The text for the user menu Profile navigation link.',
},
account: {
id: 'header.menu.account.label',
defaultMessage: 'Account',
description: 'The text for the user menu Account navigation link.',
},
orderHistory: {
id: 'header.menu.orderHistory.label',
defaultMessage: 'Order History',
description: 'The text for the user menu Order History navigation link.',
},
skipNavLink: {
id: 'header.navigation.skipNavLink',
defaultMessage: 'Skip to main content.',
description: 'A link used by screen readers to allow users to skip to the main content of the page.',
},
signOut: {
id: 'header.menu.signOut.label',
defaultMessage: 'Sign Out',
description: 'The label for the user menu Sign Out action.',
},
registerSentenceCase: {
id: 'header.register.sentenceCase',
defaultMessage: 'Register',
description: 'Text in a button, prompting the user to register.',
},
signInSentenceCase: {
id: 'header.signIn.sentenceCase',
defaultMessage: 'Sign in',
description: 'Text in a button, prompting the user to log in.',
},
});
export default messages;

View File

@@ -16,7 +16,7 @@ exports[`DemoWarning component snapshots snapshot: disabled flag is present 1`]
</Alert.Heading>
<p>
<FormattedMessage
defaultMessage="You are using the Demo Mode of the new Enhanced ORA Staff Grader interface. You will be unable to submit grades until you activate the feature."
defaultMessage="You are demoing the new ORA staff grading experience. You will be unable to submit grades until you activate the feature. This will become the default grading experience on May 9th (05/09/2022). To opt-in early, or opt-out, please contact Partner Support."
description="Demo mode message"
id="ora-grading.ReviewModal.demoMessage"
/>

View File

@@ -10,7 +10,7 @@ const messages = defineMessages({
},
demoModeMessage: {
id: 'ora-grading.ReviewModal.demoMessage',
defaultMessage: 'You are using the Demo Mode of the new Enhanced ORA Staff Grader interface. You will be unable to submit grades until you activate the feature.',
defaultMessage: 'You are demoing the new ORA staff grading experience. You will be unable to submit grades until you activate the feature. This will become the default grading experience on May 9th (05/09/2022). To opt-in early, or opt-out, please contact Partner Support.',
description: 'Demo mode message',
},
});

View File

@@ -9,7 +9,7 @@ export const filterHooks = () => {
if (!setAllFilters || !state.filters) {
return {};
}
const clearFilters = React.useCallback(() => setAllFilters([]), []);
const clearFilters = React.useCallback(() => setAllFilters([]), [setAllFilters]);
const headerMap = headers.reduce(
(obj, cur) => ({ ...obj, [cur.id]: cur.Header }),
{},

View File

@@ -34,7 +34,7 @@ describe('FilterStatusComponent hooks', () => {
it('uses React.useCallback to clear filters, only once', () => {
mockTableContext(context);
const { cb, prereqs } = module.filterHooks().clearFilters.useCallback;
expect(prereqs).toEqual([]);
expect(prereqs).toEqual([context.setAllFilters]);
expect(context.setAllFilters).not.toHaveBeenCalled();
cb();
expect(context.setAllFilters).toHaveBeenCalledWith([]);

View File

@@ -19,3 +19,10 @@ span.pgn__icon.breadcrumb-arrow {
}
}
.submissions-table {
.pgn__data-table-filters-breakout-filter {
.pgn__form-group {
margin-bottom: 0;
}
}
}

View File

@@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
export const SelectedBulkAction = ({ selectedFlatRows, handleClick }) => (
<Button
onClick={handleClick(selectedFlatRows)}
variant="primary"
className="view-selected-responses-btn"
>
<FormattedMessage {...messages.viewSelectedResponses} values={{ value: selectedFlatRows.length }} />
</Button>
);
SelectedBulkAction.defaultProps = {
selectedFlatRows: [],
};
SelectedBulkAction.propTypes = {
selectedFlatRows: PropTypes.arrayOf(PropTypes.object),
handleClick: PropTypes.func.isRequired,
};
export default SelectedBulkAction;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { shallow } from 'enzyme';
import { SelectedBulkAction } from './SelectedBulkAction';
describe('SelectedBulkAction component', () => {
const props = {
selectedFlatRows: [{ id: 1 }, { id: 2 }],
handleClick: jest.fn(),
};
test('snapshots', () => {
const el = shallow(<SelectedBulkAction {...props} handleClick={() => jest.fn()} />);
expect(el).toMatchSnapshot();
});
test('handleClick', () => {
shallow(<SelectedBulkAction {...props} />);
expect(props.handleClick).toHaveBeenCalledWith(props.selectedFlatRows);
});
});

View File

@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import moment from 'moment';
import {
DataTable,
@@ -16,6 +17,8 @@ import { selectors, thunkActions } from 'data/redux';
import StatusBadge from 'components/StatusBadge';
import FilterStatusComponent from './FilterStatusComponent';
import TableAction from './TableAction';
import SelectedBulkAction from './SelectedBulkAction';
import messages from './messages';
@@ -23,12 +26,6 @@ import messages from './messages';
* <SubmissionsTable />
*/
export class SubmissionsTable extends React.Component {
constructor(props) {
super(props);
this.handleViewAllResponsesClick = this.handleViewAllResponsesClick.bind(this);
this.selectedBulkAction = this.selectedBulkAction.bind(this);
}
get gradeStatusOptions() {
return Object.keys(gradingStatuses).map(statusKey => ({
name: this.translate(lmsMessages[gradingStatuses[statusKey]]),
@@ -53,7 +50,7 @@ export class SubmissionsTable extends React.Component {
}
formatDate = ({ value }) => {
const date = new Date(value);
const date = new Date(moment(value));
return date.toLocaleString();
}
@@ -65,22 +62,9 @@ export class SubmissionsTable extends React.Component {
translate = (...args) => this.props.intl.formatMessage(...args);
handleViewAllResponsesClick(data) {
handleViewAllResponsesClick = (data) => () => {
const getsubmissionUUID = (row) => row.original.submissionUUID;
const rows = data.selectedRows.length ? data.selectedRows : data.tableInstance.rows;
this.props.loadSelectionForReview(rows.map(getsubmissionUUID));
}
selectedBulkAction(selectedFlatRows) {
return {
buttonText: this.translate(
messages.viewSelectedResponses,
{ value: selectedFlatRows.length },
),
className: 'view-selected-responses-btn',
handleClick: this.handleViewAllResponsesClick,
variant: 'primary',
};
this.props.loadSelectionForReview(data.map(getsubmissionUUID));
}
render() {
@@ -88,59 +72,56 @@ export class SubmissionsTable extends React.Component {
return null;
}
return (
<DataTable
isFilterable
FilterStatusComponent={FilterStatusComponent}
numBreakoutFilters={2}
defaultColumnValues={{ Filter: TextFilter }}
isSelectable
isSortable
isPaginated
itemCount={this.props.listData.length}
initialState={{ pageSize: 10, pageIndex: 0 }}
data={this.props.listData}
tableActions={[
{
buttonText: this.translate(messages.viewAllResponses),
handleClick: this.handleViewAllResponsesClick,
className: 'view-all-responses-btn',
variant: 'primary',
},
]}
bulkActions={[
this.selectedBulkAction,
]}
columns={[
{
Header: this.userLabel,
accessor: this.userAccessor,
},
{
Header: this.dateSubmittedLabel,
accessor: submissionFields.dateSubmitted,
Cell: this.formatDate,
disableFilters: true,
},
{
Header: this.translate(messages.grade),
accessor: submissionFields.score,
Cell: this.formatGrade,
disableFilters: true,
},
{
Header: this.translate(messages.gradingStatus),
accessor: submissionFields.gradingStatus,
Cell: this.formatStatus,
Filter: MultiSelectDropdownFilter,
filter: 'includesValue',
filterChoices: this.gradeStatusOptions,
},
]}
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.TableFooter />
</DataTable>
<div className="submissions-table">
<DataTable
isFilterable
FilterStatusComponent={FilterStatusComponent}
numBreakoutFilters={2}
defaultColumnValues={{ Filter: TextFilter }}
isSelectable
isSortable
isPaginated
itemCount={this.props.listData.length}
initialState={{ pageSize: 10, pageIndex: 0 }}
data={this.props.listData}
tableActions={[
<TableAction handleClick={this.handleViewAllResponsesClick} />,
]}
bulkActions={[
<SelectedBulkAction handleClick={this.handleViewAllResponsesClick} />,
]}
columns={[
{
Header: this.userLabel,
accessor: this.userAccessor,
},
{
Header: this.dateSubmittedLabel,
accessor: submissionFields.dateSubmitted,
Cell: this.formatDate,
disableFilters: true,
},
{
Header: this.translate(messages.grade),
accessor: submissionFields.score,
Cell: this.formatGrade,
disableFilters: true,
},
{
Header: this.translate(messages.gradingStatus),
accessor: submissionFields.gradingStatus,
Cell: this.formatStatus,
Filter: MultiSelectDropdownFilter,
filter: 'includesValue',
filterChoices: this.gradeStatusOptions,
},
]}
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.TableFooter />
</DataTable>
</div>
);
}
}

View File

@@ -20,6 +20,8 @@ import {
} from './SubmissionsTable';
jest.mock('./FilterStatusComponent', () => jest.fn().mockName('FilterStatusComponent'));
jest.mock('./TableAction', () => jest.fn().mockName('TableAction'));
jest.mock('./SelectedBulkAction', () => jest.fn().mockName('SelectedBulkAction'));
jest.mock('data/redux', () => ({
selectors: {
@@ -43,9 +45,9 @@ let el;
jest.useFakeTimers('modern');
const dates = [
new Date(16131215154955).toLocaleTimeString(),
new Date(16131225154955).toLocaleTimeString(),
new Date(16131215250955).toLocaleTimeString(),
'2021-12-08 09:06:15.319213+00:00',
'2021-12-10 18:06:15.319213+00:00',
'2021-12-11 07:06:15.319213+00:00',
];
const individualData = [
@@ -128,7 +130,6 @@ describe('SubmissionsTable component', () => {
describe('snapshots', () => {
beforeEach(() => {
mockMethod('handleViewAllResponsesClick');
mockMethod('selectedBulkAction');
mockMethod('formatDate');
mockMethod('formatGrade');
mockMethod('formatStatus');
@@ -165,9 +166,6 @@ describe('SubmissionsTable component', () => {
['itemCount', 3],
['initialState', { pageSize: 10, pageIndex: 0 }],
])('%s = %p', (key, value) => expect(tableProps[key]).toEqual(value));
test('bulkActions linked to selectedBulkAction', () => {
expect(tableProps.bulkActions).toEqual([el.instance().selectedBulkAction]);
});
describe('individual columns', () => {
let columns;
beforeEach(() => {
@@ -277,41 +275,14 @@ describe('SubmissionsTable component', () => {
});
describe('handleViewAllResponsesClick', () => {
it('calls loadSelectionForReview with submissionUUID from all rows if there are no selectedRows', () => {
const data = {
selectedRows: [
],
tableInstance: {
rows: [
{ original: { submissionUUID: '123' } },
{ original: { submissionUUID: '456' } },
{ original: { submissionUUID: '789' } },
],
},
};
el.instance().handleViewAllResponsesClick(data);
const data = [
{ original: { submissionUUID: '123' } },
{ original: { submissionUUID: '456' } },
{ original: { submissionUUID: '789' } },
];
el.instance().handleViewAllResponsesClick(data)();
expect(el.instance().props.loadSelectionForReview).toHaveBeenCalledWith(['123', '456', '789']);
});
it('calls loadSelectionForReview with submissionUUID from selected rows if there are any', () => {
const data = {
selectedRows: [
{ original: { submissionUUID: '123' } },
{ original: { submissionUUID: '456' } },
{ original: { submissionUUID: '789' } },
],
};
el.instance().handleViewAllResponsesClick(data);
expect(
el.instance().props.loadSelectionForReview,
).toHaveBeenCalledWith(['123', '456', '789']);
});
});
describe('selectedBulkAction', () => {
it('includes selection length and triggers handleViewAllResponsesClick', () => {
const rows = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const action = el.instance().selectedBulkAction(rows);
expect(action.buttonText).toEqual(expect.stringContaining(rows.length.toString()));
expect(action.handleClick).toEqual(el.instance().handleViewAllResponsesClick);
});
});
});
});

View File

@@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
export const TableAction = ({ tableInstance, handleClick }) => (
<Button
onClick={handleClick(tableInstance.rows)}
variant="primary"
className="view-all-responses-btn"
>
<FormattedMessage {...messages.viewAllResponses} />
</Button>
);
TableAction.defaultProps = {
tableInstance: {
rows: [],
},
};
TableAction.propTypes = {
tableInstance: PropTypes.shape({
rows: PropTypes.arrayOf(PropTypes.object),
}),
handleClick: PropTypes.func.isRequired,
};
export default TableAction;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TableAction } from './TableAction';
describe('TableAction component', () => {
const props = {
tableInstance: { rows: [{ id: 1 }, { id: 2 }] },
handleClick: jest.fn(),
};
test('snapshots', () => {
const el = shallow(<TableAction {...props} handleClick={() => jest.fn()} />);
expect(el).toMatchSnapshot();
});
test('handleClick', () => {
shallow(<TableAction {...props} />);
expect(props.handleClick).toHaveBeenCalledWith(props.tableInstance.rows);
});
});

View File

@@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectedBulkAction component snapshots 1`] = `
<Button
className="view-selected-responses-btn"
onClick={[MockFunction]}
variant="primary"
>
<FormattedMessage
defaultMessage="View selected responses ({value})"
description="Button text to load selected responses for review/grading"
id="ora-grading.ListView.viewSelectedResponses"
values={
Object {
"value": 2,
}
}
/>
</Button>
`;

View File

@@ -3,237 +3,243 @@
exports[`SubmissionsTable component component render tests snapshots snapshot: empty (no list data) 1`] = `""`;
exports[`SubmissionsTable component component render tests snapshots snapshot: happy path 1`] = `
<DataTable
FilterStatusComponent={[MockFunction FilterStatusComponent]}
bulkActions={
Array [
[MockFunction this.selectedBulkAction],
]
}
columns={
Array [
Object {
"Header": "Username",
"accessor": "username",
},
Object {
"Cell": [MockFunction this.formatDate],
"Header": "Learner submission date",
"accessor": "dateSubmitted",
"disableFilters": true,
},
Object {
"Cell": [MockFunction this.formatGrade],
"Header": "Grade",
"accessor": "score",
"disableFilters": true,
},
Object {
"Cell": [MockFunction this.formatStatus],
"Filter": "MultiSelectDropdownFilter",
"Header": "Grading status",
"accessor": "gradingStatus",
"filter": "includesValue",
"filterChoices": Array [
Object {
"name": "Ungraded",
"value": "ungraded",
},
Object {
"name": "Grading Completed",
"value": "graded",
},
Object {
"name": "Currently being graded by someone else",
"value": "locked",
},
Object {
"name": "You are currently grading this response",
"value": "in-progress",
},
],
},
]
}
data={
Array [
Object {
"dateSubmitted": "9:05:54 PM",
"gradingStatus": "ungraded",
"score": Object {
"pointsEarned": 1,
"pointsPossible": 10,
},
"username": "username-1",
},
Object {
"dateSubmitted": "11:52:34 PM",
"gradingStatus": "graded",
"score": Object {
"pointsEarned": 2,
"pointsPossible": 10,
},
"username": "username-2",
},
Object {
"dateSubmitted": "9:07:30 PM",
"gradingStatus": "in-progress",
"score": Object {
"pointsEarned": 3,
"pointsPossible": 10,
},
"username": "username-3",
},
]
}
defaultColumnValues={
Object {
"Filter": "TextFilter",
}
}
initialState={
Object {
"pageIndex": 0,
"pageSize": 10,
}
}
isFilterable={true}
isPaginated={true}
isSelectable={true}
isSortable={true}
itemCount={3}
numBreakoutFilters={2}
tableActions={
Array [
Object {
"buttonText": "View all responses",
"className": "view-all-responses-btn",
"handleClick": [MockFunction this.handleViewAllResponsesClick],
"variant": "primary",
},
]
}
<div
className="submissions-table"
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.TableFooter />
</DataTable>
<DataTable
FilterStatusComponent={[MockFunction FilterStatusComponent]}
bulkActions={
Array [
<mockConstructor
handleClick={[MockFunction this.handleViewAllResponsesClick]}
/>,
]
}
columns={
Array [
Object {
"Header": "Username",
"accessor": "username",
},
Object {
"Cell": [MockFunction this.formatDate],
"Header": "Learner submission date",
"accessor": "dateSubmitted",
"disableFilters": true,
},
Object {
"Cell": [MockFunction this.formatGrade],
"Header": "Grade",
"accessor": "score",
"disableFilters": true,
},
Object {
"Cell": [MockFunction this.formatStatus],
"Filter": "MultiSelectDropdownFilter",
"Header": "Grading status",
"accessor": "gradingStatus",
"filter": "includesValue",
"filterChoices": Array [
Object {
"name": "Ungraded",
"value": "ungraded",
},
Object {
"name": "Grading Completed",
"value": "graded",
},
Object {
"name": "Currently being graded by someone else",
"value": "locked",
},
Object {
"name": "You are currently grading this response",
"value": "in-progress",
},
],
},
]
}
data={
Array [
Object {
"dateSubmitted": "2021-12-08 09:06:15.319213+00:00",
"gradingStatus": "ungraded",
"score": Object {
"pointsEarned": 1,
"pointsPossible": 10,
},
"username": "username-1",
},
Object {
"dateSubmitted": "2021-12-10 18:06:15.319213+00:00",
"gradingStatus": "graded",
"score": Object {
"pointsEarned": 2,
"pointsPossible": 10,
},
"username": "username-2",
},
Object {
"dateSubmitted": "2021-12-11 07:06:15.319213+00:00",
"gradingStatus": "in-progress",
"score": Object {
"pointsEarned": 3,
"pointsPossible": 10,
},
"username": "username-3",
},
]
}
defaultColumnValues={
Object {
"Filter": "TextFilter",
}
}
initialState={
Object {
"pageIndex": 0,
"pageSize": 10,
}
}
isFilterable={true}
isPaginated={true}
isSelectable={true}
isSortable={true}
itemCount={3}
numBreakoutFilters={2}
tableActions={
Array [
<mockConstructor
handleClick={[MockFunction this.handleViewAllResponsesClick]}
/>,
]
}
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.TableFooter />
</DataTable>
</div>
`;
exports[`SubmissionsTable component component render tests snapshots snapshot: team happy path 1`] = `
<DataTable
FilterStatusComponent={[MockFunction FilterStatusComponent]}
bulkActions={
Array [
[MockFunction this.selectedBulkAction],
]
}
columns={
Array [
Object {
"Header": "Team name",
"accessor": "teamName",
},
Object {
"Cell": [MockFunction this.formatDate],
"Header": "Team submission date",
"accessor": "dateSubmitted",
"disableFilters": true,
},
Object {
"Cell": [MockFunction this.formatGrade],
"Header": "Grade",
"accessor": "score",
"disableFilters": true,
},
Object {
"Cell": [MockFunction this.formatStatus],
"Filter": "MultiSelectDropdownFilter",
"Header": "Grading status",
"accessor": "gradingStatus",
"filter": "includesValue",
"filterChoices": Array [
Object {
"name": "Ungraded",
"value": "ungraded",
},
Object {
"name": "Grading Completed",
"value": "graded",
},
Object {
"name": "Currently being graded by someone else",
"value": "locked",
},
Object {
"name": "You are currently grading this response",
"value": "in-progress",
},
],
},
]
}
data={
Array [
Object {
"dateSubmitted": "9:05:54 PM",
"gradingStatus": "ungraded",
"score": Object {
"pointsEarned": 1,
"pointsPossible": 10,
},
"teamName": "teamName-1",
},
Object {
"dateSubmitted": "11:52:34 PM",
"gradingStatus": "graded",
"score": Object {
"pointsEarned": 2,
"pointsPossible": 10,
},
"teamName": "teamName-2",
},
Object {
"dateSubmitted": "9:07:30 PM",
"gradingStatus": "in-progress",
"score": Object {
"pointsEarned": 3,
"pointsPossible": 10,
},
"teamName": "teamName-3",
},
]
}
defaultColumnValues={
Object {
"Filter": "TextFilter",
}
}
initialState={
Object {
"pageIndex": 0,
"pageSize": 10,
}
}
isFilterable={true}
isPaginated={true}
isSelectable={true}
isSortable={true}
itemCount={3}
numBreakoutFilters={2}
tableActions={
Array [
Object {
"buttonText": "View all responses",
"className": "view-all-responses-btn",
"handleClick": [MockFunction this.handleViewAllResponsesClick],
"variant": "primary",
},
]
}
<div
className="submissions-table"
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.TableFooter />
</DataTable>
<DataTable
FilterStatusComponent={[MockFunction FilterStatusComponent]}
bulkActions={
Array [
<mockConstructor
handleClick={[MockFunction this.handleViewAllResponsesClick]}
/>,
]
}
columns={
Array [
Object {
"Header": "Team name",
"accessor": "teamName",
},
Object {
"Cell": [MockFunction this.formatDate],
"Header": "Team submission date",
"accessor": "dateSubmitted",
"disableFilters": true,
},
Object {
"Cell": [MockFunction this.formatGrade],
"Header": "Grade",
"accessor": "score",
"disableFilters": true,
},
Object {
"Cell": [MockFunction this.formatStatus],
"Filter": "MultiSelectDropdownFilter",
"Header": "Grading status",
"accessor": "gradingStatus",
"filter": "includesValue",
"filterChoices": Array [
Object {
"name": "Ungraded",
"value": "ungraded",
},
Object {
"name": "Grading Completed",
"value": "graded",
},
Object {
"name": "Currently being graded by someone else",
"value": "locked",
},
Object {
"name": "You are currently grading this response",
"value": "in-progress",
},
],
},
]
}
data={
Array [
Object {
"dateSubmitted": "2021-12-08 09:06:15.319213+00:00",
"gradingStatus": "ungraded",
"score": Object {
"pointsEarned": 1,
"pointsPossible": 10,
},
"teamName": "teamName-1",
},
Object {
"dateSubmitted": "2021-12-10 18:06:15.319213+00:00",
"gradingStatus": "graded",
"score": Object {
"pointsEarned": 2,
"pointsPossible": 10,
},
"teamName": "teamName-2",
},
Object {
"dateSubmitted": "2021-12-11 07:06:15.319213+00:00",
"gradingStatus": "in-progress",
"score": Object {
"pointsEarned": 3,
"pointsPossible": 10,
},
"teamName": "teamName-3",
},
]
}
defaultColumnValues={
Object {
"Filter": "TextFilter",
}
}
initialState={
Object {
"pageIndex": 0,
"pageSize": 10,
}
}
isFilterable={true}
isPaginated={true}
isSelectable={true}
isSortable={true}
itemCount={3}
numBreakoutFilters={2}
tableActions={
Array [
<mockConstructor
handleClick={[MockFunction this.handleViewAllResponsesClick]}
/>,
]
}
>
<DataTable.TableControlBar />
<DataTable.Table />
<DataTable.TableFooter />
</DataTable>
</div>
`;

View File

@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TableAction component snapshots 1`] = `
<Button
className="view-all-responses-btn"
onClick={[MockFunction]}
variant="primary"
>
<FormattedMessage
defaultMessage="View all responses"
description="Button text to load all responses for review/grading"
id="ora-grading.ListView.viewAllResponses"
/>
</Button>
`;

View File

@@ -1,7 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FileRenderer, isSupported } from 'components/FilePreview';
import { FileRenderer } from 'components/FilePreview';
import { isSupported } from 'components/FilePreview/hooks';
/**
* <PreviewDisplay />

View File

@@ -7,7 +7,6 @@ import { PreviewDisplay } from './PreviewDisplay';
jest.mock('components/FilePreview', () => ({
FileRenderer: () => 'FileRenderer',
isSupported: jest.requireActual('components/FilePreview').isSupported,
}));
describe('PreviewDisplay', () => {

View File

@@ -43,6 +43,11 @@
.preview-display {
padding: map-get($spacers, 3) 0;
}
.response-display-text-content {
white-space: pre-line;
overflow: hidden;
}
}
@include media-breakpoint-down(sm) {

View File

@@ -2,11 +2,13 @@ import React from 'react';
import PropTypes from 'prop-types';
import {
Card, Collapsible, Icon, DataTable,
Card, Collapsible, Icon, DataTable, Button,
} from '@edx/paragon';
import { ArrowDropDown, ArrowDropUp } from '@edx/paragon/icons';
import { ArrowDropDown, ArrowDropUp, WarningFilled } from '@edx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { downloadAllLimit, downloadSingleLimit } from 'data/constants/files';
import FileNameCell from './components/FileNameCell';
import FileExtensionCell from './components/FileExtensionCell';
import FilePopoverCell from './components/FilePopoverCell';
@@ -19,7 +21,17 @@ import messages from './messages';
*/
export class SubmissionFiles extends React.Component {
get title() {
return `Submission Files (${this.props.files.length})`;
return `${this.props.intl.formatMessage(messages.submissionFiles)} (${this.props.files.length})`;
}
get canDownload() {
let totalFileSize = 0;
const exceedFileSize = this.props.files.some(file => {
totalFileSize += file.size;
return file.size > downloadSingleLimit;
});
return !exceedFileSize && totalFileSize < downloadAllLimit;
}
render() {
@@ -70,7 +82,15 @@ export class SubmissionFiles extends React.Component {
</Collapsible.Body>
</Collapsible.Advanced>
<Card.Footer className="text-right">
<FileDownload files={files} />
{
this.canDownload ? <FileDownload files={files} /> : (
<div>
<Icon className="d-inline-block align-middle" src={WarningFilled} />
<span className="exceed-download-text"> {intl.formatMessage(messages.exceedFileSize)} </span>
<Button disabled>{intl.formatMessage(messages.downloadFiles)}</Button>
</div>
)
}
</Card.Footer>
</>
) : (

View File

@@ -1,12 +1,16 @@
import React from 'react';
import { shallow } from 'enzyme';
import { downloadAllLimit, downloadSingleLimit } from 'data/constants/files';
import { formatMessage } from 'testUtils';
import { SubmissionFiles } from './SubmissionFiles';
import messages from './messages';
jest.mock('./components/FileNameCell', () => jest.fn().mockName('FileNameCell'));
jest.mock('./components/FileExtensionCell', () => jest.fn().mockName('FileExtensionCell'));
jest.mock('./components/FilePopoverCell', () => jest.fn().mockName('FilePopoverCell'));
jest.mock('./FileDownload', () => 'FileDownload');
describe('SubmissionFiles', () => {
describe('component', () => {
@@ -16,25 +20,34 @@ describe('SubmissionFiles', () => {
name: 'some file name.jpg',
description: 'description for the file',
downloadURL: '/valid-url-wink-wink',
size: 0,
},
{
name: 'file number 2.jpg',
description: 'description for this file',
downloadURL: '/url-2',
size: 0,
},
],
};
let el;
beforeAll(() => {
el = shallow(<SubmissionFiles intl={{ formatMessage }} />);
beforeEach(() => {
el = shallow(<SubmissionFiles intl={{ formatMessage }} {...props} />);
});
describe('snapshot', () => {
test('files does not exist', () => {
test('files existed for props', () => {
expect(el).toMatchSnapshot();
});
test('files exited for props', () => {
el.setProps({ ...props });
test('files does not exist', () => {
el.setProps({ files: [] });
expect(el).toMatchSnapshot();
});
test('files size exceed', () => {
const files = props.files.map(file => ({ ...file, size: downloadSingleLimit + 1 }));
el.setProps({ files });
expect(el).toMatchSnapshot();
});
});
@@ -43,12 +56,47 @@ describe('SubmissionFiles', () => {
test('title', () => {
const titleEl = el.find('.submission-files-title>h3');
expect(titleEl.text()).toEqual(
`Submission Files (${props.files.length})`,
`${formatMessage(messages.submissionFiles)} (${props.files.length})`,
);
expect(el.instance().title).toEqual(
`Submission Files (${props.files.length})`,
`${formatMessage(messages.submissionFiles)} (${props.files.length})`,
);
});
describe('canDownload', () => {
test('normal file size', () => {
expect(el.instance().canDownload).toEqual(true);
});
test('one of the file exceed the limit', () => {
const oneFileExceed = [{ ...props.files[0], size: downloadSingleLimit + 1 }, props.files[1]];
oneFileExceed.forEach(file => expect(file.size < downloadAllLimit).toEqual(true));
el.setProps({ files: oneFileExceed });
expect(el.instance().canDownload).toEqual(false);
const warningEl = el.find('span.exceed-download-text');
expect(warningEl.text().trim()).toEqual(formatMessage(messages.exceedFileSize));
});
test('total file size exceed the limit', () => {
const length = 20;
const totalFilesExceed = new Array(length).fill({
name: 'some file name.jpg',
description: 'description for the file',
downloadURL: '/valid-url-wink-wink',
size: (downloadAllLimit + 1) / length,
});
totalFilesExceed.forEach(file => {
expect(file.size < downloadAllLimit).toEqual(true);
expect(file.size < downloadSingleLimit).toEqual(true);
});
el.setProps({ files: totalFilesExceed });
expect(el.instance().canDownload).toEqual(false);
});
});
});
});
});

View File

@@ -14,7 +14,7 @@ exports[`SubmissionFiles component snapshot files does not exist 1`] = `
</Card>
`;
exports[`SubmissionFiles component snapshot files exited for props 1`] = `
exports[`SubmissionFiles component snapshot files existed for props 1`] = `
<Card
className="submission-files"
>
@@ -75,11 +75,13 @@ exports[`SubmissionFiles component snapshot files exited for props 1`] = `
"description": "description for the file",
"downloadURL": "/valid-url-wink-wink",
"name": "some file name.jpg",
"size": 0,
},
Object {
"description": "description for this file",
"downloadURL": "/url-2",
"name": "file number 2.jpg",
"size": 0,
},
]
}
@@ -93,18 +95,20 @@ exports[`SubmissionFiles component snapshot files exited for props 1`] = `
<Card.Footer
className="text-right"
>
<Connect(FileDownload)
<FileDownload
files={
Array [
Object {
"description": "description for the file",
"downloadURL": "/valid-url-wink-wink",
"name": "some file name.jpg",
"size": 0,
},
Object {
"description": "description for this file",
"downloadURL": "/url-2",
"name": "file number 2.jpg",
"size": 0,
},
]
}
@@ -112,3 +116,105 @@ exports[`SubmissionFiles component snapshot files exited for props 1`] = `
</Card.Footer>
</Card>
`;
exports[`SubmissionFiles component snapshot files size exceed 1`] = `
<Card
className="submission-files"
>
<Collapsible.Advanced
defaultOpen={true}
>
<Collapsible.Trigger
className="submission-files-title"
>
<h3>
Submission Files (2)
</h3>
<Collapsible.Visible
whenClosed={true}
>
<Icon
src={[MockFunction icons.ArrowDropDown]}
/>
</Collapsible.Visible>
<Collapsible.Visible
whenOpen={true}
>
<Icon
src={[MockFunction icons.ArrowDropUp]}
/>
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body
className="submission-files-body"
>
<div
className="submission-files-table"
>
<DataTable
columns={
Array [
Object {
"Cell": [MockFunction FileNameCell],
"Header": "Name",
"accessor": "name",
},
Object {
"Cell": [MockFunction FileExtensionCell],
"Header": "File Extension",
"accessor": "name",
"id": "extension",
},
Object {
"Cell": [MockFunction FilePopoverCell],
"Header": "File Metadata",
"accessor": "",
},
]
}
data={
Array [
Object {
"description": "description for the file",
"downloadURL": "/valid-url-wink-wink",
"name": "some file name.jpg",
"size": 1610612737,
},
Object {
"description": "description for this file",
"downloadURL": "/url-2",
"name": "file number 2.jpg",
"size": 1610612737,
},
]
}
itemCount={2}
>
<DataTable.Table />
</DataTable>
</div>
</Collapsible.Body>
</Collapsible.Advanced>
<Card.Footer
className="text-right"
>
<div>
<Icon
className="d-inline-block align-middle"
/>
<span
className="exceed-download-text"
>
Exceeded the allow download size
</span>
<Button
disabled={true}
>
Download files
</Button>
</div>
</Card.Footer>
</Card>
`;

View File

@@ -5,9 +5,11 @@ exports[`ResponseDisplay component snapshot file upload disable with valid respo
className="response-display"
>
<Card>
<Card.Body>
<Card.Section
className="response-display-text-content"
>
parsed html (sanitized (some text response here))
</Card.Body>
</Card.Section>
</Card>
</div>
`;
@@ -62,9 +64,11 @@ exports[`ResponseDisplay component snapshot file upload enable with valid respon
}
/>
<Card>
<Card.Body>
<Card.Section
className="response-display-text-content"
>
parsed html (sanitized (some text response here))
</Card.Body>
</Card.Section>
</Card>
</div>
`;

View File

@@ -48,7 +48,7 @@ export class ResponseDisplay extends React.Component {
/* eslint-disable react/no-array-index-key */
this.textContents.map((textContent, index) => (
<Card key={index}>
<Card.Body>{textContent}</Card.Body>
<Card.Section className="response-display-text-content">{textContent}</Card.Section>
</Card>
))
}

View File

@@ -36,6 +36,16 @@ const messages = defineMessages({
defaultMessage: 'Retry download',
description: 'Download files failed state label',
},
submissionFiles: {
id: 'ora-grading.ResponseDisplay.SubmissionFiles.submissionFile',
defaultMessage: 'Submission Files',
description: 'Total submission files',
},
exceedFileSize: {
id: 'ora-grading.ResponseDisplay.SubmissionFiles.fileSizeExceed',
defaultMessage: 'Exceeded the allow download size',
description: 'Exceed the allow download size error message',
},
});
export default messages;

View File

@@ -1,138 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Button } from '@edx/paragon';
import { Cancel, Highlight } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import { gradingStatuses as statuses } from 'data/services/lms/constants';
import StopGradingConfirmModal from './StopGradingConfirmModal';
import OverrideGradeConfirmModal from './OverrideGradeConfirmModal';
import messages from './messages';
export const buttonArgs = {
[statuses.ungraded]: {
label: messages.startGrading,
iconAfter: Highlight,
},
[statuses.graded]: {
label: messages.overrideGrade,
iconAfter: Highlight,
},
[statuses.inProgress]: {
label: messages.stopGrading,
iconAfter: Cancel,
},
};
export class StartGradingButton extends React.Component {
constructor(props) {
super(props);
this.state = {
showConfirmStopGrading: false,
showConfirmOverrideGrade: false,
};
this.showConfirmStopGrading = this.showConfirmStopGrading.bind(this);
this.hideConfirmStopGrading = this.hideConfirmStopGrading.bind(this);
this.showConfirmOverrideGrade = this.showConfirmOverrideGrade.bind(this);
this.hideConfirmOverrideGrade = this.hideConfirmOverrideGrade.bind(this);
this.confirmStopGrading = this.confirmStopGrading.bind(this);
this.confirmOverrideGrade = this.confirmOverrideGrade.bind(this);
this.handleClick = this.handleClick.bind(this);
}
showConfirmStopGrading() {
this.setState({ showConfirmStopGrading: true });
}
hideConfirmStopGrading() {
this.setState({ showConfirmStopGrading: false });
}
showConfirmOverrideGrade() {
this.setState({ showConfirmOverrideGrade: true });
}
hideConfirmOverrideGrade() {
this.setState({ showConfirmOverrideGrade: false });
}
confirmStopGrading() {
this.hideConfirmStopGrading();
this.props.stopGrading();
}
confirmOverrideGrade() {
this.hideConfirmOverrideGrade();
this.props.startGrading();
}
handleClick() {
if (this.props.gradingStatus === statuses.inProgress) {
this.showConfirmStopGrading();
} else if (this.props.gradingStatus === statuses.graded) {
this.showConfirmOverrideGrade();
} else {
this.props.startGrading();
}
}
render() {
const { gradingStatus } = this.props;
if (gradingStatus === statuses.locked) {
return null;
}
const args = buttonArgs[gradingStatus];
return (
<>
<Button
variant="primary"
iconAfter={args.iconAfter}
onClick={this.handleClick}
disabled={this.props.gradeIsPending || this.props.lockIsPending}
>
<FormattedMessage {...args.label} />
</Button>
<OverrideGradeConfirmModal
isOpen={this.state.showConfirmOverrideGrade}
onCancel={this.hideConfirmOverrideGrade}
onConfirm={this.confirmOverrideGrade}
/>
<StopGradingConfirmModal
isOpen={this.state.showConfirmStopGrading}
onCancel={this.hideConfirmStopGrading}
onConfirm={this.confirmStopGrading}
isOverride={this.props.gradeStatus === statuses.graded}
/>
</>
);
}
}
StartGradingButton.propTypes = {
gradeStatus: PropTypes.string.isRequired,
gradingStatus: PropTypes.string.isRequired,
startGrading: PropTypes.func.isRequired,
stopGrading: PropTypes.func.isRequired,
gradeIsPending: PropTypes.bool.isRequired,
lockIsPending: PropTypes.bool.isRequired,
};
export const mapStateToProps = (state) => ({
gradeIsPending: selectors.requests.isPending(state, { requestKey: RequestKeys.submitGrade }),
lockIsPending: selectors.requests.isPending(state, { requestKey: RequestKeys.setLock }),
gradeStatus: selectors.grading.selected.gradeStatus(state),
gradingStatus: selectors.grading.selected.gradingStatus(state),
});
export const mapDispatchToProps = {
startGrading: thunkActions.grading.startGrading,
stopGrading: thunkActions.grading.cancelGrading,
};
export default connect(mapStateToProps, mapDispatchToProps)(StartGradingButton);

View File

@@ -1,132 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import { gradingStatuses as statuses } from 'data/services/lms/constants';
import {
StartGradingButton,
mapStateToProps,
mapDispatchToProps,
} from './StartGradingButton';
jest.mock('data/redux', () => ({
selectors: {
grading: {
selected: {
gradeStatus: (state) => ({ gradeStatus: state }),
gradingStatus: (state) => ({ gradingStatus: state }),
},
},
requests: { isPending: (...args) => ({ isPending: args }) },
},
thunkActions: {
grading: {
startGrading: jest.fn(),
stopGrading: jest.fn(),
},
},
}));
jest.mock('./OverrideGradeConfirmModal', () => 'OverrideGradeConfirmModal');
jest.mock('./StopGradingConfirmModal', () => 'StopGradingConfirmModal');
let el;
describe('StartGradingButton component', () => {
describe('component', () => {
const props = {
gradeIsPending: false,
lockIsPending: false,
};
beforeEach(() => {
props.startGrading = jest.fn().mockName('this.props.startGrading');
props.stopGrading = jest.fn().mockName('this.props.stopGrading');
});
describe('snapshotes', () => {
const mockedEl = (gradingStatus, gradeStatus) => {
const renderedEl = shallow(
<StartGradingButton
{...props}
gradingStatus={gradingStatus}
gradeStatus={gradeStatus || gradingStatus}
/>,
);
const mockMethod = (methodName) => {
renderedEl.instance()[methodName] = jest.fn().mockName(`this.${methodName}`);
};
mockMethod('handleClick');
mockMethod('hideConfirmOverrideGrade');
mockMethod('confirmOverrideGrade');
mockMethod('hideConfirmStopGrading');
mockMethod('confirmStopGrading');
return renderedEl;
};
test('snapshot: locked (null)', () => {
el = mockedEl(statuses.locked);
expect(el.instance().render()).toMatchSnapshot();
expect(el.isEmptyRender()).toEqual(true);
});
test('snapshot: ungraded (startGrading callback)', () => {
expect(mockedEl(statuses.ungraded).instance().render()).toMatchSnapshot();
});
test('snapshot: grade pending (disabled)', () => {
el = mockedEl(statuses.ungraded);
el.setProps({ gradeIsPending: true });
expect(el.instance().render()).toMatchSnapshot();
});
test('snapshot: lock pending (disabled)', () => {
el = mockedEl(statuses.ungraded);
el.setProps({ lockIsPending: true });
expect(el.instance().render()).toMatchSnapshot();
});
test('snapshot: graded, confirmOverride (startGrading callback)', () => {
el = mockedEl(statuses.graded);
el.setState({ showConfirmOverrideGrade: true });
expect(el.instance().render()).toMatchSnapshot();
});
test('snapshot: inProgress, isOverride, confirmStop (stopGrading callback)', () => {
el = mockedEl(statuses.inProgress, statuses.graded);
el.setState({ showConfirmStopGrading: true });
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = { some: 'test-state' };
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('gradeIsPending loads from requests.gradeIsPending(submitGrade)', () => {
expect(mapped.gradeIsPending).toEqual(
selectors.requests.isPending(
testState,
{ requestKey: RequestKeys.submitGrade },
),
);
});
test('lockIsPending loads from requests.lockIsPending(setLock)', () => {
expect(mapped.lockIsPending).toEqual(
selectors.requests.isPending(
testState,
{ requestKey: RequestKeys.setLock },
),
);
});
test('gradeStatus loads from grading.selected.gradeStatus', () => {
expect(mapped.gradeStatus).toEqual(selectors.grading.selected.gradeStatus(testState));
});
test('gradingStatus loads from grading.selected.gradingStatus', () => {
expect(mapped.gradingStatus).toEqual(selectors.grading.selected.gradingStatus(testState));
});
});
describe('mapDispatchToProps', () => {
it('loads startGrading from thunkActions.grading.stargGrading', () => {
expect(mapDispatchToProps.startGrading).toEqual(thunkActions.grading.startGrading);
});
it('loads stopGrading from thunkActions.grading.cancelGrading', () => {
expect(mapDispatchToProps.stopGrading).toEqual(thunkActions.grading.cancelGrading);
});
});
});

View File

@@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StartGradingButton component component snapshots hide: renders empty component if hook.hide is true 1`] = `""`;
exports[`StartGradingButton component component snapshots smoke test: forwards props to components from hooks 1`] = `
<Fragment>
<Button
props="hooks.buttonArgs"
variant="primary"
/>
<OverrideGradeConfirmModal
props="hooks.overrideGradeArgs"
/>
<StopGradingConfirmModal
props="hooks.stopGradingArgs"
/>
</Fragment>
`;

View File

@@ -0,0 +1,141 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Cancel, Highlight } from '@edx/paragon/icons';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import { gradingStatuses as statuses } from 'data/services/lms/constants';
import { StrictDict } from 'utils';
import * as module from './hooks';
import messages from './messages';
export const buttonConfig = {
[statuses.ungraded]: {
label: messages.startGrading,
iconAfter: Highlight,
},
[statuses.graded]: {
label: messages.overrideGrade,
iconAfter: Highlight,
},
[statuses.inProgress]: {
label: messages.stopGrading,
iconAfter: Cancel,
},
};
export const state = StrictDict({
showConfirmStopGrading: (val) => React.useState(val),
showConfirmOverrideGrade: (val) => React.useState(val),
});
export const reduxValues = () => ({
gradeStatus: useSelector(selectors.grading.selected.gradeStatus),
gradingStatus: useSelector(selectors.grading.selected.gradingStatus),
gradeIsPending: useSelector((reduxState) => (
selectors.requests.isPending(reduxState, { requestKey: RequestKeys.submitGrade })
)),
lockIsPending: useSelector((reduxState) => (
selectors.requests.isPending(reduxState, { requestKey: RequestKeys.setLock })
)),
});
export const buttonArgs = ({
intl,
dispatch,
overrideGradeState,
stopGradingState,
gradingStatus,
isPending,
}) => ({
iconAfter: module.buttonConfig[gradingStatus].iconAfter,
children: intl.formatMessage(module.buttonConfig[gradingStatus].label),
disabled: isPending,
onClick: () => {
if (gradingStatus === statuses.inProgress) {
stopGradingState.setShow(true);
} else if (gradingStatus === statuses.graded) {
overrideGradeState.setShow(true);
} else {
dispatch(thunkActions.grading.startGrading());
}
},
});
export const overrideGradeArgs = ({
dispatch,
overrideGradeState: { show, setShow },
}) => ({
isOpen: show,
onCancel: () => setShow(false),
onConfirm: () => {
setShow(false);
dispatch(thunkActions.grading.startGrading());
},
});
export const stopGradingArgs = ({
dispatch,
isGraded,
stopGradingState: { show, setShow },
}) => ({
isOpen: show,
onCancel: () => setShow(false),
onConfirm: () => {
setShow(false);
dispatch(thunkActions.grading.cancelGrading());
},
isOverride: isGraded,
});
export const buttonHooks = ({
dispatch,
intl,
}) => {
const showState = {
stopGrading: state.showConfirmStopGrading(false),
overrideGrade: state.showConfirmOverrideGrade(false),
};
const overrideGradeState = {
show: showState.overrideGrade[0],
setShow: showState.overrideGrade[1],
};
const stopGradingState = {
show: showState.stopGrading[0],
setShow: showState.stopGrading[1],
};
const {
gradeStatus,
gradingStatus,
gradeIsPending,
lockIsPending,
} = module.reduxValues();
const hide = gradingStatus === statuses.locked;
if (hide) {
return { hide };
}
return {
hide,
overrideGradeArgs: module.overrideGradeArgs({
dispatch,
overrideGradeState,
}),
stopGradingArgs: module.stopGradingArgs({
dispatch,
stopGradingState,
isGraded: gradeStatus === statuses.graded,
}),
buttonArgs: module.buttonArgs({
intl,
dispatch,
stopGradingState,
overrideGradeState,
isPending: lockIsPending || gradeIsPending,
gradingStatus,
}),
};
};

View File

@@ -0,0 +1,280 @@
import { useSelector } from 'react-redux';
import { formatMessage, MockUseState } from 'testUtils';
import { RequestKeys } from 'data/constants/requests';
import { gradingStatuses } from 'data/services/lms/constants';
import { selectors, thunkActions } from 'data/redux';
import { keyStore } from 'utils';
import * as hooks from './hooks';
jest.mock('react-redux', () => ({
useSelector: args => ({ useSelector: args }),
}));
jest.mock('data/redux', () => ({
selectors: {
grading: {
selected: {
gradeStatus: jest.fn((...args) => ({ gradeStatus: args })),
gradingStatus: jest.fn((...args) => ({ gradingStatus: args })),
},
},
requests: {
isPending: jest.fn((...args) => ({ isPending: args })),
},
},
thunkActions: {
grading: {
startGrading: jest.fn((...args) => ({ startGrading: args })),
cancelGrading: jest.fn((...args) => ({ cancelGrading: args })),
},
},
}));
const state = new MockUseState(hooks);
const hookKeys = keyStore(hooks);
let hook;
const testValue = 'my-test-value';
const dispatch = jest.fn();
const intl = { formatMessage };
describe('Start Grading Button hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('state hooks', () => {
state.testGetter(state.keys.showConfirmStopGrading);
state.testGetter(state.keys.showConfirmOverrideGrade);
});
describe('redux values', () => {
const testState = { my: 'test-state' };
beforeEach(() => {
hook = hooks.reduxValues();
});
test('gradeStatus drawn from selected grade status', () => {
expect(hook.gradeStatus).toEqual(
useSelector(selectors.grading.selected.gradeStatus),
);
});
test('gradingStatus drawn from selected grading status', () => {
expect(hook.gradingStatus).toEqual(
useSelector(selectors.grading.selected.gradingStatus),
);
});
test('gradeIsPending drawn from requests.isPending on submitGrade request', () => {
expect(hook.gradeIsPending.useSelector(testState)).toEqual(
selectors.requests.isPending(testState, { requestKey: RequestKeys.submitGrade }),
);
});
test('lockIsPending drawn from requests.isPending on setLock request', () => {
expect(hook.lockIsPending.useSelector(testState)).toEqual(
selectors.requests.isPending(testState, { requestKey: RequestKeys.setLock }),
);
});
});
describe('non-state hooks', () => {
beforeEach(state.mock);
afterEach(state.restore);
describe('buttonArgs', () => {
const props = {
intl,
dispatch,
overrideGradeState: { setShow: jest.fn() },
stopGradingState: { setShow: jest.fn() },
gradingStatus: gradingStatuses.ungraded,
isPending: false,
};
describe('returned args', () => {
const testStatusConfig = (status) => {
describe(`status config for ${status} submissions`, () => {
beforeEach(() => {
selectors.grading.selected.gradingStatus.mockReturnValue(status);
hook = hooks.buttonArgs({ ...props, gradingStatus: status });
});
it('loads configured iconAfter', () => {
expect(hook.iconAfter).toEqual(hooks.buttonConfig[status].iconAfter);
});
it('loads and formats label from config', () => {
expect(hook.children).toEqual(formatMessage(hooks.buttonConfig[status].label));
});
describe('onClick', () => {
if (status === gradingStatuses.inProgress) {
it('shows the confirm stop-grading modal', () => {
hook.onClick();
expect(props.stopGradingState.setShow).toHaveBeenCalledWith(true);
});
} else if (status === gradingStatuses.graded) {
it('shows the confirm stop-grading modal', () => {
hook.onClick();
expect(props.overrideGradeState.setShow).toHaveBeenCalledWith(true);
});
} else {
it('dispatches the startGrading thunkAction', () => {
hook.onClick();
expect(props.dispatch).toHaveBeenCalledWith(thunkActions.grading.startGrading());
});
}
});
});
};
testStatusConfig(gradingStatuses.ungraded);
testStatusConfig(gradingStatuses.graded);
testStatusConfig(gradingStatuses.inProgress);
});
});
describe('overrideGradeArgs', () => {
const props = { show: 'test-show', setShow: jest.fn() };
beforeEach(() => {
hook = hooks.overrideGradeArgs({ dispatch, overrideGradeState: props });
});
test('isOpen returns the override grade show state', () => {
expect(hook.isOpen).toEqual(props.show);
});
test('onCancel: sets override grade show state to false', () => {
hook.onCancel();
expect(props.setShow).toHaveBeenCalledWith(false);
});
describe('onConfirm', () => {
test('sets override grade show state to false and starts grading', () => {
hook.onConfirm();
expect(props.setShow).toHaveBeenCalledWith(false);
expect(dispatch).toHaveBeenCalledWith(thunkActions.grading.startGrading());
});
});
});
describe('stopGradingArgs', () => {
const props = { show: 'test-show', setShow: jest.fn() };
beforeEach(() => {
hook = hooks.stopGradingArgs({
dispatch,
isGraded: testValue,
stopGradingState: props,
});
});
test('isOpen returns the stop grading show state', () => {
expect(hook.isOpen).toEqual(props.show);
});
test('onCancel: sets stop grading show state to false', () => {
hook.onCancel();
expect(props.setShow).toHaveBeenCalledWith(false);
});
describe('onConfirm', () => {
test('sets stop grading show state to false and cancels grading', () => {
hook.onConfirm();
expect(props.setShow).toHaveBeenCalledWith(false);
expect(dispatch).toHaveBeenCalledWith(thunkActions.grading.cancelGrading());
});
});
test('isOverride is set to isGraded arg', () => {
expect(hook.isOverride).toEqual(testValue);
});
});
describe('button component hooks', () => {
const reduxValues = {
gradeStatus: 'redux-values.gradeStatus',
gradingStatus: 'redux-values.gradingStatus',
gradeIsPending: false,
lockIsPending: false,
};
const mocks = {
buttonArgs: jest.fn(args => ({ buttonArgs: args })),
overrideGradeArgs: jest.fn(args => ({ overrideGradeArgs: args })),
reduxValues: () => reduxValues,
stopGradingArgs: jest.fn(args => ({ stopGradingArgs: args })),
};
let overrideGradeState;
let stopGradingState;
const mockHooks = (values) => {
jest.spyOn(hooks, hookKeys.buttonArgs).mockImplementationOnce(mocks.buttonArgs);
jest.spyOn(hooks, hookKeys.overrideGradeArgs).mockImplementationOnce(mocks.overrideGradeArgs);
jest.spyOn(hooks, hookKeys.reduxValues).mockImplementationOnce(() => ({
...reduxValues,
...values,
}));
jest.spyOn(hooks, hookKeys.stopGradingArgs).mockImplementationOnce(mocks.stopGradingArgs);
};
beforeEach(() => {
mockHooks(reduxValues);
hook = hooks.buttonHooks({ dispatch, intl });
overrideGradeState = {
show: state.stateVals.showConfirmOverrideGrade,
setShow: state.setState.showConfirmOverrideGrade,
};
stopGradingState = {
show: state.stateVals.showConfirmStopGrading,
setShow: state.setState.showConfirmStopGrading,
};
});
describe('behavior', () => {
it('initializes showConfirmStopGrading and showConfirmOverrideGrade to false', () => {
expect(hooks.state.showConfirmStopGrading).toHaveBeenCalledWith(false);
expect(hooks.state.showConfirmOverrideGrade).toHaveBeenCalledWith(false);
});
});
describe('returned object', () => {
test('hide returns true iff gradingStatus is not locked', () => {
expect(hook.hide).toEqual(false);
mockHooks({ gradingStatus: gradingStatuses.locked });
hook = hooks.buttonHooks({ dispatch, intl });
expect(hook.hide).toEqual(true);
});
test('returns only hide hook if locked', () => {
mockHooks({ gradingStatus: gradingStatuses.locked });
hook = hooks.buttonHooks({ dispatch, intl });
expect(hook).toEqual({ hide: true });
});
test('overrideGradeArgs: calls local hook with dispatch and override grade state', () => {
expect(hook.overrideGradeArgs).toEqual(mocks.overrideGradeArgs({
dispatch,
overrideGradeState,
}));
});
describe('stopGradingArgs forwards local hook called with', () => {
test('dispatch, stop grading state, and if submission is graded', () => {
expect(hook.stopGradingArgs).toEqual(mocks.stopGradingArgs({
dispatch,
stopGradingState,
isGraded: false,
}));
mockHooks({ gradeStatus: gradingStatuses.graded });
hook = hooks.buttonHooks({ dispatch, intl });
expect(hook.stopGradingArgs).toEqual(mocks.stopGradingArgs({
dispatch,
stopGradingState,
isGraded: true,
}));
});
});
describe('buttonArgs forwards local hook called with', () => {
test('props, local state, grading status and whether lock or grade are pending', () => {
expect(hook.buttonArgs).toEqual(mocks.buttonArgs({
intl,
dispatch,
stopGradingState,
overrideGradeState,
gradingStatus: reduxValues.gradingStatus,
isPending: false,
}));
[
{ gradeIsPending: true },
{ lockIsPending: true },
{ gradeIsPending: true, lockIsPending: true },
].forEach(values => {
mockHooks(values);
hook = hooks.buttonHooks({ dispatch, intl });
expect(hook.buttonArgs).toEqual(mocks.buttonArgs({
intl,
dispatch,
stopGradingState,
overrideGradeState,
gradingStatus: reduxValues.gradingStatus,
isPending: true,
}));
});
});
});
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More