Skip to content

Instantly share code, notes, and snippets.

@danielpowell4
Last active June 9, 2021 14:10
Show Gist options
  • Save danielpowell4/1c03e624968b5e5b07b2017105bbe314 to your computer and use it in GitHub Desktop.
Save danielpowell4/1c03e624968b5e5b07b2017105bbe314 to your computer and use it in GitHub Desktop.
abstracted, client side filtering with location.search navigation via hooks
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;
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;
.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;
}
}
}
}
}
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