Last active
June 9, 2021 14:10
-
-
Save danielpowell4/1c03e624968b5e5b07b2017105bbe314 to your computer and use it in GitHub Desktop.
abstracted, client side filtering with location.search navigation via hooks
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from "react"; | |
import { GET } from "../utils/service"; // a fetch wrapper | |
import { useFilters } from "../hooks"; | |
import FilterForm from "../components/FilterForm"; | |
// filterTemplate instructs useFilters | |
// which filters should be allowed | |
// and how they are to traverse the data object tree | |
// for this example the filterable data looks like this: | |
// [ | |
// { | |
// ...rest, | |
// semester: "STRING", | |
// locationId: 1, | |
// locationName: "STRING", | |
// enrollmentTypes: [ | |
// { ...others, value: "STRING", filterLabel: "STRING" } | |
// ] | |
// } | |
// ] | |
const filterTemplate = [ | |
{ | |
label: "SEMESTER", | |
filterKey: "semesters", | |
type: "checkbox", | |
optionValueKeys: ["semester"], | |
}, | |
{ | |
label: "LOCATION", | |
filterKey: "class_location_ids", | |
type: "checkbox", | |
optionKeys: [], // value + label off top-level object directly | |
valueKeys: ["locationId"], | |
labelKeys: ["locationName"], | |
}, | |
{ | |
label: "SIGNUP TYPE", | |
filterKey: "enrollmentTypes", | |
type: "checkbox", | |
optionKeys: ["enrollmentTypes"], | |
valueKeys: ["value"], | |
labelKeys: ["filterLabel"], | |
}, | |
]; | |
const CourseOfferings = ({ courseOfferingEndpoint }) => { | |
const [isLoading, setIsLoading] = React.useState(false); | |
const [lastFetchedAt, setLastFetchedAt] = React.useState(); | |
const [classes, setClasses] = React.useState([]); | |
const [error, setError] = React.useState(); | |
// setup filters | |
const [filters, activeFilter, filteredClasses] = | |
useFilters(filterTemplate, classes); | |
// fetch inventory from API | |
React.useEffect(() => { | |
if (!isLoading && !lastFetchedAt && !error) { | |
setIsLoading(true); | |
GET(courseOfferingEndpoint) | |
.then(({ res }) => { | |
setClasses(classTypes); | |
setLastFetchedAt(Date.now()); | |
setIsLoading(false); | |
}) | |
.catch((err) => { | |
setError(err); | |
setLastFetchedAt(Date.now()); | |
}); | |
} | |
}, [isLoading, lastFetchedAt, error, courseOfferingEndpoint]); | |
if (!!error) { | |
return ( | |
<div className="courseOfferings" style={{ minHeight: `12rem` }}> | |
<div /> | |
<div className="courseOfferings__content"> | |
<h2>Now Enrolling</h2> | |
<hr /> | |
<p style={{ color: `var(--error)`, minHeight: `20rem` }}> | |
Oh no! {error.message} | |
</p> | |
</div> | |
</div> | |
); | |
} | |
if (!!isLoading) { | |
return ( | |
<div className="courseOfferings" style={{ minHeight: `20rem` }}> | |
<div /> | |
<div className="courseOfferings__content"> | |
<h2>Now Enrolling</h2> | |
<hr /> | |
<p>Loading classes...</p> | |
</div> | |
</div> | |
); | |
} | |
if (!!lastFetchedAt && !!filteredClasses.length) { | |
return ( | |
<div className="courseOfferings"> | |
<FilterForm | |
filters={filters} | |
activeFilter={activeFilter} | |
/> | |
<div className="courseOfferings__content"> | |
<h2 className="title">Now Enrolling</h2> | |
<ul className="offering-list"> | |
{filteredClasses.map((offering) => ( | |
<Item key={offering.classTypeId} {...offering} /> | |
)} | |
</ul> | |
)} | |
</div> | |
</div> | |
); | |
} | |
const areFiltersActive = Object.keys(activeFilter).some( | |
(filterKey) => !!activeFilter[filterKey].length | |
); | |
return ( | |
<div className="courseOfferings" style={{ minHeight: `20rem` }}> | |
<FilterForm | |
filters={filters} | |
activeFilter={activeFilter} | |
/> | |
<div className="courseOfferings__content"> | |
<h2>Now Enrolling</h2> | |
<hr /> | |
<p> | |
{areFiltersActive ? "No matching courses" : "No upcoming offerings"} | |
</p> | |
<p>Contact our team to discuss other options!</p> | |
</div> | |
</div> | |
); | |
}; | |
export default Example; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from "react"; | |
import { navigate } from "gatsby"; // history.push could work similarily if you aren't on gatsby! | |
import { Formik, Field, useFormikContext } from "formik"; | |
import { buildQueryString } from "../utils/service"; | |
import "./FilterForm.scss"; | |
const FilterCheckboxGroup = ({ filter }) => { | |
const labelId = `${filter.filterKey}_label`; | |
return ( | |
<div className="filter-group"> | |
<h4 className="filter-group__label" id={labelId}> | |
{filter.label} | |
</h4> | |
<ul | |
className="filter-group__options" | |
role="group" | |
aria-labelledby={labelId} | |
> | |
{filter.options.map((opt) => ( | |
<li className="filter-group__options__item" key={opt.id}> | |
<Field | |
type={filter.type} | |
id={opt.id} | |
name={opt.name} | |
value={opt.value} | |
/> | |
<label htmlFor={opt.id}>{opt.label}</label> | |
</li> | |
))} | |
</ul> | |
</div> | |
); | |
}; | |
// based off of formik's AutoSave example | |
const DELAY = 200; | |
const SubmitOnChange = () => { | |
const { submitForm, values } = useFormikContext(); | |
const firstMountedAt = React.useRef(false); // skips save on initial load | |
React.useEffect(() => { | |
if (firstMountedAt.current && firstMountedAt.current < Date.now() - DELAY) { | |
submitForm(); | |
} else if (!firstMountedAt.current) { | |
firstMountedAt.current = Date.now(); | |
} | |
}, [submitForm, values]); | |
return null; | |
}; | |
const FilterForm = ({ filters, activeFilter }) => { | |
return ( | |
<Formik | |
initialValues={activeFilter} | |
onSubmit={async (values) => { | |
const nextSearch = buildQueryString(values); | |
const nextPath = nextSearch | |
? `${window.location.pathname}?${nextSearch}` | |
: window.location.pathname; | |
navigate(nextPath, { | |
replace: true, | |
state: { disableScrollUpdate: true }, | |
}); | |
}} | |
enableReinitialize // allows initialValues to update | |
> | |
{(formik) => ( | |
<form | |
className="FilterForm" | |
onSubmit={formik.handleSubmit} | |
> | |
<SubmitOnChange /> | |
{filters.map((filter, filterIndex) => ( | |
<FilterCheckboxGroup key={filterIndex} filter={filter} /> | |
))} | |
</form> | |
)} | |
</Formik> | |
); | |
}; | |
export default FilterForm; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.FilterForm { | |
.filter-group { | |
margin: 0 0 1rem; | |
&__label { | |
text-transform: capitalize; | |
} | |
&__options { | |
padding: 0; | |
list-style-type: none; | |
&__item { | |
input[type="checkbox"] { | |
margin-right: .25rem; | |
} | |
label { | |
font-weight: normal; | |
font-family: var(--fontStack); | |
cursor: pointer; | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from "react"; | |
import useSearchParams from "./useSearchParams"; | |
// ruby's 'dig' for JS | |
// adapted from https://github.com/joe-re/object-dig/blob/master/src/index.js | |
const dig = (target, keys = []) => { | |
let digged = target; | |
for (const key of keys) { | |
if (typeof digged === "undefined" || digged === null) { | |
return undefined; | |
} | |
digged = digged[key]; | |
} | |
return digged; | |
}; | |
// option builders | |
const buildOption = (name, value, label) => { | |
const id = `${name}_${String(value).toLowerCase().split(" ").join("_")}`; | |
return { id, name, value, label }; | |
}; | |
const buildOptions = (collection, filter) => { | |
let options = []; | |
for (let item of collection) { | |
if (filter.optionValueKeys) { | |
// value and label will be matching strings | |
const rawValue = dig(item, filter.optionValueKeys); | |
if (Array.isArray(rawValue)) { | |
// associated with many | |
for (let value of rawValue) { | |
if (!!value && !options.some((opt) => opt.value === value)) { | |
const label = value; | |
const option = buildOption(filter.filterKey, value, label); | |
options.push(option); | |
} | |
} | |
} else { | |
// is as is | |
if (!!rawValue && !options.some((opt) => opt.value === rawValue)) { | |
const label = rawValue; | |
const option = buildOption(filter.filterKey, rawValue, label); | |
options.push(option); | |
} | |
} | |
} else { | |
// label != value but come from same object | |
const rawNestedItem = dig(item, filter.optionKeys); | |
if (Array.isArray(rawNestedItem)) { | |
for (let nestedItem of rawNestedItem) { | |
const value = dig(nestedItem, filter.valueKeys); | |
if (!!value && !options.some((opt) => opt.value === value)) { | |
const label = dig(nestedItem, filter.labelKeys); | |
const option = buildOption(filter.filterKey, value, label); | |
options.push(option); | |
} | |
} | |
} else { | |
const value = dig(rawNestedItem, filter.valueKeys); | |
if (!!value && !options.some((opt) => opt.value === value)) { | |
const label = dig(rawNestedItem, filter.labelKeys); | |
const option = buildOption(filter.filterKey, value, label); | |
options.push(option); | |
} | |
} | |
} | |
} | |
if (!!filter.sort) { | |
return options.sort(filter.sort) | |
} else { | |
return options.sort((a, b) => a.value.localeCompare(b.value)); | |
} | |
}; | |
// builds initial filter state | |
// looks like `{ filterKey1: initialValue1, filterKey2, initialValue2 }` | |
const buildInitialFilter = (filters, searchParams) => | |
filters.reduce((initialFilter, filter) => { | |
initialFilter[filter.filterKey] = dig(searchParams, [filter.filterKey]) ?? []; // checkbox assumed! | |
return initialFilter; | |
}, {}); | |
// callback to filter active items in collection | |
const filterItem = (item, activeFilter, filters) => { | |
// check check filter key, try to knock out | |
// only supports value collections (checkbox) | |
for (const filter of filters) { | |
const activeValue = activeFilter[filter.filterKey]; | |
if (activeValue?.length) { | |
if (filter.optionValueKeys) { | |
// value and label are matching strings | |
const rawValue = dig(item, filter.optionValueKeys); | |
if (Array.isArray(rawValue)) { | |
// filter for any overlap | |
if (!activeValue.some((val) => rawValue.includes(val))) { | |
return false; | |
} | |
} else { | |
// filter for exact matching | |
if (!activeValue.includes(rawValue)) { | |
return false; | |
} | |
} | |
} else { | |
// label != value but come from same object | |
const rawNestedItem = dig(item, filter.optionKeys); | |
if (Array.isArray(rawNestedItem)) { | |
// filter for any overlap | |
const itemValues = rawNestedItem.map(nested => dig(nested, filter.valueKeys)); | |
if (!activeValue.some((val) => itemValues.includes(val))) { | |
return false; | |
} | |
} else { | |
// filter for exact matching | |
const itemValue = dig(rawNestedItem, filter.valueKeys) | |
if (!activeValue.includes(itemValue)) { | |
return false | |
} | |
} | |
} | |
} | |
} | |
return true; // nothing said no! | |
}; | |
// Finally... | |
// the actual hook | |
// NOTE: currently only checkbox filters are supported | |
const useFilters = (filterTemplate, collection) => { | |
const searchParams = useSearchParams(); | |
const filters = React.useMemo( | |
() => | |
filterTemplate | |
.map((filter) => ({ | |
...filter, | |
options: buildOptions(collection, filter), | |
})) | |
.filter((filter) => filter.options.length > 1), | |
[filterTemplate, collection] | |
); | |
const [activeFilter, setActiveFilter] = React.useState( | |
buildInitialFilter(filters) | |
); | |
// if filters change (like from a fetch request or navigation), | |
// be sure initial filter state watches! | |
React.useEffect(() => { | |
setActiveFilter(() => buildInitialFilter(filters, searchParams)) | |
}, [filters, searchParams]) | |
const activeCollection = React.useMemo( | |
() => collection.filter((item) => filterItem(item, activeFilter, filters)), | |
[collection, filters, activeFilter] | |
); | |
return [filters, activeFilter, activeCollection]; | |
}; | |
export default useFilters; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment