Compare commits
55 Commits
open-relea
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54c6c57b42 | ||
|
|
6722dbdce5 | ||
|
|
fed06641df | ||
|
|
689b8b48f0 | ||
|
|
258f4377d8 | ||
|
|
dcdc96778c | ||
|
|
adade6e48d | ||
|
|
06aea1ff68 | ||
|
|
054304902f | ||
|
|
ba9bddbda1 | ||
|
|
706d69aeca | ||
|
|
6d3ed03cac | ||
|
|
21a35cde82 | ||
|
|
66f85ee17e | ||
|
|
140cfc1639 | ||
|
|
26906d45f7 | ||
|
|
a753170cb7 | ||
|
|
690140ce46 | ||
|
|
6764a9766c | ||
|
|
c646b88543 | ||
|
|
b1d11119db | ||
|
|
35532fed92 | ||
|
|
15952d808a | ||
|
|
3a928e42bc | ||
|
|
15e756673f | ||
|
|
cba03d305c | ||
|
|
956dee9a6d | ||
|
|
4f7d3aeb57 | ||
|
|
d4f1383822 | ||
|
|
5efd1466bf | ||
|
|
36bd27517c | ||
|
|
6c884ce215 | ||
|
|
8b4f554cf6 | ||
|
|
0b1b079abd | ||
|
|
b2c52111d7 | ||
|
|
18bc94e2ff | ||
|
|
0f41df2cf3 | ||
|
|
91fbb8978a | ||
|
|
5aecd88c70 | ||
|
|
2bf499fb43 | ||
|
|
c217c32196 | ||
|
|
5f12c4fb8e | ||
|
|
4d7d95e490 | ||
|
|
0a90024de9 | ||
|
|
91d06e9788 | ||
|
|
74423bf359 | ||
|
|
7e9eab24b0 | ||
|
|
91dd10917f | ||
|
|
b2098be114 | ||
|
|
64ac98c310 | ||
|
|
8a80e2a70e | ||
|
|
a936d970db | ||
|
|
56c6c88638 | ||
|
|
9c42bfbd8a | ||
|
|
69733f7837 |
2
.env
2
.env
@@ -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=''
|
||||
|
||||
@@ -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=''
|
||||
|
||||
@@ -4,8 +4,13 @@ const config = createConfig('eslint', {
|
||||
rules: {
|
||||
'import/no-named-as-default': 'off',
|
||||
'import/no-named-as-default-member': 'off',
|
||||
'import/no-import-module-exports': 'off',
|
||||
'import/no-self-import': 'off',
|
||||
'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
"react/forbid-prop-types": ["error", { "forbid": ["any", "array"] }], // arguable object proptype is use when I do not care about the shape of the object
|
||||
'no-import-assign': 'off',
|
||||
'no-promise-executor-return': 'off',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
6
.github/CODEOWNERS
vendored
Normal file
6
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Code owners for frontend-app-ora-grading
|
||||
|
||||
# These owners will be the default owners for everything in
|
||||
# the repo. Unless a later match takes precedence, they will
|
||||
# be requested for review when someone opens a pull request.
|
||||
* @edx/content-aurora
|
||||
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Run the workflow that adds new tickets that are either:
|
||||
# - labelled "DEPR"
|
||||
# - title starts with "[DEPR]"
|
||||
# - body starts with "Proposal Date" (this is the first template field)
|
||||
# to the org-wide DEPR project board
|
||||
|
||||
name: Add newly created DEPR issues to the DEPR project board
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
routeissue:
|
||||
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -11,17 +11,16 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
node: [12, 14, 16]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
# Because of node 18 bug (https://github.com/nodejs/node/issues/47563), Pinning node version 18.15 until the next release of node
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: 18.15
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -39,7 +38,7 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
- name: Run Coverage
|
||||
uses: codecov/codecov-action@v2
|
||||
uses: codecov/codecov-action@v3
|
||||
|
||||
- name: Send failure notification
|
||||
if: ${{ failure() }}
|
||||
|
||||
2
.github/workflows/commitlint.yml
vendored
2
.github/workflows/commitlint.yml
vendored
@@ -7,4 +7,4 @@ on:
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
uses: edx/.github/.github/workflows/commitlint.yml@master
|
||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
|
||||
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
#check package-lock file version
|
||||
|
||||
name: Lockfile Version check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
14
Makefile
14
Makefile
@@ -3,18 +3,16 @@ npm-install-%: ## install specified % npm package
|
||||
git add package.json
|
||||
|
||||
transifex_resource = frontend-app-ora-grading
|
||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,fr_CA,it_IT,pt_PT,de_DE,uk,ru,hi"
|
||||
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
|
||||
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
|
||||
NPM_TESTS=build i18n_extract lint test is-es5
|
||||
NPM_TESTS=build i18n_extract lint test
|
||||
|
||||
.PHONY: test
|
||||
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
|
||||
@@ -49,15 +47,15 @@ push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
# 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
21
README.md
Normal 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.
|
||||
@@ -12,6 +12,10 @@ module.exports = createConfig('jest', {
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/segment.js',
|
||||
'src/postcss.config.js',
|
||||
'testUtils', // don't unit test jest mocking tools
|
||||
'src/data/services/lms/fakeData', // don't unit test mock data
|
||||
'src/test', // don't unit test integration test utils
|
||||
],
|
||||
testTimeout: 120000,
|
||||
testEnvironment: 'jsdom',
|
||||
});
|
||||
|
||||
49713
package-lock.json
generated
49713
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -8,8 +8,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"coveralls": "cat ./coverage/lcov.info | coveralls",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"lint": "fedx-scripts eslint --ext .jsx,.js src/",
|
||||
"lint-fix": "fedx-scripts eslint --fix --ext .jsx,.js src/",
|
||||
@@ -27,9 +25,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-edx.org@^2.0.3",
|
||||
"@edx/frontend-component-footer": "10.1.6",
|
||||
"@edx/frontend-platform": "1.12.4",
|
||||
"@edx/paragon": "16.14.4",
|
||||
"@edx/frontend-component-footer": "^11.1.1",
|
||||
"@edx/frontend-component-header": "^3.1.1",
|
||||
"@edx/frontend-platform": "^2.5.1",
|
||||
"@edx/paragon": "^19.9.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.36",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
||||
@@ -51,11 +50,12 @@
|
||||
"history": "5.0.1",
|
||||
"html-react-parser": "^1.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"node-sass": "^6.0.1",
|
||||
"moment": "^2.29.3",
|
||||
"prop-types": "15.7.2",
|
||||
"query-string": "7.0.1",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react": "^16.14.0",
|
||||
"react-dom": "^16.14.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-intl": "^5.20.9",
|
||||
"react-pdf": "^5.5.0",
|
||||
"react-redux": "^7.2.4",
|
||||
@@ -73,22 +73,21 @@
|
||||
"whatwg-fetch": "^3.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/frontend-build": "9.1.1",
|
||||
"@edx/frontend-build": "12.4",
|
||||
"@edx/reactifex": "^2.1.1",
|
||||
"@testing-library/jest-dom": "^5.14.1",
|
||||
"@testing-library/react": "^12.1.0",
|
||||
"axios-mock-adapter": "^1.20.0",
|
||||
"codecov": "^3.8.3",
|
||||
"enzyme-adapter-react-16": "^1.15.6",
|
||||
"es-check": "^6.0.0",
|
||||
"fetch-mock": "^9.11.0",
|
||||
"husky": "^7.0.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "27.0.6",
|
||||
"jest-expect-message": "^1.0.2",
|
||||
"react-dev-utils": "^11.0.4",
|
||||
"react-test-renderer": "^17.0.2",
|
||||
"react-dev-utils": "^12.0.1",
|
||||
"react-test-renderer": "^16.14.0",
|
||||
"reactifex": "1.1.1",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
"semantic-release": "^17.4.5"
|
||||
"semantic-release": "^19.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en-us" dir="ltr">
|
||||
<head>
|
||||
<title>ORA Enhanced Staff Grader | <%= process.env.SITE_NAME %></title>
|
||||
<title>ORA staff grading | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
|
||||
@@ -4,24 +4,28 @@ import { connect } from 'react-redux';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
|
||||
import { selectors } from 'data/redux';
|
||||
|
||||
import DemoWarning from 'containers/DemoWarning';
|
||||
import CourseHeader from 'containers/CourseHeader';
|
||||
import CTA from 'containers/CTA';
|
||||
import ListView from 'containers/ListView';
|
||||
|
||||
import './App.scss';
|
||||
import Head from './components/Head';
|
||||
|
||||
export const App = ({ courseMetadata, isEnabled }) => (
|
||||
<Router>
|
||||
<div>
|
||||
<CourseHeader
|
||||
<Head />
|
||||
<Header
|
||||
courseTitle={courseMetadata.title}
|
||||
courseNumber={courseMetadata.number}
|
||||
courseOrg={courseMetadata.org}
|
||||
/>
|
||||
{!isEnabled && <DemoWarning />}
|
||||
<CTA />
|
||||
<main>
|
||||
<ListView />
|
||||
</main>
|
||||
|
||||
26
src/App.scss
26
src/App.scss
@@ -42,32 +42,6 @@ $input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.
|
||||
}
|
||||
}
|
||||
|
||||
.course-header {
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid black;
|
||||
|
||||
.course-title-lockup {
|
||||
min-width: 0;
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
.btn {
|
||||
height: 3rem;
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#paragon-portal-root {
|
||||
.pgn__modal-layer {
|
||||
.pgn__modal-close-container {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
|
||||
import ListView from 'containers/ListView';
|
||||
|
||||
@@ -16,11 +17,15 @@ jest.mock('data/redux', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-component-header', () => ({
|
||||
LearningHeader: 'Header',
|
||||
}));
|
||||
jest.mock('@edx/frontend-component-footer', () => 'Footer');
|
||||
|
||||
jest.mock('containers/DemoWarning', () => 'DemoWarning');
|
||||
jest.mock('containers/CTA', () => 'CTA');
|
||||
jest.mock('containers/ListView', () => 'ListView');
|
||||
jest.mock('containers/CourseHeader', () => 'CourseHeader');
|
||||
jest.mock('components/Head', () => 'Head');
|
||||
|
||||
const logo = 'fakeLogo.png';
|
||||
let el;
|
||||
@@ -57,5 +62,16 @@ describe('App router component', () => {
|
||||
test('Footer logo drawn from env variable', () => {
|
||||
expect(router.find(Footer).props().logo).toEqual(logo);
|
||||
});
|
||||
|
||||
test('Header to use courseMetadata props', () => {
|
||||
const {
|
||||
courseTitle,
|
||||
courseNumber,
|
||||
courseOrg,
|
||||
} = router.find(Header).props();
|
||||
expect(courseTitle).toEqual(props.courseMetadata.title);
|
||||
expect(courseNumber).toEqual(props.courseMetadata.number);
|
||||
expect(courseOrg).toEqual(props.courseMetadata.org);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
exports[`App router component snapshot: disabled (show demo warning) 1`] = `
|
||||
<BrowserRouter>
|
||||
<div>
|
||||
<CourseHeader
|
||||
<Head />
|
||||
<Header
|
||||
courseNumber="course-number"
|
||||
courseOrg="course-org"
|
||||
courseTitle="course-title"
|
||||
/>
|
||||
<DemoWarning />
|
||||
<CTA />
|
||||
<main>
|
||||
<ListView />
|
||||
</main>
|
||||
@@ -22,11 +24,13 @@ exports[`App router component snapshot: disabled (show demo warning) 1`] = `
|
||||
exports[`App router component snapshot: enabled 1`] = `
|
||||
<BrowserRouter>
|
||||
<div>
|
||||
<CourseHeader
|
||||
<Head />
|
||||
<Header
|
||||
courseNumber="course-number"
|
||||
courseOrg="course-org"
|
||||
courseTitle="course-title"
|
||||
/>
|
||||
<CTA />
|
||||
<main>
|
||||
<ListView />
|
||||
</main>
|
||||
|
||||
35
src/__snapshots__/index.test.jsx.snap
Normal file
35
src/__snapshots__/index.test.jsx.snap
Normal 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>
|
||||
`;
|
||||
@@ -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>
|
||||
|
||||
@@ -6,144 +6,80 @@ import {
|
||||
Icon, Form, ActionRow, IconButton,
|
||||
} from '@edx/paragon';
|
||||
import { ChevronLeft, ChevronRight } from '@edx/paragon/icons';
|
||||
import pdfjsWorker from 'react-pdf/node_modules/pdfjs-dist/build/pdf.worker.entry';
|
||||
import pdfjsWorker from 'react-pdf/dist/esm/pdf.worker.entry';
|
||||
|
||||
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||
import { rendererHooks } from './pdfHooks';
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
|
||||
/**
|
||||
* <PDFRenderer />
|
||||
*/
|
||||
export class PDFRenderer extends React.Component {
|
||||
static INITIAL_STATE = {
|
||||
pageNumber: 1,
|
||||
numPages: 1,
|
||||
relativeHeight: 0,
|
||||
};
|
||||
export const PDFRenderer = ({
|
||||
onError,
|
||||
onSuccess,
|
||||
url,
|
||||
}) => {
|
||||
const {
|
||||
pageNumber,
|
||||
numPages,
|
||||
relativeHeight,
|
||||
wrapperRef,
|
||||
onDocumentLoadSuccess,
|
||||
onLoadPageSuccess,
|
||||
onDocumentLoadError,
|
||||
onInputPageChange,
|
||||
onNextPageButtonClick,
|
||||
onPrevPageButtonClick,
|
||||
hasNext,
|
||||
hasPrev,
|
||||
} = rendererHooks({ onError, onSuccess });
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = { ...PDFRenderer.INITIAL_STATE };
|
||||
|
||||
this.wrapperRef = React.createRef();
|
||||
this.onDocumentLoadSuccess = this.onDocumentLoadSuccess.bind(this);
|
||||
this.onDocumentLoadError = this.onDocumentLoadError.bind(this);
|
||||
this.onLoadPageSuccess = this.onLoadPageSuccess.bind(this);
|
||||
this.onPrevPageButtonClick = this.onPrevPageButtonClick.bind(this);
|
||||
this.onNextPageButtonClick = this.onNextPageButtonClick.bind(this);
|
||||
this.onInputPageChange = this.onInputPageChange.bind(this);
|
||||
}
|
||||
|
||||
onDocumentLoadSuccess = ({ numPages }) => {
|
||||
this.props.onSuccess();
|
||||
this.setState({ numPages });
|
||||
};
|
||||
|
||||
onLoadPageSuccess = (page) => {
|
||||
const pageWidth = page.view[2];
|
||||
const pageHeight = page.view[3];
|
||||
const wrapperHeight = this.wrapperRef.current.getBoundingClientRect().width;
|
||||
const relativeHeight = (wrapperHeight * pageHeight) / pageWidth;
|
||||
if (relativeHeight !== this.state.relativeHeight) {
|
||||
this.setState({ relativeHeight });
|
||||
}
|
||||
};
|
||||
|
||||
onDocumentLoadError = (error) => {
|
||||
let status;
|
||||
switch (error.name) {
|
||||
case 'MissingPDFException':
|
||||
status = 404;
|
||||
break;
|
||||
default:
|
||||
status = 500;
|
||||
break;
|
||||
}
|
||||
this.props.onError(status);
|
||||
};
|
||||
|
||||
onInputPageChange = ({ target: { value } }) => {
|
||||
this.setPageNumber(parseInt(value, 10));
|
||||
}
|
||||
|
||||
onPrevPageButtonClick = () => {
|
||||
this.setPageNumber(this.state.pageNumber - 1);
|
||||
}
|
||||
|
||||
onNextPageButtonClick = () => {
|
||||
this.setPageNumber(this.state.pageNumber + 1);
|
||||
}
|
||||
|
||||
setPageNumber(pageNumber) {
|
||||
if (pageNumber > 0 && pageNumber <= this.state.numPages) {
|
||||
this.setState({ pageNumber });
|
||||
}
|
||||
}
|
||||
|
||||
get hasNext() {
|
||||
return this.state.pageNumber < this.state.numPages;
|
||||
}
|
||||
|
||||
get hasPrev() {
|
||||
return this.state.pageNumber > 1;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div ref={this.wrapperRef} className="pdf-renderer">
|
||||
<Document
|
||||
file={this.props.url}
|
||||
onLoadSuccess={this.onDocumentLoadSuccess}
|
||||
onLoadError={this.onDocumentLoadError}
|
||||
>
|
||||
{/* <Outline /> */}
|
||||
<div
|
||||
className="page-wrapper"
|
||||
style={{
|
||||
height: this.state.relativeHeight,
|
||||
}}
|
||||
>
|
||||
<Page
|
||||
pageNumber={this.state.pageNumber}
|
||||
onLoadSuccess={this.onLoadPageSuccess}
|
||||
/>
|
||||
</div>
|
||||
</Document>
|
||||
<ActionRow className="d-flex justify-content-center m-0">
|
||||
<IconButton
|
||||
size="inline"
|
||||
alt="previous pdf page"
|
||||
iconAs={Icon}
|
||||
src={ChevronLeft}
|
||||
disabled={!this.hasPrev}
|
||||
onClick={this.onPrevPageButtonClick}
|
||||
return (
|
||||
<div ref={wrapperRef} className="pdf-renderer">
|
||||
<Document
|
||||
file={url}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
>
|
||||
{/* <Outline /> */}
|
||||
<div className="page-wrapper" style={{ height: relativeHeight }}>
|
||||
<Page pageNumber={pageNumber} onLoadSuccess={onLoadPageSuccess} />
|
||||
</div>
|
||||
</Document>
|
||||
<ActionRow className="d-flex justify-content-center m-0">
|
||||
<IconButton
|
||||
size="inline"
|
||||
alt="previous pdf page"
|
||||
iconAs={Icon}
|
||||
src={ChevronLeft}
|
||||
disabled={!hasPrev}
|
||||
onClick={onPrevPageButtonClick}
|
||||
/>
|
||||
<Form.Group className="d-flex align-items-center m-0">
|
||||
<Form.Label isInline>Page </Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
min={0}
|
||||
max={numPages}
|
||||
value={pageNumber}
|
||||
onChange={onInputPageChange}
|
||||
/>
|
||||
<Form.Group className="d-flex align-items-center m-0">
|
||||
<Form.Label isInline>Page </Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
min={0}
|
||||
max={this.state.numPages}
|
||||
value={this.state.pageNumber}
|
||||
onChange={this.onInputPageChange}
|
||||
/>
|
||||
<Form.Label isInline> of {this.state.numPages}</Form.Label>
|
||||
</Form.Group>
|
||||
<IconButton
|
||||
size="inline"
|
||||
alt="next pdf page"
|
||||
iconAs={Icon}
|
||||
src={ChevronRight}
|
||||
disabled={!this.hasNext}
|
||||
onClick={this.onNextPageButtonClick}
|
||||
/>
|
||||
</ActionRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
<Form.Label isInline> of {numPages}</Form.Label>
|
||||
</Form.Group>
|
||||
<IconButton
|
||||
size="inline"
|
||||
alt="next pdf page"
|
||||
iconAs={Icon}
|
||||
src={ChevronRight}
|
||||
disabled={!hasNext}
|
||||
onClick={onNextPageButtonClick}
|
||||
/>
|
||||
</ActionRow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PDFRenderer.defaultProps = {};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,6 @@ exports[`TXT Renderer Component snapshot 1`] = `
|
||||
<pre
|
||||
className="txt-renderer"
|
||||
>
|
||||
Content of some_url.txt
|
||||
test-content
|
||||
</pre>
|
||||
`;
|
||||
|
||||
81
src/components/FilePreview/BaseRenderers/pdfHooks.jsx
Normal file
81
src/components/FilePreview/BaseRenderers/pdfHooks.jsx
Normal 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,
|
||||
};
|
||||
};
|
||||
148
src/components/FilePreview/BaseRenderers/pdfHooks.test.js
Normal file
148
src/components/FilePreview/BaseRenderers/pdfHooks.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
34
src/components/FilePreview/BaseRenderers/textHooks.js
Normal file
34
src/components/FilePreview/BaseRenderers/textHooks.js
Normal 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 };
|
||||
};
|
||||
95
src/components/FilePreview/BaseRenderers/textHooks.test.js
Normal file
95
src/components/FilePreview/BaseRenderers/textHooks.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ exports[`File Preview Card component snapshot 1`] = `
|
||||
overlay={
|
||||
<Popover
|
||||
className="overlay-help-popover"
|
||||
id="file-popover"
|
||||
>
|
||||
<Popover.Content>
|
||||
<h1>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
102
src/components/FilePreview/hooks.js
Normal file
102
src/components/FilePreview/hooks.js
Normal 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,
|
||||
};
|
||||
};
|
||||
117
src/components/FilePreview/hooks.test.js
Normal file
117
src/components/FilePreview/hooks.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export { default as FileRenderer, isSupported } from './FileRenderer';
|
||||
export { default as FileRenderer } from './FileRenderer';
|
||||
export { isSupported } from './hooks';
|
||||
|
||||
14
src/components/Head/__snapshots__/index.test.jsx.snap
Normal file
14
src/components/Head/__snapshots__/index.test.jsx.snap
Normal 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>
|
||||
`;
|
||||
20
src/components/Head/index.jsx
Normal file
20
src/components/Head/index.jsx
Normal 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';
|
||||
|
||||
const 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;
|
||||
25
src/components/Head/index.test.jsx
Normal file
25
src/components/Head/index.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
11
src/components/Head/messages.js
Normal file
11
src/components/Head/messages.js
Normal 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;
|
||||
@@ -6,6 +6,7 @@ exports[`Info Popover Component snapshot 1`] = `
|
||||
overlay={
|
||||
<Popover
|
||||
className="overlay-help-popover"
|
||||
id="info-popover"
|
||||
>
|
||||
<Popover.Content>
|
||||
<div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
35
src/components/StatusBadge.test.jsx
Normal file
35
src/components/StatusBadge.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
55
src/components/__snapshots__/StatusBadge.test.jsx.snap
Normal file
55
src/components/__snapshots__/StatusBadge.test.jsx.snap
Normal 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>
|
||||
`;
|
||||
@@ -1,16 +0,0 @@
|
||||
const configuration = {
|
||||
// BASE_URL: process.env.BASE_URL,
|
||||
LMS_BASE_URL: process.env.LMS_BASE_URL,
|
||||
// LOGIN_URL: process.env.LOGIN_URL,
|
||||
// LOGOUT_URL: process.env.LOGOUT_URL,
|
||||
// CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH,
|
||||
// REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT,
|
||||
// DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
|
||||
// SECURE_COOKIES: process.env.NODE_ENV !== 'development',
|
||||
// SEGMENT_KEY: process.env.SEGMENT_KEY,
|
||||
// ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
|
||||
};
|
||||
|
||||
const features = {};
|
||||
|
||||
export { configuration, features };
|
||||
11
src/containers/CTA/CTA.test.jsx
Normal file
11
src/containers/CTA/CTA.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
31
src/containers/CTA/__snapshots__/CTA.test.jsx.snap
Normal file
31
src/containers/CTA/__snapshots__/CTA.test.jsx.snap
Normal 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>
|
||||
`;
|
||||
29
src/containers/CTA/index.jsx
Normal file
29
src/containers/CTA/index.jsx
Normal 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;
|
||||
23
src/containers/CTA/messages.js
Normal file
23
src/containers/CTA/messages.js
Normal 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);
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import message from './messages';
|
||||
|
||||
export const getRegisterUrl = () => {
|
||||
const { LMS_BASE_URL } = getConfig();
|
||||
const locationHref = encodeURIComponent(global.location.href);
|
||||
return `${LMS_BASE_URL}/register?next=${locationHref}`;
|
||||
};
|
||||
|
||||
export const AnonymousUserMenu = ({ intl }) => (
|
||||
<div>
|
||||
<Button
|
||||
className="mr-3"
|
||||
variant="outline-primary"
|
||||
href={getRegisterUrl()}
|
||||
>
|
||||
{intl.formatMessage(message.registerSentenceCase)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
href={`${getLoginRedirectUrl(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(message.signInSentenceCase)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
AnonymousUserMenu.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(AnonymousUserMenu);
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { AnonymousUserMenu } from './AnonymousUserMenu';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: () => ({
|
||||
LMS_BASE_URL: '<LMS_BASE_URL>',
|
||||
}),
|
||||
}));
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getLoginRedirectUrl: (url) => `redirect:${url}`,
|
||||
}));
|
||||
|
||||
describe('Header AnonymousUserMenu component', () => {
|
||||
const props = {
|
||||
intl: { formatMessage: (msg) => msg.defaultMessage },
|
||||
};
|
||||
test('snapshot', () => {
|
||||
expect(
|
||||
shallow(<AnonymousUserMenu {...props} />),
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
export const UserAvatar = ({ username }) => (
|
||||
<Dropdown.Toggle variant="outline-primary">
|
||||
<FontAwesomeIcon
|
||||
icon={faUserCircle}
|
||||
className="d-md-none"
|
||||
size="lg"
|
||||
/>
|
||||
<span data-hj-suppress className="d-none d-md-inline">
|
||||
{username}
|
||||
</span>
|
||||
</Dropdown.Toggle>
|
||||
);
|
||||
UserAvatar.propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
UserAvatar.defaultProps = {};
|
||||
|
||||
export default UserAvatar;
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import UserAvatar from './UserAvatar';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: () => ({
|
||||
LMS_BASE_URL: '<LMS_BASE_URL>',
|
||||
LOGOUT_URL: '<LOGOUT_URL>',
|
||||
SUPPORT_URL: '<SUPPORT_URL>',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Header AuthenticatedUserDropdown UserAvatar component', () => {
|
||||
const props = {
|
||||
username: 'test-username',
|
||||
};
|
||||
test('snapshot', () => {
|
||||
expect(
|
||||
shallow(<UserAvatar {...props} />),
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
export class UserMenu extends React.Component {
|
||||
menuItem(href, message) {
|
||||
return (
|
||||
<Dropdown.Item href={href}>
|
||||
{this.props.intl.formatMessage(message)}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { username } = this.props;
|
||||
const { LMS_BASE_URL, LOGOUT_URL } = getConfig();
|
||||
return (
|
||||
<Dropdown.Menu className="dropdown-menu-right">
|
||||
{this.menuItem(`${LMS_BASE_URL}/dashboard`, messages.dashboard)}
|
||||
{this.menuItem(`${LMS_BASE_URL}/u/${username}`, messages.profile)}
|
||||
{this.menuItem(`${LMS_BASE_URL}/account/settings`, messages.account)}
|
||||
{this.menuItem(LOGOUT_URL, messages.signOut)}
|
||||
</Dropdown.Menu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UserMenu.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
username: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
UserMenu.defaultProps = {};
|
||||
|
||||
export default injectIntl(UserMenu);
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { UserMenu } from './UserMenu';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: () => ({
|
||||
LMS_BASE_URL: '<LMS_BASE_URL>',
|
||||
LOGOUT_URL: '<LOGOUT_URL>',
|
||||
SUPPORT_URL: '<SUPPORT_URL>',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Header AuthenticatedUserDropdown UserMenu component', () => {
|
||||
const props = {
|
||||
intl: { formatMessage: (msg) => msg.defaultMessage },
|
||||
username: 'test-username',
|
||||
};
|
||||
test('snapshot', () => {
|
||||
expect(
|
||||
shallow(<UserMenu {...props} />),
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header AuthenticatedUserDropdown UserAvatar component snapshot 1`] = `
|
||||
<Dropdown.Toggle
|
||||
variant="outline-primary"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="d-md-none"
|
||||
icon={
|
||||
Object {
|
||||
"icon": Array [
|
||||
496,
|
||||
512,
|
||||
Array [],
|
||||
"f2bd",
|
||||
"M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 96c48.6 0 88 39.4 88 88s-39.4 88-88 88-88-39.4-88-88 39.4-88 88-88zm0 344c-58.7 0-111.3-26.6-146.5-68.2 18.8-35.4 55.6-59.8 98.5-59.8 2.4 0 4.8.4 7.1 1.1 13 4.2 26.6 6.9 40.9 6.9 14.3 0 28-2.7 40.9-6.9 2.3-.7 4.7-1.1 7.1-1.1 42.9 0 79.7 24.4 98.5 59.8C359.3 421.4 306.7 448 248 448z",
|
||||
],
|
||||
"iconName": "user-circle",
|
||||
"prefix": "fas",
|
||||
}
|
||||
}
|
||||
size="lg"
|
||||
/>
|
||||
<span
|
||||
className="d-none d-md-inline"
|
||||
data-hj-suppress={true}
|
||||
>
|
||||
test-username
|
||||
</span>
|
||||
</Dropdown.Toggle>
|
||||
`;
|
||||
@@ -1,28 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header AuthenticatedUserDropdown UserMenu component snapshot 1`] = `
|
||||
<Dropdown.Menu
|
||||
className="dropdown-menu-right"
|
||||
>
|
||||
<Dropdown.Item
|
||||
href="<LMS_BASE_URL>/dashboard"
|
||||
>
|
||||
Dashboard
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
href="<LMS_BASE_URL>/u/test-username"
|
||||
>
|
||||
Profile
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
href="<LMS_BASE_URL>/account/settings"
|
||||
>
|
||||
Account
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
href="<LOGOUT_URL>"
|
||||
>
|
||||
Sign Out
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
`;
|
||||
@@ -1,22 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header AuthenticatedUserDropdown component snapshot 1`] = `
|
||||
<Fragment>
|
||||
<a
|
||||
className="text-gray-700 mr-3"
|
||||
href="<SUPPORT_URL>"
|
||||
>
|
||||
Help
|
||||
</a>
|
||||
<Dropdown
|
||||
className="user-dropdown"
|
||||
>
|
||||
<UserAvatar
|
||||
username="test-username"
|
||||
/>
|
||||
<UserMenu
|
||||
username="test-username"
|
||||
/>
|
||||
</Dropdown>
|
||||
</Fragment>
|
||||
`;
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
|
||||
import UserMenu from './UserMenu';
|
||||
import UserAvatar from './UserAvatar';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
export const AuthenticatedUserDropdown = ({
|
||||
intl,
|
||||
username,
|
||||
}) => (
|
||||
<>
|
||||
<a className="text-gray-700 mr-3" href={`${getConfig().SUPPORT_URL}`}>
|
||||
{intl.formatMessage(messages.help)}
|
||||
</a>
|
||||
<Dropdown className="user-dropdown">
|
||||
<UserAvatar username={username} />
|
||||
<UserMenu username={username} />
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
|
||||
AuthenticatedUserDropdown.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
username: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
AuthenticatedUserDropdown.defaultProps = {};
|
||||
|
||||
export default injectIntl(AuthenticatedUserDropdown);
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { AuthenticatedUserDropdown } from '.';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: () => ({
|
||||
SUPPORT_URL: '<SUPPORT_URL>',
|
||||
}),
|
||||
}));
|
||||
jest.mock('./UserAvatar', () => 'UserAvatar');
|
||||
jest.mock('./UserMenu', () => 'UserMenu');
|
||||
|
||||
describe('Header AuthenticatedUserDropdown component', () => {
|
||||
const props = {
|
||||
intl: { formatMessage: (msg) => msg.defaultMessage },
|
||||
username: 'test-username',
|
||||
};
|
||||
test('snapshot', () => {
|
||||
expect(
|
||||
shallow(<AuthenticatedUserDropdown {...props} />),
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const CourseLabel = ({
|
||||
courseOrg,
|
||||
courseNumber,
|
||||
courseTitle,
|
||||
}) => (
|
||||
<div
|
||||
className="flex-grow-1 course-title-lockup"
|
||||
style={{ lineHeight: 1 }}
|
||||
>
|
||||
<span className="d-block small m-0">
|
||||
{courseOrg} {courseNumber}
|
||||
</span>
|
||||
<span className="d-block m-0 font-weight-bold course-title">
|
||||
{courseTitle}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
CourseLabel.propTypes = {
|
||||
courseOrg: PropTypes.string,
|
||||
courseNumber: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
};
|
||||
CourseLabel.defaultProps = {
|
||||
courseOrg: null,
|
||||
courseNumber: null,
|
||||
courseTitle: null,
|
||||
};
|
||||
|
||||
export default CourseLabel;
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import CourseLabel from './CourseLabel';
|
||||
|
||||
const courseData = {
|
||||
courseOrg: 'course-org',
|
||||
courseNumber: 'course-number',
|
||||
courseTitle: 'course-title',
|
||||
};
|
||||
|
||||
describe('Header CourseLabel component', () => {
|
||||
test('snapshot', () => {
|
||||
expect(
|
||||
shallow(<CourseLabel {...courseData} />),
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
const LinkedLogo = () => (
|
||||
<a
|
||||
className="logo"
|
||||
href={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
>
|
||||
<img
|
||||
className="d-block"
|
||||
src={getConfig().LOGO_URL}
|
||||
alt={getConfig().SITE_NAME}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
|
||||
export default LinkedLogo;
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import LinkedLogo from './LinkedLogo';
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: () => ({
|
||||
LMS_BASE_URL: '<getConfig().LMS_BASE_URL>',
|
||||
LOGO_URL: '<getConfig().LOGO_URL>',
|
||||
SITE_NAME: '<getConfig().SITE_NAME>',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Header CourseLabel component', () => {
|
||||
test('snapshot', () => {
|
||||
expect(
|
||||
shallow(<LinkedLogo />),
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header AnonymousUserMenu component snapshot 1`] = `
|
||||
<div>
|
||||
<Button
|
||||
className="mr-3"
|
||||
href="<LMS_BASE_URL>/register?next=http%3A%2F%2Flocalhost%2F"
|
||||
variant="outline-primary"
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
<Button
|
||||
href="redirect:http://localhost/"
|
||||
variant="primary"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,25 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header CourseLabel component snapshot 1`] = `
|
||||
<div
|
||||
className="flex-grow-1 course-title-lockup"
|
||||
style={
|
||||
Object {
|
||||
"lineHeight": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="d-block small m-0"
|
||||
>
|
||||
course-org
|
||||
|
||||
course-number
|
||||
</span>
|
||||
<span
|
||||
className="d-block m-0 font-weight-bold course-title"
|
||||
>
|
||||
course-title
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,14 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header CourseLabel component snapshot 1`] = `
|
||||
<a
|
||||
className="logo"
|
||||
href="<getConfig().LMS_BASE_URL>/dashboard"
|
||||
>
|
||||
<img
|
||||
alt="<getConfig().SITE_NAME>"
|
||||
className="d-block"
|
||||
src="<getConfig().LOGO_URL>"
|
||||
/>
|
||||
</a>
|
||||
`;
|
||||
@@ -1,51 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Header component snapshot 1`] = `
|
||||
<header
|
||||
className="course-header"
|
||||
>
|
||||
<a
|
||||
className="sr-only sr-only-focusable"
|
||||
href="#main-content"
|
||||
>
|
||||
Skip to main content.
|
||||
</a>
|
||||
<div
|
||||
className="container-xl py-2 d-flex align-items-center"
|
||||
>
|
||||
<LinkedLogo />
|
||||
<CourseLabel
|
||||
courseNumber="course-number"
|
||||
courseOrg="course-org"
|
||||
courseTitle="course-title"
|
||||
/>
|
||||
<AnonymousUserMenu />
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
|
||||
exports[`Header component snapshot with authenticatedUser 1`] = `
|
||||
<header
|
||||
className="course-header"
|
||||
>
|
||||
<a
|
||||
className="sr-only sr-only-focusable"
|
||||
href="#main-content"
|
||||
>
|
||||
Skip to main content.
|
||||
</a>
|
||||
<div
|
||||
className="container-xl py-2 d-flex align-items-center"
|
||||
>
|
||||
<LinkedLogo />
|
||||
<CourseLabel
|
||||
courseNumber="course-number"
|
||||
courseOrg="course-org"
|
||||
courseTitle="course-title"
|
||||
/>
|
||||
<AuthenticatedUserDropdown
|
||||
username="test"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
@@ -1,47 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import AnonymousUserMenu from './AnonymousUserMenu';
|
||||
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
|
||||
import LinkedLogo from './LinkedLogo';
|
||||
import CourseLabel from './CourseLabel';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
export const Header = ({
|
||||
courseOrg,
|
||||
courseNumber,
|
||||
courseTitle,
|
||||
intl,
|
||||
}) => {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
return (
|
||||
<header className="course-header">
|
||||
<a className="sr-only sr-only-focusable" href="#main-content">
|
||||
{intl.formatMessage(messages.skipNavLink)}
|
||||
</a>
|
||||
<div className="container-xl py-2 d-flex align-items-center">
|
||||
<LinkedLogo />
|
||||
<CourseLabel {...{ courseOrg, courseNumber, courseTitle }} />
|
||||
{authenticatedUser
|
||||
? (<AuthenticatedUserDropdown username={authenticatedUser.username} />)
|
||||
: (<AnonymousUserMenu />)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
Header.propTypes = {
|
||||
courseOrg: PropTypes.string,
|
||||
courseNumber: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
Header.defaultProps = {
|
||||
courseOrg: null,
|
||||
courseNumber: null,
|
||||
courseTitle: null,
|
||||
};
|
||||
|
||||
export default injectIntl(Header);
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { Header } from '.';
|
||||
|
||||
jest.mock('./AnonymousUserMenu', () => 'AnonymousUserMenu');
|
||||
jest.mock('./AuthenticatedUserDropdown', () => 'AuthenticatedUserDropdown');
|
||||
jest.mock('./LinkedLogo', () => 'LinkedLogo');
|
||||
jest.mock('./CourseLabel', () => 'CourseLabel');
|
||||
|
||||
jest.mock('@edx/frontend-platform/react', () => ({
|
||||
AppContext: { authenticatedUser: null },
|
||||
}));
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useContext: (context) => context,
|
||||
}));
|
||||
|
||||
const courseData = {
|
||||
courseOrg: 'course-org',
|
||||
courseNumber: 'course-number',
|
||||
courseTitle: 'course-title',
|
||||
};
|
||||
|
||||
describe('Header component', () => {
|
||||
const props = {
|
||||
...courseData,
|
||||
intl: { formatMessage: (msg) => msg.defaultMessage },
|
||||
};
|
||||
test('snapshot', () => {
|
||||
expect(shallow(<Header {...props} />)).toMatchSnapshot();
|
||||
});
|
||||
test('snapshot with authenticatedUser', () => {
|
||||
AppContext.authenticatedUser = { username: 'test' };
|
||||
expect(shallow(<Header {...props} />)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
courseMaterial: {
|
||||
id: 'learn.navigation.course.tabs.label',
|
||||
defaultMessage: 'Course Material',
|
||||
description: 'The accessible label for course tabs navigation',
|
||||
},
|
||||
dashboard: {
|
||||
id: 'header.menu.dashboard.label',
|
||||
defaultMessage: 'Dashboard',
|
||||
description: 'The text for the user menu Dashboard navigation link.',
|
||||
},
|
||||
help: {
|
||||
id: 'header.help.label',
|
||||
defaultMessage: 'Help',
|
||||
description: 'The text for the link to the Help Center',
|
||||
},
|
||||
profile: {
|
||||
id: 'header.menu.profile.label',
|
||||
defaultMessage: 'Profile',
|
||||
description: 'The text for the user menu Profile navigation link.',
|
||||
},
|
||||
account: {
|
||||
id: 'header.menu.account.label',
|
||||
defaultMessage: 'Account',
|
||||
description: 'The text for the user menu Account navigation link.',
|
||||
},
|
||||
orderHistory: {
|
||||
id: 'header.menu.orderHistory.label',
|
||||
defaultMessage: 'Order History',
|
||||
description: 'The text for the user menu Order History navigation link.',
|
||||
},
|
||||
skipNavLink: {
|
||||
id: 'header.navigation.skipNavLink',
|
||||
defaultMessage: 'Skip to main content.',
|
||||
description: 'A link used by screen readers to allow users to skip to the main content of the page.',
|
||||
},
|
||||
signOut: {
|
||||
id: 'header.menu.signOut.label',
|
||||
defaultMessage: 'Sign Out',
|
||||
description: 'The label for the user menu Sign Out action.',
|
||||
},
|
||||
registerSentenceCase: {
|
||||
id: 'header.register.sentenceCase',
|
||||
defaultMessage: 'Register',
|
||||
description: 'Text in a button, prompting the user to register.',
|
||||
},
|
||||
signInSentenceCase: {
|
||||
id: 'header.signIn.sentenceCase',
|
||||
defaultMessage: 'Sign in',
|
||||
description: 'Text in a button, prompting the user to log in.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -33,27 +33,25 @@ export class RadioCriterion extends React.Component {
|
||||
isInvalid,
|
||||
} = this.props;
|
||||
return (
|
||||
<>
|
||||
<Form.RadioSet name={config.name} value={data}>
|
||||
{config.options.map((option) => (
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
description={intl.formatMessage(messages.optionPoints, { points: option.points })}
|
||||
onChange={this.onChange}
|
||||
disabled={!isGrading}
|
||||
>
|
||||
{option.label}
|
||||
</Form.Radio>
|
||||
))}
|
||||
{isInvalid && (
|
||||
<Form.Control.Feedback type="invalid" className="feedback-error-msg">
|
||||
{intl.formatMessage(messages.rubricSelectedError)}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.RadioSet>
|
||||
</>
|
||||
<Form.RadioSet name={config.name} value={data}>
|
||||
{config.options.map((option) => (
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
description={intl.formatMessage(messages.optionPoints, { points: option.points })}
|
||||
onChange={this.onChange}
|
||||
disabled={!isGrading}
|
||||
>
|
||||
{option.label}
|
||||
</Form.Radio>
|
||||
))}
|
||||
{isInvalid && (
|
||||
<Form.Control.Feedback type="invalid" className="feedback-error-msg">
|
||||
{intl.formatMessage(messages.rubricSelectedError)}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.RadioSet>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +1,85 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Radio Criterion Container snapshot is grading 1`] = `
|
||||
<React.Fragment>
|
||||
<Form.RadioSet
|
||||
name="random name"
|
||||
value="selected radio option"
|
||||
<Form.RadioSet
|
||||
name="random name"
|
||||
value="selected radio option"
|
||||
>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="1 points"
|
||||
disabled={false}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name"
|
||||
>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="1 points"
|
||||
disabled={false}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name"
|
||||
>
|
||||
this label
|
||||
</Form.Radio>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="2 points"
|
||||
disabled={false}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name 2"
|
||||
>
|
||||
this label 2
|
||||
</Form.Radio>
|
||||
</Form.RadioSet>
|
||||
</React.Fragment>
|
||||
this label
|
||||
</Form.Radio>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="2 points"
|
||||
disabled={false}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name 2"
|
||||
>
|
||||
this label 2
|
||||
</Form.Radio>
|
||||
</Form.RadioSet>
|
||||
`;
|
||||
|
||||
exports[`Radio Criterion Container snapshot is not grading 1`] = `
|
||||
<React.Fragment>
|
||||
<Form.RadioSet
|
||||
name="random name"
|
||||
value="selected radio option"
|
||||
<Form.RadioSet
|
||||
name="random name"
|
||||
value="selected radio option"
|
||||
>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="1 points"
|
||||
disabled={true}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name"
|
||||
>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="1 points"
|
||||
disabled={true}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name"
|
||||
>
|
||||
this label
|
||||
</Form.Radio>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="2 points"
|
||||
disabled={true}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name 2"
|
||||
>
|
||||
this label 2
|
||||
</Form.Radio>
|
||||
</Form.RadioSet>
|
||||
</React.Fragment>
|
||||
this label
|
||||
</Form.Radio>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="2 points"
|
||||
disabled={true}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name 2"
|
||||
>
|
||||
this label 2
|
||||
</Form.Radio>
|
||||
</Form.RadioSet>
|
||||
`;
|
||||
|
||||
exports[`Radio Criterion Container snapshot radio contain invalid response 1`] = `
|
||||
<React.Fragment>
|
||||
<Form.RadioSet
|
||||
name="random name"
|
||||
value="selected radio option"
|
||||
<Form.RadioSet
|
||||
name="random name"
|
||||
value="selected radio option"
|
||||
>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="1 points"
|
||||
disabled={false}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name"
|
||||
>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="1 points"
|
||||
disabled={false}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name"
|
||||
>
|
||||
this label
|
||||
</Form.Radio>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="2 points"
|
||||
disabled={false}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name 2"
|
||||
>
|
||||
this label 2
|
||||
</Form.Radio>
|
||||
<Form.Control.Feedback
|
||||
className="feedback-error-msg"
|
||||
type="invalid"
|
||||
>
|
||||
Rubric selection is required
|
||||
</Form.Control.Feedback>
|
||||
</Form.RadioSet>
|
||||
</React.Fragment>
|
||||
this label
|
||||
</Form.Radio>
|
||||
<Form.Radio
|
||||
className="criteria-option"
|
||||
description="2 points"
|
||||
disabled={false}
|
||||
onChange={[MockFunction this.onChange]}
|
||||
value="option name 2"
|
||||
>
|
||||
this label 2
|
||||
</Form.Radio>
|
||||
<Form.Control.Feedback
|
||||
className="feedback-error-msg"
|
||||
type="invalid"
|
||||
>
|
||||
Rubric selection is required
|
||||
</Form.Control.Feedback>
|
||||
</Form.RadioSet>
|
||||
`;
|
||||
|
||||
@@ -16,7 +16,7 @@ exports[`DemoWarning component snapshots snapshot: disabled flag is present 1`]
|
||||
</Alert.Heading>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="You are using the Demo Mode of the new Enhanced ORA Staff Grader interface. You will be unable to submit grades until you activate the feature."
|
||||
defaultMessage="You are demoing the new ORA staff grading experience. You will be unable to submit grades until you activate the feature. This will become the default grading experience on May 9th (05/09/2022). To opt-in early, or opt-out, please contact Partner Support."
|
||||
description="Demo mode message"
|
||||
id="ora-grading.ReviewModal.demoMessage"
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ const messages = defineMessages({
|
||||
},
|
||||
demoModeMessage: {
|
||||
id: 'ora-grading.ReviewModal.demoMessage',
|
||||
defaultMessage: 'You are using the Demo Mode of the new Enhanced ORA Staff Grader interface. You will be unable to submit grades until you activate the feature.',
|
||||
defaultMessage: 'You are demoing the new ORA staff grading experience. You will be unable to submit grades until you activate the feature. This will become the default grading experience on May 9th (05/09/2022). To opt-in early, or opt-out, please contact Partner Support.',
|
||||
description: 'Demo mode message',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
{},
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -19,3 +19,10 @@ span.pgn__icon.breadcrumb-arrow {
|
||||
}
|
||||
}
|
||||
|
||||
.submissions-table {
|
||||
.pgn__data-table-filters-breakout-filter {
|
||||
.pgn__form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const ListViewBreadcrumb = ({ courseId, oraName }) => (
|
||||
</Hyperlink>
|
||||
<p className="py-4">
|
||||
<span className="h3">{oraName}</span>
|
||||
<Hyperlink className="align-middle" destination={urls.ora(courseId, locationId)}>
|
||||
<Hyperlink className="align-middle" destination={urls.ora(courseId, locationId())}>
|
||||
<Icon src={Launch} className="d-inline-block" />
|
||||
</Hyperlink>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('ListViewBreadcrumb component', () => {
|
||||
test('ora destination', () => {
|
||||
expect(
|
||||
el.find(Hyperlink).at(1).props().destination,
|
||||
).toEqual(urls.ora(props.courseId, constants.locationId));
|
||||
).toEqual(urls.ora(props.courseId, constants.locationId()));
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
|
||||
27
src/containers/ListView/SelectedBulkAction.jsx
Normal file
27
src/containers/ListView/SelectedBulkAction.jsx
Normal 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;
|
||||
20
src/containers/ListView/SelectedBulkAction.test.jsx
Normal file
20
src/containers/ListView/SelectedBulkAction.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,9 +50,9 @@ export class SubmissionsTable extends React.Component {
|
||||
}
|
||||
|
||||
formatDate = ({ value }) => {
|
||||
const date = new Date(value);
|
||||
const date = new Date(moment(value));
|
||||
return date.toLocaleString();
|
||||
}
|
||||
};
|
||||
|
||||
formatGrade = ({ value: score }) => (
|
||||
score === null ? '-' : `${score.pointsEarned}/${score.pointsPossible}`
|
||||
@@ -65,82 +62,66 @@ 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() {
|
||||
if (!this.props.listData.length) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
30
src/containers/ListView/TableAction.jsx
Normal file
30
src/containers/ListView/TableAction.jsx
Normal 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;
|
||||
20
src/containers/ListView/TableAction.test.jsx
Normal file
20
src/containers/ListView/TableAction.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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 />
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PreviewDisplay } from './PreviewDisplay';
|
||||
|
||||
jest.mock('components/FilePreview', () => ({
|
||||
FileRenderer: () => 'FileRenderer',
|
||||
isSupported: jest.requireActual('components/FilePreview').isSupported,
|
||||
}));
|
||||
|
||||
describe('PreviewDisplay', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user