Compare commits

..

24 Commits

Author SHA1 Message Date
Eugene Dyudyunov
5fa0c1ab66 fix: BadOraLocationResponse error (#168)
Refactor the locationId constant for subdirectory-based deployments.

Exclude the MFE's `PUBLIC_PATH` from the constant.
2023-04-18 13:06:01 -04:00
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
127 changed files with 11249 additions and 9838 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

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: edx/.github/.github/workflows/lockfileversion-check.yml@master
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,9 @@ 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',

13754
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,10 +27,10 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-edx.org@^2.0.3",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-component-header": "^2.4.6",
"@edx/frontend-platform": "^1.15.6",
"@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",
@@ -52,10 +52,12 @@
"history": "5.0.1",
"html-react-parser": "^1.3.0",
"lodash": "^4.17.21",
"moment": "^2.29.3",
"prop-types": "15.7.2",
"query-string": "7.0.1",
"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.4",
"@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-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

@@ -9,19 +9,23 @@ import { LearningHeader as Header } from '@edx/frontend-component-header';
import { selectors } from 'data/redux';
import DemoWarning from 'containers/DemoWarning';
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>
<Head />
<Header
courseTitle={courseMetadata.title}
courseNumber={courseMetadata.number}
courseOrg={courseMetadata.org}
/>
{!isEnabled && <DemoWarning />}
<CTA />
<main>
<ListView />
</main>

View File

@@ -23,7 +23,9 @@ jest.mock('@edx/frontend-component-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('components/Head', () => 'Head');
const logo = 'fakeLogo.png';
let el;

View File

@@ -3,12 +3,14 @@
exports[`App router component snapshot: disabled (show demo warning) 1`] = `
<BrowserRouter>
<div>
<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>
<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

@@ -9,141 +9,77 @@ import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
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

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

@@ -10,6 +10,7 @@ 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', () => {

View File

@@ -95,7 +95,7 @@ exports[`SubmissionFiles component snapshot files existed for props 1`] = `
<Card.Footer
className="text-right"
>
<Connect(FileDownload)
<FileDownload
files={
Array [
Object {

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

@@ -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,
}));
});
});
});
});
});
});
});

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { Button } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import StopGradingConfirmModal from '../StopGradingConfirmModal';
import OverrideGradeConfirmModal from '../OverrideGradeConfirmModal';
import * as hooks from './hooks';
export const StartGradingButton = ({ intl }) => {
const dispatch = useDispatch();
const {
hide,
buttonArgs,
overrideGradeArgs,
stopGradingArgs,
} = hooks.buttonHooks({ dispatch, intl });
if (hide) {
return null;
}
return (
<>
<Button variant="primary" {...buttonArgs} />
<OverrideGradeConfirmModal {...overrideGradeArgs} />
<StopGradingConfirmModal {...stopGradingArgs} />
</>
);
};
StartGradingButton.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(StartGradingButton);

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { shallow } from 'enzyme';
import { useDispatch } from 'react-redux';
import { formatMessage } from 'testUtils';
import * as hooks from './hooks';
import { StartGradingButton } from '.';
jest.mock('../OverrideGradeConfirmModal', () => 'OverrideGradeConfirmModal');
jest.mock('../StopGradingConfirmModal', () => 'StopGradingConfirmModal');
jest.mock('./hooks', () => ({
buttonHooks: jest.fn(),
}));
const intl = { formatMessage };
let el;
describe('StartGradingButton component', () => {
describe('component', () => {
const dispatch = useDispatch();
const props = { intl };
const buttonHooks = {
hide: false,
buttonArgs: { props: 'hooks.buttonArgs' },
overrideGradeArgs: { props: 'hooks.overrideGradeArgs' },
stopGradingArgs: { props: 'hooks.stopGradingArgs' },
};
describe('behavior', () => {
it('initializes buttonHooks with dispatch and intl fields', () => {
hooks.buttonHooks.mockReturnValueOnce(buttonHooks);
el = shallow(<StartGradingButton {...props} />);
expect(hooks.buttonHooks).toHaveBeenCalledWith({ dispatch, intl });
});
});
describe('snapshots', () => {
test('hide: renders empty component if hook.hide is true', () => {
hooks.buttonHooks.mockReturnValueOnce({ ...buttonHooks, hide: true });
el = shallow(<StartGradingButton {...props} />);
expect(el).toMatchSnapshot();
expect(el.isEmptyRender()).toEqual(true);
});
test('smoke test: forwards props to components from hooks', () => {
hooks.buttonHooks.mockReturnValueOnce(buttonHooks);
el = shallow(<StartGradingButton {...props} />);
expect(el).toMatchSnapshot();
expect(el.isEmptyRender()).toEqual(false);
});
});
});
});

View File

@@ -0,0 +1,21 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
startGrading: {
id: 'ora-grading.ReviewActions.StartGradingButton.startGrading',
defaultMessage: 'Start grading',
description: 'Review pane button text to start grading',
},
overrideGrade: {
id: 'ora-grading.ReviewActions.StartGradingButton.overrideGrade',
defaultMessage: 'Override grade',
description: 'Review pane button text to start grading an already graded submission',
},
stopGrading: {
id: 'ora-grading.ReviewActions.StartGradingButton.stopGrading',
defaultMessage: 'Stop grading this response',
description: 'Review pane button text to stop grading',
},
});
export default messages;

View File

