Skip to content

Commit

Permalink
Merge 56dce71 into 428c6e2
Browse files Browse the repository at this point in the history
  • Loading branch information
taneliang committed Dec 10, 2017
2 parents 428c6e2 + 56dce71 commit b0d5d84
Show file tree
Hide file tree
Showing 18 changed files with 2,527 additions and 3 deletions.
1,867 changes: 1,867 additions & 0 deletions v3/__mocks__/venueInformation.json

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion v3/src/js/apis/nusmods.js
@@ -1,5 +1,5 @@
// @flow
import type { ModuleCode } from 'types/modules';
import type { ModuleCode, Semester } from 'types/modules';

import config from 'config';

Expand All @@ -22,6 +22,11 @@ const NUSModsApi = {
modulesUrl: (): string => {
return `${ayBaseUrl}/moduleInformation.json`;
},

// List of all venue's info for one semester in the current acad year
venuesUrl: (semester: Semester): string => {
return `${ayBaseUrl}/${semester}/venueInformation.json`;
},
};

export default NUSModsApi;
23 changes: 23 additions & 0 deletions v3/src/js/types/venues.js
@@ -0,0 +1,23 @@
// @flow
import type { Venue, RawLesson, ModuleCode, DayText, LessonTime } from 'types/modules';

// Components within a venue availability class:
export type VenueOccupiedState = 'vacant' | 'occupied';
export type Availability = { [LessonTime]: VenueOccupiedState } // E.g. { "1000": "vacant", "1030": "occupied", ... }

// Raw lessons obtained from venue info API includes ModuleCode by default
export type VenueLesson = RawLesson & {
ModuleCode: ModuleCode,
};

// A venue's availability info for one day
// E.g. { "Day": "Monday", "Classes": [...], "Availability": {...} }
export type DayAvailability = {
Day: DayText,
Classes: VenueLesson[],
Availability: Availability,
}

