Skip to content

Commit

Permalink
Merge pull request #2368 from studentinsights/feature/reading-persist…
Browse files Browse the repository at this point in the history
…ence

Reading: store grouping state locally, snapshot grouping state and post to server
  • Loading branch information
kevinrobinson committed Jan 23, 2019
2 parents 4908bbb + f90a063 commit 1737bb6
Show file tree
Hide file tree
Showing 16 changed files with 549 additions and 97 deletions.
28 changes: 28 additions & 0 deletions app/assets/javascripts/components/Lifecycle.js
@@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';

// Wraps lifecycle events, so that a caller can
// describe imperative side-effecting actions based on when something
// is rendered, without having to put that inside the
// component definition.
//
// Example:
// <Lifecycle componentWillMount={this.prefetchScreenTwoData}>
// <ScreenOne />
// </Lifecycle>
export default class Lifecycle extends React.Component {
componentWillMount(nextProps, nextState) {
const {componentWillMount} = this.props;
if (componentWillMount) componentWillMount(nextProps, nextState);
}

render() {
const {children} = this.props;
return children;
}
}

Lifecycle.propTypes = {
children: PropTypes.node.isRequired,
componentWillMount: PropTypes.func
};
19 changes: 19 additions & 0 deletions app/assets/javascripts/components/Lifecycle.test.js
@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom';
import Lifecycle from './Lifecycle.js';


it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<Lifecycle><div>hello</div></Lifecycle>, div);
});


it('calls componentWillMount', () => {
const div = document.createElement('div');
const props = {
componentWillMount: jest.fn()
};
ReactDOM.render(<Lifecycle {...props}><div>hello</div></Lifecycle>, div);
expect(props.componentWillMount).toHaveBeenCalled();
});
63 changes: 63 additions & 0 deletions app/assets/javascripts/reading/Autosaver.js
@@ -0,0 +1,63 @@
import React from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';

// Autosaves on a throttled interval, calling `doSaveFn` whenever `readSnapshotFn`
// has changed (using _.isEqual).
export default class Autosaver extends React.Component {
constructor(props) {
super(props);
this.state = {
lastSavedSnapshot: null
};

this.doAutoSaveChanges = _.throttle(this.doAutoSaveChanges, props.autoSaveIntervalMs);
this.onPostDone = this.onPostDone.bind(this);
this.onPostError = this.onPostError.bind(this);
}

componentDidUpdate() {
this.doAutoSaveChanges();
}

componentWillUnmount() {
if (this.doAutoSaveChanges.flush) this.doAutoSaveChanges.flush(); // flush any queued changes
}

isDirty() {
const {readSnapshotFn} = this.props;
const {lastSavedSnapshot} = this.state;
const snapshot = readSnapshotFn();
if (snapshot === undefined || snapshot === null) return false;
return !_.isEqual(lastSavedSnapshot, snapshot);
}

// This method is throttled.
doAutoSaveChanges() {
const {doSaveFn} = this.props;
if (!this.isDirty()) return;

doSaveFn()
.then(this.onPostDone)
.catch(this.onPostError);
}

onPostDone(snapshotForSaving) {
this.setState({lastSavedSnapshot: snapshotForSaving});
}

onPostError(error) {
window.Rollbar.error && window.Rollbar.error('Autosaver#onPostError', error);
}

render() {
const {children} = this.props;
return children;
}
}
Autosaver.propTypes = {
readSnapshotFn: PropTypes.func.isRequired,
doSaveFn: PropTypes.func.isRequired,
children: PropTypes.node.isRequired,
autoSaveIntervalMs: PropTypes.number.isRequired
};
28 changes: 28 additions & 0 deletions app/assets/javascripts/reading/Autosaver.test.js
@@ -0,0 +1,28 @@
import React from 'react';
import ReactDOM from 'react-dom';
import Autosaver from './Autosaver';


export function testProps(props) {
return {
readSnapshotFn() {
return { foo: 'bar' };
},
doSaveFn() {
return Promise.resolve({ foo: 'bazzzzz-updated'});
},
autoSaveIntervalMs: 300,
...props
};
}


it('renders without crashing', () => {
const el = document.createElement('div');
const props = testProps();
ReactDOM.render(
<Autosaver {...props}>
<div>hello!</div>
</Autosaver>
, el);
});
40 changes: 19 additions & 21 deletions app/assets/javascripts/reading/CreateGroups.js
Expand Up @@ -16,15 +16,11 @@ import {
readDoc,
somervilleDibelsThresholdsFor
} from './readingData';


// TODO(kr) import path
import {
reordered,
insertedInto,
UNPLACED_ROOM_KEY,
initialStudentIdsByRoom
} from '../class_lists/studentIdsByRoomFunctions';
UNPLACED_ROOM_KEY
} from './studentIdsByRoomFunctions';


// For making and reviewing reading groups.
Expand All @@ -33,8 +29,7 @@ export default class CreateGroups extends React.Component {
super(props);

this.state = {
dialogForStudentId: null,
studentIdsByRoom: initialStudentIdsByRoom(groups(props.classrooms).length, props.readingStudents)
dialogForStudentId: null
};
this.onDragEnd = this.onDragEnd.bind(this);
}
Expand All @@ -47,21 +42,21 @@ export default class CreateGroups extends React.Component {
}

onDragEnd(dragEndResult) {
const {studentIdsByRoom} = this.state;
const {studentIdsByRoom, onStudentIdsByRoomChanged} = this.props;
const updatedStudentIdsByRoom = studentIdsByRoomAfterDrag(studentIdsByRoom, dragEndResult);
this.setState({studentIdsByRoom: updatedStudentIdsByRoom});
onStudentIdsByRoomChanged({studentIdsByRoom: updatedStudentIdsByRoom});
}

