diff --git a/.travis.yml b/.travis.yml index eb61bc27bf..07a6aab895 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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: diff --git a/v3/.flowconfig b/v3/.flowconfig index 483a4592e0..efa4ec1f91 100644 --- a/v3/.flowconfig +++ b/v3/.flowconfig @@ -6,7 +6,7 @@ [libs] [options] -module.name_mapper='^\(actions\|apis\|config\|data\|middlewares\|reducers\|storage\|types\|utils\|views\)\/?\(.*\)$' -> '/src/js/\1/\2' +module.name_mapper='^\(actions\|apis\|config\|data\|middlewares\|reducers\|storage\|types\|utils\|views\|test-utils\)\/?\(.*\)$' -> '/src/js/\1/\2' module.name_mapper='^\styles\/?\(.*\)$' -> '/src/styles/\1' module.name_mapper='^\__mocks__\/?\(.*\)$' -> '/__mocks__/\1' module.file_ext=.jsx diff --git a/v3/jest.config.js b/v3/jest.config.js index 4479dca6ea..efe3f09f96 100644 --- a/v3/jest.config.js +++ b/v3/jest.config.js @@ -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', ], diff --git a/v3/src/js/apis/nusmods.js b/v3/src/js/apis/nusmods.js index d8eac4f7da..24f7e92293 100644 --- a/v3/src/js/apis/nusmods.js +++ b/v3/src/js/apis/nusmods.js @@ -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'; */ @@ -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; diff --git a/v3/src/js/test-utils/async.js b/v3/src/js/test-utils/async.js new file mode 100644 index 0000000000..da90d635bd --- /dev/null +++ b/v3/src/js/test-utils/async.js @@ -0,0 +1,6 @@ +// @flow + +import util from 'util'; + +// eslint-disable-next-line import/prefer-default-export +export const nextTick = util.promisify(process.nextTick); diff --git a/v3/src/js/utils/filters/filterTestHelpers.js b/v3/src/js/test-utils/filterTestHelpers.js similarity index 100% rename from v3/src/js/utils/filters/filterTestHelpers.js rename to v3/src/js/test-utils/filterTestHelpers.js diff --git a/v3/src/js/types/views.js b/v3/src/js/types/views.js index 3eb1493d5c..87901eeb7b 100644 --- a/v3/src/js/types/views.js +++ b/v3/src/js/types/views.js @@ -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 }; +export type DepartmentFaculty = { [Department]: Faculty }; export type PageRange = { current: number, diff --git a/v3/src/js/utils/filters/FilterGroup.js b/v3/src/js/utils/filters/FilterGroup.js index 696ab7ee6f..28c962c17b 100644 --- a/v3/src/js/utils/filters/FilterGroup.js +++ b/v3/src/js/utils/filters/FilterGroup.js @@ -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, diff --git a/v3/src/js/utils/filters/TimeslotFilter.test.js b/v3/src/js/utils/filters/TimeslotFilter.test.js index 6bf6e21247..1fec425287 100644 --- a/v3/src/js/utils/filters/TimeslotFilter.test.js +++ b/v3/src/js/utils/filters/TimeslotFilter.test.js @@ -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 diff --git a/v3/src/js/utils/moduleFilters.js b/v3/src/js/utils/moduleFilters.js index 991abc49d7..fbc0aaecce 100644 --- a/v3/src/js/utils/moduleFilters.js +++ b/v3/src/js/utils/moduleFilters.js @@ -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'; @@ -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 } = mapValues( + groupBy(Object.keys(faculties), department => faculties[department]), + departments => new Set(departments), + ); + + const filters = map(facultyDepartments, (departments: Set, 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 } = { - [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; diff --git a/v3/src/js/utils/moduleFilters.test.js b/v3/src/js/utils/moduleFilters.test.js index 09bc821919..b023436849 100644 --- a/v3/src/js/utils/moduleFilters.test.js +++ b/v3/src/js/utils/moduleFilters.test.js @@ -2,65 +2,95 @@ import _ from 'lodash'; import FilterGroup, { ID_DELIMITER } from 'utils/filters/FilterGroup'; -import filterGroups from './moduleFilters'; +import { defaultGroups, DEPARTMENT, FACULTY } from './moduleFilters'; +import ModuleFilter from './filters/ModuleFilter'; -const groups: FilterGroup<*>[] = _.values(filterGroups); +describe('defaultGroups()', () => { + const groups: FilterGroup<*>[] = _.values(defaultGroups({ + Biology: 'Science', + Physics: 'Science', + 'Computer Science': 'School Of Computing', + })); -function expectUnique(arr: T[]) { - // Set in JS iterates over elements in insertion order, so we can use them for - // uniqueness test - expect(Array.from(new Set(arr))).toEqual(arr); -} + function expectUnique(arr: T[]) { + // Set in JS iterates over elements in insertion order, so we can use them for + // uniqueness test + expect(Array.from(new Set(arr))).toEqual(arr); + } -function testGroups(testFn: (group: FilterGroup<*>) => void) { - // Runs a test against each group wrapped in a test block with the group's - // label. This let us see which specific filter group is failing the test - groups.forEach((group) => { - test(group.label, () => testFn(group)); + function testGroups(testFn: (group: FilterGroup<*>) => void) { + // Runs a test against each group wrapped in a test block with the group's + // label. This let us see which specific filter group is failing the test + groups.forEach((group) => { + test(group.label, () => testFn(group)); + }); + } + + test('groups should have unique id and label', () => { + expectUnique(groups.map((group: FilterGroup<*>) => group.id)); + expectUnique(groups.map((group: FilterGroup<*>) => group.label)); }); -} -test('groups should have unique id and label', () => { - expectUnique(groups.map((group: FilterGroup<*>) => group.id)); - expectUnique(groups.map((group: FilterGroup<*>) => group.label)); -}); + describe('filters should have unique id', () => { + testGroups((group) => { + expectUnique(_.values(group.filters).map(filter => filter.id)); + expectUnique(_.values(group.filters).map(filter => filter.label)); + }); + }); -describe('filters should have unique id', () => { - testGroups((group) => { - expectUnique(_.values(group.filters).map(filter => filter.id)); - expectUnique(_.values(group.filters).map(filter => filter.label)); + describe('filter ID should not contain delimiter', () => { + testGroups((group) => { + _.values(group.filters).forEach((filter) => { + expect(filter.id).not.toContain(ID_DELIMITER); + }); + }); }); -}); -describe('filter ID should not contain delimiter', () => { - testGroups((group) => { - _.values(group.filters).forEach((filter) => { - expect(filter.id).not.toContain(ID_DELIMITER); + describe('either all or none of filter ID in a group should be integer', () => { + // Numerical keys are sorted differently when iterating over objects + // See: http://2ality.com/2015/10/property-traversal-order-es6.html#integer-indices + // Since we rely on object iteration order to determine the order of filters, + // mixing key types will almost certainly produce unexpected results + const INTEGER_REGEX = /^(0|[1-9]\d*)$/; + + testGroups((group) => { + const keys = _.values(group.filters).map(filter => filter.id); + const isInteger = keys.every(key => INTEGER_REGEX.test(key)); + const isString = keys.every(key => !INTEGER_REGEX.test(key)); + + expect(keys).not.toHaveLength(0); + expect(isInteger || isString).toBe(true); + + if (isInteger) { + const numeric = keys.map(key => parseInt(key, 10)); + // Also check that numeric keys are incrementing to ensure order is correct + numeric.slice(1).forEach((key, i) => { + expect(numeric[i]).toBeLessThan(key); + }); + } }); }); -}); -describe('either all or none of filter ID in a group should be integer', () => { - // Numerical keys are sorted differently when iterating over objects - // See: http://2ality.com/2015/10/property-traversal-order-es6.html#integer-indices - // Since we rely on object iteration order to determine the order of filters, - // mixing key types will almost certainly produce unexpected results - const INTEGER_REGEX = /^(0|[1-9]\d*)$/; - - testGroups((group) => { - const keys = _.values(group.filters).map(filter => filter.id); - const isInteger = keys.every(key => INTEGER_REGEX.test(key)); - const isString = keys.every(key => !INTEGER_REGEX.test(key)); - - expect(keys).not.toHaveLength(0); - expect(isInteger || isString).toBe(true); - - if (isInteger) { - const numeric = keys.map(key => parseInt(key, 10)); - // Also check that numeric keys are incrementing to ensure order is correct - numeric.slice(1).forEach((key, i) => { - expect(numeric[i]).toBeLessThan(key); - }); + test('faculty filters should be created', () => { + const facultyFilters = groups.find(group => group.id === FACULTY); + + expect(facultyFilters).toBeTruthy(); + if (facultyFilters) { + expect(_.size(facultyFilters.filters)).toBe(2); + } + }); + + test('department filters should be created', () => { + const departmentFilters = groups.find(group => group.id === DEPARTMENT); + + expect(departmentFilters).toBeTruthy(); + if (departmentFilters) { + expect(_.size(departmentFilters.filters)).toBe(3); } }); + + test('should create search filter', () => { + const filterGroups = defaultGroups({}, '?q=42'); + expect(filterGroups.q.filters['42']).toBeInstanceOf(ModuleFilter); + }); }); diff --git a/v3/src/js/utils/moduleSearch.js b/v3/src/js/utils/moduleSearch.js index 8c1ed1500d..43dbdff338 100644 --- a/v3/src/js/utils/moduleSearch.js +++ b/v3/src/js/utils/moduleSearch.js @@ -39,7 +39,7 @@ export function createSearchPredicate(searchTerm: string): SearchableModule => b export function createSearchFilter(searchTerm: string): FilterGroup { const predicate = createSearchPredicate(searchTerm); - const filter = new ModuleFilter(searchTerm, searchTerm, predicate); + const filter = new ModuleFilter(encodeURIComponent(searchTerm), searchTerm, predicate); return new FilterGroup(SEARCH_QUERY_KEY, 'Search', [filter]).toggle(filter); } diff --git a/v3/src/js/views/components/SideMenu.jsx b/v3/src/js/views/components/SideMenu.jsx index 5c4b013b46..7682e0f4c5 100644 --- a/v3/src/js/views/components/SideMenu.jsx +++ b/v3/src/js/views/components/SideMenu.jsx @@ -3,8 +3,9 @@ import React, { PureComponent, type Node } from 'react'; import classnames from 'classnames'; import { Menu, Close } from 'views/components/icons'; - +import makeResponsive from 'views/hocs/makeResponsive'; import Fab from './Fab'; + import styles from './SideMenu.scss'; type Props = { @@ -12,20 +13,21 @@ type Props = { openIcon: Node, closeIcon: Node, isOpen: boolean, + matchBreakpoint: boolean, toggleMenu: (boolean) => void, }; export const OPEN_MENU_LABEL = 'Open menu'; export const CLOSE_MENU_LABEL = 'Close menu'; -export default class SideMenu extends PureComponent { +export class SideMenuComponent extends PureComponent { static defaultProps = { openIcon: , closeIcon: , }; render() { - const { isOpen, toggleMenu, children, openIcon, closeIcon } = this.props; + const { isOpen, matchBreakpoint, toggleMenu, children, openIcon, closeIcon } = this.props; return (
@@ -36,7 +38,7 @@ export default class SideMenu extends PureComponent { {isOpen ? closeIcon : openIcon} - {isOpen && + {isOpen && !matchBreakpoint &&
toggleMenu(false)} @@ -49,3 +51,5 @@ export default class SideMenu extends PureComponent { ); } } + +export default makeResponsive(SideMenuComponent, 'md'); diff --git a/v3/src/js/views/components/filters/Checklist.jsx b/v3/src/js/views/components/filters/Checklist.jsx new file mode 100644 index 0000000000..87f0aff54b --- /dev/null +++ b/v3/src/js/views/components/filters/Checklist.jsx @@ -0,0 +1,38 @@ +// @flow + +import React from 'react'; +import classnames from 'classnames'; +import ModuleFilter from 'utils/filters/ModuleFilter'; +import styles from './styles.scss'; + +type Props = { + filters: ModuleFilter[], + onChange: (ModuleFilter) => void, + getCount: (ModuleFilter) => number, +}; + +export default function ({ filters, onChange, getCount }: Props) { + return ( +
    + {filters.map((filter: ModuleFilter) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/v3/src/js/views/components/filters/ChecklistFilters.jsx b/v3/src/js/views/components/filters/ChecklistFilters.jsx index 907c7955a3..287be133d0 100644 --- a/v3/src/js/views/components/filters/ChecklistFilters.jsx +++ b/v3/src/js/views/components/filters/ChecklistFilters.jsx @@ -1,13 +1,12 @@ // @flow import React from 'react'; -import classnames from 'classnames'; import { values } from 'lodash'; import type { OnFilterChange } from 'types/views'; import FilterGroup from 'utils/filters/FilterGroup'; -import ModuleFilter from 'utils/filters/ModuleFilter'; import styles from './styles.scss'; +import Checklist from './Checklist'; type Props = { onFilterChange: OnFilterChange, @@ -22,27 +21,11 @@ export default function ChecklistFilters(props: Props) { return (

{group.label}

-
    - {values(group.filters).map((filter: ModuleFilter) => ( -
  • - -
  • - ))} -
+ onFilterChange(group.toggle(filter))} + getCount={filter => filter.count(moduleCodes)} + />
); } diff --git a/v3/src/js/views/components/filters/DropdownListFilters.jsx b/v3/src/js/views/components/filters/DropdownListFilters.jsx new file mode 100644 index 0000000000..14010dac42 --- /dev/null +++ b/v3/src/js/views/components/filters/DropdownListFilters.jsx @@ -0,0 +1,192 @@ +// @flow + +import React, { PureComponent } from 'react'; +import Downshift from 'downshift'; +import classnames from 'classnames'; +import { each, values, uniq } from 'lodash'; + +import type { OnFilterChange } from 'types/views'; + +import { Search, ChevronDown } from 'views/components/icons'; +import makeResponsive from 'views/hocs/makeResponsive'; +import FilterGroup from 'utils/filters/FilterGroup'; +import ModuleFilter from 'utils/filters/ModuleFilter'; +import { highlight } from 'utils/react'; +import styles from './styles.scss'; +import Checklist from './Checklist'; + +type Props = { + onFilterChange: OnFilterChange, + groups: FilterGroup[], + group: FilterGroup<*>, + matchBreakpoint: boolean, +}; + +type State = { + isFocused: boolean, + searchedFilters: string[], +} + +export class DropdownListFiltersComponent extends PureComponent { + searchInput: ?HTMLInputElement; + + constructor(props: Props) { + super(props); + + this.state = { + isFocused: false, + searchedFilters: values(props.group.filters) + .filter(filter => filter.enabled) + .map(filter => filter.id), + }; + } + + onSelectItem = (selectedItem: string) => { + if (!selectedItem) return; + const { group, onFilterChange } = this.props; + onFilterChange(group.toggle(selectedItem)); + this.setState({ searchedFilters: uniq([...this.state.searchedFilters, selectedItem]) }); + }; + + focusInput = () => { + if (this.searchInput) this.searchInput.focus(); + }; + + displayedFilters(inputValue?: string): [ModuleFilter, number][] { + const { group, groups } = this.props; + const moduleCodes = FilterGroup.union(groups, group); + + // Pick out filters that match the search which have at least one matching module + const filterCount: Map = new Map(); + each(group.filters, (filter) => { + if (inputValue && !filter.label.toLowerCase().includes(inputValue.toLowerCase())) { + return; + } + + const count = filter.count(moduleCodes); + if (count) filterCount.set(filter.id, count); + }); + + // Sort by name in alphabetical order and return together with count + return Array.from(filterCount.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([id, count]) => [group.filters[id], count]); + } + + render() { + const { group, groups, onFilterChange, matchBreakpoint } = this.props; + const moduleCodes = FilterGroup.union(groups, group); + + const htmlId = `dropdown-filter-${group.id}`; + const placeholder = `Add ${group.label.toLowerCase()} filter...`; + + const searchedFilters = this.state.searchedFilters + .map(filterId => group.filters[filterId]); + + return ( +
+

