Skip to content

Commit

Permalink
Merge pull request #2382 from studentinsights/feature/home-feed-stude…
Browse files Browse the repository at this point in the history
…nt-voice-cards

Student voice: Highlight new student voice surveys in home feed
  • Loading branch information
kevinrobinson committed Jan 29, 2019
2 parents e3b8cdf + 97a5333 commit e17e68d
Show file tree
Hide file tree
Showing 18 changed files with 611 additions and 44 deletions.
17 changes: 14 additions & 3 deletions app/assets/javascripts/feed/FeedView.js
Expand Up @@ -3,6 +3,7 @@ import React from 'react';
import EventNoteCard from './EventNoteCard';
import BirthdayCard from './BirthdayCard';
import IncidentCard from './IncidentCard';
import StudentVoiceCard from './StudentVoiceCard';


// Pure UI component for rendering feed cards (eg, on the home page)
Expand All @@ -16,7 +17,8 @@ export default class FeedView extends React.Component {
const cardProps = cardPropsFn ? cardPropsFn(type, json) : {};
if (type === 'event_note_card') return this.renderEventNoteCard(json, cardProps);
if (type === 'birthday_card') return this.renderBirthdayCard(json, cardProps);
if (type === 'incident_card') return this.renderIncidenCard(json, cardProps);
if (type === 'incident_card') return this.renderIncidentCard(json, cardProps);
if (type === 'student_voice') return this.renderStudentVoiceCard(json, cardProps);
console.warn('Unexpected card type: ', type); // eslint-disable-line no-console
})}
</div>
Expand All @@ -41,14 +43,23 @@ export default class FeedView extends React.Component {
/>;
}

renderIncidenCard(json, cardProps = {}) {
renderIncidentCard(json, cardProps = {}) {
return <IncidentCard
key={json.id}
style={styles.card}
incidentCard={json}
{...cardProps}
/>;
}
}