// Describes venueInformation.json
// E.g. { "LT16": [DayAvailability1, DayAvailability2, ...], "LT17": [...], ... }
export type VenueInfo = { [Venue]: DayAvailability[] };
2 changes: 2 additions & 0 deletions v3/src/js/views/Routes.jsx
Expand Up @@ -5,6 +5,7 @@ import { Switch, Route, Redirect } from 'react-router-dom';
import TimetableContainer from 'views/timetable/TimetableContainer';
import ModulePageContainer from 'views/browse/ModulePageContainer';
import ModuleFinderContainer from 'views/browse/ModuleFinderContainer';
import VenuesContainer from 'views/venues/VenuesContainer';
import SettingsContainer from 'views/settings/SettingsContainer';
import AboutContainer from 'views/static/AboutContainer';
import TeamContainer from 'views/static/TeamContainer';
Expand All @@ -22,6 +23,7 @@ export default function Routes() {
<Route path="/timetable" component={TimetableContainer} />
<Route exact path="/modules" component={ModuleFinderContainer} />
<Route path="/modules/:moduleCode/:slug?" component={ModulePageContainer} />
<Route path="/venues/:venue?" component={VenuesContainer} />
<Route path="/settings" component={SettingsContainer} />
<Route path="/team" component={TeamContainer} />
<Route path="/developers" component={DevelopersContainer} />
Expand Down
2 changes: 2 additions & 0 deletions v3/src/js/views/components/icons/index.js
Expand Up @@ -9,6 +9,7 @@ import EyeOff from 'react-feather/dist/icons/eye-off';
import Facebook from 'react-feather/dist/icons/facebook';
import GitHub from 'react-feather/dist/icons/github';
import Image from 'react-feather/dist/icons/image';
import Map from 'react-feather/dist/icons/map';
import Settings from 'react-feather/dist/icons/settings';
import Search from 'react-feather/dist/icons/search';
import Sidebar from 'react-feather/dist/icons/sidebar';
Expand All @@ -27,6 +28,7 @@ export {
Facebook,
GitHub,
Image,
Map,
LinkedIn,
Search,
Settings,
Expand Down
6 changes: 5 additions & 1 deletion v3/src/js/views/layout/Navtabs.jsx
@@ -1,7 +1,7 @@
// @flow
import React from 'react';
import { NavLink } from 'react-router-dom';
import { Calendar, Search, Settings } from 'views/components/icons/index';
import { Calendar, Map, Search, Settings } from 'views/components/icons/index';

import styles from './Navtabs.scss';

Expand All @@ -16,6 +16,10 @@ function Navtabs() {
<Search className={styles.icon} />
<span className={styles.title}>Browse</span>
</NavLink>
<NavLink className={styles.link} activeClassName={styles.linkActive} to="/venues">
<Map className={styles.icon} />
<span className={styles.title}>Venues</span>
</NavLink>
<NavLink className={styles.link} activeClassName={styles.linkActive} to="/settings">
<Settings className={styles.icon} />
<span className={styles.title}>Settings</span>
Expand Down
16 changes: 16 additions & 0 deletions v3/src/js/views/layout/__snapshots__/Navtabs.test.jsx.snap
Expand Up @@ -36,6 +36,22 @@ exports[`renders into nav element 1`] = `
Browse
</span>
</NavLink>
<NavLink
activeClassName="linkActive"
className="link"
to="/venues"
>
<Map
className="icon"
color="currentColor"
size="24"
/>
<span
className="title"
>
Venues
</span>
</NavLink>
<NavLink
activeClassName="linkActive"
className="link"
Expand Down
2 changes: 1 addition & 1 deletion v3/src/js/views/timetable/Timetable.jsx
Expand Up @@ -26,7 +26,7 @@ class Timetable extends PureComponent<Props> {
render() {
const schoolDays = SCHOOLDAYS.filter(day => day !== 'Saturday' || this.props.lessons.Saturday);

const lessons: Array<Lesson> = _.flattenDeep(Object.values(this.props.lessons));
const lessons: Array<Lesson> = _.flattenDeep(_.values(this.props.lessons));
const { startingIndex, endingIndex } = calculateBorderTimings(lessons);

return (
Expand Down
68 changes: 68 additions & 0 deletions v3/src/js/views/venues/VenueDetailRow.jsx
@@ -0,0 +1,68 @@
// @flow
import React, { PureComponent } from 'react';
import { flatten, noop } from 'lodash';
import { arrangeLessonsForWeek, colorLessonsByType } from 'utils/timetables';
import Timetable from 'views/timetable/Timetable';

import type { DayAvailability } from 'types/venues';
import type { Venue } from 'types/modules';

import styles from './VenueDetailRow.scss';

type Props = {
name: Venue,
availability: DayAvailability[],
expanded: boolean,
onClick: (Venue, string) => void,
}

export default class VenueDetailRow extends PureComponent<Props> {
static defaultProps = {
name: '',
availability: [],
expanded: false,
onClick: noop,
};

arrangedLessons() {
if (!this.props.expanded) {
return null;
}

const availability: DayAvailability[] = this.props.availability;
// const lessons = flatMap(availability, a => a.Classes) // Not using flatMap as it results in a Flow error
const lessons = flatten(availability.map(dayAvail => dayAvail.Classes))
.map(venueLesson => ({ ...venueLesson, ModuleTitle: '' }));
const coloredLessons = colorLessonsByType(lessons);
return arrangeLessonsForWeek(coloredLessons);
}

render() {
const { name, onClick } = this.props;
const lessons = this.arrangedLessons();
const venueHref = `/venues/${encodeURIComponent(name)}`;

return (
<li className={styles.venueDetailRow}>
<h4>
<a
href={venueHref}
onClick={(e) => {
e.preventDefault();
onClick(name, venueHref);
}}
>{name}</a>
</h4>
{lessons ? (
<div className={styles.venueTimetable}>
<Timetable
lessons={lessons}
isVerticalOrientation={false}
onModifyCell={noop}
/>
</div>
) : null}
</li>
);
}
}
20 changes: 20 additions & 0 deletions v3/src/js/views/venues/VenueDetailRow.scss
@@ -0,0 +1,20 @@
@import "~styles/utils/modules-entry.scss";

.venueDetailRow {
margin-top: 1rem;
border-top: 1px $gray-lighter solid;

&:first-child {
margin-top: 0.25rem;
border-top: 0;
}

a {
width: 100%;
}
}

.venueTimetable {
margin-bottom: 1rem;
overflow: auto;
}
22 changes: 22 additions & 0 deletions v3/src/js/views/venues/VenueDetailRow.test.jsx
@@ -0,0 +1,22 @@
// @flow
import React from 'react';
import { shallow } from 'enzyme';
import venueInfo from '__mocks__/venueInformation.json';
import VenueDetailRow from './VenueDetailRow';

const minProps = {
name: 'CQT/SR0315',
availability: venueInfo['CQT/SR0315'],
};

describe('VenueDetailRow', () => {
test('it displays an anchor tag when not expanded', () => {
const wrapper = shallow(<VenueDetailRow {...minProps} />);
expect(wrapper).toMatchSnapshot();
});

test('it displays an anchor tag and a timetable when expanded', () => {
const wrapper = shallow(<VenueDetailRow {...minProps} expanded />);
expect(wrapper).toMatchSnapshot();
});
});
32 changes: 32 additions & 0 deletions v3/src/js/views/venues/VenueList.jsx
@@ -0,0 +1,32 @@
// @flow
import React from 'react';
import { map } from 'lodash';
import VenueDetailRow from 'views/venues/VenueDetailRow';

import type { VenueInfo } from 'types/venues';
import type { Venue } from 'types/modules';

import styles from './VenueList.scss';

type Props = {
venues: VenueInfo,
expandedVenue: Venue,
onSelect: (Venue, string) => void; // Called with venue name and venue URL (/venues/<venue>)
};

export default function VenueList(props: Props) {
const lowercaseExpandedVenue = props.expandedVenue.toLowerCase();
return (
<ul className={styles.venueList}>
{map(props.venues, (availability, name) => (
<VenueDetailRow
key={name}
name={name}
availability={availability}
expanded={name.toLowerCase() === lowercaseExpandedVenue}
onClick={props.onSelect}
/>
))}
</ul>
);
}
4 changes: 4 additions & 0 deletions v3/src/js/views/venues/VenueList.scss
@@ -0,0 +1,4 @@
.venueList {
padding-left: 0;
list-style-type: none;
}
52 changes: 52 additions & 0 deletions v3/src/js/views/venues/VenueList.test.jsx
@@ -0,0 +1,52 @@
// @flow
import React from 'react';
import { mount } from 'enzyme';
import venueInfo from '__mocks__/venueInformation.json';
import VenueList from './VenueList';

const minProps = {
venues: venueInfo,
expandedVenue: '',
onSelect: () => {},
};

describe('VenueList', () => {
test('it renders all venues as VenueDetailRows', () => {
const wrapper = mount(<VenueList {...minProps} />);
expect(wrapper.find('VenueDetailRow')).toHaveLength(Object.keys(venueInfo).length);
});

test('it expands venues appropriately', () => {
let wrapper;

// Does not expand when there is no expandedVenue
wrapper = mount(<VenueList {...minProps} />);
expect(wrapper.find('VenueDetailRow').filterWhere(r => r.prop('expanded'))).toHaveLength(0);

// Does not expand when no venue names match
wrapper = mount(<VenueList {...minProps} expandedVenue="covfefe" />);
expect(wrapper.find('VenueDetailRow').filterWhere(r => r.prop('expanded'))).toHaveLength(0);

// Expands a valid venue
wrapper = mount(<VenueList {...minProps} expandedVenue="LT17" />);
expect(wrapper.find('VenueDetailRow').filterWhere(r => r.prop('expanded'))).toHaveLength(1);
expect(wrapper.find('VenueDetailRow').filterWhere(r => r.prop('name') === 'LT17').first().prop('expanded')).toBe(true);

// Does not expand partial match
expect(wrapper.find('VenueDetailRow').filterWhere(r => r.prop('name') === 'LT170').first().prop('expanded')).toBe(false);

// Venue name case insensitivity
wrapper = mount(<VenueList {...minProps} expandedVenue="cqt/SR0622" />);
expect(wrapper.find('VenueDetailRow').filterWhere(r => r.prop('expanded'))).toHaveLength(1);
expect(wrapper.find('VenueDetailRow').filterWhere(r => r.prop('name') === 'CQT/SR0622').first().prop('expanded')).toBe(true);
});

test('it calls onSelect when a row is clicked', () => {
const mockOnSelect = jest.fn();
const wrapper = mount(<VenueList {...minProps} onSelect={mockOnSelect} />);
const lt17Row = wrapper.find('VenueDetailRow').filterWhere(r => r.prop('name') === 'LT17').first();
lt17Row.find('a').simulate('click');
expect(mockOnSelect.mock.calls).toHaveLength(1);
expect(mockOnSelect.mock.calls[0]).toEqual(['LT17', '/venues/LT17']);
});
});

0 comments on commit b0d5d84

Please sign in to comment.