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:
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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'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,
|
||||
};
|
||||
@@ -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) => ({
|
||||
38
src/components/GradebookFiltersHeader.jsx
Normal file
38
src/components/GradebookFiltersHeader.jsx
Normal 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);
|
||||
56
src/components/GradebookFiltersHeader.test.jsx
Normal file
56
src/components/GradebookFiltersHeader.test.jsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
28
src/components/GradesTab/__snapshots__/test.jsx.snap
Normal file
28
src/components/GradesTab/__snapshots__/test.jsx.snap
Normal 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>
|
||||
`;
|
||||
81
src/components/GradesTab/index.jsx
Normal file
81
src/components/GradesTab/index.jsx
Normal 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'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);
|
||||
97
src/components/GradesTab/test.jsx
Normal file
97
src/components/GradesTab/test.jsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
30
src/components/WithSidebar/__snapshots__/test.jsx.snap
Normal file
30
src/components/WithSidebar/__snapshots__/test.jsx.snap
Normal 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>
|
||||
`;
|
||||
72
src/components/WithSidebar/index.jsx
Normal file
72
src/components/WithSidebar/index.jsx
Normal 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);
|
||||
124
src/components/WithSidebar/test.jsx
Normal file
124
src/components/WithSidebar/test.jsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable import/no-named-as-default */
|
||||
import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user