feat: submission files overview

chore: refactor css and file locations

chore: update fake data and file props

chore: create functional component for showing submission files

chore: update ResponseDisplay test

fix: fix bad snapshot

chore: update linting because husky does do it

chore: update snapshot

chore: update requested change

chore: update mock and snapshot
This commit is contained in:
Leangseu Kim
2021-10-22 16:58:19 -04:00
committed by leangseu-edx
parent 3de4dd86a3
commit 5361080bc3
15 changed files with 572 additions and 95 deletions

View File

@@ -0,0 +1,43 @@
@import "@edx/paragon/scss/core/core";
.response-display {
padding: map-get($spacers, 0);
max-width: map-get($container-max-widths, "sm");
overflow-y: hidden;
height: fit-content;
.submission-files {
padding: map-get($spacers, 3);
margin-bottom: map-get($spacers, 2);
.submission-files-title {
border-radius: calc(0.375rem - 1px);
border-bottom: 1px solid transparent;
transition: border-color 100ms ease 150ms;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
> h3 {
margin-bottom: 0;
}
&.no-submissions {
cursor: initial;
> h3 {
color: $gray-300;
}
}
}
.submission-files-body {
padding: map-get($spacers, 3) 0;
thead {
display: none;
}
}
}
}

View File

@@ -0,0 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Card, Collapsible, Icon, DataTable,
} from '@edx/paragon';
import { ArrowDropDown, ArrowDropUp } from '@edx/paragon/icons';
/* eslint react/prop-types: 0 */
export const HeaderEllipsesCell = ({ value }) => (
<div className="text-truncate">{value}</div>
);
/**
* <SubmissionFiles />
*/
export class SubmissionFiles extends React.Component {
get title() {
return `Submission Files (${this.props.files.length})`;
}
renderHeaderEllipsesCell = HeaderEllipsesCell;
render() {
const { files } = this.props;
return (
<Card className="submission-files">
{files.length ? (
<Collapsible.Advanced defaultOpen>
<Collapsible.Trigger className="submission-files-title">
<h3>{this.title}</h3>
<Collapsible.Visible whenClosed>
<Icon src={ArrowDropDown} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={ArrowDropUp} />
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="submission-files-body">
<DataTable
columns={[
{
Header: 'Name',
accessor: 'name',
Cell: this.renderHeaderEllipsesCell,
},
{
Header: 'Description',
accessor: 'description',
Cell: this.renderHeaderEllipsesCell,
},
]}
data={files}
>
<DataTable.Table />
</DataTable>
</Collapsible.Body>
</Collapsible.Advanced>
) : (
<div className="submission-files-title no-submissions">
<h3>{this.title}</h3>
</div>
)}
</Card>
);
}
}
SubmissionFiles.defaultProps = {
files: [],
};
SubmissionFiles.propTypes = {
files: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
description: PropTypes.string,
downloadUrl: PropTypes.string,
}),
),
};
export default SubmissionFiles;

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { shallow } from 'enzyme';
import { SubmissionFiles, HeaderEllipsesCell } from './SubmissionFiles';
jest.mock('@edx/paragon', () => {
const Card = () => 'Card';
const Collapsible = {};
Collapsible.Advanced = 'Collapsible.Advanced';
Collapsible.Trigger = 'Collapsible.Trigger';
Collapsible.Visible = 'Collapsible.Visible';
Collapsible.Body = 'Collapsible.Body';
const Button = () => 'Button';
const Icon = () => 'Icon';
const DataTable = () => 'DataTable';
DataTable.Table = 'DataTable.Table';
return {
Card,
Collapsible,
Button,
Icon,
DataTable,
};
});
jest.mock('@edx/paragon/icons', () => ({
ArrowDropDown: jest.fn().mockName('Icons.ArrowDropDown'),
ArrowDropUp: jest.fn().mockName('Icons.ArrowDropUp'),
}));
describe('SubmissionFiles', () => {
describe('component', () => {
const props = {
files: [
{
name: 'some file name.jpg',
description: 'description for the file',
downloadUrl: '/valid-url-wink-wink',
},
{
name: 'file number 2.jpg',
description: 'description for this file',
downloadUrl: '/url-2',
},
],
};
let el;
beforeAll(() => {
el = shallow(<SubmissionFiles />);
});
describe('snapshot', () => {
beforeAll(() => {
el.instance().renderHeaderEllipsesCell = jest.fn().mockName('HeaderEllipsesCell');
});
test('files does not exist', () => {
expect(el).toMatchSnapshot();
});
test('files exited for props', () => {
el.setProps({ ...props });
expect(el).toMatchSnapshot();
});
});
describe('behavior', () => {
test('title', () => {
const titleEl = el.find('.submission-files-title>h3');
expect(titleEl.text()).toEqual(`Submission Files (${props.files.length})`);
expect(el.instance().title).toEqual(`Submission Files (${props.files.length})`);
});
});
});
});
describe('HeaderEllipsesCell', () => {
describe('component', () => {
const props = {
value: 'some test text value',
};
let el;
beforeEach(() => {
el = shallow(<HeaderEllipsesCell {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
describe('behavior', () => {
test('content', () => {
expect(el.text()).toEqual(props.value);
});
});
});
});

View File

@@ -0,0 +1,91 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HeaderEllipsesCell component snapshot 1`] = `
<div
className="text-truncate"
>
some test text value
</div>
`;
exports[`SubmissionFiles component snapshot files does not exist 1`] = `
<Card
className="submission-files"
>
<div
className="submission-files-title no-submissions"
>
<h3>
Submission Files (0)
</h3>
</div>
</Card>
`;
exports[`SubmissionFiles component snapshot files exited for props 1`] = `
<Card
className="submission-files"
>
<Collapsible.Advanced
defaultOpen={true}
>
<Collapsible.Trigger
className="submission-files-title"
>
<h3>
Submission Files (2)
</h3>
<Collapsible.Visible
whenClosed={true}
>
<Icon
src={[MockFunction Icons.ArrowDropDown]}
/>
</Collapsible.Visible>
<Collapsible.Visible
whenOpen={true}
>
<Icon
src={[MockFunction Icons.ArrowDropUp]}
/>
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body
className="submission-files-body"
>
<DataTable
columns={
Array [
Object {
"Cell": [MockFunction HeaderEllipsesCell],
"Header": "Name",
"accessor": "name",
},
Object {
"Cell": [MockFunction HeaderEllipsesCell],
"Header": "Description",
"accessor": "description",
},
]
}
data={
Array [
Object {
"description": "description for the file",
"downloadUrl": "/valid-url-wink-wink",
"name": "some file name.jpg",
},
Object {
"description": "description for this file",
"downloadUrl": "/url-2",
"name": "file number 2.jpg",
},
]
}
>
<DataTable.Table />
</DataTable>
</Collapsible.Body>
</Collapsible.Advanced>
</Card>
`;

View File

@@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ResponseDisplay component snapshot no response 1`] = `
<div
className="response-display"
>
<SubmissionFiles
files={Array []}
/>
<Card>
<Card.Body>
parsed html (sanitized ())
</Card.Body>
</Card>
</div>
`;
exports[`ResponseDisplay component snapshot with valid response 1`] = `
<div
className="response-display"
>
<SubmissionFiles
files={
Array [
Object {
"description": "description for the file",
"downloadUrl": "/valid-url-wink-wink",
"name": "some file name.jpg",
},
Object {
"description": "description for this file",
"downloadUrl": "/url-2",
"name": "file number 2.jpg",
},
]
}
/>
<Card>
<Card.Body>
parsed html (sanitized (some text response here))
</Card.Body>
</Card>
</div>
`;

View File

@@ -2,9 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Card,
} from '@edx/paragon';
import { Card } from '@edx/paragon';
import createDOMPurify from 'dompurify';
@@ -12,6 +10,10 @@ import parse from 'html-react-parser';
import selectors from 'data/selectors';
import SubmissionFiles from './SubmissionFiles';
import './ResponseDisplay.scss';
/**
* <ResponseDisplay />
*/
@@ -25,36 +27,43 @@ export class ResponseDisplay extends React.Component {
return parse(this.purify.sanitize(this.props.response.text));
}
get hasResponse() {
return this.props.response !== undefined;
get submittedFiles() {
return this.props.response.files;
}
render() {
return (
<Card className="response-card">
{this.hasResponse && (
<Card.Body>
{this.textContent}
</Card.Body>
)}
</Card>
<div className="response-display">
<SubmissionFiles files={this.submittedFiles} />
<Card>
<Card.Body>{this.textContent}</Card.Body>
</Card>
</div>
);
}
}
ResponseDisplay.defaultProps = {
response: {
text: '',
files: [],
},
};
ResponseDisplay.propTypes = {
response: PropTypes.shape({
text: PropTypes.string,
}).isRequired,
files: PropTypes.arrayOf(
PropTypes.shape({
fileName: PropTypes.string,
}),
).isRequired,
}),
};
export const mapStateToProps = (state) => ({
response: selectors.grading.selected.response(state),
});
export const mapDispatchToProps = {
};
export const mapDispatchToProps = {};
export default connect(mapStateToProps, mapDispatchToProps)(ResponseDisplay);

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { shallow } from 'enzyme';
import createDOMPurify from 'dompurify';
import parse from 'html-react-parser';
import selectors from 'data/selectors';
import { ResponseDisplay, mapStateToProps } from '.';
jest.mock('@edx/paragon', () => {
const Card = () => 'Card';
Card.Body = 'Card.Body';
return {
Card,
};
});
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
grading: {
selected: {
response: (state) => ({ response: state }),
},
},
},
}));
jest.mock('./SubmissionFiles', () => 'SubmissionFiles');
jest.mock('dompurify', () => () => ({
sanitize: (text) => `sanitized (${text})`,
}));
jest.mock('html-react-parser', () => (text) => `parsed html (${text})`);
describe('ResponseDisplay', () => {
describe('component', () => {
const props = {
response: {
text: 'some text response here',
files: [
{
name: 'some file name.jpg',
description: 'description for the file',
downloadUrl: '/valid-url-wink-wink',
},
{
name: 'file number 2.jpg',
description: 'description for this file',
downloadUrl: '/url-2',
},
],
},
};
let el;
beforeAll(() => {
global.window = {};
el = shallow(<ResponseDisplay />);
});
describe('snapshot', () => {
test('no response', () => {
expect(el).toMatchSnapshot();
});
test('with valid response', () => {
el.setProps({ ...props });
expect(el).toMatchSnapshot();
});
});
describe('behavior', () => {
test('get textContent', () => {
expect(el.instance().textContent).toEqual(
parse(createDOMPurify(window).sanitize(props.response.text)),
);
});
test('get submittedFiles', () => {
expect(el.instance().submittedFiles).toEqual(props.response.files);
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = {
dummyText: 'text',
dummyFiles: ['files', 'file-2'],
};
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('response loads from grading.selected.response', () => {
expect(mapped.response).toEqual(
selectors.grading.selected.response(testState),
);
});
});
});

View File

@@ -0,0 +1,37 @@
@import "@edx/paragon/scss/core/core";
// action reviews
.review-actions {
padding: map_get($spacers, 3);
flex-direction: row;
background-color: $light-200;
.review-actions-username {
flex-grow: 1;
}
.review-actions-status {
margin-left: map_get($spacers, 3);
vertical-align: middle;
}
.review-actions-group {
margin-left: 0;
flex-shrink: 0;
align-self: stretch;
.submission-navigation {
float: right;
padding: map-get($spacers, 1);
}
}
}
@include media-breakpoint-down(md) {
.review-actions {
flex-direction: column;
align-items: flex-start !important;
}
.review-actions-username {
padding-bottom: map-get($spacers, 3);
}
}

View File

@@ -14,6 +14,8 @@ import StatusBadge from 'components/StatusBadge';
import StartGradingButton from './components/StartGradingButton';
import SubmissionNavigation from './components/SubmissionNavigation';
import './ReviewActions.scss';
export const ReviewActions = ({
gradingStatus,
toggleShowRubric,

View File

@@ -1,41 +1,5 @@
@import "@edx/paragon/scss/core/core";
// action reviews
.review-actions {
padding: map_get($spacers, 3);
flex-direction: row;
background-color: $light-200;
.review-actions-username {
flex-grow: 1;
}
.review-actions-status {
margin-left: map_get($spacers, 3);
vertical-align: middle;
}
.review-actions-group {
margin-left: 0;
flex-shrink: 0;
align-self: stretch;
.submission-navigation {
float: right;
padding: map-get($spacers, 1);
}
}
}
@include media-breakpoint-down(md) {
.review-actions {
flex-direction: column;
align-items: flex-start !important;
}
.review-actions-username {
padding-bottom: map-get($spacers, 3);
}
}
.review-modal-body {
background-color: $gray-300 !important;
padding: inherit;
@@ -53,14 +17,6 @@
margin: auto;
height: 100%;
}
// text response
.response-card {
padding: map-get($spacers, 0);
max-width: map-get($container-max-widths, "sm");
overflow-y: hidden;
height: fit-content;
}
}
@include media-breakpoint-down(sm) {
@@ -68,36 +24,8 @@
padding: 0 !important;
overflow-y: hidden !important;
.response-card {
height: 100%;
}
.content-block .col {
padding: 0;
}
}
}
.grading-rubric-card {
width: 320px;
height: fit-content;
max-height: 100%;
.grading-rubric-header {
box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.3) !important;
display: flex;
justify-content: center;
padding: map-get($spacers, 3);
}
.grading-rubric-body {
overflow-y: scroll;
}
.grading-rubric-footer {
box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.3) !important;
display: flex;
justify-content: center;
padding: map-get($spacers, 3);
}
}
}

View File

@@ -11,7 +11,7 @@ import {
import selectors from 'data/selectors';
import actions from 'data/actions';
import ResponseDisplay from 'components/ResponseDisplay';
import ResponseDisplay from 'containers/ResponseDisplay';
import Rubric from 'containers/Rubric';
import ReviewActions from 'containers/ReviewActions';

View File

@@ -43,3 +43,27 @@
}
}
.grading-rubric-card {
width: 320px;
height: fit-content;
max-height: 100%;
.grading-rubric-header {
box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.3) !important;
display: flex;
justify-content: center;
padding: map-get($spacers, 3);
}
.grading-rubric-body {
overflow-y: scroll;
}
.grading-rubric-footer {
box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.3) !important;
display: flex;
justify-content: center;
padding: map-get($spacers, 3);
}
}

