Skip to content

Commit

Permalink
Implement faculty and department filters (#537)
Browse files Browse the repository at this point in the history
* Initialize search query in the constructor instead of componentWillMount

* Improve docs

* Initial implementation of department/faculty filters

* Fix tests

* Improve tests

* Implement department/faculty UI

* Fix Travis build config

* Improve dropdown styles and overrides

* Add focused state to DropdownListFilters

* Add label

* Use makeResponsive to hide <SideMenu> overlay

* Use native <select> on mobile

* Improve <select> UI

- Hide filters that are do not have any modules
- Sort alphabetically
- Add a placeholder first item

* Add checkmark indicator for enabled filters

* Improve combobox UI

* Ignore test coverage in test-utils

* Extract common Checklist component
  • Loading branch information
ZhangYiJiang authored Dec 22, 2017
1 parent d51f304 commit 6e7c3a4
Show file tree
Hide file tree
Showing 27 changed files with 814 additions and 328 deletions.
10 changes: 6 additions & 4 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
language: node_js
dist: trusty
sudo: false
env:
matrix:
- PROJECT="v2"
- PROJECT="v3"
matrix:
include:
- env: PROJECT="v2"
node_js: 6
- env: PROJECT="v3"
node_js: lts/*
cache:
yarn: true
directories:
Expand Down
2 changes: 1 addition & 1 deletion v3/.flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[libs]

[options]
module.name_mapper='^\(actions\|apis\|config\|data\|middlewares\|reducers\|storage\|types\|utils\|views\)\/?\(.*\)$' -> '<PROJECT_ROOT>/src/js/\1/\2'
module.name_mapper='^\(actions\|apis\|config\|data\|middlewares\|reducers\|storage\|types\|utils\|views\|test-utils\)\/?\(.*\)$' -> '<PROJECT_ROOT>/src/js/\1/\2'
module.name_mapper='^\styles\/?\(.*\)$' -> '<PROJECT_ROOT>/src/styles/\1'
module.name_mapper='^\__mocks__\/?\(.*\)$' -> '<PROJECT_ROOT>/__mocks__/\1'
module.file_ext=.jsx
Expand Down
1 change: 1 addition & 0 deletions v3/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = {
'src/**/*.{js|jsx}',
],
coveragePathIgnorePatterns: [
'src/js/test_utils',
// Code in this file triggers this bug - https://github.com/istanbuljs/babel-plugin-istanbul/issues/116
'src/js/views/modules/ModulePageContent.jsx',
],
Expand Down
26 changes: 13 additions & 13 deletions v3/src/js/apis/nusmods.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @flow
// This file uses comment type because we want to import it in Webpack configs
/* eslint-disable spaced-comment */
/* eslint-disable spaced-comment, arrow-parens */

/*:: import type { ModuleCode, Semester } from 'types/modules'; */

Expand All @@ -12,24 +12,24 @@ const NUSModsApi = {
ayBaseUrl: ()/*: string */ => ayBaseUrl,

// List of modules for the entire acad year.
moduleListUrl: ()/*: string */ => {
return `${ayBaseUrl}/moduleList.json`;
},
moduleListUrl: ()/*: string */ =>
`${ayBaseUrl}/moduleList.json`,

// Module for that acad year. Not tied to any semester.
moduleDetailsUrl: (moduleCode /*: ModuleCode */)/*: string */ => {
return `${ayBaseUrl}/modules/${moduleCode}.json`;
},
moduleDetailsUrl: (moduleCode /*: ModuleCode */)/*: string */ =>
`${ayBaseUrl}/modules/${moduleCode}.json`,

// List of all modules for the entire acad year
modulesUrl: ()/*: string */ => {
return `${ayBaseUrl}/moduleInformation.json`;
},
modulesUrl: ()/*: string */ =>
`${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`;
},
venuesUrl: (semester/*: Semester */)/*: string */ =>
`${ayBaseUrl}/${semester}/venueInformation.json`,

// List of departments mapped to faculties
facultyDepartmentsUrl: (semester/*: Semester */)/*: string */ =>
`${ayBaseUrl}/${semester}/facultyDepartments.json`,
};

module.exports = NUSModsApi;
6 changes: 6 additions & 0 deletions v3/src/js/test-utils/async.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// @flow

import util from 'util';

