diff --git a/package.json b/package.json index f1415c2..26c24c8 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.scss b/src/App.scss index f63c0bf..1af5934 100755 --- a/src/App.scss +++ b/src/App.scss @@ -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"; diff --git a/src/components/Gradebook/BulkManagement.jsx b/src/components/BulkManagement.jsx similarity index 100% rename from src/components/Gradebook/BulkManagement.jsx rename to src/components/BulkManagement.jsx diff --git a/src/components/Drawer/index.jsx b/src/components/Drawer/index.jsx deleted file mode 100644 index 1052f9e..0000000 --- a/src/components/Drawer/index.jsx +++ /dev/null @@ -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 ( -
- -
- {this.props.mainContent(this.toggleOpen)} -
-
- ); - } -} - -Drawer.propTypes = { - initiallyOpen: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, - mainContent: PropTypes.func.isRequired, - title: PropTypes.node.isRequired, -}; diff --git a/src/components/Gradebook/index.jsx b/src/components/Gradebook/index.jsx deleted file mode 100644 index 237e308..0000000 --- a/src/components/Gradebook/index.jsx +++ /dev/null @@ -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 ( - ( -
- - - - - - - - -

Step 2: View or Modify Individual Grades

- - -
- - -
- - - - - -

* available for learners in the Master's track only

- -
- {this.props.showBulkManagement - && ( - - - - )} -
-
- )} - initiallyOpen={false} - title={( - <> - Filter By... - - )} - > - -
- ); - } -} - -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, -}; diff --git a/src/components/Gradebook/GradebookFilters/AssignmentFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/AssignmentFilter/__snapshots__/test.jsx.snap similarity index 100% rename from src/components/Gradebook/GradebookFilters/AssignmentFilter/__snapshots__/test.jsx.snap rename to src/components/GradebookFilters/AssignmentFilter/__snapshots__/test.jsx.snap diff --git a/src/components/Gradebook/GradebookFilters/AssignmentFilter/index.jsx b/src/components/GradebookFilters/AssignmentFilter/index.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/AssignmentFilter/index.jsx rename to src/components/GradebookFilters/AssignmentFilter/index.jsx diff --git a/src/components/Gradebook/GradebookFilters/AssignmentFilter/test.jsx b/src/components/GradebookFilters/AssignmentFilter/test.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/AssignmentFilter/test.jsx rename to src/components/GradebookFilters/AssignmentFilter/test.jsx diff --git a/src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/test.jsx.snap similarity index 100% rename from src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/__snapshots__/test.jsx.snap rename to src/components/GradebookFilters/AssignmentGradeFilter/__snapshots__/test.jsx.snap diff --git a/src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/index.jsx b/src/components/GradebookFilters/AssignmentGradeFilter/index.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/index.jsx rename to src/components/GradebookFilters/AssignmentGradeFilter/index.jsx diff --git a/src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/test.jsx b/src/components/GradebookFilters/AssignmentGradeFilter/test.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/AssignmentGradeFilter/test.jsx rename to src/components/GradebookFilters/AssignmentGradeFilter/test.jsx diff --git a/src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/test.jsx.snap similarity index 100% rename from src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/__snapshots__/test.jsx.snap rename to src/components/GradebookFilters/AssignmentTypeFilter/__snapshots__/test.jsx.snap diff --git a/src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/index.jsx b/src/components/GradebookFilters/AssignmentTypeFilter/index.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/index.jsx rename to src/components/GradebookFilters/AssignmentTypeFilter/index.jsx diff --git a/src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/test.jsx b/src/components/GradebookFilters/AssignmentTypeFilter/test.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/AssignmentTypeFilter/test.jsx rename to src/components/GradebookFilters/AssignmentTypeFilter/test.jsx diff --git a/src/components/Gradebook/GradebookFilters/CourseGradeFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/CourseGradeFilter/__snapshots__/test.jsx.snap similarity index 100% rename from src/components/Gradebook/GradebookFilters/CourseGradeFilter/__snapshots__/test.jsx.snap rename to src/components/GradebookFilters/CourseGradeFilter/__snapshots__/test.jsx.snap diff --git a/src/components/Gradebook/GradebookFilters/CourseGradeFilter/index.jsx b/src/components/GradebookFilters/CourseGradeFilter/index.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/CourseGradeFilter/index.jsx rename to src/components/GradebookFilters/CourseGradeFilter/index.jsx diff --git a/src/components/Gradebook/GradebookFilters/CourseGradeFilter/test.jsx b/src/components/GradebookFilters/CourseGradeFilter/test.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/CourseGradeFilter/test.jsx rename to src/components/GradebookFilters/CourseGradeFilter/test.jsx diff --git a/src/components/Gradebook/GradebookFilters/PercentGroup.jsx b/src/components/GradebookFilters/PercentGroup.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/PercentGroup.jsx rename to src/components/GradebookFilters/PercentGroup.jsx diff --git a/src/components/Gradebook/GradebookFilters/PercentGroup.test.jsx b/src/components/GradebookFilters/PercentGroup.test.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/PercentGroup.test.jsx rename to src/components/GradebookFilters/PercentGroup.test.jsx diff --git a/src/components/Gradebook/GradebookFilters/SelectGroup.jsx b/src/components/GradebookFilters/SelectGroup.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/SelectGroup.jsx rename to src/components/GradebookFilters/SelectGroup.jsx diff --git a/src/components/Gradebook/GradebookFilters/SelectGroup.test.jsx b/src/components/GradebookFilters/SelectGroup.test.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/SelectGroup.test.jsx rename to src/components/GradebookFilters/SelectGroup.test.jsx diff --git a/src/components/Gradebook/GradebookFilters/StudentGroupsFilter/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/test.jsx.snap similarity index 100% rename from src/components/Gradebook/GradebookFilters/StudentGroupsFilter/__snapshots__/test.jsx.snap rename to src/components/GradebookFilters/StudentGroupsFilter/__snapshots__/test.jsx.snap diff --git a/src/components/Gradebook/GradebookFilters/StudentGroupsFilter/index.jsx b/src/components/GradebookFilters/StudentGroupsFilter/index.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/StudentGroupsFilter/index.jsx rename to src/components/GradebookFilters/StudentGroupsFilter/index.jsx diff --git a/src/components/Gradebook/GradebookFilters/StudentGroupsFilter/test.jsx b/src/components/GradebookFilters/StudentGroupsFilter/test.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/StudentGroupsFilter/test.jsx rename to src/components/GradebookFilters/StudentGroupsFilter/test.jsx diff --git a/src/components/Gradebook/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap b/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap similarity index 100% rename from src/components/Gradebook/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap rename to src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap diff --git a/src/components/Gradebook/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap b/src/components/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap similarity index 100% rename from src/components/Gradebook/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap rename to src/components/GradebookFilters/__snapshots__/SelectGroup.test.jsx.snap diff --git a/src/components/Gradebook/GradebookFilters/__snapshots__/test.jsx.snap b/src/components/GradebookFilters/__snapshots__/test.jsx.snap similarity index 100% rename from src/components/Gradebook/GradebookFilters/__snapshots__/test.jsx.snap rename to src/components/GradebookFilters/__snapshots__/test.jsx.snap diff --git a/src/components/Gradebook/GradebookFilters/index.jsx b/src/components/GradebookFilters/index.jsx similarity index 97% rename from src/components/Gradebook/GradebookFilters/index.jsx rename to src/components/GradebookFilters/index.jsx index 0a27cb9..9b639ed 100644 --- a/src/components/Gradebook/GradebookFilters/index.jsx +++ b/src/components/GradebookFilters/index.jsx @@ -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) => ({ diff --git a/src/components/Gradebook/GradebookFilters/test.jsx b/src/components/GradebookFilters/test.jsx similarity index 100% rename from src/components/Gradebook/GradebookFilters/test.jsx rename to src/components/GradebookFilters/test.jsx diff --git a/src/components/GradebookFiltersHeader.jsx b/src/components/GradebookFiltersHeader.jsx new file mode 100644 index 0000000..0883d2e --- /dev/null +++ b/src/components/GradebookFiltersHeader.jsx @@ -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 }) => ( + <> +

+ + +); +GradebookFiltersHeader.propTypes = { + // redux + closeMenu: PropTypes.func.isRequired, +}; + +export const mapStateToProps = () => ({}); + +export const mapDispatchToProps = { + closeMenu: thunkActions.app.filterMenu.close, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(GradebookFiltersHeader); diff --git a/src/components/GradebookFiltersHeader.test.jsx b/src/components/GradebookFiltersHeader.test.jsx new file mode 100644 index 0000000..6a43f7c --- /dev/null +++ b/src/components/GradebookFiltersHeader.test.jsx @@ -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(); + expect(el).toMatchSnapshot(); + }); + }); + }); + describe('mapDispatchToProps', () => { + describe('closeMenu', () => { + test('from thunkActions.app.filterMenu.close', () => { + expect(mapDispatchToProps.closeMenu).toEqual( + thunkActions.app.filterMenu.close, + ); + }); + }); + }); +}); diff --git a/src/components/Gradebook/GradebookHeader.jsx b/src/components/GradebookHeader.jsx similarity index 100% rename from src/components/Gradebook/GradebookHeader.jsx rename to src/components/GradebookHeader.jsx diff --git a/src/components/Gradebook/GradebookHeader.test.jsx b/src/components/GradebookHeader.test.jsx similarity index 100% rename from src/components/Gradebook/GradebookHeader.test.jsx rename to src/components/GradebookHeader.test.jsx diff --git a/src/components/Gradebook/BulkManagementControls.jsx b/src/components/GradesTab/BulkManagementControls.jsx similarity index 100% rename from src/components/Gradebook/BulkManagementControls.jsx rename to src/components/GradesTab/BulkManagementControls.jsx diff --git a/src/components/Gradebook/EditModal/ModalHeaders.jsx b/src/components/GradesTab/EditModal/ModalHeaders.jsx similarity index 100% rename from src/components/Gradebook/EditModal/ModalHeaders.jsx rename to src/components/GradesTab/EditModal/ModalHeaders.jsx diff --git a/src/components/Gradebook/EditModal/OverrideTable/AdjustedGradeInput.jsx b/src/components/GradesTab/EditModal/OverrideTable/AdjustedGradeInput.jsx similarity index 100% rename from src/components/Gradebook/EditModal/OverrideTable/AdjustedGradeInput.jsx rename to src/components/GradesTab/EditModal/OverrideTable/AdjustedGradeInput.jsx diff --git a/src/components/Gradebook/EditModal/OverrideTable/ReasonInput.jsx b/src/components/GradesTab/EditModal/OverrideTable/ReasonInput.jsx similarity index 100% rename from src/components/Gradebook/EditModal/OverrideTable/ReasonInput.jsx rename to src/components/GradesTab/EditModal/OverrideTable/ReasonInput.jsx diff --git a/src/components/Gradebook/EditModal/OverrideTable/index.jsx b/src/components/GradesTab/EditModal/OverrideTable/index.jsx similarity index 100% rename from src/components/Gradebook/EditModal/OverrideTable/index.jsx rename to src/components/GradesTab/EditModal/OverrideTable/index.jsx diff --git a/src/components/Gradebook/EditModal/index.jsx b/src/components/GradesTab/EditModal/index.jsx similarity index 100% rename from src/components/Gradebook/EditModal/index.jsx rename to src/components/GradesTab/EditModal/index.jsx diff --git a/src/components/Gradebook/GradebookTable/Fields.jsx b/src/components/GradesTab/GradebookTable/Fields.jsx similarity index 100% rename from src/components/Gradebook/GradebookTable/Fields.jsx rename to src/components/GradesTab/GradebookTable/Fields.jsx diff --git a/src/components/Gradebook/GradebookTable/GradeButton.jsx b/src/components/GradesTab/GradebookTable/GradeButton.jsx similarity index 100% rename from src/components/Gradebook/GradebookTable/GradeButton.jsx rename to src/components/GradesTab/GradebookTable/GradeButton.jsx diff --git a/src/components/Gradebook/GradebookTable/LabelReplacements.jsx b/src/components/GradesTab/GradebookTable/LabelReplacements.jsx similarity index 100% rename from src/components/Gradebook/GradebookTable/LabelReplacements.jsx rename to src/components/GradesTab/GradebookTable/LabelReplacements.jsx diff --git a/src/components/Gradebook/GradebookTable/index.jsx b/src/components/GradesTab/GradebookTable/index.jsx similarity index 100% rename from src/components/Gradebook/GradebookTable/index.jsx rename to src/components/GradesTab/GradebookTable/index.jsx diff --git a/src/components/Gradebook/gradebook.scss b/src/components/GradesTab/GradesTab.scss similarity index 100% rename from src/components/Gradebook/gradebook.scss rename to src/components/GradesTab/GradesTab.scss diff --git a/src/components/Gradebook/ScoreViewInput.jsx b/src/components/GradesTab/ScoreViewInput.jsx similarity index 100% rename from src/components/Gradebook/ScoreViewInput.jsx rename to src/components/GradesTab/ScoreViewInput.jsx diff --git a/src/components/Gradebook/SearchControls.jsx b/src/components/GradesTab/SearchControls.jsx similarity index 97% rename from src/components/Gradebook/SearchControls.jsx rename to src/components/GradesTab/SearchControls.jsx index 732d417..49fb324 100644 --- a/src/components/Gradebook/SearchControls.jsx +++ b/src/components/GradesTab/SearchControls.jsx @@ -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); diff --git a/src/components/Gradebook/SearchControls.test.jsx b/src/components/GradesTab/SearchControls.test.jsx similarity index 87% rename from src/components/Gradebook/SearchControls.test.jsx rename to src/components/GradesTab/SearchControls.test.jsx index ebb9cd8..05419f1 100644 --- a/src/components/Gradebook/SearchControls.test.jsx +++ b/src/components/GradesTab/SearchControls.test.jsx @@ -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); }); }); }); diff --git a/src/components/Gradebook/SpinnerIcon.jsx b/src/components/GradesTab/SpinnerIcon.jsx similarity index 100% rename from src/components/Gradebook/SpinnerIcon.jsx rename to src/components/GradesTab/SpinnerIcon.jsx diff --git a/src/components/Gradebook/StatusAlerts.jsx b/src/components/GradesTab/StatusAlerts.jsx similarity index 100% rename from src/components/Gradebook/StatusAlerts.jsx rename to src/components/GradesTab/StatusAlerts.jsx diff --git a/src/components/Gradebook/StatusAlerts.test.jsx b/src/components/GradesTab/StatusAlerts.test.jsx similarity index 100% rename from src/components/Gradebook/StatusAlerts.test.jsx rename to src/components/GradesTab/StatusAlerts.test.jsx diff --git a/src/components/Gradebook/UsersLabel.jsx b/src/components/GradesTab/UsersLabel.jsx similarity index 100% rename from src/components/Gradebook/UsersLabel.jsx rename to src/components/GradesTab/UsersLabel.jsx diff --git a/src/components/Gradebook/__snapshots__/SearchControls.test.jsx.snap b/src/components/GradesTab/__snapshots__/SearchControls.test.jsx.snap similarity index 100% rename from src/components/Gradebook/__snapshots__/SearchControls.test.jsx.snap rename to src/components/GradesTab/__snapshots__/SearchControls.test.jsx.snap diff --git a/src/components/Gradebook/__snapshots__/StatusAlerts.test.jsx.snap b/src/components/GradesTab/__snapshots__/StatusAlerts.test.jsx.snap similarity index 100% rename from src/components/Gradebook/__snapshots__/StatusAlerts.test.jsx.snap rename to src/components/GradesTab/__snapshots__/StatusAlerts.test.jsx.snap diff --git a/src/components/GradesTab/__snapshots__/test.jsx.snap b/src/components/GradesTab/__snapshots__/test.jsx.snap new file mode 100644 index 0000000..9c9fe20 --- /dev/null +++ b/src/components/GradesTab/__snapshots__/test.jsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GradesTab Component snapshots basic snapshot 1`] = ` + + + + + +

+ Step 2: View or Modify Individual Grades +

+ +
+ + +
+ + +

+ * available for learners in the Master's track only +

+ +
+`; diff --git a/src/components/GradesTab/index.jsx b/src/components/GradesTab/index.jsx new file mode 100644 index 0000000..e206026 --- /dev/null +++ b/src/components/GradesTab/index.jsx @@ -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 ( + <> + + + + + +

Step 2: View or Modify Individual Grades

+ + +
+ + +
+ + + + +

* available for learners in the Master's track only

+ + + ); + } +} + +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); diff --git a/src/components/GradesTab/test.jsx b/src/components/GradesTab/test.jsx new file mode 100644 index 0000000..7607d84 --- /dev/null +++ b/src/components/GradesTab/test.jsx @@ -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(); + }); + 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(); + 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, + ); + }); + }); + }); +}); diff --git a/src/components/Drawer/Drawer.scss b/src/components/WithSidebar/WithSidebar.scss similarity index 54% rename from src/components/Drawer/Drawer.scss rename to src/components/WithSidebar/WithSidebar.scss index 372d273..03f63b9 100644 --- a/src/components/Drawer/Drawer.scss +++ b/src/components/WithSidebar/WithSidebar.scss @@ -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 { diff --git a/src/components/WithSidebar/__snapshots__/test.jsx.snap b/src/components/WithSidebar/__snapshots__/test.jsx.snap new file mode 100644 index 0000000..6a433fc --- /dev/null +++ b/src/components/WithSidebar/__snapshots__/test.jsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WithSidebar Component snapshots basic snapshot 1`] = ` +
+ +
+ + aby in a bi + +
+
+`; diff --git a/src/components/WithSidebar/index.jsx b/src/components/WithSidebar/index.jsx new file mode 100644 index 0000000..814649f --- /dev/null +++ b/src/components/WithSidebar/index.jsx @@ -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: + * {children} + */ +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 ( +
+ +
+ { this.props.children} +
+
+ ); + } +} + +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); diff --git a/src/components/WithSidebar/test.jsx b/src/components/WithSidebar/test.jsx new file mode 100644 index 0000000..2c91cdf --- /dev/null +++ b/src/components/WithSidebar/test.jsx @@ -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: (
Some Sidebar Content
), + sidebarHeader: (
A really nice sidebar header
), + children: (aby in a bi), + isClosed: true, + isOpening: false, + open: false, + }; + + beforeEach(() => { + props = { + ...props, + handleSlideDone: jest.fn().mockName('handleSlideDone'), + }; + }); + + describe('Component', () => { + describe('behavior', () => { + let el; + beforeEach(() => { + el = shallow(); + }); + 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(); + 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, + ); + }); + }); + }); +}); diff --git a/src/components/__snapshots__/GradebookFiltersHeader.test.jsx.snap b/src/components/__snapshots__/GradebookFiltersHeader.test.jsx.snap new file mode 100644 index 0000000..a9f0ee4 --- /dev/null +++ b/src/components/__snapshots__/GradebookFiltersHeader.test.jsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GradebookFiltersHeader Component snapshots basic snapshot 1`] = ` + +

