diff --git a/src/App.tsx b/src/App.tsx index c081066..b636045 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import React from "react"; import "./App.css"; import ProjectList from "./components/ProjectList"; import FilterBar from "./components/FilterBar"; +import SearchBar from "./components/SearchBar"; const App: React.FC = () => { return ( @@ -10,6 +11,7 @@ const App: React.FC = () => {

glTF Project Explorer

+
diff --git a/src/components/SearchBar.css b/src/components/SearchBar.css new file mode 100644 index 0000000..a114ff8 --- /dev/null +++ b/src/components/SearchBar.css @@ -0,0 +1,24 @@ +.search-bar { + background-color: #fcfcfc; + border-radius: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.25); + margin: 1rem; + padding: 1rem; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.search-bar h1 { + font-size: 1.5rem; + margin: 0; + padding: 0; +} + +.search-bar input { + margin: 0.5rem 0 0 0; + padding: 0.25rem 0.5rem; + height: 2rem; + line-height: 1.5rem; + font-size: 1rem; +} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..5990dea --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,52 @@ +import React, { useCallback } from "react"; +import { connect } from "react-redux"; +import { IAppState } from "../interfaces/IAppState"; +import { updateTitleSubstringFilter } from "../store/filters/Actions"; +import "./SearchBar.css"; + +export interface ISearchBarProps { + titleSubstring: string; + updateTitleSubstringFilter: typeof updateTitleSubstringFilter; +} + +const SearchBar: React.FC = props => { + const { titleSubstring, updateTitleSubstringFilter } = props; + + const handleSearch = useCallback( + (event: React.ChangeEvent) => { + const newTitleSubstring = event.target.value; + updateTitleSubstringFilter(newTitleSubstring); + }, + [updateTitleSubstringFilter] + ); + + return ( +
+

Search by Title

+ +
+ ); +}; + +function mapStateToProps(state: IAppState) { + const { + filters: { titleSubstring } + } = state; + + return { + titleSubstring + }; +} + +const mapDispatchToProps = { + updateTitleSubstringFilter +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(SearchBar); diff --git a/src/interfaces/IAppState.ts b/src/interfaces/IAppState.ts index 1f8aa6c..6fa61cd 100644 --- a/src/interfaces/IAppState.ts +++ b/src/interfaces/IAppState.ts @@ -19,6 +19,7 @@ export interface IFiltersState { types: IFilter[]; languages: IFilter[]; licenses: IFilter[]; + titleSubstring: string; selected: Set; } diff --git a/src/store/filters/Actions.ts b/src/store/filters/Actions.ts index 6fe6078..a9ba310 100644 --- a/src/store/filters/Actions.ts +++ b/src/store/filters/Actions.ts @@ -1,6 +1,7 @@ import { IUpdateFiltersAction, - IUpdateSelectedFiltersAction + IUpdateSelectedFiltersAction, + IUpdateTitleSubstringFilterAction } from "./Interfaces"; import { FilterActionTypes } from "./Types"; import { IFilter } from "../../interfaces/IFilter"; @@ -9,14 +10,16 @@ export function updateFilters( tasks: IFilter[], types: IFilter[], licenses: IFilter[], - languages: IFilter[] + languages: IFilter[], + titleSubstring: string ): IUpdateFiltersAction { return { type: FilterActionTypes.UPDATE_FILTERS, tasks, types, licenses, - languages + languages, + titleSubstring }; } @@ -30,3 +33,12 @@ export function updateSelectedFilters( selected }; } + +export function updateTitleSubstringFilter( + titleSubstring: string +): IUpdateTitleSubstringFilterAction { + return { + type: FilterActionTypes.UPDATE_TITLE_SUBSTRING_FILTER, + titleSubstring + }; +} diff --git a/src/store/filters/Interfaces.ts b/src/store/filters/Interfaces.ts index 8df0e54..36904a7 100644 --- a/src/store/filters/Interfaces.ts +++ b/src/store/filters/Interfaces.ts @@ -3,7 +3,8 @@ import { FilterActionTypes } from "./Types"; export type FiltersActions = | IUpdateFiltersAction - | IUpdateSelectedFiltersAction; + | IUpdateSelectedFiltersAction + | IUpdateTitleSubstringFilterAction; export interface IUpdateFiltersAction { readonly type: FilterActionTypes.UPDATE_FILTERS; @@ -11,9 +12,15 @@ export interface IUpdateFiltersAction { readonly types: IFilter[]; readonly languages: IFilter[]; readonly licenses: IFilter[]; + readonly titleSubstring: string; } export interface IUpdateSelectedFiltersAction { readonly type: FilterActionTypes.UPDATE_SELECTED_FILTERS; readonly selected: Set; } + +export interface IUpdateTitleSubstringFilterAction { + readonly type: FilterActionTypes.UPDATE_TITLE_SUBSTRING_FILTER; + readonly titleSubstring: string; +} diff --git a/src/store/filters/Reducers.ts b/src/store/filters/Reducers.ts index ae76968..9565183 100644 --- a/src/store/filters/Reducers.ts +++ b/src/store/filters/Reducers.ts @@ -8,6 +8,7 @@ export function filters( types: [], licenses: [], languages: [], + titleSubstring: "", selected: new Set() }, action: FiltersActions @@ -19,13 +20,19 @@ export function filters( tasks: action.tasks, types: action.types, licenses: action.licenses, - languages: action.languages + languages: action.languages, + titleSubstring: action.titleSubstring }; case FilterActionTypes.UPDATE_SELECTED_FILTERS: return { ...state, selected: action.selected }; + case FilterActionTypes.UPDATE_TITLE_SUBSTRING_FILTER: + return { + ...state, + titleSubstring: action.titleSubstring + }; default: return state; } diff --git a/src/store/filters/Sagas.ts b/src/store/filters/Sagas.ts index 99b2b54..b56aca0 100644 --- a/src/store/filters/Sagas.ts +++ b/src/store/filters/Sagas.ts @@ -5,6 +5,8 @@ import { ProjectsActionTypes } from "../projects/Types"; import * as projectSelectors from "../projects/Selectors"; import * as actions from "./Actions"; +const DEFAULT_FULL_TEXT_TITLE_VALUE = ""; + export function calculateTaskFilters(projects: IProjectInfo[]) { const tasks = [ ...new Set(projects.flatMap(p => p.task).filter(x => x)) @@ -41,7 +43,15 @@ export function* calculateFilters() { call(calculateLicenseFilters, projects), call(calculateLanguageFilters, projects) ]); - yield put(actions.updateFilters(tasks, types, licenses, languages)); + yield put( + actions.updateFilters( + tasks, + types, + licenses, + languages, + DEFAULT_FULL_TEXT_TITLE_VALUE + ) + ); yield put(actions.updateSelectedFilters(new Set())); } diff --git a/src/store/filters/Selectors.ts b/src/store/filters/Selectors.ts index 199a864..bfc1bd0 100644 --- a/src/store/filters/Selectors.ts +++ b/src/store/filters/Selectors.ts @@ -7,3 +7,8 @@ export const getSelectedFilters = createSelector( getFilters, filters => filters.selected ); + +export const getTitleSubstring = createSelector( + getFilters, + filters => filters.titleSubstring +); diff --git a/src/store/filters/Types.ts b/src/store/filters/Types.ts index 22df01f..01b4529 100644 --- a/src/store/filters/Types.ts +++ b/src/store/filters/Types.ts @@ -1,5 +1,6 @@ export enum FilterActionTypes { UPDATE_FILTERS = "UPDATE_FILTERS", UPDATE_SELECTED_FILTERS = "UPDATE_SELECTED_FILTERS", + UPDATE_TITLE_SUBSTRING_FILTER = "UPDATE_TITLE_SUBSTRING_FILTER", PERFORM_SEARCH = "PERFORM_SEARCH" } diff --git a/src/store/results/Sagas.ts b/src/store/results/Sagas.ts index c033e47..e0e37de 100644 --- a/src/store/results/Sagas.ts +++ b/src/store/results/Sagas.ts @@ -1,4 +1,4 @@ -import { takeEvery, all, put, select } from "redux-saga/effects"; +import { takeEvery, all, put, select, debounce } from "redux-saga/effects"; import * as projectSelectors from "../projects/Selectors"; import * as filterSelectors from "../filters/Selectors"; import { IProjectInfo } from "../../interfaces/IProjectInfo"; @@ -10,17 +10,12 @@ interface IGroupedFilters { [dimension: string]: IFilter[]; } -export function* applyFilters() { - const [projects, selectedFilters]: [IProjectInfo[], Set] = yield all( - [ - select(projectSelectors.getProjects), - select(filterSelectors.getSelectedFilters) - ] - ); - +function applyTagFilters( + projects: IProjectInfo[], + selectedFilters: Set +): IProjectInfo[] { if (selectedFilters.size < 1) { - yield put(actions.storeResults(projects)); - return; + return projects; } const dimensions = Object.values(FilterDimension); @@ -37,7 +32,7 @@ export function* applyFilters() { {} ); - const results = projects.filter(project => { + return projects.filter(project => { let match = false; for (const dimension of dimensions) { @@ -59,10 +54,41 @@ export function* applyFilters() { return match; }); +} + +function applyTitleSearchFilter( + projects: IProjectInfo[], + titleSubstring?: string +): IProjectInfo[] { + if (!titleSubstring) { + return projects; + } + + return projects.filter(p => p.name.includes(titleSubstring)); +} + +export function* applyFilters() { + const [projects, selectedFilters, titleSubstring]: [ + IProjectInfo[], + Set, + string + ] = yield all([ + select(projectSelectors.getProjects), + select(filterSelectors.getSelectedFilters), + select(filterSelectors.getTitleSubstring) + ]); + + const interimResults = applyTagFilters(projects, selectedFilters); + const results = applyTitleSearchFilter(interimResults, titleSubstring); yield put(actions.storeResults(results)); } export function* watchForResultUpdates() { yield takeEvery(FilterActionTypes.UPDATE_SELECTED_FILTERS, applyFilters); + yield debounce( + 500, + FilterActionTypes.UPDATE_TITLE_SUBSTRING_FILTER, + applyFilters + ); }