refactor: update drawer logic and container layout (#190)

* update drawer layout and container logic

* stop renaming the FilterBadges component

* docstring for WithSidebar

* add unit tests

* v1.4.33
This commit is contained in:
Ben Warzeski
2021-06-07 16:24:37 -04:00
committed by GitHub
parent 2029a7cef3
commit d727420c37
70 changed files with 836 additions and 290 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@edx/frontend-app-gradebook",
"version": "1.4.32",
"version": "1.4.33",
"description": "edx editable gradebook-ui to manipulate grade overrides on subsections",
"repository": {
"type": "git",

View File

@@ -11,5 +11,5 @@ $input-focus-box-shadow: $input-box-shadow; // hack to get upgrade to paragon 4.
@import "~@edx/frontend-component-footer/dist/_footer";
@import "./components/Gradebook/gradebook";
@import "./components/Drawer/Drawer";
@import "./components/GradesTab/GradesTab";
@import "./components/WithSidebar/WithSidebar";

View File

@@ -1,85 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
export default class Drawer extends React.Component {
constructor(props) {
super(props);
this.state = {
open: props.initiallyOpen,
transitioning: false,
};
}
close = () => {
if (this.state.open) {
this.toggleOpen();
}
};
toggleOpen = () => {
this.setState({ transitioning: true });
// defer the transition to the next repaint so we can be sure that
// opening drawer is visible before it transitions
// (the start state of the opening animation doesn't work if the element starts hidden)
this.deferToNextRepaint(() => this.setState(prevState => ({ open: !prevState.open })));
};
handleSlideDone = (e) => {
if (e.currentTarget === e.target) {
this.setState({ transitioning: false });
}
};
deferToNextRepaint(callback) {
window.requestAnimationFrame(() => window.setTimeout(callback, 0));
}
render() {
return (
<div className="d-flex drawer-container">
<aside
className={classNames(
'drawer',
{
open: this.state.open,
'd-none': !this.state.transitioning && !this.state.open,
},
)}
onTransitionEnd={this.handleSlideDone}
>
<div className="drawer-header">
<h2>{this.props.title}</h2>
<Button
className="p-1"
onClick={this.close}
aria-label="Close Filters"
>
<FontAwesomeIcon icon={faTimes} />
</Button>
</div>
{this.props.children}
</aside>
<div
className={classNames(
'drawer-contents',
'position-relative',
!this.state.drawerTransitioning && this.state.drawerOpen && 'opened',
)}
>
{this.props.mainContent(this.toggleOpen)}
</div>
</div>
);
}
}
Drawer.propTypes = {
initiallyOpen: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
mainContent: PropTypes.func.isRequired,
title: PropTypes.node.isRequired,
};

View File

@@ -1,133 +0,0 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import {
Tab,
Tabs,
} from '@edx/paragon';
import queryString from 'query-string';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFilter } from '@fortawesome/free-solid-svg-icons';
import PageButtons from '../PageButtons';
import Drawer from '../Drawer';
import ConnectedFilterBadges from '../FilterBadges';
import GradebookHeader from './GradebookHeader';
import BulkManagement from './BulkManagement';
import BulkManagementControls from './BulkManagementControls';
import EditModal from './EditModal';
import GradebookFilters from './GradebookFilters';
import GradebookTable from './GradebookTable';
import SearchControls from './SearchControls';
import StatusAlerts from './StatusAlerts';
import SpinnerIcon from './SpinnerIcon';
import ScoreViewInput from './ScoreViewInput';
import UsersLabel from './UsersLabel';
export default class Gradebook extends React.Component {
constructor(props) {
super(props);
this.myRef = React.createRef();
}
componentDidMount() {
const urlQuery = queryString.parse(this.props.location.search);
this.props.initializeApp(this.props.courseId, urlQuery);
}
getActiveTabs = () => (
this.props.showBulkManagement ? ['Grades', 'BulkManagement'] : ['Grades']
);
updateQueryParams = (queryParams) => {
const parsed = queryString.parse(this.props.location.search);
Object.keys(queryParams).forEach((key) => {
if (queryParams[key]) {
parsed[key] = queryParams[key];
} else {
delete parsed[key];
}
});
this.props.history.push(`?${queryString.stringify(parsed)}`);
};
handleFilterBadgeClose = filterNames => () => {
this.props.resetFilters(filterNames);
this.updateQueryParams(filterNames.reduce(
(obj, filterName) => ({ ...obj, [filterName]: false }),
{},
));
this.props.fetchGrades();
}
render() {
return (
<Drawer
mainContent={toggleFilterDrawer => (
<div className="px-3 gradebook-content">
<GradebookHeader />
<Tabs defaultActiveKey="grades">
<Tab eventKey="grades" title="Grades">
<SpinnerIcon />
<SearchControls toggleFilterDrawer={toggleFilterDrawer} />
<ConnectedFilterBadges handleFilterBadgeClose={this.handleFilterBadgeClose} />
<StatusAlerts />
<h4>Step 2: View or Modify Individual Grades</h4>
<UsersLabel />
<div className="d-flex justify-content-between align-items-center mb-2">
<ScoreViewInput />
<BulkManagementControls />
</div>
<PageButtons {...this.props} />
<GradebookTable />
<PageButtons />
<p>* available for learners in the Master&apos;s track only</p>
<EditModal />
</Tab>
{this.props.showBulkManagement
&& (
<Tab eventKey="bulk_management" title="Bulk Management">
<BulkManagement />
</Tab>
)}
</Tabs>
</div>
)}
initiallyOpen={false}
title={(
<>
<FontAwesomeIcon icon={faFilter} /> Filter By...
</>
)}
>
<GradebookFilters updateQueryParams={this.updateQueryParams} />
</Drawer>
);
}
}
Gradebook.defaultProps = {
courseId: '',
location: {
search: '',
},
showBulkManagement: false,
};
Gradebook.propTypes = {
courseId: PropTypes.string,
fetchGrades: PropTypes.func.isRequired,
history: PropTypes.shape({
push: PropTypes.func,
}).isRequired,
initializeApp: PropTypes.func.isRequired,
location: PropTypes.shape({
search: PropTypes.string,
}),
resetFilters: PropTypes.func.isRequired,
showBulkManagement: PropTypes.bool,
};

View File

@@ -3,7 +3,10 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Collapsible, Form } from '@edx/paragon';
import {
Collapsible,
Form,
} from '@edx/paragon';
import actions from 'data/actions';
import selectors from 'data/selectors';
@@ -73,10 +76,11 @@ GradebookFilters.defaultProps = {
includeCourseRoleMembers: false,
};
GradebookFilters.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// redux
fetchGrades: PropTypes.func.isRequired,
includeCourseRoleMembers: PropTypes.bool,
updateIncludeCourseRoleMembers: PropTypes.func.isRequired,
updateQueryParams: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({

View File

@@ -0,0 +1,38 @@
/* eslint-disable react/sort-comp, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Icon,
IconButton,
} from '@edx/paragon';
import { Close } from '@edx/paragon/icons';
import thunkActions from 'data/thunkActions';
export const GradebookFiltersHeader = ({ closeMenu }) => (
<>
<h2><Icon className="fa fa-filter" /></h2>
<IconButton
className="p-1"
onClick={closeMenu}
iconAs={Icon}
src={Close}
alt="Close Filters"
aria-label="Close Filters"
/>
</>
);
GradebookFiltersHeader.propTypes = {
// redux
closeMenu: PropTypes.func.isRequired,
};
export const mapStateToProps = () => ({});
export const mapDispatchToProps = {
closeMenu: thunkActions.app.filterMenu.close,
};
export default connect(mapStateToProps, mapDispatchToProps)(GradebookFiltersHeader);

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { shallow } from 'enzyme';
import thunkActions from 'data/thunkActions';
import {
GradebookFiltersHeader,
mapDispatchToProps,
} from './GradebookFiltersHeader';
jest.mock('@edx/paragon', () => ({
Icon: jest.fn().mockName('Paragon.Icon'),
IconButton: () => 'IconButton',
}));
jest.mock('@edx/paragon/icons', () => ({
close: jest.fn().mockName('Paragon.icons.Close'),
}));
jest.mock('data/thunkActions', () => ({
__esModule: true,
default: {
app: {
filterMenu: {
close: jest.fn().mockName('closeFilterMenu'),
},
},
},
}));
describe('GradebookFiltersHeader', () => {
let props;
beforeEach(() => {
props = {
closeMenu: jest.fn().mockName('props.closeMenu'),
};
});
describe('Component', () => {
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<GradebookFiltersHeader {...props} />);
expect(el).toMatchSnapshot();
});
});
});
describe('mapDispatchToProps', () => {
describe('closeMenu', () => {
test('from thunkActions.app.filterMenu.close', () => {
expect(mapDispatchToProps.closeMenu).toEqual(
thunkActions.app.filterMenu.close,
);
});
});
});
});

View File

@@ -60,11 +60,11 @@ export class SearchControls extends React.Component {
}
SearchControls.propTypes = {
toggleFilterDrawer: PropTypes.func.isRequired,
// From Redux
fetchGrades: PropTypes.func.isRequired,
searchValue: PropTypes.string.isRequired,
setSearchValue: PropTypes.func.isRequired,
toggleFilterDrawer: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
@@ -74,6 +74,7 @@ export const mapStateToProps = (state) => ({
export const mapDispatchToProps = {
fetchGrades: thunkActions.grades.fetchGrades,
setSearchValue: actions.app.setSearchValue,
toggleFilterDrawer: thunkActions.app.filterMenu.toggle,
};
export default connect(mapStateToProps, mapDispatchToProps)(SearchControls);

View File

@@ -23,7 +23,10 @@ jest.mock('data/thunkActions', () => ({
__esModule: true,
default: {
grades: {
fetchGrades: jest.fn(),
fetchGrades: jest.fn().mockName('thunkActions.grades.fetchGrades'),
},
app: {
filterMenu: { toggle: jest.fn().mockName('thunkActions.app.filterMenu') },
},
},
}));
@@ -47,6 +50,15 @@ describe('SearchControls', () => {
};
describe('Component', () => {
describe('Snapshots', () => {
test('basic snapshot', () => {
const wrapper = searchControls();
wrapper.instance().onChange = jest.fn().mockName('onChange');
wrapper.instance().onClear = jest.fn().mockName('onClear');
expect(wrapper.instance().render()).toMatchSnapshot();
});
});
describe('onChange', () => {
it('saves the changed search value to Gradebook state', () => {
const wrapper = searchControls();
@@ -80,14 +92,11 @@ describe('SearchControls', () => {
test('setSearchValue from actions.app.setSearchValue', () => {
expect(mapDispatchToProps.setSearchValue).toEqual(actions.app.setSearchValue);
});
});
describe('Snapshots', () => {
test('basic snapshot', () => {
const wrapper = searchControls();
wrapper.instance().onChange = jest.fn().mockName('onChange');
wrapper.instance().onClear = jest.fn().mockName('onClear');
expect(wrapper.instance().render()).toMatchSnapshot();
test('toggleFilterDrawer from thunkActions.app.filterMenu.toggle', () => {
expect(
mapDispatchToProps.toggleFilterDrawer,
).toEqual(thunkActions.app.filterMenu.toggle);
});
});
});

View File

@@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradesTab Component snapshots basic snapshot 1`] = `
<React.Fragment>
<SpinnerIcon />
<SearchControls />
<FilterBadges
handleFilterBadgeClose={[MockFunction this.handleFilterBadgeClose]}
/>
<StatusAlerts />
<h4>
Step 2: View or Modify Individual Grades
</h4>
<UsersLabel />
<div
className="d-flex justify-content-between align-items-center mb-2"
>
<ScoreViewInput />
<BulkManagementControls />
</div>
<GradebookTable />
<PageButtons />
<p>
* available for learners in the Master's track only
</p>
<EditModal />
</React.Fragment>
`;

View File

@@ -0,0 +1,81 @@
/* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import PageButtons from '../PageButtons';
import FilterBadges from '../FilterBadges';
import BulkManagementControls from './BulkManagementControls';
import EditModal from './EditModal';
import GradebookTable from './GradebookTable';
import SearchControls from './SearchControls';
import StatusAlerts from './StatusAlerts';
import SpinnerIcon from './SpinnerIcon';
import ScoreViewInput from './ScoreViewInput';
import UsersLabel from './UsersLabel';
export class GradesTab extends React.Component {
constructor(props) {
super(props);
this.handleFilterBadgeClose = this.handleFilterBadgeClose.bind(this);
}
handleFilterBadgeClose(filterNames) {
return () => {
this.props.resetFilters(filterNames);
this.props.updateQueryParams(filterNames.reduce(
(obj, filterName) => ({ ...obj, [filterName]: false }),
{},
));
this.props.fetchGrades();
};
}
render() {
return (
<>
<SpinnerIcon />
<SearchControls />
<FilterBadges handleFilterBadgeClose={this.handleFilterBadgeClose} />
<StatusAlerts />
<h4>Step 2: View or Modify Individual Grades</h4>
<UsersLabel />
<div className="d-flex justify-content-between align-items-center mb-2">
<ScoreViewInput />
<BulkManagementControls />
</div>
<GradebookTable />
<PageButtons />
<p>* available for learners in the Master&apos;s track only</p>
<EditModal />
</>
);
}
}
GradesTab.defaultProps = {};
GradesTab.propTypes = {
updateQueryParams: PropTypes.func.isRequired,
// redux
fetchGrades: PropTypes.func.isRequired,
resetFilters: PropTypes.func.isRequired,
};
export const mapStateToProps = () => ({});
export const mapDispatchToProps = {
fetchGrades: thunkActions.grades.fetchGrades,
resetFilters: actions.filters.reset,
};
export default connect(mapStateToProps, mapDispatchToProps)(GradesTab);

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { shallow } from 'enzyme';
import actions from 'data/actions';
import thunkActions from 'data/thunkActions';
import {
GradesTab,
mapDispatchToProps,
} from '.';
jest.mock('data/actions', () => ({
__esModule: true,
default: {
filters: { resetFilters: jest.fn() },
},
}));
jest.mock('data/thunkActions', () => ({
__esModule: true,
default: {
grades: { fetchGrades: jest.fn() },
},
}));
jest.mock('../PageButtons', () => 'PageButtons');
jest.mock('../FilterBadges', () => 'FilterBadges');
jest.mock('./BulkManagementControls', () => 'BulkManagementControls');
jest.mock('./EditModal', () => 'EditModal');
jest.mock('./GradebookTable', () => 'GradebookTable');
jest.mock('./SearchControls', () => 'SearchControls');
jest.mock('./StatusAlerts', () => 'StatusAlerts');
jest.mock('./SpinnerIcon', () => 'SpinnerIcon');
jest.mock('./ScoreViewInput', () => 'ScoreViewInput');
jest.mock('./UsersLabel', () => 'UsersLabel');
describe('GradesTab', () => {
let props;
beforeEach(() => {
props = {
updateQueryParams: jest.fn(),
fetchGrades: jest.fn(),
resetFilters: jest.fn(),
};
});
describe('Component', () => {
const filterNames = ['duck', 'Duck', 'Duuuuuck', 'GOOOOSE!'];
describe('behavior', () => {
let el;
beforeEach(() => {
el = shallow(<GradesTab {...props} />);
});
describe('handleFilterBadgeClose', () => {
beforeEach(() => {
el.instance().handleFilterBadgeClose(filterNames)();
});
it('calls props.resetFilters with the filters', () => {
expect(props.resetFilters).toHaveBeenCalledWith(filterNames);
});
it('calls props.updateQueryParams with a reset-filters obj', () => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
[filterNames[0]]: false,
[filterNames[1]]: false,
[filterNames[2]]: false,
[filterNames[3]]: false,
});
});
it('calls fetchGrades', () => {
expect(props.fetchGrades).toHaveBeenCalledWith();
});
});
});
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<GradesTab {...props} />);
el.instance().handleFilterBadgeClose = jest.fn().mockName('this.handleFilterBadgeClose');
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapDispatchToProps', () => {
describe('fetchGrades', () => {
test('from thunkActions.grades.fetchGrades', () => {
expect(mapDispatchToProps.fetchGrades).toEqual(
thunkActions.grades.fetchGrades,
);
});
});
describe('resetFilters', () => {
test('from actions.filters.reset', () => {
expect(mapDispatchToProps.resetFilters).toEqual(
actions.filters.reset,
);
});
});
});
});

View File

@@ -1,45 +1,45 @@
$drawer-width: 350px;
$sidebar-width: 350px;
.drawer-contents {
.sidebar-contents {
overflow-x: auto;
transition: margin 300ms cubic-bezier(0.4,0,0.2,1);
margin-left: 0;
.drawer.open + & {
margin-left: $drawer-width;
.sidebar.open + & {
margin-left: $sidebar-width;
}
&.opened {
width: calc(100vw - #{$drawer-width});
&.opening {
width: calc(100vw - #{$sidebar-width});
}
}
.drawer-contents {
.sidebar-contents {
overflow-x: auto;
transition: margin 300ms cubic-bezier(0.4,0,0.2,1);
margin-left: 0;
.drawer.open + & {
margin-left: $drawer-width;
.sidebar.open + & {
margin-left: $sidebar-width;
}
&.opened {
width: calc(100vw - #{$drawer-width});
&.opening {
width: calc(100vw - #{$sidebar-width});
}
}
.drawer-header {
.sidebar-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 15px;
}
.drawer-container .collapsible {
.sidebar-container .collapsible {
margin-bottom: 1em;
}
.drawer {
.sidebar {
height: 100%;
width: $drawer-width;
width: $sidebar-width;
position: absolute;
transform: translateX(-$drawer-width);
transform: translateX(-$sidebar-width);
flex-direction: column;
transition: transform 300ms cubic-bezier(0.4,0,0.2,1);
&.open {

View File

@@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`WithSidebar Component snapshots basic snapshot 1`] = `
<div
className="d-flex sidebar-container"
>
<aside
className="sidebar-class-names"
onTransitionEnd={[MockFunction handleSlideDone]}
>
<div
className="sidebar-header"
>
<div>
A really nice sidebar header
</div>
</div>
<div>
Some Sidebar Content
</div>
</aside>
<div
className="content-class-names"
>
<b>
aby in a bi
</b>
</div>
</div>
`;

View File

@@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import classNames from 'classnames';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
/**
* WithSidebar
* Simple wrapper around a content component, with a collapsible sidebar, whose open/closed
* state is drawn from redux.
*
* Wraps child react content in a container to enable the sidebar behavior.
*
* @param {JSX} children - page content
* @param {JSX} sidebar - sidebar content
* @param {JSX} sidebarHeader - sidebar header content
*
* Ex Usage:
* <WithSidebar sidebar={sidebarContent} sidebarHeader={sidebarHeader}>{children}</WithSidebar>
*/
export class WithSidebar extends React.Component {
get sidebarClassNames() {
return classNames('sidebar', { open: this.props.open, 'd-none': this.props.isClosed });
}
get contentClassNames() {
return classNames('sidebar-contents', 'position-relative', {
opening: this.props.isOpening,
});
}
render() {
return (
<div className="d-flex sidebar-container">
<aside className={this.sidebarClassNames} onTransitionEnd={this.props.handleSlideDone}>
<div className="sidebar-header">
{ this.props.sidebarHeader }
</div>
{ this.props.sidebar }
</aside>
<div className={this.contentClassNames}>
{ this.props.children}
</div>
</div>
);
}
}
WithSidebar.propTypes = {
children: PropTypes.node.isRequired,
sidebar: PropTypes.node.isRequired,
sidebarHeader: PropTypes.node.isRequired,
// redux
isClosed: PropTypes.bool.isRequired,
isOpening: PropTypes.bool.isRequired,
open: PropTypes.bool.isRequired,
handleSlideDone: PropTypes.func.isRequired,
};
export const mapStateToProps = (state) => ({
open: selectors.app.filterMenu.open(state),
isClosed: selectors.app.filterMenu.isClosed(state),
isOpening: selectors.app.filterMenu.isOpening(state),
});
export const mapDispatchToProps = {
handleSlideDone: thunkActions.app.filterMenu.handleTransitionEnd,
};
export default connect(mapStateToProps, mapDispatchToProps)(WithSidebar);

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { shallow } from 'enzyme';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import {
WithSidebar,
mapStateToProps,
mapDispatchToProps,
} from '.';
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
filterMenu: {
isClosed: jest.fn(state => ({ isClosed: state })),
isOpening: jest.fn(state => ({ isOpening: state })),
open: jest.fn(state => ({ open: state })),
},
},
},
}));
jest.mock('data/thunkActions', () => ({
__esModule: true,
default: {
app: {
filterMenu: {
handleTransitionEnd: jest.fn().mockName('handleTransitionEnd'),
},
},
},
}));
describe('WithSidebar', () => {
let props = {
sidebar: (<div>Some Sidebar Content</div>),
sidebarHeader: (<div>A really nice sidebar header</div>),
children: (<b>aby in a bi</b>),
isClosed: true,
isOpening: false,
open: false,
};
beforeEach(() => {
props = {
...props,
handleSlideDone: jest.fn().mockName('handleSlideDone'),
};
});
describe('Component', () => {
describe('behavior', () => {
let el;
beforeEach(() => {
el = shallow(<WithSidebar {...props} />);
});
describe('sidebarClassNames', () => {
const getVal = () => el.instance().sidebarClassNames.split(' ');
it('returns a "sidebar" classname', () => {
expect(getVal()).toContain('sidebar');
});
it('includes an open className iff props.open', () => {
expect(getVal()).not.toContain('open');
el.setProps({ open: true });
expect(getVal()).toContain('open');
});
it('includes a d-none className iff props.isClosed', () => {
expect(getVal()).toContain('d-none');
el.setProps({ isClosed: false });
expect(getVal()).not.toContain('d-none');
});
});
describe('contentClassNames', () => {
const getVal = () => el.instance().contentClassNames.split(' ');
it('includes sidebar-contents and position-relative classNames', () => {
expect(getVal()).toContain('sidebar-contents');
expect(getVal()).toContain('position-relative');
});
it('includes an opening class iff props.isOpening', () => {
expect(getVal()).not.toContain('opening');
el.setProps({ isOpening: true });
expect(getVal()).toContain('opening');
});
});
});
describe('snapshots', () => {
test('basic snapshot', () => {
const el = shallow(<WithSidebar {...props} />);
const sidebarClassNames = 'sidebar-class-names';
const contentClassNames = 'content-class-names';
jest.spyOn(el.instance(), 'sidebarClassNames', 'get').mockReturnValue(sidebarClassNames);
jest.spyOn(el.instance(), 'contentClassNames', 'get').mockReturnValue(contentClassNames);
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
const testState = { A: 'laska' };
let mapped;
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('open from app.filterMenu.open', () => {
expect(mapped.open).toEqual(selectors.app.filterMenu.open(testState));
});
test('isClosed from app.filterMenu.isClosed', () => {
expect(mapped.isClosed).toEqual(selectors.app.filterMenu.isClosed(testState));
});
test('open from app.filterMenu.isOpening', () => {
expect(mapped.isOpening).toEqual(selectors.app.filterMenu.isOpening(testState));
});
});
describe('mapDispatchToProps', () => {
describe('handleSlideDone', () => {
test('from thunkActions.app.filterMenu.handleTransitionEnd', () => {
expect(mapDispatchToProps.handleSlideDone).toEqual(
thunkActions.app.filterMenu.handleTransitionEnd,
);
});
});
});
});

View File

@@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GradebookFiltersHeader Component snapshots basic snapshot 1`] = `
<Fragment>
<h2>
<mockConstructor
className="fa fa-filter"
/>
</h2>
<IconButton
alt="Close Filters"
aria-label="Close Filters"
className="p-1"
iconAs={[MockFunction Paragon.Icon]}
onClick={[MockFunction props.closeMenu]}
/>
</Fragment>
`;

View File

@@ -1,45 +1,83 @@
/* eslint-disable import/no-named-as-default */
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import queryString from 'query-string';
import { Tab, Tabs } from '@edx/paragon';
import thunkActions from 'data/thunkActions';
import actions from 'data/actions';
import selectors from 'data/selectors';
import thunkActions from 'data/thunkActions';
import Gradebook from 'components/Gradebook';
import WithSidebar from 'components/WithSidebar';
import GradebookHeader from 'components/GradebookHeader';
import GradesTab from 'components/GradesTab';
import GradebookFilters from 'components/GradebookFilters';
import GradebookFiltersHeader from 'components/GradebookFiltersHeader';
import BulkManagement from 'components/BulkManagement';
const mapStateToProps = (state, ownProps) => {
const {
root,
filters,
grades,
} = selectors;
export class GradebookPage extends React.Component {
constructor(props) {
super(props);
this.updateQueryParams = this.updateQueryParams.bind(this);
}
const { courseId } = ownProps.match.params;
return {
courseId,
filteredUsersCount: grades.filteredUsersCount(state),
gradeExportUrl: root.gradeExportUrl(state, { courseId }),
interventionExportUrl: root.interventionExportUrl(state, { courseId }),
selectedTrack: filters.track(state),
selectedCohort: filters.cohort(state),
selectedAssignmentType: filters.assignmentType(state),
showBulkManagement: root.showBulkManagement(state, { courseId }),
showSpinner: root.shouldShowSpinner(state),
totalUsersCount: grades.totalUsersCount(state),
};
componentDidMount() {
const urlQuery = queryString.parse(this.props.location.search);
this.props.initializeApp(this.props.courseId, urlQuery);
}
updateQueryParams(queryParams) {
const parsed = queryString.parse(this.props.location.search);
Object.keys(queryParams).forEach((key) => {
if (queryParams[key]) {
parsed[key] = queryParams[key];
} else {
delete parsed[key];
}
});
}
render() {
return (
<WithSidebar
sidebar={<GradebookFilters updateQueryParams={this.updateQueryParams} />}
sidebarHeader={<GradebookFiltersHeader />}
>
<div className="px-3 gradebook-content">
<GradebookHeader />
<Tabs defaultActiveKey="grades">
<Tab eventKey="grades" title="Grades">
<GradesTab updateQueryParams={this.updateQueryParams} />
</Tab>
{this.props.showBulkManagement && (
<Tab eventKey="bulk_management" title="Bulk Management">
<BulkManagement />
</Tab>
)}
</Tabs>
</div>
</WithSidebar>
);
}
}
GradebookPage.defaultProps = {
location: { search: '' },
};
GradebookPage.propTypes = {
location: PropTypes.shape({ search: PropTypes.string }),
courseId: PropTypes.string.isRequired,
initializeApp: PropTypes.func.isRequired,
showBulkManagement: PropTypes.bool.isRequired,
};
const mapDispatchToProps = {
toggleFormat: actions.grades.toggleGradeFormat,
resetFilters: actions.filters.reset,
export const mapStateToProps = (state, ownProps) => ({
courseId: ownProps.match.params.courseId,
showBulkManagement: selectors.root.showBulkManagement(state),
});
export const mapDispatchToProps = {
initializeApp: thunkActions.app.initialize,
fetchGrades: thunkActions.grades.fetchGrades,
getRoles: thunkActions.roles.fetchRoles,
getTracks: thunkActions.tracks.fetchTracks,
};
const GradebookPage = connect(
mapStateToProps,
mapDispatchToProps,
)(Gradebook);
export default GradebookPage;
export default connect(mapStateToProps, mapDispatchToProps)(GradebookPage);

View File

@@ -12,11 +12,19 @@ const closeModal = createAction('closeModal');
* @param {string} courseId - course ID from app context
*/
const setCourseId = createAction('setCourseId');
const filterMenu = StrictDict({
endTransition: createAction('filterMenu/endTransition'),
startTransition: createAction('filterMenu/startTransition'),
toggle: createAction('filterMenu/toggle'),
});
/**
* setModalStateFromTable({ userEntry, subsection })
* sets modalState
* */
*/
const setModalStateFromTable = createAction('setModalStateFromTable');
/**
* setSearchValue(searchValue)
* sets searchValue in local state
@@ -48,9 +56,10 @@ const setModalState = createAction('setModalState', (modalState) => ({
export default StrictDict({
closeModal,
filterMenu,
setCourseId,
setModalState,
setModalStateFromTable,
setSearchValue,
setLocalFilter,
setModalStateFromTable,
});

View File

@@ -22,6 +22,10 @@ const initialState = {
updateUserId: null,
updateUserName: null,
},
filterMenu: {
open: false,
transitioning: false,
},
searchValue: '',
};
@@ -31,6 +35,21 @@ const app = (state = initialState, { type, payload }) => {
return { ...state, modalState: { ...initialState.modalState } };
case actions.setCourseId.toString():
return { ...state, courseId: payload };
case actions.filterMenu.startTransition.toString():
return {
...state,
filterMenu: { ...state.filterMenu, transitioning: true },
};
case actions.filterMenu.endTransition.toString():
return {
...state,
filterMenu: { ...state.filterMenu, transitioning: false },
};
case actions.filterMenu.toggle.toString():
return {
...state,
filterMenu: { ...state.filterMenu, open: !state.filterMenu.open },
};
case actions.setLocalFilter.toString():
return {
...state,

View File

@@ -68,6 +68,14 @@ const areCourseGradeFiltersValid = (state) => {
return validity.isMinValid && validity.isMaxValid;
};
const isFilterMenuClosed = ({ app: { filterMenu } }) => (
!filterMenu.open && !filterMenu.transitioning
);
const isFilterMenuOpening = ({ app: { filterMenu } }) => (
filterMenu.transitioning && filterMenu.open
);
const modalSelectors = simpleSelectorFactory(
({ app: { modalState } }) => modalState,
[
@@ -81,6 +89,11 @@ const modalSelectors = simpleSelectorFactory(
],
);
const filterMenuSelectors = simpleSelectorFactory(
({ app: { filterMenu } }) => filterMenu,
['open', 'transitioning'],
);
const simpleSelectors = simpleSelectorFactory(
({ app }) => app,
[
@@ -98,4 +111,9 @@ export default StrictDict({
editUpdateData,
...simpleSelectors,
modalState: StrictDict(modalSelectors),
filterMenu: StrictDict({
...filterMenuSelectors,
isClosed: isFilterMenuClosed,
isOpening: isFilterMenuOpening,
}),
});

View File

@@ -93,6 +93,50 @@ describe('app selectors', () => {
selectors.courseGradeFilterValidity = old;
});
});
describe('filterMenu', () => {
describe('isClosed', () => {
const testSelector = (open, transitioning, expected) => {
expect(exportedSelectors.filterMenu.isClosed({
app: { filterMenu: { open, transitioning } },
})).toEqual(expected);
};
it('returns true if filterMenu is !open and !transitioning', () => {
testSelector(false, false, true);
});
it('returns false if filterMenu is not (!open and !transitioning)', () => {
testSelector(true, false, false);
testSelector(false, true, false);
testSelector(true, true, false);
});
});
describe('isOpening', () => {
const testSelector = (open, transitioning, expected) => {
expect(exportedSelectors.filterMenu.isOpening({
app: { filterMenu: { open, transitioning } },
})).toEqual(expected);
};
it('returns true if filter menu is transitioning AND open', () => {
testSelector(true, true, true);
});
it('returns true if filter menu is not (transitioning AND open)', () => {
testSelector(false, false, false);
testSelector(true, false, false);
testSelector(false, true, false);
});
});
describe('simpleSelectors', () => {
const testFilterMenuSelector = (key) => {
test(key, () => {
expect(
exportedSelectors.filterMenu[key]({ app: { filterMenu: { [key]: testVal } } }),
).toEqual(testVal);
});
};
testFilterMenuSelector('open');
testFilterMenuSelector('transitioning');
});
});
describe('modalSelectors', () => {
const testModalSelector = (key) => {
test(key, () => {

View File

@@ -1,8 +1,36 @@
/* eslint-disable import/no-self-import */
import { StrictDict } from 'utils';
import actions from 'data/actions';
import selectors from 'data/selectors';
import { fetchGradeOverrideHistory } from './grades';
import { fetchRoles } from './roles';
import * as module from './app';
export const initialize = (courseId, urlQuery) => (dispatch) => {
dispatch(actions.app.setCourseId(courseId));
dispatch(actions.filters.initialize(urlQuery));
dispatch(fetchRoles());
};
export const filterMenu = StrictDict({
close: () => (dispatch, getState) => {
if (selectors.app.filterMenu.open(getState())) {
dispatch(module.filterMenu.toggle());
}
},
handleTransitionEnd: (event) => (dispatch) => {
if (event.currentTarget === event.target) {
dispatch(actions.app.filterMenu.endTransition());
}
},
toggle: () => (dispatch) => {
dispatch(actions.app.filterMenu.startTransition());
const toggleMenu = () => dispatch(actions.app.filterMenu.toggle());
const animationCb = () => window.setTimeout(toggleMenu);
window.requestAnimationFrame(animationCb);
},
});
export const setModalStateFromTable = ({ userEntry, subsection }) => (
(dispatch) => {
@@ -11,13 +39,8 @@ export const setModalStateFromTable = ({ userEntry, subsection }) => (
}
);
export const initialize = (courseId, urlQuery) => (dispatch) => {
dispatch(actions.app.setCourseId(courseId));
dispatch(actions.filters.initialize(urlQuery));
dispatch(fetchRoles());
};
export default StrictDict({
initialize,
filterMenu,
setModalStateFromTable,
});

View File

@@ -1,4 +1,6 @@
import actions from 'data/actions';
import selectors from 'data/selectors';
import * as thunkActions from './app';
import { fetchGradeOverrideHistory } from './grades';
import { fetchRoles } from './roles';
@@ -9,6 +11,16 @@ jest.mock('./grades', () => ({
jest.mock('./roles', () => ({
fetchRoles: jest.fn(() => ({ type: 'fetchRoles' })),
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
filterMenu: {
open: jest.fn(state => ({ menuOpen: state })),
},
},
},
}));
describe('app thunkActions', () => {
describe('setModalStateFromTable', () => {
@@ -25,6 +37,48 @@ describe('app thunkActions', () => {
]);
});
});
describe('filterMenu', () => {
describe('close', () => {
it('calls filterMenu.toggle iff menu is open', () => {
const { toggle } = thunkActions.filterMenu;
const dispatch = jest.fn();
thunkActions.filterMenu.toggle = jest.fn(() => ({ type: 'filterMenuToggle' }));
selectors.app.filterMenu.open.mockReturnValue(false);
thunkActions.filterMenu.close()(dispatch, jest.fn());
expect(dispatch).not.toHaveBeenCalled();
selectors.app.filterMenu.open.mockReturnValue(true);
thunkActions.filterMenu.close()(dispatch, jest.fn());
expect(dispatch).toHaveBeenCalledWith(thunkActions.filterMenu.toggle());
thunkActions.filterMenu.toggle = toggle;
});
});
describe('handleTransitionEnd', () => {
it('ends filterMenu transition iff event target has not changed', () => {
const dispatch = jest.fn();
thunkActions.filterMenu.handleTransitionEnd({ target: 1, currentTarget: 2 })(dispatch);
expect(dispatch).not.toHaveBeenCalled();
thunkActions.filterMenu.handleTransitionEnd({ target: 1, currentTarget: 1 })(dispatch);
expect(dispatch).toHaveBeenCalled();
});
});
describe('toggle', () => {
it('starts transition and toggles on timeout at next animation frame', () => {
const dispatch = jest.fn(action => ({ dispatch: action }));
const reqAnimFrame = window.requestAnimationFrame;
const { setTimeout } = window;
window.requestAnimationFrame = jest.fn();
window.setTimeout = jest.fn(fn => ({ setTimeout: fn() }));
thunkActions.filterMenu.toggle()(dispatch);
expect(dispatch).toHaveBeenCalled();
expect(dispatch.mock.calls[0][0]).toEqual(actions.app.filterMenu.startTransition());
const animCb = window.requestAnimationFrame.mock.calls[0][0];
expect(animCb()).toEqual({ setTimeout: dispatch(actions.app.filterMenu.toggle()) });
expect(dispatch.mock.calls[1][0]).toEqual(actions.app.filterMenu.toggle());
window.requestAnimationFrame = reqAnimFrame;
window.setTimeout = setTimeout;
});
});
});
describe('initialize', () => {
it('loads course id, and initailzes filters from urlQuery before fetching roles', () => {
const courseId = 'an ID';

View File

@@ -1,3 +1,4 @@
/* eslint-disable import/no-named-as-default */
import 'core-js/stable';
import 'regenerator-runtime/runtime';