render() {
const {readingStudents, classrooms} = this.props;
const {dialogForStudentId, studentIdsByRoom} = this.state;
const {readingStudents, studentIdsByRoom, classrooms} = this.props;
const {dialogForStudentId} = this.state;
return (
<div className="CreateGroups" style={styles.root}>
<SectionHeading>Reading Groups: 3rd grade at Arthur D. Healey</SectionHeading>
{dialogForStudentId && this.renderDialog(dialogForStudentId)}
<DragDropContext onDragEnd={this.onDragEnd}>
<div>
{groups(classrooms).map((group, groupIndex) => {
{createGroups(classrooms).map((group, groupIndex) => {
const {groupKey} = group;
const studentsInRoom = studentIdsByRoom[groupKey].map(studentId => {
return _.find(readingStudents, { id: studentId });
Expand Down Expand Up @@ -94,12 +89,7 @@ export default class CreateGroups extends React.Component {
renderRow(group, groupIndex, studentsInGroup) {
const {text, groupKey} = group;
return (
<div key={groupKey} style={{
flex: 1,
display: 'flex',
flexDirection: 'row',
margin: 5
}}>
<div key={groupKey} style={styles.rowContainer}>
{this.renderGroupName(groupKey, groupIndex, text, studentsInGroup)}
<Droppable
droppableId={groupKey}
Expand Down Expand Up @@ -223,6 +213,8 @@ CreateGroups.contextTypes = {
nowFn: PropTypes.func.isRequired
};
CreateGroups.propTypes = {
studentIdsByRoom: PropTypes.object.isRequired,
onStudentIdsByRoomChanged: PropTypes.func.isRequired,
schoolName: PropTypes.string.isRequired,
grade: PropTypes.string.isRequired,
benchmarkPeriodKey: PropTypes.string.isRequired,
Expand All @@ -247,10 +239,16 @@ const styles = {
root: {
position: 'relative'
},
rowContainer: {
flex: 1,
display: 'flex',
flexDirection: 'row',
margin: 5,
marginBottom: 10
},
row: {
display: 'flex',
fontSize: 12,
marginBottom: 5,
marginRight: 5,
height: ROW_HEIGHT,
background: '#f8f8f8',
Expand Down Expand Up @@ -377,7 +375,7 @@ function fAndPRange(fAndPs) {
return [_.first(sortedValidValues), _.last(sortedValidValues)].join(' to ');
}

function groups(classrooms) {
export function createGroups(classrooms) {
const unplacedGroup = {
groupKey: UNPLACED_ROOM_KEY,
text: 'Not placed'
Expand Down
56 changes: 53 additions & 3 deletions app/assets/javascripts/reading/CreateGroups.story.js
@@ -1,8 +1,58 @@
import React from 'react';
import PropTypes from 'prop-types';
import {storiesOf} from '@storybook/react';
import {action} from '@storybook/addon-actions';
import {testProps, testEl} from './CreateGroups.test';


function storyProps(props = {}) {
return {
...testProps(),
onStudentIdsByRoomChanged: action('onStudentIdsByRoomChanged'),
...props
};
}

storiesOf('reading/CreateGroups', module) // eslint-disable-line no-undef
.add('mock photo', () => testEl(testProps()))
.add('fallback photo', () => testEl(testProps({useMockPhoto: true})));

.add('with state container', () => (
<StateContainer defaultStudentIdsByRoom={storyProps().studentIdsByRoom}>
{({studentIdsByRoom, onStudentIdsByRoomChanged}) => (
testEl(storyProps({
studentIdsByRoom,
onStudentIdsByRoomChanged,
useMockPhoto: true
}))
)}
</StateContainer>
))
.add('mock photo', () => testEl(storyProps()))
.add('fallback photo', () => testEl(storyProps({useMockPhoto: true})));


class StateContainer extends React.Component {
constructor(props) {
super(props);
this.state = {
studentIdsByRoom: props.defaultStudentIdsByRoom
};

this.onStudentIdsByRoomChanged = this.onStudentIdsByRoomChanged.bind(this);
}

onStudentIdsByRoomChanged({studentIdsByRoom}) {
this.setState({studentIdsByRoom});
}

render() {
const {children} = this.props;
const {studentIdsByRoom} = this.state;
return children({
studentIdsByRoom,
onStudentIdsByRoomChanged: this.onStudentIdsByRoomChanged
});
}
}
StateContainer.propTypes = {
defaultStudentIdsByRoom: PropTypes.any,
children: PropTypes.func.isRequired
};
9 changes: 6 additions & 3 deletions app/assets/javascripts/reading/CreateGroups.test.js
Expand Up @@ -3,12 +3,15 @@ import ReactDOM from 'react-dom';
import renderer from 'react-test-renderer';
import {withNowContext} from '../testing/NowContainer';
import PerDistrictContainer from '../components/PerDistrictContainer';
import CreateGroups from './CreateGroups';
import CreateGroups, {createGroups} from './CreateGroups';
import propsFixture from './CreateGroups.fixture';
import {initialStudentIdsByRoom} from './studentIdsByRoomFunctions';


export function testProps(props) {
export function testProps(props = {}) {
const {classrooms, readingStudents} = propsFixture;
return {
studentIdsByRoom: initialStudentIdsByRoom(createGroups(classrooms).length, readingStudents),
onStudentIdsByRoomChanged: jest.fn(),
...propsFixture,
...props
};
Expand Down

0 comments on commit 1737bb6

Please sign in to comment.