+ +

+ +
+`; diff --git a/src/components/Gradebook/__snapshots__/GradebookHeader.test.jsx.snap b/src/components/__snapshots__/GradebookHeader.test.jsx.snap similarity index 100% rename from src/components/Gradebook/__snapshots__/GradebookHeader.test.jsx.snap rename to src/components/__snapshots__/GradebookHeader.test.jsx.snap diff --git a/src/containers/GradebookPage/index.jsx b/src/containers/GradebookPage/index.jsx index 08b10b7..7516b8a 100644 --- a/src/containers/GradebookPage/index.jsx +++ b/src/containers/GradebookPage/index.jsx @@ -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 ( + } + sidebarHeader={} + > +
+ + + + + + {this.props.showBulkManagement && ( + + + + )} + +
+
+ ); + } +} +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); diff --git a/src/data/actions/app.js b/src/data/actions/app.js index ef99538..9ed8807 100644 --- a/src/data/actions/app.js +++ b/src/data/actions/app.js @@ -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, }); diff --git a/src/data/reducers/app.js b/src/data/reducers/app.js index 9750313..8af1ad4 100644 --- a/src/data/reducers/app.js +++ b/src/data/reducers/app.js @@ -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, diff --git a/src/data/selectors/app.js b/src/data/selectors/app.js index 0690eee..186b89d 100644 --- a/src/data/selectors/app.js +++ b/src/data/selectors/app.js @@ -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, + }), }); diff --git a/src/data/selectors/app.test.js b/src/data/selectors/app.test.js index 40c7fad..60418a2 100644 --- a/src/data/selectors/app.test.js +++ b/src/data/selectors/app.test.js @@ -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, () => { diff --git a/src/data/thunkActions/app.js b/src/data/thunkActions/app.js index c90bc69..9604a26 100644 --- a/src/data/thunkActions/app.js +++ b/src/data/thunkActions/app.js @@ -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, }); diff --git a/src/data/thunkActions/app.test.js b/src/data/thunkActions/app.test.js index 625c74e..3d77fd9 100644 --- a/src/data/thunkActions/app.test.js +++ b/src/data/thunkActions/app.test.js @@ -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'; diff --git a/src/index.jsx b/src/index.jsx index a863a25..6991185 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,3 +1,4 @@ +/* eslint-disable import/no-named-as-default */ import 'core-js/stable'; import 'regenerator-runtime/runtime';