renderStudentVoiceCard(json, cardProps = {}) {
return <StudentVoiceCard
key={json.latest_form_timestamp}
style={styles.card}
studentVoiceCardJson={json}
{...cardProps}
/>;
}
}
FeedView.propTypes = {
feedCards: PropTypes.arrayOf(PropTypes.shape({
Expand Down
2 changes: 1 addition & 1 deletion app/assets/javascripts/feed/IncidentCard.story.js
Expand Up @@ -4,5 +4,5 @@ import {withDefaultNowContext} from '../testing/NowContainer';
import IncidentCard from './IncidentCard';
import {testProps} from './IncidentCard.test';

storiesOf('home/IncidentCard', module) // eslint-disable-line no-undef
storiesOf('feed/IncidentCard', module) // eslint-disable-line no-undef
.add('all', () => withDefaultNowContext(<IncidentCard {...testProps()} />));
96 changes: 96 additions & 0 deletions app/assets/javascripts/feed/StudentVoiceCard.js
@@ -0,0 +1,96 @@
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'lodash';
import hash from 'object-hash';
import Card from '../components/Card';


// Render a card in the feed showing there are student voice surveys in,
// and allow clicking to see list of students and then jump to their profiles.
export default class StudentVoiceCard extends React.Component {
constructor(props) {
super(props);
this.state = {
isExpanded: false,
shuffleSeed: props.shuffleSeed || _.random(0, 32)
};

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

onExpandClicked(e) {
e.preventDefault();
this.setState({isExpanded: true});
}

render() {
const {style = {}} = this.props;
return (
<Card className="StudentVoiceCard" style={style}>
{this.renderStudentsAndCount()}
</Card>
);
}

renderStudentsAndCount() {
const {studentVoiceCardJson} = this.props;
const {students} = studentVoiceCardJson;
const {isExpanded, shuffleSeed} = this.state;
const emoji = <span style={{position: 'relative', top: 1}}>💬</span>;

// If the list is <=3 students, show them all inline with links.
// If it's more, pick two random to show and then +n more, which expands.
if (students.length === 0) {
return null; // shouldn't happen, but guard
} else if (students.length === 1) {
return <div>{this.renderStudentLink(students[0])} shared their perspective in a student voice survey. {emoji}</div>;
} else if (students.length === 2) {
return <div>{this.renderStudentLink(students[0])} and {this.renderStudentLink(students[1])} shared their perspective in student voice surveys. {emoji}</div>;
} else if (students.length === 3) {
return <div>{this.renderStudentLink(students[0])}, {this.renderStudentLink(students[1])} and {this.renderStudentLink(students[2])} shared their perspective in student voice surveys. {emoji}</div>;
}

const shuffledStudents = _.sortBy(students, student => hash({...student, shuffleSeed}));
if (!isExpanded) {
return <div>{this.renderStudentLink(shuffledStudents[0])}, {this.renderStudentLink(shuffledStudents[1])} and {this.renderExpandableListLink(shuffledStudents.slice(2))} shared their perspective in student voice surveys. {emoji}</div>;
} else {
return <div>{this.renderStudentLink(shuffledStudents[0])}, {this.renderStudentLink(shuffledStudents[1])} and {this.renderMoreStudentsText(shuffledStudents.slice(2))} shared their perspective in student voice surveys. {emoji} {this.renderExpandedList(shuffledStudents.slice(2))}</div>;
}
}

renderMoreStudentsText(restOfStudents) {
return `${restOfStudents.length} more students`;
}

renderExpandableListLink(restOfStudents) {
return <a href="#" onClick={this.onExpandClicked}>{this.renderMoreStudentsText(restOfStudents)}</a>;
}

renderExpandedList(restOfStudents) {
const sortedStudents = _.sortBy(restOfStudents, student => `${student.last_name} ${student.first_name}`);
return (
<div style={{paddingTop: 10, paddingLeft: 10, maxHeight: 190, overflowY: 'scroll'}}>
{sortedStudents.map(student => (
<div key={student.id} style={{padding: 5, paddingTop: 0}}>{this.renderStudentLink(student)}</div>
))}
</div>
);
}

renderStudentLink(student) {
return <a style={{fontWeight: 'bold'}} href={`/students/${student.id}`}>{student.first_name} {student.last_name}</a>;
}
}
StudentVoiceCard.propTypes = {
studentVoiceCardJson: PropTypes.shape({
latest_form_timestamp: PropTypes.string.isRequired,
imported_forms_for_date_count: PropTypes.number.isRequired,
students: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.number.isRequired,
first_name: PropTypes.string.isRequired,
last_name: PropTypes.string.isRequired
})).isRequired
}).isRequired,
style: PropTypes.object,
shuffleSeed: PropTypes.number
};
15 changes: 15 additions & 0 deletions app/assets/javascripts/feed/StudentVoiceCard.story.js
@@ -0,0 +1,15 @@
import React from 'react';
import {storiesOf} from '@storybook/react';
import {testEl, propsWithNStudents} from './StudentVoiceCard.test';

storiesOf('feed/StudentVoiceCard', module) // eslint-disable-line no-undef
.add('combinations', () => (
<div>
{testEl(propsWithNStudents(1))}
{testEl(propsWithNStudents(2))}
{testEl(propsWithNStudents(3))}
{testEl(propsWithNStudents(4))}
{testEl({...propsWithNStudents(4), shuffleSeed: 100})}
{testEl({...propsWithNStudents(4), shuffleSeed: 200})}
</div>
));
86 changes: 86 additions & 0 deletions app/assets/javascripts/feed/StudentVoiceCard.test.js
@@ -0,0 +1,86 @@
import React from 'react';
import ReactDOM from 'react-dom';
import renderer from 'react-test-renderer';
import StudentVoiceCard from './StudentVoiceCard';
import {withDefaultNowContext} from '../testing/NowContainer';

function testStudents() {
return [{
id: 4,
first_name: 'Luke',
last_name: 'Skywalker'
}, {
id: 5,
first_name: 'Mari',
last_name: 'Kenobi'
}, {
id: 6,
first_name: 'Kylo',
last_name: 'Ren'
}, {
id: 7,
first_name: 'Darth',
last_name: 'Tater'
}];
}

export function testProps(props = {}) {
return {
shuffleSeed: 42,
studentVoiceCardJson: {
latest_form_timestamp: '2018-03-11T11:03:00.000Z',
imported_forms_for_date_count: 2,
students: testStudents()
},
...props
};
}

export function propsWithNStudents(n) {
const props = testProps();
return {
...props,
studentVoiceCardJson: {
...props.studentVoiceCardJson,
students: testStudents().slice(0, n)
}
};
}


export function testEl(props) {
return withDefaultNowContext(<StudentVoiceCard {...props} />);
}

it('renders without crashing', () => {
const el = document.createElement('div');
ReactDOM.render(testEl(testProps()), el);
expect($(el).text()).toContain('Darth Tater, Kylo Ren and 2 more students shared their perspective in student voice surveys. 💬');
expect($(el).find('a').toArray().map(el => $(el).attr('href'))).toEqual([
'/students/7',
'/students/6',
'#'
]);
});

describe('snapshots', () => {
it('works with one student', () => {
const tree = renderer.create(testEl(propsWithNStudents(1))).toJSON();
expect(tree).toMatchSnapshot();
});

it('works with two students', () => {
const tree = renderer.create(testEl(propsWithNStudents(2))).toJSON();
expect(tree).toMatchSnapshot();
});

it('works with three students', () => {
const tree = renderer.create(testEl(propsWithNStudents(3))).toJSON();
expect(tree).toMatchSnapshot();
});

it('works with four students', () => {
const tree = renderer.create(testEl(propsWithNStudents(4))).toJSON();
expect(tree).toMatchSnapshot();
});
});

0 comments on commit e17e68d

Please sign in to comment.