View File

@@ -4,7 +4,7 @@ exports[`Rubric Container snapshot is grading 1`] = `
<Card
className="grading-rubric-card"
>
<Component
<Card.Body
className="grading-rubric-body"
>
<h3>
@@ -38,7 +38,7 @@ exports[`Rubric Container snapshot is grading 1`] = `
/>
<hr />
<RubricFeedback />
</Component>
</Card.Body>
<div
className="grading-rubric-footer"
>
@@ -53,7 +53,7 @@ exports[`Rubric Container snapshot is not grading 1`] = `
<Card
className="grading-rubric-card"
>
<Component
<Card.Body
className="grading-rubric-body"
>
<h3>
@@ -87,6 +87,6 @@ exports[`Rubric Container snapshot is not grading 1`] = `
/>
<hr />
<RubricFeedback />
</Component>
</Card.Body>
</Card>
`;

View File

@@ -9,7 +9,7 @@ jest.mock('./RubricFeedback', () => 'RubricFeedback');
jest.mock('@edx/paragon', () => {
const Card = () => 'Card';
Card.Body = () => 'Card.Body';
Card.Body = 'Card.Body';
const Button = () => 'Button';
return { Button, Card };
});

View File

@@ -6,11 +6,36 @@ Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi
Phasellus porttitor vel magna et auctor. Nulla porttitor convallis aliquam. Donec cursus, ipsum ut egestas bibendum, purus metus dignissim est, ac condimentum leo felis eget diam. In magna mi, tincidunt id sapien id, fermentum vestibulum quam. Quisque et dui sed urna convallis rutrum pellentesque quis sapien. Cras non lectus velit. Praesent semper eros id risus mollis, quis interdum quam imperdiet. Sed nec vulputate tortor, at tristique tortor.
</div>`;
const descriptiveText = (fileName) => `This is some descriptive text for (${fileName}). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.`;
const allFiles = [
'presentation.pdf',
'example.jpg',
'diagram.png',
'notes.doc',
'recording.wav',
];
const getFiles = (submissionId) => {
const index = parseInt(submissionId.split('-')[1], 10);
const numFiles = index % allFiles.length;
const files = [];
for (let i = 0; i < numFiles; i++) {
const fileName = `${submissionId}_${allFiles[i]}`;
files.push({
name: fileName,
description: descriptiveText(fileName),
downloadUrl: `/download/${fileName}/`,
});
}
return files;
};
// eslint-disable-next-line
export const mockSubmission = (submissionId) => ({
response: {
text: responseText(submissionId),
files: [],
files: getFiles(submissionId),
},
gradeStatus: submissionList[submissionId].gradeStatus,
lockStatus: submissionList[submissionId].lockStatus,