// eslint-disable-next-line import/prefer-default-export
export const nextTick = util.promisify(process.nextTick);
File renamed without changes.
5 changes: 5 additions & 0 deletions v3/src/js/types/views.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
// @flow
import FilterGroup from 'utils/filters/FilterGroup';
import type { Department, Faculty } from './modules';

/* components/ModulesSelect.jsx */
export type SelectOption = { label: string, value: string };

/* browse/ModuleFinderContainer */
export type FilterGroupId = string;

export type OnFilterChange = FilterGroup<*> => any;
export type FilterGroups = { [FilterGroupId]: FilterGroup<any> };
export type DepartmentFaculty = { [Department]: Faculty };

export type PageRange = {
current: number,
Expand Down
3 changes: 1 addition & 2 deletions v3/src/js/utils/filters/FilterGroup.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@ import { keyBy, values } from 'lodash';
import update from 'immutability-helper';

import type { Module, ModuleCode } from 'types/modules';
import type { FilterGroupId } from 'types/views';

import { intersection, union } from 'utils/set';
import ModuleFilter from './ModuleFilter';

export const ID_DELIMITER = ',';

export type FilterGroupId = string;

/**
* A filter group is a collection of module filters. A module filter is a simple function
* that returns true or false given a module, and when applied to an array of modules,
Expand Down
2 changes: 1 addition & 1 deletion v3/src/js/utils/filters/TimeslotFilter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import cs3216 from '__mocks__/modules/CS3216.json';

import Combinatorics from 'js-combinatorics';
import { DaysOfWeek, TimesOfDay, Semesters, Timeslots } from 'types/modules';
import { testFilter } from 'test-utils/filterTestHelpers';
import TimeslotFilter, { TimeslotTypes } from './TimeslotFilter';
import { testFilter } from './filterTestHelpers';

test('test() should filter modules according to their lecture timeslot', () => {
// Generate all possible combinations of parameters to test against
Expand Down
170 changes: 128 additions & 42 deletions v3/src/js/utils/moduleFilters.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// @flow
import { entries } from 'lodash';
import { map, mapValues, each, isEmpty, groupBy, kebabCase } from 'lodash';
import update from 'immutability-helper';
import qs from 'query-string';

import type { FilterGroupId } from 'utils/filters/FilterGroup';
import type { FilterGroups, DepartmentFaculty } from 'types/views';
import type { Module, Faculty, Department } from 'types/modules';

import { Timeslots } from 'types/modules';
import config from 'config';
Expand All @@ -10,50 +13,133 @@ import TimeslotFilter from 'utils/filters/TimeslotFilter';
import Filter from 'utils/filters/ModuleFilter';
import FilterGroup from 'utils/filters/FilterGroup';
import { getModuleSemesterData } from 'utils/modules';
import { createSearchFilter, SEARCH_QUERY_KEY } from './moduleSearch';

export const LEVELS = 'level';
export const LECTURE_TIMESLOTS = 'lecture';
export const TUTORIAL_TIMESLOTS = 'tutorial';
export const MODULE_CREDITS = 'mc';
export const SEMESTER = 'sem';
export const FACULTY = 'faculty';
export const DEPARTMENT = 'department';

/**
* Invert the { [Faculty]: Departments[] } mapping to { [Department]: Faculty }
*/
export function invertFacultyDepartments(mapping: { [Faculty]: Department[] }): DepartmentFaculty {
const departmentFaculty = {};
each(mapping, (departments, faculty) => {
departments.forEach((department) => {
departmentFaculty[department] = faculty;
});
});
return departmentFaculty;
}

/**
* Update the provided filter groups to the state in the query string immutably
*/
export function updateGroups(groups: FilterGroups, query: string): FilterGroups {
const params = qs.parse(query);
const updater = {};

each(groups, (group) => {
const currentQuery = group.toQueryString();
if (currentQuery === params[group.id] || (!params[group.id] && !currentQuery)) return;
updater[group.id] = { $set: group.fromQueryString(params[group.id]) };
});

if (isEmpty(updater)) return groups;
return update(groups, updater);
}

/**
* Serialize the provided FilterGroups into query string
*/
export function serializeGroups(groups: FilterGroups): string {
const query = {};

each(groups, (group) => {
const value = group.toQueryString();
if (!value) return;
query[group.id] = value;
});

return qs.stringify(query, { encode: false });
}

function makeFacultyFilter(faculties: DepartmentFaculty) {
const facultyDepartments: { [Faculty]: Set<Department> } = mapValues(
groupBy(Object.keys(faculties), department => faculties[department]),
departments => new Set(departments),
);

const filters = map(facultyDepartments, (departments: Set<Department>, faculty: Faculty) =>
new Filter(kebabCase(faculty), faculty, (module: Module) => departments.has(module.Department)));

return new FilterGroup(FACULTY, 'Faculties', filters);
}

function makeDepartmentFilter(faculties: DepartmentFaculty) {
return new FilterGroup(
DEPARTMENT,
'Departments',
Object.keys(faculties).map((department: Department) =>
new Filter(kebabCase(department), department, (module: Module) => module.Department === department)),
);
}

export function defaultGroups(faculties: DepartmentFaculty, query: string = ''): FilterGroups {
const params = qs.parse(query);

const groups: FilterGroups = {
[SEMESTER]: new FilterGroup(
SEMESTER,
'Available In',
map(config.semesterNames, (name, semesterStr) => {
const semester = parseInt(semesterStr, 10);
return new Filter(semesterStr, name, module => !!getModuleSemesterData(module, semester));
}),
),

[LEVELS]: new FilterGroup(
LEVELS,
'Levels',
[1, 2, 3, 4, 5, 6].map(level => new LevelFilter(level)),
),

[LECTURE_TIMESLOTS]: new FilterGroup(
LECTURE_TIMESLOTS,
'With Lectures At',
Timeslots.map(([day, time]) => new TimeslotFilter(day, time, 'Lecture')),
),

[TUTORIAL_TIMESLOTS]: new FilterGroup(
TUTORIAL_TIMESLOTS,
'With Tutorials At',
Timeslots.map(([day, time]) => new TimeslotFilter(day, time, 'Tutorial')),
),

[MODULE_CREDITS]: new FilterGroup(MODULE_CREDITS, 'Module Credit', [
new Filter('0', '0-3 MC', module => parseFloat(module.ModuleCredit) <= 3),
new Filter('4', '4 MC', module => module.ModuleCredit === '4'),
new Filter('5', '5-8 MC', (module) => {
const credits = parseFloat(module.ModuleCredit);
return credits > 4 && credits <= 8;
}),
new Filter('8', 'More than 8 MC', module => parseInt(module.ModuleCredit, 10) > 8),
]),

[DEPARTMENT]: makeDepartmentFilter(faculties),

[FACULTY]: makeFacultyFilter(faculties),
};

// Search query group
if (params[SEARCH_QUERY_KEY]) {
groups[SEARCH_QUERY_KEY] = createSearchFilter(params[SEARCH_QUERY_KEY]);
}

return updateGroups(groups, query);
}

const groups: { [FilterGroupId]: FilterGroup<any> } = {
[SEMESTER]: new FilterGroup(
SEMESTER,
'Available In',
entries(config.semesterNames).map(([semesterStr, name]) => {
const semester = parseInt(semesterStr, 10);
return new Filter(semesterStr, name, module => !!getModuleSemesterData(module, semester));
}),
),

[LEVELS]: new FilterGroup(
LEVELS,
'Levels',
[1, 2, 3, 4, 5, 6].map(level => new LevelFilter(level)),
),

[LECTURE_TIMESLOTS]: new FilterGroup(
LECTURE_TIMESLOTS,
'With Lectures At',
Timeslots.map(([day, time]) => new TimeslotFilter(day, time, 'Lecture')),
),

[TUTORIAL_TIMESLOTS]: new FilterGroup(
TUTORIAL_TIMESLOTS,
'With Tutorials At',
Timeslots.map(([day, time]) => new TimeslotFilter(day, time, 'Tutorial')),
),

[MODULE_CREDITS]: new FilterGroup(MODULE_CREDITS, 'Module Credit', [
new Filter('0', '0-3 MC', module => parseFloat(module.ModuleCredit) <= 3),
new Filter('4', '4 MC', module => module.ModuleCredit === '4'),
new Filter('5', '5-8 MC', (module) => {
const credits = parseFloat(module.ModuleCredit);
return credits > 4 && credits <= 8;
}),
new Filter('8', 'More than 8 MC', module => parseInt(module.ModuleCredit, 10) > 8),
]),
};

export default groups;
Loading

0 comments on commit 6e7c3a4

Please sign in to comment.