+ +

+ + {/* Use a search-select combo dropdown on desktop */} + {matchBreakpoint ? + { + this.onSelectItem(selectedItem); + clearSelection(); + }} + render={({ + getInputProps, + getItemProps, + openMenu, + isOpen, + inputValue, + highlightedIndex, + }) => ( +
+
+ + { this.searchInput = r; }} + {...getInputProps({ + onFocus: () => { + this.setState({ isFocused: true }); + openMenu(); + }, + onBlur: () => this.setState({ isFocused: false }), + className: classnames('form-control form-control-sm', styles.searchInput), + placeholder, + id: htmlId, + })} + /> + +
+ + {isOpen && +
+ {this.displayedFilters(inputValue).map(([filter, count], index) => ( + ))} +
} +
+ )} + /> + : + /* Use a native select for mobile devices */ + + evt.target.selectedIndex = 0; // eslint-disable-line no-param-reassign + }} + > + + {this.displayedFilters().map(([filter, count]) => ( + + ))} + } + + {/* Show all filters that have been selected at some point */} + onFilterChange(group.toggle(filter))} + getCount={filter => filter.count(moduleCodes)} + /> +
+ ); + } +} + +export default makeResponsive(DropdownListFiltersComponent, 'md'); diff --git a/v3/src/js/views/components/filters/TimeslotFilters.jsx b/v3/src/js/views/components/filters/TimeslotFilters.jsx index d0e6ed565e..c3fe41aeae 100644 --- a/v3/src/js/views/components/filters/TimeslotFilters.jsx +++ b/v3/src/js/views/components/filters/TimeslotFilters.jsx @@ -30,7 +30,7 @@ export default function TimeslotFilters(props: Props) { children.set(timeslot,