@@ -1,143 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StartGradingButton component component snapshotes snapshot: grade pending (disabled) 1`] = `
<React.Fragment>
<Button
disabled={true}
iconAfter={[MockFunction icons.Highlight]}
onClick={[MockFunction this.handleClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Start grading"
description="Review pane button text to start grading"
id="ora-grading.ReviewActions.StartGradingButton.startGrading"
/>
</Button>
<OverrideGradeConfirmModal
isOpen={false}
onCancel={[MockFunction this.hideConfirmOverrideGrade]}
onConfirm={[MockFunction this.confirmOverrideGrade]}
/>
<StopGradingConfirmModal
isOpen={false}
isOverride={false}
onCancel={[MockFunction this.hideConfirmStopGrading]}
onConfirm={[MockFunction this.confirmStopGrading]}
/>
</React.Fragment>
`;
exports[`StartGradingButton component component snapshotes snapshot: graded, confirmOverride (startGrading callback) 1`] = `
<React.Fragment>
<Button
disabled={false}
iconAfter={[MockFunction icons.Highlight]}
onClick={[MockFunction this.handleClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Override grade"
description="Review pane button text to start grading an already graded submission"
id="ora-grading.ReviewActions.StartGradingButton.overrideGrade"
/>
</Button>
<OverrideGradeConfirmModal
isOpen={true}
onCancel={[MockFunction this.hideConfirmOverrideGrade]}
onConfirm={[MockFunction this.confirmOverrideGrade]}
/>
<StopGradingConfirmModal
isOpen={false}
isOverride={true}
onCancel={[MockFunction this.hideConfirmStopGrading]}
onConfirm={[MockFunction this.confirmStopGrading]}
/>
</React.Fragment>
`;
exports[`StartGradingButton component component snapshotes snapshot: inProgress, isOverride, confirmStop (stopGrading callback) 1`] = `
<React.Fragment>
<Button
disabled={false}
iconAfter={[MockFunction icons.Cancel]}
onClick={[MockFunction this.handleClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Stop grading this response"
description="Review pane button text to stop grading"
id="ora-grading.ReviewActions.StartGradingButton.stopGrading"
/>
</Button>
<OverrideGradeConfirmModal
isOpen={false}
onCancel={[MockFunction this.hideConfirmOverrideGrade]}
onConfirm={[MockFunction this.confirmOverrideGrade]}
/>
<StopGradingConfirmModal
isOpen={true}
isOverride={true}
onCancel={[MockFunction this.hideConfirmStopGrading]}
onConfirm={[MockFunction this.confirmStopGrading]}
/>
</React.Fragment>
`;
exports[`StartGradingButton component component snapshotes snapshot: lock pending (disabled) 1`] = `
<React.Fragment>
<Button
disabled={true}
iconAfter={[MockFunction icons.Highlight]}
onClick={[MockFunction this.handleClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Start grading"
description="Review pane button text to start grading"
id="ora-grading.ReviewActions.StartGradingButton.startGrading"
/>
</Button>
<OverrideGradeConfirmModal
isOpen={false}
onCancel={[MockFunction this.hideConfirmOverrideGrade]}
onConfirm={[MockFunction this.confirmOverrideGrade]}
/>
<StopGradingConfirmModal
isOpen={false}
isOverride={false}
onCancel={[MockFunction this.hideConfirmStopGrading]}
onConfirm={[MockFunction this.confirmStopGrading]}
/>
</React.Fragment>
`;
exports[`StartGradingButton component component snapshotes snapshot: locked (null) 1`] = `null`;
exports[`StartGradingButton component component snapshotes snapshot: ungraded (startGrading callback) 1`] = `
<React.Fragment>
<Button
disabled={false}
iconAfter={[MockFunction icons.Highlight]}
onClick={[MockFunction this.handleClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Start grading"
description="Review pane button text to start grading"
id="ora-grading.ReviewActions.StartGradingButton.startGrading"
/>
</Button>
<OverrideGradeConfirmModal
isOpen={false}
onCancel={[MockFunction this.hideConfirmOverrideGrade]}
onConfirm={[MockFunction this.confirmOverrideGrade]}
/>
<StopGradingConfirmModal
isOpen={false}
isOverride={false}
onCancel={[MockFunction this.hideConfirmStopGrading]}
onConfirm={[MockFunction this.confirmStopGrading]}
/>
</React.Fragment>
`;

View File

@@ -16,7 +16,7 @@ import ReviewError from './ReviewError';
*/
export class LockErrors extends React.Component {
get errorProp() {
if (this.errorStatus === ErrorStatuses.forbidden) {
if (this.props.errorStatus === ErrorStatuses.forbidden) {
return {
heading: messages.errorLockContestedHeading,
message: messages.errorLockContested,

View File

@@ -1,92 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { actions, selectors, thunkActions } from 'data/redux';
import { RequestKeys, ErrorStatuses } from 'data/constants/requests';
import messages from './messages';
import ReviewError from './ReviewError';
/**
* <SubmitErrors />
*/
export class SubmitErrors extends React.Component {
constructor(props) {
super(props);
this.dismissError = this.dismissError.bind(this);
}
get gradeNotSubmitted() {
return {
confirm: { onClick: this.props.resubmit, message: messages.resubmitGrade },
headingMessage: messages.gradeNotSubmittedHeading,
contentMessage: messages.gradeNotSubmittedContent,
};
}
get errorSubmittingGrade() {
return {
headingMessage: messages.errorSubmittingGradeHeading,
contentMessage: messages.errorSubmittingGradeContent,
};
}
get errorProps() {
if (this.props.errorStatus === ErrorStatuses.badRequest) {
return this.gradeNotSubmitted;
}
if (this.props.errorStatus === ErrorStatuses.conflict) {
return this.errorSubmittingGrade;
}
// TODO: Network-Log an error here for unhandled error type
return this.gradeNotSubmitted;
}
dismissError() {
this.props.clearRequest({ requestKey: RequestKeys.submitGrade });
}
render() {
if (!this.props.errorStatus) {
return null;
}
const props = this.errorProps;
return (
<ReviewError
actions={{
cancel: { onClick: this.dismissError, message: messages.dismiss },
confirm: props.confirm,
}}
headingMessage={props.headingMessage}
>
<FormattedMessage {...props.contentMessage} />
</ReviewError>
);
}
}
SubmitErrors.defaultProps = {
errorStatus: undefined,
};
SubmitErrors.propTypes = {
// redux
clearRequest: PropTypes.func.isRequired,
errorStatus: PropTypes.number,
resubmit: PropTypes.func.isRequired,
};
const requestKey = RequestKeys.submitGrade;
export const mapStateToProps = (state) => ({
errorStatus: selectors.requests.errorStatus(state, { requestKey }),
});
export const mapDispatchToProps = {
clearRequest: actions.requests.clearRequest,
resubmit: thunkActions.grading.submitGrade,
};
export default connect(mapStateToProps, mapDispatchToProps)(SubmitErrors);

View File

@@ -1,79 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { actions, selectors, thunkActions } from 'data/redux';
import { ErrorStatuses, RequestKeys } from 'data/constants/requests';
import {
SubmitErrors,
mapStateToProps,
mapDispatchToProps,
} from './SubmitErrors';
jest.mock('data/redux', () => ({
actions: {
requests: {
clearRequest: jest.fn().mockName('actions.requests.clearRequest'),
},
},
selectors: {
requests: {
errorStatus: (...args) => ({ errorStatus: args }),
},
},
thunkActions: {
grading: {
submitGrade: jest.fn().mockName('thunkActions.grading.submitGrade'),
},
},
}));
let el;
jest.mock('./ReviewError', () => 'ReviewError');
const requestKey = RequestKeys.submitGrade;
describe('SubmitErrors component', () => {
const props = {};
describe('component', () => {
beforeEach(() => {
props.resubmit = jest.fn();
props.clearRequest = jest.fn();
el = shallow(<SubmitErrors {...props} />);
el.instance().dismissError = jest.fn().mockName('this.dismissError');
});
describe('snapshots', () => {
test('snapshot: no failure', () => {
expect(el.instance().render()).toMatchSnapshot();
});
test('snapshot: with network failure', () => {
el.setProps({ errorStatus: ErrorStatuses.badRequest });
expect(el.instance().render()).toMatchSnapshot();
});
test('snapshot: with conflict failure', () => {
el.setProps({ errorStatus: ErrorStatuses.conflict });
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = { some: 'test-state' };
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('errorStatus loads from requests.errorStatus(fetchSubmission)', () => {
expect(mapped.errorStatus).toEqual(
selectors.requests.errorStatus(testState, { requestKey }),
);
});
});
describe('mapDispatchToProps', () => {
it('loads clearRequest from actions.requests.clearRequest', () => {
expect(mapDispatchToProps.clearRequest).toEqual(actions.requests.clearRequest);
});
it('loads resubmit from thunkActions.grading.submitGrade', () => {
expect(mapDispatchToProps.resubmit).toEqual(thunkActions.grading.submitGrade);
});
});
});

View File

@@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SubmitErrors component snapshots snapshot: no failure 1`] = `""`;
exports[`SubmitErrors component snapshots snapshot: with valid error, loads from hook 1`] = `
<ReviewError
actions={
Object {
"cancel": "hooks.reviewActions.cancel",
"confirm": "hooks.reviewActions.confirm",
}
}
headingMessage="hooks.headingMessage"
>
hooks.content
</ReviewError>
`;

View File

@@ -0,0 +1,67 @@
import { useSelector } from 'react-redux';
import { actions, selectors, thunkActions } from 'data/redux';
import { RequestKeys, ErrorStatuses } from 'data/constants/requests';
import messages from './messages';
import * as module from './hooks';
const requestKey = RequestKeys.submitGrade;
export const badRequestError = ({ dispatch }) => ({
confirm: {
onClick: () => dispatch(thunkActions.grading.submitGrade()),
message: messages.resubmitGrade,
},
headingMessage: messages.gradeNotSubmittedHeading,
contentMessage: messages.gradeNotSubmittedContent,
});
export const conflictError = () => ({
headingMessage: messages.errorSubmittingGradeHeading,
contentMessage: messages.errorSubmittingGradeContent,
});
export const defaultError = module.badRequestError;
export const errorProps = ({
dispatch,
errorStatus,
}) => {
const errors = {
[ErrorStatuses.badRequest]: module.badRequestError({ dispatch }),
[ErrorStatuses.conflict]: module.conflictError({ dispatch }),
default: module.defaultError({ dispatch }),
};
// TODO: Network-Log an error here for unhandled error type
// if (errors[errorStatus] === undefined) { }
return errors[errorStatus] || errors.default;
};
export const errorStatusSelector = (state) => selectors.requests.errorStatus(state, { requestKey });
export const rendererHooks = ({
dispatch,
intl,
}) => {
const errorStatus = useSelector(module.errorStatusSelector);
if (!errorStatus) {
return { show: false };
}
const error = module.errorProps({ dispatch, errorStatus });
return {
show: true,
reviewActions: {
cancel: {
onClick: () => dispatch(actions.requests.clearRequest({ requestKey })),
message: messages.dismiss,
},
confirm: error.confirm,
},
headingMessage: error.headingMessage,
content: intl.formatMessage(error.contentMessage),
};
};

View File

@@ -0,0 +1,140 @@
import { useDispatch, useSelector } from 'react-redux';
import { keyStore } from 'utils';
import { formatMessage } from 'testUtils';
import { actions, selectors, thunkActions } from 'data/redux';
import { RequestKeys, ErrorStatuses } from 'data/constants/requests';
import messages from './messages';
import * as hooks from './hooks';
jest.mock('data/redux', () => ({
selectors: {
requests: {
errorStatus: (...args) => ({ errorStatus: args }),
},
},
actions: {
requests: {
clearRequest: (args) => ({ clearRequest: args }),
},
},
thunkActions: {
grading: {
submitGrade: jest.fn((args) => ({ submitGrade: args })),
},
},
}));
const hookKeys = keyStore(hooks);
const dispatch = useDispatch();
const intl = { formatMessage };
const testState = { my: 'test-state' };
const requestKey = RequestKeys.submitGrade;
let errorStatus;
let hook;
describe('Review Modal Submit Error hooks', () => {
beforeEach(jest.clearAllMocks);
describe('badRequestError', () => {
beforeEach(() => { hook = hooks.badRequestError({ dispatch }); });
it('returns messages from gradeNotSubmitted error messages', () => {
expect(hook.headingMessage).toEqual(messages.gradeNotSubmittedHeading);
expect(hook.contentMessage).toEqual(messages.gradeNotSubmittedContent);
});
test('onClick, dispatches thunkAction to submit grade', () => {
hook.confirm.onClick();
expect(dispatch).toHaveBeenCalledWith(thunkActions.grading.submitGrade());
});
it('provides a confirm resubmitGrade message', () => {
expect(hook.confirm.message).toEqual(messages.resubmitGrade);
});
});
describe('conflictError', () => {
beforeEach(() => { hook = hooks.conflictError({ dispatch }); });
it('returns messages from errorSubmittingGrade error messages', () => {
expect(hook.headingMessage).toEqual(messages.errorSubmittingGradeHeading);
expect(hook.contentMessage).toEqual(messages.errorSubmittingGradeContent);
});
it('does not provide an onClick event', () => {
expect(hook.onClick).toEqual(undefined);
});
});
test('defaultError returns badRequestError', () => {
expect(hooks.defaultError).toEqual(hooks.badRequestError);
});
describe('errorProps', () => {
const mockedError = (args) => ({ mockedError: args });
const mockError = (hookKey) => {
jest.spyOn(hooks, hookKey).mockImplementationOnce(mockedError);
};
test('on bad request, returns badRequestError', () => {
mockError(hookKeys.badRequestError);
expect(
hooks.errorProps({ dispatch, errorStatus: ErrorStatuses.badRequest }),
).toEqual(mockedError({ dispatch }));
});
test('on conflict, returns conflictError', () => {
mockError(hookKeys.conflictError);
expect(
hooks.errorProps({ dispatch, errorStatus: ErrorStatuses.conflict }),
).toEqual(mockedError({ dispatch }));
});
test('on unhandled error type, returns defaultError', () => {
mockError(hookKeys.defaultError);
expect(
hooks.errorProps({ dispatch, errorStatus: 'fake-status' }),
).toEqual(mockedError({ dispatch }));
});
});
describe('errorStatusSelector', () => {
it('returns the errorStatus of the submitGrade request', () => {
expect(hooks.errorStatusSelector(testState)).toEqual(
selectors.requests.errorStatus(testState, { requestKey }),
);
});
});
describe('rendererHooks', () => {
it('calls useSelector once on errorStatusSelector', () => {
hooks.rendererHooks({ dispatch, intl });
expect(useSelector.mock.calls).toEqual([[hooks.errorStatusSelector]]);
});
it('returns only a false show value if errorStatus is empty', () => {
useSelector.mockReturnValueOnce(false);
expect(hooks.rendererHooks({ dispatch, intl })).toEqual({ show: false });
});
describe('with valid error status', () => {
errorStatus = 'test-status';
const mockErrorProps = (args) => ({
confirm: { confirm: args },
headingMessag: { headingMessage: args },
contentMessage: { contentMessage: args },
});
const mockProps = mockErrorProps({ dispatch, errorStatus });
beforeEach(() => {
useSelector.mockReturnValueOnce(errorStatus);
jest.spyOn(hooks, hookKeys.errorProps).mockImplementationOnce(mockErrorProps);
hook = hooks.rendererHooks({ dispatch, intl });
});
describe('reviewActions', () => {
describe('cancel', () => {
test('onClick, dispatches action to clear submit grade action', () => {
hook.reviewActions.cancel.onClick();
expect(dispatch).toHaveBeenCalledWith(actions.requests.clearRequest({ requestKey }));
});
test('provides dismiss message', () => {
expect(hook.reviewActions.cancel.message).toEqual(messages.dismiss);
});
});
test('confirm forwards confirm action from errorProps', () => {
expect(hook.reviewActions.confirm).toEqual(mockProps.confirm);
});
});
test('loads headingMessage from errorProps', () => {
expect(hook.headingMessage).toEqual(mockProps.headingMessage);
});
test('formats contentMessage from errorProps', () => {
expect(hook.content).toEqual(formatMessage(mockProps.contentMessage));
});
});
});
});

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { rendererHooks } from './hooks';
import ReviewError from '../ReviewError';
/**
* <SubmitErrors />
*/
export const SubmitErrors = ({ intl }) => {
const dispatch = useDispatch();
const {
show,
reviewActions,
headingMessage,
content,
} = rendererHooks({ dispatch, intl });
if (!show) { return null; }
return (
<ReviewError
actions={reviewActions}
headingMessage={headingMessage}
>
{content}
</ReviewError>
);
};
SubmitErrors.propTypes = {
// injected
intl: intlShape.isRequired,
};
export default injectIntl(SubmitErrors);

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { shallow } from 'enzyme';
import { keyStore } from 'utils';
import { formatMessage } from 'testUtils';
import * as hooks from './hooks';
import { SubmitErrors } from '.';
jest.mock('../ReviewError', () => 'ReviewError');
const hookKeys = keyStore(hooks);
describe('SubmitErrors component', () => {
const props = { intl: { formatMessage } };
describe('snapshots', () => {
test('snapshot: no failure', () => {
jest.spyOn(hooks, hookKeys.rendererHooks).mockReturnValueOnce({ show: false });
const el = shallow(<SubmitErrors {...props} />);
expect(el).toMatchSnapshot();
expect(el.isEmptyRender()).toEqual(true);
});
test('snapshot: with valid error, loads from hook', () => {
const mockHook = {
show: true,
reviewActions: {
confirm: 'hooks.reviewActions.confirm',
cancel: 'hooks.reviewActions.cancel',
},
headingMessage: 'hooks.headingMessage',
content: 'hooks.content',
};
jest.spyOn(hooks, hookKeys.rendererHooks).mockReturnValueOnce(mockHook);
expect(shallow(<SubmitErrors {...props} />)).toMatchSnapshot();
});
});
});

View File

@@ -0,0 +1,38 @@
/* eslint-disable quotes */
import { defineMessages } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
const messages = defineMessages({
gradeNotSubmittedHeading: {
id: 'ora-grading.ReviewModal.gradeNotSubmitted.heading',
defaultMessage: 'Grade not submitted',
description: 'Grade submission network error heading',
},
gradeNotSubmittedContent: {
id: 'ora-grading.ReviewModal.gradeNotSubmitted.Content',
defaultMessage: "We're sorry, something went wrong when we tried to submit this grade. Please try again.",
description: 'Grade submission network error message',
},
resubmitGrade: {
id: 'ora-grading.ReviewModal.resubmitGrade',
defaultMessage: 'Resubmit grate',
description: 'Resubmit grade button after network failure',
},
dismiss: {
id: 'ora-grading.ReviewModal.dismiss',
defaultMessage: 'Dismiss',
description: 'Dismiss error action button text',
},
errorSubmittingGradeHeading: {
id: 'ora-grading.ReviewModal.errorSubmittingGrade.Heading',
defaultMessage: 'Error submitting grade',
description: 'Error Submitting Grade heading text',
},
errorSubmittingGradeContent: {
id: 'ora-grading.ReviewModal.errorSubmittingGrade.Content',
defaultMessage: 'It looks like someone else got here first! Your grade submission has been rejected',
description: 'Error Submitting Grade content',
},
});
export default StrictDict(messages);

View File

@@ -24,16 +24,16 @@ exports[`LockErrors component component snapshots snapshot: error with conflicte
<ReviewError
headingMessage={
Object {
"defaultMessage": "Invalid request. Please check your input.",
"description": "Error lock request for missing params",
"id": "ora-grading.ReviewModal.errorLockBadRequestHeading",
"defaultMessage": "The lock owned by another user",
"description": "Error lock by someone else",
"id": "ora-grading.ReviewModal.errorLockContestedHeading",
}
}
>
<FormattedMessage
defaultMessage="Invalid request. Please check your input."
description="Error lock request for missing params"
id="ora-grading.ReviewModal.errorLockBadRequest"
defaultMessage="The lock owned by another user"
description="Error lock by someone else"
id="ora-grading.ReviewModal.errorLockContested"
/>
</ReviewError>
`;

View File

@@ -1,72 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SubmitErrors component component snapshots snapshot: no failure 1`] = `null`;
exports[`SubmitErrors component component snapshots snapshot: with conflict failure 1`] = `
<ReviewError
actions={
Object {
"cancel": Object {
"message": Object {
"defaultMessage": "Dismiss",
"description": "Dismiss error action button text",
"id": "ora-grading.ReviewModal.dismiss",
},
"onClick": [MockFunction this.dismissError],
},
"confirm": undefined,
}
}
headingMessage={
Object {
"defaultMessage": "Error submitting grade",
"description": "Error Submitting Grade heading text",
"id": "ora-grading.ReviewModal.errorSubmittingGrade.Heading",
}
}
>
<FormattedMessage
defaultMessage="It looks like someone else got here first! Your grade submission has been rejected"
description="Error Submitting Grade content"
id="ora-grading.ReviewModal.errorSubmittingGrade.Content"
/>
</ReviewError>
`;
exports[`SubmitErrors component component snapshots snapshot: with network failure 1`] = `
<ReviewError
actions={
Object {
"cancel": Object {
"message": Object {
"defaultMessage": "Dismiss",
"description": "Dismiss error action button text",
"id": "ora-grading.ReviewModal.dismiss",
},
"onClick": [MockFunction this.dismissError],
},
"confirm": Object {
"message": Object {
"defaultMessage": "Resubmit grate",
"description": "Resubmit grade button after network failure",
"id": "ora-grading.ReviewModal.resubmitGrade",
},
"onClick": [MockFunction],
},
}
}
headingMessage={
Object {
"defaultMessage": "Grade not submitted",
"description": "Grade submission network error heading",
"id": "ora-grading.ReviewModal.gradeNotSubmitted.heading",
}
}
>
<FormattedMessage
defaultMessage="We're sorry, something went wrong when we tried to submit this grade. Please try again."
description="Grade submission network error message"
id="ora-grading.ReviewModal.gradeNotSubmitted.Content"
/>
</ReviewError>
`;

View File

@@ -11,45 +11,11 @@ exports[`ReviewModal component component snapshots closed 1`] = `
className="review-modal"
isOpen={false}
modalBodyClassName="review-modal-body"
onClose={[MockFunction this.onClose]}
onClose={[MockFunction hooks.onClose]}
title="test-ora-name"
>
<LoadingMessage
message={
Object {
"defaultMessage": "Loading response",
"description": "loading text for submission response review screen",
"id": "ora-grading.ReviewModal.loadingResponse",
}
}
/>
<CloseReviewConfirmModal
isOpen={false}
onCancel={[MockFunction this.hideConfirmCloseReviewGrade]}
onConfirm={[MockFunction this.confirmCloseReviewGrade]}
/>
</FullscreenModal>
`;
exports[`ReviewModal component component snapshots error 1`] = `
<FullscreenModal
beforeBodyNode={
<React.Fragment>
<ReviewActions />
<DemoWarning />
</React.Fragment>
}
className="review-modal"
isOpen={true}
modalBodyClassName="review-modal-body"
onClose={[MockFunction this.onClose]}
title="test-ora-name"
>
<ReviewContent />
<CloseReviewConfirmModal
isOpen={false}
onCancel={[MockFunction this.hideConfirmCloseReviewGrade]}
onConfirm={[MockFunction this.confirmCloseReviewGrade]}
prop="hooks.closeConfirmModalProps"
/>
</FullscreenModal>
`;
@@ -65,7 +31,7 @@ exports[`ReviewModal component component snapshots loading 1`] = `
className="review-modal"
isOpen={true}
modalBodyClassName="review-modal-body"
onClose={[MockFunction this.onClose]}
onClose={[MockFunction hooks.onClose]}
title="test-ora-name"
>
<ReviewContent />
@@ -79,9 +45,7 @@ exports[`ReviewModal component component snapshots loading 1`] = `
}
/>
<CloseReviewConfirmModal
isOpen={false}
onCancel={[MockFunction this.hideConfirmCloseReviewGrade]}
onConfirm={[MockFunction this.confirmCloseReviewGrade]}
prop="hooks.closeConfirmModalProps"
/>
</FullscreenModal>
`;
@@ -97,37 +61,12 @@ exports[`ReviewModal component component snapshots success 1`] = `
className="review-modal"
isOpen={true}
modalBodyClassName="review-modal-body"
onClose={[MockFunction this.onClose]}
onClose={[MockFunction hooks.onClose]}
title="test-ora-name"
>
<ReviewContent />
<CloseReviewConfirmModal
isOpen={false}
onCancel={[MockFunction this.hideConfirmCloseReviewGrade]}
onConfirm={[MockFunction this.confirmCloseReviewGrade]}
/>
</FullscreenModal>
`;
exports[`ReviewModal component component snapshots success, demo (title message) 1`] = `
<FullscreenModal
beforeBodyNode={
<React.Fragment>
<ReviewActions />
<DemoWarning />
</React.Fragment>
}
className="review-modal"
isOpen={true}
modalBodyClassName="review-modal-body"
onClose={[MockFunction this.onClose]}
title="test-ora-name - Grading Demo"
>
<ReviewContent />
<CloseReviewConfirmModal
isOpen={false}
onCancel={[MockFunction this.hideConfirmCloseReviewGrade]}
onConfirm={[MockFunction this.confirmCloseReviewGrade]}
prop="hooks.closeConfirmModalProps"
/>
</FullscreenModal>
`;

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { StrictDict } from 'utils';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import messages from './messages';
import * as module from './hooks';
export const state = StrictDict({
showConfirmCloseReviewGrade: (val) => React.useState(val),
});
export const reduxValues = () => ({
errorStatus: useSelector((val) => (
selectors.requests.errorStatus(val, { requestKey: RequestKeys.fetchSubmission })
)),
hasGradingProgress: useSelector(selectors.grading.hasGradingProgress),
isEnabled: useSelector(selectors.app.isEnabled),
isLoaded: useSelector((val) => (
selectors.requests.isCompleted(val, { requestKey: RequestKeys.fetchSubmission })
)),
isOpen: useSelector(selectors.app.showReview),
oraName: useSelector(selectors.app.ora.name),
});
export const rendererHooks = ({
dispatch,
intl: { formatMessage },
}) => {
const [show, setShow] = state.showConfirmCloseReviewGrade(false);
const {
errorStatus,
hasGradingProgress,
isEnabled,
isLoaded,
isOpen,
oraName,
} = module.reduxValues();
const onClose = () => {
if (hasGradingProgress) {
setShow(true);
} else {
dispatch(thunkActions.app.cancelReview());
}
};
return {
onClose,
isLoading: !(errorStatus || isLoaded),
title: isEnabled
? `${oraName} - ${formatMessage(messages.demoTitleMessage)}`
: oraName,
isOpen,
closeConfirmModalProps: {
isOpen: show,
onCancel: () => setShow(false),
onConfirm: () => {
setShow(false);
dispatch(thunkActions.app.cancelReview());
},
},
};
};

View File

@@ -0,0 +1,168 @@
import { useSelector } from 'react-redux';
import { keyStore } from 'utils';
import { MockUseState, formatMessage } from 'testUtils';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import messages from './messages';
import * as hooks from './hooks';
const state = new MockUseState(hooks);
jest.useFakeTimers('modern');
jest.mock('data/redux', () => ({
selectors: {
app: {
isEnabled: (args) => ({ isEnabled: args }),
ora: { name: (...args) => ({ oraName: args }) },
showReview: (...args) => ({ showReview: args }),
},
grading: {
hasGradingProgress: (args) => ({ hasGradingProgress: args }),
selected: {
response: (...args) => ({ selectedResponse: args }),
},
},
requests: {
isCompleted: (...args) => ({ isCompleted: args }),
errorStatus: (...args) => ({ errorStatus: args }),
},
},
actions: {
app: {
setShowReview: jest.fn(),
},
},
thunkActions: {
app: {
initialize: jest.fn(),
cancelReview: jest.fn(),
},
grading: {
cancelGrading: jest.fn(),
},
},
}));
const requestKey = RequestKeys.fetchSubmission;
const hookKeys = keyStore(hooks);
const testState = { my: 'test-state' };
const intl = { formatMessage };
const dispatch = jest.fn();
describe('ReviewModal hooks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('state hooks', () => {
state.testGetter(state.keys.showConfirmCloseReviewGrade);
});
describe('redux values', () => {
const values = hooks.reduxValues();
const reduxKeys = keyStore(values);
const testSelector = (key, selector) => {
expect(values[key]).toEqual(useSelector(selector));
};
const testRequestSelector = (key, selector) => {
expect(values[key].useSelector(testState)).toEqual(
selector(testState, { requestKey }),
);
};
test('errorStatus loads the error status of the fetchSubmission request', () => {
testRequestSelector(reduxKeys.errorStatus, selectors.requests.errorStatus);
});
test('hasGradingProgress loads grading.hasGradingProgress', () => {
testSelector(reduxKeys.hasGradingProgress, selectors.grading.hasGradingProgress);
});
test('isEnabled loads app.isEnabled', () => {
testSelector(reduxKeys.isEnabled, selectors.app.isEnabled);
});
test('isLoaded loads if fetchSubmission is complete', () => {
testRequestSelector(reduxKeys.isLoaded, selectors.requests.isCompleted);
});
test('isOpen loads app.showReview', () => {
testSelector(reduxKeys.isOpen, selectors.app.showReview);
});
test('oraName loads app.ora.name', () => {
testSelector(reduxKeys.oraName, selectors.app.ora.name);
});
test('hasGradingProgress loads grading.hasGradingProgress', () => {
testSelector(reduxKeys.hasGradingProgress, selectors.grading.hasGradingProgress);
});
});
describe('non-state hooks', () => {
beforeEach(state.mock);
afterEach(state.restore);
const reduxValues = {
errorStatus: null,
hasGradingProgress: false,
isEnabled: false,
isLoaded: false,
isOpen: false,
oraName: 'ora-NAAAAMME',
};
const mockRedux = (newVals) => {
jest.spyOn(hooks, hookKeys.reduxValues).mockReturnValueOnce({
...reduxValues,
...newVals,
});
};
const loadHook = (newVals) => {
mockRedux(newVals);
return hooks.rendererHooks({ dispatch, intl });
};
describe('rendererHooks - returned object:', () => {
let hook;
describe('onClose', () => {
it('sets showConfirmCloseReviewGrade to true if hasGradingProgress', () => {
hook = loadHook({ hasGradingProgress: true });
hook.onClose();
expect(state.setState.showConfirmCloseReviewGrade).toHaveBeenCalledWith(true);
});
it('cancels review if there is no grading progress', () => {
hook = loadHook({});
hook.onClose();
expect(dispatch).toHaveBeenCalledWith(thunkActions.app.cancelReview());
});
});
test('isLoading returns true iff is not loaded, or if there is an error status', () => {
hook = loadHook({});
expect(hook.isLoading).toEqual(true);
hook = loadHook({ errorStatus: 'some-status' });
expect(hook.isLoading).toEqual(false);
hook = loadHook({ isLoaded: true });
expect(hook.isLoading).toEqual(false);
});
test('title is ora name, with appended demo title message if isEnabled', () => {
hook = loadHook({});
expect(hook.title).toEqual(reduxValues.oraName);
hook = loadHook({ isEnabled: true });
expect(hook.title).toEqual(
[reduxValues.oraName, formatMessage(messages.demoTitleMessage)].join(' - '),
);
});
test('isOpen is loaded from redux value, defaulted to false', () => {
expect(loadHook({}).isOpen).toEqual(false);
expect(loadHook({ isOpen: true }).isOpen).toEqual(true);
});
describe('closeConfirmModalProps', () => {
test('loads isOpen from showConfirmCloseReviewGrade state', () => {
expect(loadHook({}).closeConfirmModalProps.isOpen).toEqual(false);
expect(state.stateVals.showConfirmCloseReviewGrade).toEqual(false);
state.mockVal(state.keys.showConfirmCloseReviewGrade, true);
expect(loadHook({}).closeConfirmModalProps.isOpen).toEqual(true);
});
test('onCancel - sets showConfirmCloseReviewGrade to false', () => {
loadHook({}).closeConfirmModalProps.onCancel();
expect(state.setState.showConfirmCloseReviewGrade).toHaveBeenCalledWith(false);
});
test('onConfirm - sets showConfirmCloseReviewGrade to false and cancels review', () => {
loadHook({}).closeConfirmModalProps.onConfirm();
expect(state.setState.showConfirmCloseReviewGrade).toHaveBeenCalledWith(false);
expect(dispatch).toHaveBeenCalledWith(thunkActions.app.cancelReview());
});
});
});
});
});

View File

@@ -1,13 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { useDispatch } from 'react-redux';
import { FullscreenModal } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import LoadingMessage from 'components/LoadingMessage';
import DemoWarning from 'containers/DemoWarning';
import ReviewActions from 'containers/ReviewActions';
@@ -15,121 +11,46 @@ import ReviewContent from './ReviewContent';
import CloseReviewConfirmModal from './components/CloseReviewConfirmModal';
import messages from './messages';
import * as hooks from './hooks';
import './ReviewModal.scss';
/**
* <ReviewModal />
*/
export class ReviewModal extends React.Component {
constructor(props) {
super(props);
this.state = { showConfirmCloseReviewGrade: false };
this.closeModal = this.closeModal.bind(this);
this.confirmCloseReviewGrade = this.confirmCloseReviewGrade.bind(this);
this.onClose = this.onClose.bind(this);
this.hideConfirmCloseReviewGrade = this.hideConfirmCloseReviewGrade.bind(this);
this.showConfirmCloseReviewGrade = this.showConfirmCloseReviewGrade.bind(this);
}
onClose() {
if (this.props.hasGradingProgress) {
this.showConfirmCloseReviewGrade();
} else {
this.closeModal();
}
}
get isLoading() {
return !(this.props.errorStatus || this.props.isLoaded);
}
get title() {
let title = this.props.oraName;
if (!this.props.isEnabled) {
title = `${title} - ${this.props.intl.formatMessage(messages.demoTitleMessage)}`;
}
return title;
}
closeModal() {
this.props.cancelReview();
}
showConfirmCloseReviewGrade() {
this.setState({ showConfirmCloseReviewGrade: true });
}
hideConfirmCloseReviewGrade() {
this.setState({ showConfirmCloseReviewGrade: false });
}
confirmCloseReviewGrade() {
this.hideConfirmCloseReviewGrade();
this.closeModal();
}
render() {
const { isOpen, isLoaded, errorStatus } = this.props;
return (
<FullscreenModal
title={this.title}
isOpen={isOpen}
beforeBodyNode={(
<>
<ReviewActions />
<DemoWarning />
</>
)}
onClose={this.onClose}
className="review-modal"
modalBodyClassName="review-modal-body"
>
{isOpen && <ReviewContent />}
{/* even if the modal is closed, in case we want to add transitions later */}
{!(isLoaded || errorStatus) && <LoadingMessage message={messages.loadingResponse} />}
<CloseReviewConfirmModal
isOpen={this.state.showConfirmCloseReviewGrade}
onCancel={this.hideConfirmCloseReviewGrade}
onConfirm={this.confirmCloseReviewGrade}
/>
</FullscreenModal>
);
}
}
ReviewModal.defaultProps = {
errorStatus: null,
response: null,
export const ReviewModal = ({ intl }) => {
const dispatch = useDispatch();
const {
isLoading,
title,
onClose,
isOpen,
closeConfirmModalProps,
} = hooks.rendererHooks({ dispatch, intl });
return (
<FullscreenModal
title={title}
isOpen={isOpen}
beforeBodyNode={(
<>
<ReviewActions />
<DemoWarning />
</>
)}
onClose={onClose}
className="review-modal"
modalBodyClassName="review-modal-body"
>
{isOpen && <ReviewContent />}
{/* even if the modal is closed, in case we want to add transitions later */}
{isLoading && <LoadingMessage message={messages.loadingResponse} />}
<CloseReviewConfirmModal {...closeConfirmModalProps} />
</FullscreenModal>
);
};
ReviewModal.propTypes = {
// injected
intl: intlShape.isRequired,
// redux
cancelReview: PropTypes.func.isRequired,
errorStatus: PropTypes.number,
hasGradingProgress: PropTypes.bool.isRequired,
isEnabled: PropTypes.bool.isRequired,
isLoaded: PropTypes.bool.isRequired,
isOpen: PropTypes.bool.isRequired,
oraName: PropTypes.string.isRequired,
response: PropTypes.shape({
text: PropTypes.node,
}),
};
export const mapStateToProps = (state) => ({
errorStatus: selectors.requests.errorStatus(state, { requestKey: RequestKeys.fetchSubmission }),
hasGradingProgress: selectors.grading.hasGradingProgress(state),
isEnabled: selectors.app.isEnabled(state),
isLoaded: selectors.requests.isCompleted(state, { requestKey: RequestKeys.fetchSubmission }),
isOpen: selectors.app.showReview(state),
oraName: selectors.app.ora.name(state),
response: selectors.grading.selected.response(state),
});
export const mapDispatchToProps = {
cancelReview: thunkActions.app.cancelReview,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ReviewModal));
export default injectIntl(ReviewModal);

View File

@@ -1,184 +1,58 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import {
ReviewModal,
mapStateToProps,
mapDispatchToProps,
} from '.';
import * as hooks from './hooks';
import { ReviewModal } from '.';
jest.useFakeTimers('modern');
jest.mock('data/redux', () => ({
selectors: {
app: {
isEnabled: (args) => ({ isEnabled: args }),
ora: { name: (...args) => ({ oraName: args }) },
showReview: (...args) => ({ showReview: args }),
},
grading: {
hasGradingProgress: (args) => ({ hasGradingProgress: args }),
selected: {
response: (...args) => ({ selectedResponse: args }),
},
},
requests: {
isCompleted: (...args) => ({ isCompleted: args }),
errorStatus: (...args) => ({ errorStatus: args }),
},
},
actions: {
app: {
setShowReview: jest.fn(),
},
},
thunkActions: {
app: {
initialize: jest.fn(),
},
grading: {
cancelGrading: jest.fn(),
},
},
}));
jest.mock('components/LoadingMessage', () => 'LoadingMessage');
jest.mock('containers/DemoWarning', () => 'DemoWarning');
jest.mock('containers/ReviewActions', () => 'ReviewActions');
jest.mock('./ReviewContent', () => 'ReviewContent');
jest.mock('./components/CloseReviewConfirmModal', () => 'CloseReviewConfirmModal');
const requestKey = RequestKeys.fetchSubmission;
jest.mock('./hooks', () => ({
rendererHooks: jest.fn(),
}));
const dispatch = useDispatch();
describe('ReviewModal component', () => {
let el;
const props = {
intl: { formatMessage },
hasGradingProgress: false,
errorStatus: null,
isEnabled: true,
isLoaded: false,
const hookProps = {
isLoading: false,
title: 'test-ora-name',
onClose: jest.fn().mockName('hooks.onClose'),
isOpen: false,
oraName: 'test-ora-name',
response: { text: (<div>some text</div>) },
showRubric: false,
closeConfirmModalProps: {
prop: 'hooks.closeConfirmModalProps',
},
};
const render = (newVals) => {
hooks.rendererHooks.mockReturnValueOnce({ ...hookProps, ...newVals });
return shallow(<ReviewModal intl={{ formatMessage }} />);
};
beforeEach(() => {
props.setShowReview = jest.fn();
props.stopGrading = jest.fn();
props.reloadSubmissions = jest.fn();
props.cancelReview = jest.fn();
});
describe('component', () => {
describe('snapshots', () => {
let render;
beforeEach(() => {
el = shallow(<ReviewModal {...props} />);
el.instance().onClose = jest.fn().mockName('this.onClose');
el.instance().closeModal = jest.fn().mockName('this.closeModal');
el.instance().showConfirmCloseReviewGrade = jest.fn().mockName('this.showConfirmCloseReviewGrade');
el.instance().hideConfirmCloseReviewGrade = jest.fn().mockName('this.hideConfirmCloseReviewGrade');
el.instance().confirmCloseReviewGrade = jest.fn().mockName('this.confirmCloseReviewGrade');
render = () => el.instance().render();
});
test('closed', () => {
expect(render()).toMatchSnapshot();
});
test('loading', () => {
el.setProps({ isOpen: true });
expect(render()).toMatchSnapshot();
});
test('error', () => {
el.setProps({ isOpen: true, errorStatus: 200 });
expect(render()).toMatchSnapshot();
expect(render({ isOpen: true, isLoading: true })).toMatchSnapshot();
});
test('success', () => {
el.setProps({ isOpen: true, isLoaded: true });
expect(render()).toMatchSnapshot();
});
test('success, demo (title message)', () => {
const oldEnv = process.env;
el.setProps({ isOpen: true, isLoaded: true, isEnabled: false });
expect(render()).toMatchSnapshot();
process.env = oldEnv;
});
});
describe('component', () => {
describe('onClose', () => {
test('no grading progress - close modal', () => {
el = shallow(<ReviewModal {...props} />);
el.instance().closeModal = jest.fn();
el.instance().onClose();
expect(el.state().showConfirmCloseReviewGrade).toBe(false);
expect(el.instance().closeModal).toHaveBeenCalled();
});
describe('is grading', () => {
beforeEach(() => {
el = shallow(<ReviewModal {...props} hasGradingProgress />);
el.instance().closeModal = jest.fn();
});
test('show modal', () => {
el.instance().onClose();
expect(el.state().showConfirmCloseReviewGrade).toBe(true);
});
test('cancel closing then just close confirm do nothing else', () => {
el.instance().onClose();
el.instance().hideConfirmCloseReviewGrade();
expect(el.state().showConfirmCloseReviewGrade).toBe(false);
expect(el.instance().closeModal).not.toHaveBeenCalled();
});
test('confirm closing then stop grading and close the modal', () => {
el.instance().onClose();
el.instance().confirmCloseReviewGrade();
expect(el.state().showConfirmCloseReviewGrade).toBe(false);
expect(el.instance().closeModal).toHaveBeenCalled();
});
});
expect(render({ isOpen: true })).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = { some: 'test-state' };
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('errorStatus loads from requests.errorStatus(fetchSubmission)', () => {
expect(mapped.errorStatus).toEqual(selectors.requests.errorStatus(testState, { requestKey }));
});
test('hasGradingProgress loads from grading.hasGradingProgress', () => {
expect(mapped.hasGradingProgress).toEqual(
selectors.grading.hasGradingProgress(testState),
);
});
test('oraName loads from app.ora.name', () => {
expect(mapped.oraName).toEqual(selectors.app.ora.name(testState));
});
test('isOpen loads from app.showReview', () => {
expect(mapped.isOpen).toEqual(selectors.app.showReview(testState));
});
test('response loads from grading.selected.response', () => {
expect(mapped.response).toEqual(selectors.grading.selected.response(testState));
});
test('isEnabled loads from app.isEnabled', () => {
expect(mapped.isEnabled).toEqual(selectors.app.isEnabled(testState));
});
test('isLoaded loads from requests.isCompleted(fetchSubmission)', () => {
expect(mapped.isLoaded).toEqual(selectors.requests.isCompleted(testState, { requestKey }));
});
});
describe('mapDispatchToProps', () => {
it('loads cancelReview from thunkActions.app.cancelReview', () => {
expect(mapDispatchToProps.cancelReview).toEqual(thunkActions.app.cancelReview);
describe('behavior', () => {
it('initializes renderer hook with dispatch and intl props', () => {
render();
expect(hooks.rendererHooks).toHaveBeenCalledWith({ dispatch, intl: { formatMessage } });
});
});
});

View File

@@ -46,7 +46,7 @@
.grading-rubric-card {
width: 320px;
width: 320px !important;
height: fit-content;
max-height: 100%;
margin-left: map-get($spacers, 3);

View File

@@ -1,11 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Rubric Container snapshot is grading 1`] = `
<React.Fragment>
exports[`Rubric Container shapshot: hide footer 1`] = `
<Fragment>
<Card
className="grading-rubric-card"
>
<Card.Body
<Card.Section
className="grading-rubric-body"
>
<h3>
@@ -14,20 +14,61 @@ exports[`Rubric Container snapshot is grading 1`] = `
<hr
className="m-2.5"
/>
// get this.criteria()
<CriterionContainer
key="1"
prop="hook-criteria-props-1"
/>
<CriterionContainer
key="2"
prop="hook-criteria-props-2"
/>
<CriterionContainer
key="3"
prop="hook-criteria-props-3"
/>
<hr />
<RubricFeedback />
</Card.Body>
</Card.Section>
</Card>
<DemoAlert
prop="demo-alert-props"
/>
</Fragment>
`;
exports[`Rubric Container snapshot: show footer 1`] = `
<Fragment>
<Card
className="grading-rubric-card"
>
<Card.Section
className="grading-rubric-body"
>
<h3>
Rubric
</h3>
<hr
className="m-2.5"
/>
<CriterionContainer
key="1"
prop="hook-criteria-props-1"
/>
<CriterionContainer
key="2"
prop="hook-criteria-props-2"
/>
<CriterionContainer
key="3"
prop="hook-criteria-props-3"
/>
<hr />
<RubricFeedback />
</Card.Section>
<div
className="grading-rubric-footer"
>
<StatefulButton
disabledStates={
Array [
"pending",
"complete",
]
}
labels={
Object {
"complete": "Grade Submitted",
@@ -35,181 +76,12 @@ exports[`Rubric Container snapshot is grading 1`] = `
"pending": "Submitting grade",
}
}
onClick={[MockFunction this.submitGradeHandler]}
state="default"
prop="hook-button-props"
/>
</div>
</Card>
<DemoAlert
isOpen={false}
onClose={[MockFunction this.hideDemoAlert]}
prop="demo-alert-props"
/>
</React.Fragment>
`;
exports[`Rubric Container snapshot is grading, lock is pending 1`] = `
<React.Fragment>
<Card
className="grading-rubric-card"
>
<Card.Body
className="grading-rubric-body"
>
<h3>
Rubric
</h3>
<hr
className="m-2.5"
/>
// get this.criteria()
<hr />
<RubricFeedback />
</Card.Body>
<div
className="grading-rubric-footer"
>
<StatefulButton
disabledStates={
Array [
"pending",
"complete",
]
}
labels={
Object {
"complete": "Grade Submitted",
"default": "Submit grade",
"pending": "Submitting grade",
}
}
onClick={[MockFunction this.submitGradeHandler]}
state="pending"
/>
</div>
</Card>
<DemoAlert
isOpen={false}
onClose={[MockFunction this.hideDemoAlert]}
/>
</React.Fragment>
`;
exports[`Rubric Container snapshot is grading, submit pending 1`] = `
<React.Fragment>
<Card
className="grading-rubric-card"
>
<Card.Body
className="grading-rubric-body"
>
<h3>
Rubric
</h3>
<hr
className="m-2.5"
/>
// get this.criteria()
<hr />
<RubricFeedback />
</Card.Body>
<div
className="grading-rubric-footer"
>
<StatefulButton
disabledStates={
Array [
"pending",
"complete",
]
}
labels={
Object {
"complete": "Grade Submitted",
"default": "Submit grade",
"pending": "Submitting grade",
}
}
onClick={[MockFunction this.submitGradeHandler]}
state="pending"
/>
</div>
</Card>
<DemoAlert
isOpen={false}
onClose={[MockFunction this.hideDemoAlert]}
/>
</React.Fragment>
`;
exports[`Rubric Container snapshot is not grading 1`] = `
<React.Fragment>
<Card
className="grading-rubric-card"
>
<Card.Body
className="grading-rubric-body"
>
<h3>
Rubric
</h3>
<hr
className="m-2.5"
/>
// get this.criteria()
<hr />
<RubricFeedback />
</Card.Body>
</Card>
<DemoAlert
isOpen={false}
onClose={[MockFunction this.hideDemoAlert]}
/>
</React.Fragment>
`;
exports[`Rubric Container snapshot submit completed 1`] = `
<React.Fragment>
<Card
className="grading-rubric-card"
>
<Card.Body
className="grading-rubric-body"
>
<h3>
Rubric
</h3>
<hr
className="m-2.5"
/>
// get this.criteria()
<hr />
<RubricFeedback />
</Card.Body>
<div
className="grading-rubric-footer"
>
<StatefulButton
disabledStates={
Array [
"pending",
"complete",
]
}
labels={
Object {
"complete": "Grade Submitted",
"default": "Submit grade",
"pending": "Submitting grade",
}
}
onClick={[MockFunction this.submitGradeHandler]}
state="complete"
/>
</div>
</Card>
<DemoAlert
isOpen={false}
onClose={[MockFunction this.hideDemoAlert]}
/>
</React.Fragment>
</Fragment>
`;

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { StrictDict } from 'utils';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import * as module from './hooks';
export const ButtonStates = StrictDict({
default: 'default',
pending: 'pending',
complete: 'complete',
error: 'error',
});
export const state = {
showDemoAlert: (val) => React.useState(val),
};
export const reduxValues = () => ({
criteriaIndices: useSelector(selectors.app.rubric.criteriaIndices),
gradeIsPending: useSelector(
val => selectors.requests.isPending(val, { requestKey: RequestKeys.submitGrade }),
),
isCompleted: useSelector(
val => selectors.requests.isCompleted(val, { requestKey: RequestKeys.submitGrade }),
),
isEnabled: useSelector(selectors.app.isEnabled),
isGrading: useSelector(selectors.grading.selected.isGrading),
lockIsPending: useSelector(
val => selectors.requests.isPending(val, { requestKey: RequestKeys.setLock }),
),
});
export const rendererHooks = ({
dispatch,
}) => {
const [showDemoAlert, setShowDemoAlert] = module.state.showDemoAlert(false);
const {
criteriaIndices,
gradeIsPending,
isCompleted,
isEnabled,
isGrading,
lockIsPending,
} = module.reduxValues();
const isPending = (gradeIsPending || lockIsPending);
let submitButtonState;
if (isCompleted) {
submitButtonState = ButtonStates.complete;
} else if (isPending) {
submitButtonState = ButtonStates.pending;
} else {
submitButtonState = ButtonStates.default;
}
const criteria = criteriaIndices.map((index) => ({
isGrading,
key: index,
orderNum: index,
}));
return {
criteria,
showFooter: isGrading || isCompleted,
buttonProps: {
onClick: () => (
!isEnabled
? setShowDemoAlert(true)
: dispatch(thunkActions.grading.submitGrade())
),
state: submitButtonState,
disabledStates: [ButtonStates.pending, ButtonStates.complete],
},
demoAlertProps: {
isOpen: showDemoAlert,
onClose: () => setShowDemoAlert(false),
},
};
};

View File

@@ -0,0 +1,138 @@
import { useSelector } from 'react-redux';
import { keyStore } from 'utils';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import { MockUseState } from 'testUtils';
import * as hooks from './hooks';
jest.mock('data/redux', () => ({
selectors: {
app: {
rubric: { criteriaIndices: (args) => ({ criteriaIndices: args }) },
},
grading: {
selected: { isGrading: (args) => ({ isGrading: args }) },
},
requests: {
isCompleted: (...args) => ({ isCompleted: args }),
isPending: (...args) => ({ isPending: args }),
},
},
thunkActions: {
grading: {
submitGrade: (args) => ({ submitGrade: args }),
},
},
}));
const state = new MockUseState(hooks);
const hookKeys = keyStore(hooks);
const testState = { my: 'test-state' };
const dispatch = jest.fn();
let hook;
describe('Rubric hooks', () => {
beforeEach(jest.clearAllMocks);
describe('state hooks', () => {
state.testGetter(state.keys.showDemoAlert);
});
describe('non-state hooks', () => {
beforeEach(state.mock);
afterEach(state.restore);
describe('redux values', () => {
beforeEach(() => { hook = hooks.reduxValues(); });
test('loads gradeIsPending from isPending requests selectror on submitGrade request', () => {
expect(hook.gradeIsPending.useSelector(testState)).toEqual(
selectors.requests.isPending(testState, { requestKey: RequestKeys.submitGrade }),
);
});
test('loads criteriaIndices from rubric selector', () => {
expect(hook.criteriaIndices).toEqual(useSelector(selectors.app.rubric.criteriaIndices));
});
test('loads isCompleted from requests selectror on submitGrade request', () => {
expect(hook.isCompleted.useSelector(testState)).toEqual(
selectors.requests.isCompleted(testState, { requestKey: RequestKeys.submitGrade }),
);
});
test('loads isEnabled from app selector', () => {
expect(hook.isEnabled).toEqual(useSelector(selectors.app.isEnabled));
});
test('loads isGrading from grading selector for selected submission', () => {
expect(hook.isGrading).toEqual(useSelector(selectors.grading.selected.isGrading));
});
test('loads lockIsPending from isPending requests selectror on setLock request', () => {
expect(hook.lockIsPending.useSelector(testState)).toEqual(
selectors.requests.isPending(testState, { requestKey: RequestKeys.setLock }),
);
});
});
describe('rendererHooks', () => {
const reduxValues = {
criteriaIndices: [0, 1, 2, 3, 4],
isGrading: false,
isCompleted: false,
gradeIsPending: false,
lockIsPending: false,
isEnabled: true,
};
const mockHook = (values) => {
jest.spyOn(hooks, hookKeys.reduxValues).mockReturnValueOnce({
...reduxValues,
...values,
});
hook = hooks.rendererHooks({ dispatch });
};
describe('criteria', () => {
it('maps criteria indices from redux to an object with isGrading value', () => {
const testIsGrading = 'test-is-grading';
mockHook({ isGrading: testIsGrading });
expect(hook.criteria).toEqual(reduxValues.criteriaIndices.map(index => ({
isGrading: testIsGrading,
orderNum: index,
key: index,
})));
});
});
it('shows footer is grading or completed', () => {
mockHook({});
expect(hook.showFooter).toEqual(false);
mockHook({ isGrading: true });
expect(hook.showFooter).toEqual(true);
mockHook({ isCompleted: true });
expect(hook.showFooter).toEqual(true);
});
describe('buttonProps', () => {
describe('onClick', () => {
it('shows demo alert if app is not enabled', () => {
mockHook({ isEnabled: false });
hook.buttonProps.onClick();
expect(state.setState.showDemoAlert).toHaveBeenCalledWith(true);
});
it('submits grade if app is enabled', () => {
mockHook({});
hook.buttonProps.onClick();
expect(dispatch).toHaveBeenCalledWith(thunkActions.grading.submitGrade());
});
});
});
describe('demoAlertProps', () => {
test('open linked to showDemoAlert state', () => {
mockHook({});
expect(hook.demoAlertProps.isOpen).toEqual(state.stateVals.showDemoAlert);
expect(hook.demoAlertProps.isOpen).toEqual(false);
state.mockVal(state.keys.showDemoAlert, true);
mockHook({});
expect(hook.demoAlertProps.isOpen).toEqual(true);
});
test('on close, hides demo alert', () => {
mockHook({});
hook.demoAlertProps.onClose();
expect(state.setState.showDemoAlert).toHaveBeenCalledWith(false);
});
});
});
});
});

View File

@@ -1,128 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { useDispatch } from 'react-redux';
import { Card, StatefulButton } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { StrictDict } from 'utils';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import DemoAlert from 'components/DemoAlert';
import CriterionContainer from 'containers/CriterionContainer';
import RubricFeedback from './RubricFeedback';
import * as hooks from './hooks';
import messages from './messages';
import './Rubric.scss';
const ButtonStates = StrictDict({
default: 'default',
pending: 'pending',
complete: 'complete',
error: 'error',
});
const { ButtonStates } = hooks;
/**
* <Rubric />
*/
export class Rubric extends React.Component {
constructor(props) {
super(props);
this.state = { showDemoAlert: false };
this.submitGradeHandler = this.submitGradeHandler.bind(this);
this.hideDemoAlert = this.hideDemoAlert.bind(this);
}
get submitButtonState() {
if (this.props.gradeIsPending || this.props.lockIsPending) {
return ButtonStates.pending;
}
if (this.props.isCompleted) {
return ButtonStates.complete;
}
return ButtonStates.default;
}
get criteria() {
return this.props.criteriaIndices.map((index) => (
<CriterionContainer
isGrading={this.props.isGrading}
key={index}
orderNum={index}
/>
));
}
submitGradeHandler() {
if (process.env.REACT_APP_NOT_ENABLED) {
this.setState({ showDemoAlert: true });
} else {
this.props.submitGrade();
}
}
hideDemoAlert() {
this.setState({ showDemoAlert: false });
}
render() {
const { isGrading, intl: { formatMessage } } = this.props;
return (
<>
<Card className="grading-rubric-card">
<Card.Body className="grading-rubric-body">
<h3>{formatMessage(messages.rubric)}</h3>
<hr className="m-2.5" />
{this.criteria}
<hr />
<RubricFeedback />
</Card.Body>
{(isGrading || this.props.isCompleted) && (
<div className="grading-rubric-footer">
<StatefulButton
onClick={this.submitGradeHandler}
state={this.submitButtonState}
disabledStates={[ButtonStates.pending, ButtonStates.complete]}
labels={{
[ButtonStates.default]: formatMessage(messages.submitGrade),
[ButtonStates.pending]: formatMessage(messages.submittingGrade),
[ButtonStates.complete]: formatMessage(messages.gradeSubmitted),
}}
/>
</div>
)}
</Card>
<DemoAlert isOpen={this.state.showDemoAlert} onClose={this.hideDemoAlert} />
</>
);
}
}
Rubric.defaultProps = {
criteriaIndices: [],
export const Rubric = ({ intl }) => {
const dispatch = useDispatch();
const {
criteria,
showFooter,
buttonProps,
demoAlertProps,
} = hooks.rendererHooks({ dispatch });
return (
<>
<Card className="grading-rubric-card">
<Card.Section className="grading-rubric-body">
<h3>{intl.formatMessage(messages.rubric)}</h3>
<hr className="m-2.5" />
{criteria.map(props => <CriterionContainer {...props} />)}
<hr />
<RubricFeedback />
</Card.Section>
{showFooter && (
<div className="grading-rubric-footer">
<StatefulButton
{...buttonProps}
labels={{
[ButtonStates.default]: intl.formatMessage(messages.submitGrade),
[ButtonStates.pending]: intl.formatMessage(messages.submittingGrade),
[ButtonStates.complete]: intl.formatMessage(messages.gradeSubmitted),
}}
/>
</div>
)}
</Card>
<DemoAlert {...demoAlertProps} />
</>
);
};
Rubric.propTypes = {
// injected
intl: intlShape.isRequired,
// redux
isCompleted: PropTypes.bool.isRequired,
isGrading: PropTypes.bool.isRequired,
gradeIsPending: PropTypes.bool.isRequired,
lockIsPending: PropTypes.bool.isRequired,
criteriaIndices: PropTypes.arrayOf(PropTypes.number),
submitGrade: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
isCompleted: selectors.requests.isCompleted(state, { requestKey: RequestKeys.submitGrade }),
isGrading: selectors.grading.selected.isGrading(state),
gradeIsPending: selectors.requests.isPending(state, { requestKey: RequestKeys.submitGrade }),
lockIsPending: selectors.requests.isPending(state, { requestKey: RequestKeys.setLock }),
criteriaIndices: selectors.app.rubric.criteriaIndices(state),
});
export const mapDispatchToProps = {
submitGrade: thunkActions.grading.submitGrade,
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Rubric));
export default injectIntl(Rubric);

View File

@@ -2,155 +2,38 @@ import React from 'react';
import { shallow } from 'enzyme';
import { formatMessage } from 'testUtils';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import CriterionContainer from 'containers/CriterionContainer';
import { Rubric, mapStateToProps, mapDispatchToProps } from '.';
import * as hooks from './hooks';
import { Rubric } from '.';
jest.mock('containers/CriterionContainer', () => 'CriterionContainer');
jest.mock('./RubricFeedback', () => 'RubricFeedback');
jest.mock('components/DemoAlert', () => 'DemoAlert');
jest.mock('data/redux', () => ({
selectors: {
app: {
rubric: {
criteriaIndices: jest.fn((...args) => ({
rubricCriteriaIndices: args,
})),
},
},
grading: {
selected: {
isGrading: jest.fn((...args) => ({ isGrading: args })),
},
},
requests: {
isCompleted: jest.fn((...args) => ({ isCompleted: args })),
isPending: jest.fn((...args) => ({ isPending: args })),
},
},
thunkActions: {
grading: {
submitGrade: jest.fn(),
},
},
jest.mock('./hooks', () => ({
rendererHooks: jest.fn(),
ButtonStates: jest.requireActual('./hooks').ButtonStates,
}));
describe('Rubric Container', () => {
const props = {
intl: { formatMessage },
isCompleted: false,
gradeIsPending: false,
lockIsPending: false,
isGrading: true,
criteriaIndices: [1, 2, 3, 4, 5],
submitGrade: jest.fn().mockName('this.props.submitGrade'),
};
let el;
beforeEach(() => {
el = shallow(<Rubric {...props} />);
el.instance().submitGradeHandler = jest
.fn()
.mockName('this.submitGradeHandler');
const hookProps = {
criteria: [
{ prop: 'hook-criteria-props-1', key: 1 },
{ prop: 'hook-criteria-props-2', key: 2 },
{ prop: 'hook-criteria-props-3', key: 3 },
],
showFooter: false,
buttonProps: { prop: 'hook-button-props' },
demoAlertProps: { prop: 'demo-alert-props' },
};
test('snapshot: show footer', () => {
hooks.rendererHooks.mockReturnValueOnce({ ...hookProps, showFooter: true });
expect(shallow(<Rubric {...props} />)).toMatchSnapshot();
});
describe('snapshot', () => {
beforeEach(() => {
el.instance().submitGradeHandler = jest.fn().mockName('this.submitGradeHandler');
el.instance().hideDemoAlert = jest.fn().mockName('this.hideDemoAlert');
jest.spyOn(el.instance(), 'criteria', 'get').mockReturnValue('// get this.criteria()');
});
test('is grading', () => {
expect(el.instance().render()).toMatchSnapshot();
});
test('is not grading', () => {
el.setProps({ isGrading: false });
expect(el.instance().render()).toMatchSnapshot();
});
test('is grading, submit pending', () => {
el.setProps({ gradeIsPending: true });
expect(el.instance().render()).toMatchSnapshot();
});
test('is grading, lock is pending', () => {
el.setProps({ lockIsPending: true });
expect(el.instance().render()).toMatchSnapshot();
});
test('submit completed', () => {
el.setProps({ isCompleted: true, isGrading: false });
expect(el.instance().render()).toMatchSnapshot();
});
});
describe('component', () => {
describe('render', () => {
test('criteria getter returns a CriterionContainer for each criteriaIndices value', () => {
const container = (value) => (
shallow(<CriterionContainer isGrading key={value} orderNum={value} />)
);
expect(el.instance().criteria).toMatchObject(props.criteriaIndices.map(container));
});
test('is grading (grading footer present)', () => {
expect(el.find('.grading-rubric-footer').length).toEqual(1);
const containers = el.find('CriterionContainer');
expect(containers.length).toEqual(props.criteriaIndices.length);
containers.forEach((container, i) => {
expect(container.key()).toEqual(String(props.criteriaIndices[i]));
});
});
test('is not grading (no grading footer)', () => {
el.setProps({
isGrading: false,
});
expect(el.find('.grading-rubric-footer').length).toEqual(0);
const containers = el.find('CriterionContainer');
expect(containers.length).toEqual(props.criteriaIndices.length);
containers.forEach((container, i) => {
expect(container.key()).toEqual(String(props.criteriaIndices[i]));
});
});
});
describe('behavior', () => {
test('submitGrade', () => {
el = shallow(<Rubric {...props} />);
el.instance().submitGradeHandler();
expect(props.submitGrade).toBeCalledTimes(1);
});
});
});
describe('mapStateToProps', () => {
const testState = { abitaryState: 'some data' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('isGrading from selectors.grading.selected.isGrading', () => {
expect(mapped.isGrading).toEqual(selectors.grading.selected.isGrading(testState));
});
test('gradeIsPending from selectors.requests.isPending(submitGrade)', () => {
expect(mapped.gradeIsPending).toEqual(
selectors.requests.isPending(testState, { requestKey: RequestKeys.submitGrade }),
);
});
test('lockIsPending from selectors.requests.isPending(setLock)', () => {
expect(mapped.lockIsPending).toEqual(selectors.requests.isPending(
testState, { requestKey: RequestKeys.setLock },
));
});
test('criteriaIndices from selectors.app.rubric.criteriaIndices', () => {
expect(mapped.criteriaIndices).toEqual(
selectors.app.rubric.criteriaIndices(testState),
);
});
});
describe('mapDispatchToProps', () => {
beforeEach(() => {});
test('maps thunkActions.grading.submitGrade to submitGrade prop', () => {
expect(mapDispatchToProps.submitGrade).toEqual(
thunkActions.grading.submitGrade,
);
});
test('shapshot: hide footer', () => {
hooks.rendererHooks.mockReturnValueOnce(hookProps);
expect(shallow(<Rubric {...props} />)).toMatchSnapshot();
});
});

View File

@@ -1,5 +1,4 @@
import { getConfig } from '@edx/frontend-platform';
// eslint-disable-next-line import/prefer-default-export
export const routePath = `${getConfig().PUBLIC_PATH}:courseId`;
export const locationId = window.location.pathname.slice(1);
export const locationId = window.location.pathname.replace(getConfig().PUBLIC_PATH, '');

View File

@@ -0,0 +1,24 @@
import * as platform from '@edx/frontend-platform';
import * as constants from './app';
jest.unmock('./app');
jest.mock('@edx/frontend-platform', () => {
const PUBLIC_PATH = '/test-public-path/';
return {
getConfig: () => ({ PUBLIC_PATH }),
PUBLIC_PATH,
};
});
describe('app constants', () => {
test('route path draws from public path and adds courseId', () => {
expect(constants.routePath).toEqual(`${platform.PUBLIC_PATH}:courseId`);
});
test('locationId returns trimmed pathname', () => {
const old = window.location;
window.location = { pathName: `${platform.PUBLIC_PATH}somePath.jpg` };
expect(constants.locationId).toEqual(window.location.pathname.replace(platform.PUBLIC_PATH, ''));
window.location = old;
});
});

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