Skip to content

Commit

Permalink
Merge b9a5fb3 into bffb5f0
Browse files Browse the repository at this point in the history
  • Loading branch information
taneliang committed Dec 11, 2017
2 parents bffb5f0 + b9a5fb3 commit 52dcc1b
Show file tree
Hide file tree
Showing 32 changed files with 2,817 additions and 51 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: 1 addition & 22 deletions v3/src/js/reducers/theme.js
@@ -1,12 +1,12 @@
// @flow
import type { FSA } from 'types/redux';
import type {
ColorIndex,
ColorMapping,
ThemeState,
} from 'types/reducers';

import _ from 'lodash';
import { getNewColor } from 'utils/colors';
import { ADD_MODULE, REMOVE_MODULE } from 'actions/timetables';
import { SELECT_THEME, SELECT_MODULE_COLOR, TOGGLE_TIMETABLE_ORIENTATION } from 'actions/theme';

Expand All @@ -23,27 +23,6 @@ const defaultThemeState: ThemeState = {
timetableOrientation: HORIZONTAL,
};

export const NUM_DIFFERENT_COLORS: number = 8;

// Returns a new index that is not present in the current color index.
// If there are more than NUM_DIFFERENT_COLORS modules already present,
// will try to balance the color distribution.
function getNewColor(currentColorIndices: Array<ColorIndex>): number {
function generateInitialIndices(): Array<number> {
return _.range(NUM_DIFFERENT_COLORS);
}

let availableColorIndices = generateInitialIndices();
currentColorIndices.forEach((index: ColorIndex) => {
availableColorIndices = _.without(availableColorIndices, index);
if (availableColorIndices.length === 0) {
availableColorIndices = generateInitialIndices();
}
});

return _.sample(availableColorIndices);
}

function colors(state: ColorMapping, action: FSA): ColorMapping {
if (!(action.payload && action.payload.moduleCode)) {
return state;
Expand Down
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[] };
44 changes: 44 additions & 0 deletions v3/src/js/utils/colors.js
@@ -0,0 +1,44 @@
// @flow
import { range, without, sample } from 'lodash';

import type { ColorIndex } from 'types/reducers';
import type { Lesson } from 'types/modules';

export const NUM_DIFFERENT_COLORS: number = 8;

// Returns a new index that is not present in the current color index.
// If there are more than NUM_DIFFERENT_COLORS modules already present,
// will try to balance the color distribution if randomize === true.
export function getNewColor(currentColorIndices: Array<ColorIndex>, randomize: boolean = true): ColorIndex {
function generateInitialIndices(): Array<number> {
return range(NUM_DIFFERENT_COLORS);
}

let availableColorIndices = generateInitialIndices();
currentColorIndices.forEach((index: ColorIndex) => {
availableColorIndices = without(availableColorIndices, index);
if (availableColorIndices.length === 0) {
availableColorIndices = generateInitialIndices();
}
});

if (randomize) {
return sample(availableColorIndices);
}
return availableColorIndices[0];
}

// Color lessons by a certain property of every lesson
// e.g. clbk([...], 'LessonType') colors lessons by their type
export function colorLessonsByKey(lessons: Lesson[], key: string) {
const colorMap = new Map();
return lessons.map((lesson) => {
let colorIndex = colorMap.get(lesson[key]);
if (!colorMap.has(lesson[key])) {
colorIndex = getNewColor(Array.from(colorMap.values()), false);
colorMap.set(lesson[key], colorIndex);
}

return { ...lesson, colorIndex };
});
}
71 changes: 71 additions & 0 deletions v3/src/js/utils/colors.test.js
@@ -0,0 +1,71 @@
// @flow
import { range, without } from 'lodash';

import type { ColorIndex } from 'types/reducers';
import type { Lesson } from 'types/modules';

import { NUM_DIFFERENT_COLORS, getNewColor, colorLessonsByKey } from './colors';

describe('#getNewColor()', () => {
test('it should get color without randomization', () => {
// When there are no current colors
expect(getNewColor([], false)).toBe(0);
// When there are colors that have not been picked
expect(getNewColor([0, 1], false)).toBe(2);
// When all the colors have been picked once
expect(getNewColor(range(NUM_DIFFERENT_COLORS), false)).toBe(0);
// When all the colors have been picked once or more
expect(getNewColor([...range(NUM_DIFFERENT_COLORS), 0, 1], false)).toBe(2);
});

test('it should get random color', () => {
// We're not actually testing randomness, only that the color indices returned are valid
// Check that calling getNewColor with currentColors returns an int
// in [0, NUM_DIFFERENT_COLORS] AND not in unexpectedColors
function expectValidIndex(unexpectedColors: Array<ColorIndex>, currentColors: Array<ColorIndex>) {
expect(without(range(NUM_DIFFERENT_COLORS), ...unexpectedColors))
.toContain(getNewColor(currentColors, true));
}

range(100).forEach(() => {
// When there are no current colors
expectValidIndex([], []);
// When there are colors that have not been picked
expectValidIndex([5, 3], [5, 3]);
// When all the colors have been picked once
expectValidIndex([], range(NUM_DIFFERENT_COLORS));
// When all the colors have been picked once or more
expectValidIndex([5, 3], [...range(NUM_DIFFERENT_COLORS), 5, 3]);
});
});
});

describe('#colorLessonsByKey()', () => {
const bareLesson: Lesson = {
ClassNo: '',
DayText: '',
EndTime: '',
LessonType: '',
StartTime: '',
Venue: '',
WeekText: '',
ModuleCode: '',
ModuleTitle: '',
};

test('it should assign colors deterministically', () => {
const lessons: Lesson[] = [];
range(100).forEach((i) => {
// Add 2 lessons for this ith venue
const newLesson = { ...bareLesson, Venue: `LT${i}` };
lessons.push(newLesson);
lessons.push(newLesson);

const coloredLessons = colorLessonsByKey(lessons, 'Venue');
const coloredLesson = coloredLessons[coloredLessons.length - 1];

expect(coloredLesson).toMatchObject(newLesson); // Ensure that existing lesson info wasn't modified
expect(coloredLesson).toHaveProperty('colorIndex', i % NUM_DIFFERENT_COLORS);
});
});
});
13 changes: 0 additions & 13 deletions v3/src/js/utils/timetables.js
Expand Up @@ -192,19 +192,6 @@ export function areOtherClassesAvailable(lessons: Array<RawLesson>,
return Object.keys(_.groupBy(lessonTypeGroups[lessonType], lesson => lesson.ClassNo)).length > 1;
}

export function colorLessonsByType(lessons: Lesson[]) {
const types = new Map();
return lessons.map((lesson) => {
let colorIndex = types.get(lesson.LessonType);
if (!types.has(lesson.LessonType)) {
colorIndex = types.size;
types.set(lesson.LessonType, colorIndex);
}

return { ...lesson, colorIndex };
});
}

// Find all exam clashes between modules in semester
// Returns object associating exam dates with the modules clashing on those dates
export function findExamClashes(modules: Array<Module>, semester: Semester): { string: Array<Module> } {
Expand Down
7 changes: 6 additions & 1 deletion v3/src/js/views/components/SearchBox.jsx
Expand Up @@ -12,6 +12,7 @@ type Props = {
initialSearchTerm: ?string,
placeholder: string,
onSearch: (string) => void,
rootElementRef?: (HTMLElement) => void, // For parent components to obtain a ref to the root HTMLElement
};

type State = {
Expand Down Expand Up @@ -66,8 +67,12 @@ export default class SearchBox extends PureComponent<Props, State> {
debouncedSearch: (string) => void = debounce(this.search, this.props.throttle, { leading: false });

render() {
const rootElementRef: Function = this.props.rootElementRef || (() => {}); // noop crashes here on Node 6.6
return (
<div className={classnames(styles.searchBox, { [styles.searchBoxFocused]: this.state.isFocused })}>
<div
className={classnames(styles.searchBox, { [styles.searchBoxFocused]: this.state.isFocused })}
ref={rootElementRef}
>
<label htmlFor="search-box" className="sr-only">Search</label>
<form
className={styles.searchWrapper}
Expand Down
2 changes: 1 addition & 1 deletion v3/src/js/views/components/color-picker/ColorPicker.jsx
Expand Up @@ -4,7 +4,7 @@ import _ from 'lodash';

import type { ColorIndex } from 'types/reducers';

import { NUM_DIFFERENT_COLORS } from 'reducers/theme';
import { NUM_DIFFERENT_COLORS } from 'utils/colors';

import './color-picker.scss';

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
5 changes: 3 additions & 2 deletions v3/src/js/views/components/module-info/LessonTimetable.jsx
Expand Up @@ -8,7 +8,8 @@ import type { Semester, SemesterData } from 'types/modules';

import Timetable from 'views/timetable/Timetable';
import SemesterPicker from 'views/components/module-info/SemesterPicker';
import { arrangeLessonsForWeek, colorLessonsByType } from 'utils/timetables';
import { arrangeLessonsForWeek } from 'utils/timetables';
import { colorLessonsByKey } from 'utils/colors';
import { getFirstAvailableSemester } from 'utils/modules';
import styles from './LessonTimetable.scss';

Expand Down Expand Up @@ -43,7 +44,7 @@ export default class LessonTimetableControl extends PureComponent<Props, State>
const lessons = semester.Timetable.map(lesson => ({
...lesson, ModuleCode: '', ModuleTitle: '',
}));
const coloredLessons = colorLessonsByType(lessons);
const coloredLessons = colorLessonsByKey(lessons, 'LessonType');
const arrangedLessons = arrangeLessonsForWeek(coloredLessons);

return (
Expand Down
17 changes: 17 additions & 0 deletions v3/src/js/views/errors/Warning.jsx
@@ -0,0 +1,17 @@
// @flow
import React from 'react';
import { AlertTriangle } from 'views/components/icons';
import styles from './Warning.scss';

type Props = {
message: string,
}

export default function Warning(props: Props) {
return (
<div className="text-center mt-4">
<AlertTriangle className={styles.noModulesIcon} />
<h4>{props.message}</h4>
</div>
);
}
File renamed without changes.
9 changes: 9 additions & 0 deletions v3/src/js/views/errors/Warning.test.jsx
@@ -0,0 +1,9 @@
// @flow
import React from 'react';
import { shallow } from 'enzyme';
import Warning from './Warning';

test('it displays warning message', () => {
const wrapper = shallow(<Warning message="abcde/ghi123!@#$" />);
expect(wrapper.find('[children="abcde/ghi123!@#$"]')).toHaveLength(1);
});
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}>Modules</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`] = `
Modules
</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
10 changes: 2 additions & 8 deletions v3/src/js/views/modules/ModuleFinderList.jsx
Expand Up @@ -6,9 +6,8 @@ import _ from 'lodash';
import type { Module } from 'types/modules';
import type { PageRange, PageRangeDiff, OnPageChange } from 'types/views';

import { AlertTriangle } from 'views/components/icons';
import Warning from 'views/errors/Warning';
import ModuleFinderPage from './ModuleFinderPage';
import styles from './ModuleFinderList.scss';

const MODULES_PER_PAGE = 5;

Expand Down Expand Up @@ -63,12 +62,7 @@ export default class ModuleFinderList extends Component<Props> {
const total = this.props.modules.length;

if (total === 0) {
return (
<div className="text-center mt-4">
<AlertTriangle className={styles.noModulesIcon} />
<h4>No modules found</h4>
</div>
);
return <Warning message="No modules found" />;
}

return (
Expand Down
2 changes: 2 additions & 0 deletions v3/src/js/views/routes/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/modules/ModulePageContainer';
import ModuleFinderContainer from 'views/modules/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 @@ -23,6 +24,7 @@ export default function Routes() {
<Route path="/timetable/:semester?" 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

0 comments on commit 52dcc1b

Please sign in to comment.