Skip to content

Commit eb281ea

Browse files
author
Nicholas Brown
committed
feat(opengrid): add multi-select jurisdiction filter for utilities
- Add jurisdictions[] array to ExplorerState for tracking selected state codes - Serialize/deserialize jurisdiction filter in URL (?jurisdictions=CA,TX,NY) - Add JurisdictionFilter component: dropdown with search + checkboxes - Update UtilityListPanel to render JurisdictionFilter and apply jurisdiction filtering - Update explorer/utilities/page.tsx with same multi-select behavior - Filtering logic: a utility matches if its jurisdiction field contains ANY selected state (utilities with multi-state jurisdictions like 'AL, GA, MS' match any of those states) Fixes MAI-39
1 parent 115a55c commit eb281ea

File tree

3 files changed

+447
-33
lines changed

3 files changed

+447
-33
lines changed

components/explorer/ExplorerContext.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface ExplorerState {
3030
q: string;
3131
segment: string;
3232
type: string;
33+
jurisdictions: string[]; // selected state codes for multi-select jurisdiction filter
3334
// Map interaction
3435
highlightGeoJSON: FeatureCollection | null;
3536
hoveredSlug: string | null;
@@ -44,9 +45,10 @@ type ExplorerAction =
4445
| { type: "SET_SEARCH"; q: string }
4546
| { type: "SET_SEGMENT"; segment: string }
4647
| { type: "SET_TYPE"; typeFilter: string }
48+
| { type: "SET_JURISDICTIONS"; jurisdictions: string[] }
4749
| { type: "SET_HIGHLIGHT"; geoJSON: FeatureCollection | null }
4850
| { type: "SET_HOVERED_SLUG"; slug: string | null }
49-
| { type: "SYNC_FROM_URL"; mode: ViewMode; view: EntityView | null; slug: string | null; q: string; segment: string; typeFilter: string };
51+
| { type: "SYNC_FROM_URL"; mode: ViewMode; view: EntityView | null; slug: string | null; q: string; segment: string; typeFilter: string; jurisdictions: string[] };
5052

5153
interface ExplorerContextValue {
5254
state: ExplorerState;
@@ -56,6 +58,7 @@ interface ExplorerContextValue {
5658
setSearch: (q: string) => void;
5759
setSegment: (segment: string) => void;
5860
setTypeFilter: (type: string) => void;
61+
setJurisdictions: (jurisdictions: string[]) => void;
5962
setHighlight: (geoJSON: FeatureCollection | null) => void;
6063
setHoveredSlug: (slug: string | null) => void;
6164
goBack: () => void;
@@ -72,6 +75,7 @@ const initialState: ExplorerState = {
7275
q: "",
7376
segment: "all",
7477
type: "all",
78+
jurisdictions: [],
7579
highlightGeoJSON: null,
7680
hoveredSlug: null,
7781
previousView: null,
@@ -88,6 +92,7 @@ function reducer(state: ExplorerState, action: ExplorerAction): ExplorerState {
8892
q: "",
8993
segment: "all",
9094
type: "all",
95+
jurisdictions: [],
9196
highlightGeoJSON: null,
9297
hoveredSlug: null,
9398
previousView: null,
@@ -123,6 +128,9 @@ function reducer(state: ExplorerState, action: ExplorerAction): ExplorerState {
123128
case "SET_TYPE":
124129
return { ...state, type: action.typeFilter };
125130

131+
case "SET_JURISDICTIONS":
132+
return { ...state, jurisdictions: action.jurisdictions };
133+
126134
case "SET_HIGHLIGHT":
127135
return { ...state, highlightGeoJSON: action.geoJSON };
128136

@@ -138,6 +146,7 @@ function reducer(state: ExplorerState, action: ExplorerAction): ExplorerState {
138146
q: action.q,
139147
segment: action.segment,
140148
type: action.typeFilter,
149+
jurisdictions: action.jurisdictions,
141150
};
142151

143152
default:
@@ -156,6 +165,7 @@ function stateToSearchParams(state: ExplorerState): string {
156165
if (state.q) params.set("q", state.q);
157166
if (state.segment && state.segment !== "all") params.set("segment", state.segment);
158167
if (state.type && state.type !== "all") params.set("type", state.type);
168+
if (state.jurisdictions && state.jurisdictions.length > 0) params.set("jurisdictions", state.jurisdictions.join(","));
159169
const str = params.toString();
160170
return str ? `/explore?${str}` : "/explore";
161171
}
@@ -200,6 +210,8 @@ export function ExplorerProvider({ children }: { children: ReactNode }) {
200210
const qParam = searchParams.get("q") ?? "";
201211
const segmentParam = searchParams.get("segment") ?? "all";
202212
const typeParam = searchParams.get("type") ?? "all";
213+
const jurisdictionsParam = searchParams.get("jurisdictions");
214+
const jurisdictionsFromUrl = jurisdictionsParam ? jurisdictionsParam.split(",").filter(Boolean) : [];
203215

204216
const { mode, view } = parseViewMode(viewParam);
205217

@@ -210,7 +222,8 @@ export function ExplorerProvider({ children }: { children: ReactNode }) {
210222
slugParam !== state.slug ||
211223
qParam !== state.q ||
212224
segmentParam !== state.segment ||
213-
typeParam !== state.type
225+
typeParam !== state.type ||
226+
JSON.stringify(jurisdictionsFromUrl) !== JSON.stringify(state.jurisdictions)
214227
) {
215228
isUrlSync.current = true;
216229
dispatch({
@@ -221,6 +234,7 @@ export function ExplorerProvider({ children }: { children: ReactNode }) {
221234
q: qParam,
222235
segment: segmentParam,
223236
typeFilter: typeParam,
237+
jurisdictions: jurisdictionsFromUrl,
224238
});
225239
}
226240
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -235,7 +249,7 @@ export function ExplorerProvider({ children }: { children: ReactNode }) {
235249
const url = stateToSearchParams(state);
236250
router.push(url, { scroll: false });
237251
// eslint-disable-next-line react-hooks/exhaustive-deps
238-
}, [state.mode, state.view, state.slug, state.q, state.segment, state.type]);
252+
}, [state.mode, state.view, state.slug, state.q, state.segment, state.type, state.jurisdictions]);
239253

240254
const navigateToLanding = useCallback(() => dispatch({ type: "NAVIGATE_LANDING" }), []);
241255
const navigateToList = useCallback((view: ListView) => dispatch({ type: "NAVIGATE_LIST", view }), []);
@@ -246,6 +260,7 @@ export function ExplorerProvider({ children }: { children: ReactNode }) {
246260
const setSearch = useCallback((q: string) => dispatch({ type: "SET_SEARCH", q }), []);
247261
const setSegment = useCallback((segment: string) => dispatch({ type: "SET_SEGMENT", segment }), []);
248262
const setTypeFilter = useCallback((type: string) => dispatch({ type: "SET_TYPE", typeFilter: type }), []);
263+
const setJurisdictions = useCallback((jurisdictions: string[]) => dispatch({ type: "SET_JURISDICTIONS", jurisdictions }), []);
249264
const setHighlight = useCallback(
250265
(geoJSON: FeatureCollection | null) => dispatch({ type: "SET_HIGHLIGHT", geoJSON }),
251266
[]
@@ -280,11 +295,12 @@ export function ExplorerProvider({ children }: { children: ReactNode }) {
280295
setSearch,
281296
setSegment,
282297
setTypeFilter,
298+
setJurisdictions,
283299
setHighlight,
284300
setHoveredSlug,
285301
goBack,
286302
}),
287-
[state, navigateToLanding, navigateToList, navigateToDetail, setSearch, setSegment, setTypeFilter, setHighlight, setHoveredSlug, goBack]
303+
[state, navigateToLanding, navigateToList, navigateToDetail, setSearch, setSegment, setTypeFilter, setJurisdictions, setHighlight, setHoveredSlug, goBack]
288304
);
289305

290306
return <ExplorerCtx.Provider value={value}>{children}</ExplorerCtx.Provider>;

components/explorer/panels/UtilityListPanel.tsx

Lines changed: 214 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
DataTable,
99
EmptyState,
1010
} from "@texturehq/edges";
11-
import { useCallback, useMemo } from "react";
11+
import { useCallback, useMemo, useRef, useState } from "react";
1212
import { useExplorer } from "../ExplorerContext";
1313
import { getAllUtilities, searchEntities, sortByName } from "@/lib/data";
1414
import {
@@ -41,8 +41,198 @@ const segmentFilterOptions = [
4141
})),
4242
];
4343

44+
// All US state/territory codes present in the data
45+
const ALL_STATE_CODES = [
46+
"AK","AL","AR","AZ","CA","CO","CT","DC","DE","FL","GA","HI",
47+
"IA","ID","IL","IN","KS","KY","LA","MA","MD","ME","MI","MN",
48+
"MO","MS","MT","NC","ND","NE","NH","NJ","NM","NV","NY","OH",
49+
"OK","OR","PA","RI","SC","SD","TN","TX","UT","VA","VT","WA",
50+
"WI","WV","WY",
51+
];
52+
53+
/** Returns all individual state codes from a comma-separated jurisdiction string */
54+
function parseJurisdictionStates(jurisdiction: string | null): string[] {
55+
if (!jurisdiction) return [];
56+
return jurisdiction.split(",").map((s) => s.trim()).filter(Boolean);
57+
}
58+
59+
/** True if the utility matches any of the selected jurisdictions */
60+
function matchesJurisdictions(utility: Utility, selected: string[]): boolean {
61+
if (selected.length === 0) return true;
62+
const states = parseJurisdictionStates(utility.jurisdiction);
63+
return selected.some((j) => states.includes(j));
64+
}
65+
66+
// ---------------------------------------------------------------------------
67+
// Jurisdiction multi-select dropdown
68+
// ---------------------------------------------------------------------------
69+
70+
interface JurisdictionFilterProps {
71+
selected: string[];
72+
onChange: (jurisdictions: string[]) => void;
73+
}
74+
75+
function JurisdictionFilter({ selected, onChange }: JurisdictionFilterProps) {
76+
const [open, setOpen] = useState(false);
77+
const [search, setSearch] = useState("");
78+
const ref = useRef<HTMLDivElement>(null);
79+
80+
// Close on outside click
81+
const handleBlur = useCallback((e: React.FocusEvent<HTMLDivElement>) => {
82+
if (!ref.current?.contains(e.relatedTarget as Node)) {
83+
setOpen(false);
84+
}
85+
}, []);
86+
87+
const filteredCodes = useMemo(
88+
() =>
89+
search
90+
? ALL_STATE_CODES.filter((code) =>
91+
code.toLowerCase().includes(search.toLowerCase())
92+
)
93+
: ALL_STATE_CODES,
94+
[search]
95+
);
96+
97+
const toggle = useCallback(
98+
(code: string) => {
99+
if (selected.includes(code)) {
100+
onChange(selected.filter((s) => s !== code));
101+
} else {
102+
onChange([...selected, code]);
103+
}
104+
},
105+
[selected, onChange]
106+
);
107+
108+
const clearAll = useCallback(() => {
109+
onChange([]);
110+
setSearch("");
111+
}, [onChange]);
112+
113+
const label =
114+
selected.length === 0
115+
? "All Jurisdictions"
116+
: selected.length === 1
117+
? selected[0]
118+
: `${selected.length} States`;
119+
120+
return (
121+
<div ref={ref} className="relative" onBlur={handleBlur}>
122+
<button
123+
type="button"
124+
onClick={() => setOpen((o) => !o)}
125+
className={`h-10 sm:h-8 inline-flex items-center gap-1.5 rounded-md border px-2 text-base sm:text-sm transition-colors ${
126+
selected.length > 0
127+
? "border-brand-primary bg-brand-primary/10 text-brand-primary font-medium"
128+
: "border-border-default bg-background-surface text-text-body"
129+
}`}
130+
aria-haspopup="listbox"
131+
aria-expanded={open}
132+
>
133+
{label}
134+
{selected.length > 0 && (
135+
<span
136+
role="button"
137+
tabIndex={0}
138+
aria-label="Clear jurisdiction filter"
139+
className="ml-1 text-brand-primary hover:text-brand-primary/70 leading-none"
140+
onClick={(e) => {
141+
e.stopPropagation();
142+
clearAll();
143+
}}
144+
onKeyDown={(e) => {
145+
if (e.key === "Enter" || e.key === " ") {
146+
e.stopPropagation();
147+
clearAll();
148+
}
149+
}}
150+
>
151+
×
152+
</span>
153+
)}
154+
<svg
155+
className={`w-3 h-3 ml-0.5 transition-transform ${open ? "rotate-180" : ""}`}
156+
fill="none"
157+
viewBox="0 0 24 24"
158+
stroke="currentColor"
159+
strokeWidth={2}
160+
aria-hidden="true"
161+
>
162+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
163+
</svg>
164+
</button>
165+
166+
{open && (
167+
<div
168+
className="absolute right-0 top-full mt-1 z-50 w-52 rounded-lg border border-border-default bg-background-surface shadow-lg flex flex-col"
169+
role="listbox"
170+
aria-multiselectable="true"
171+
aria-label="Filter by jurisdiction"
172+
>
173+
{/* Search */}
174+
<div className="p-2 border-b border-border-default">
175+
<input
176+
autoFocus
177+
type="text"
178+
value={search}
179+
onChange={(e) => setSearch(e.target.value)}
180+
placeholder="Search states..."
181+
className="w-full h-7 rounded-md border border-border-default bg-background-page px-2 text-sm text-text-body placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-brand-primary"
182+
/>
183+
</div>
184+
185+
{/* Options */}
186+
<div className="overflow-y-auto max-h-56 py-1">
187+
{filteredCodes.length === 0 ? (
188+
<div className="px-3 py-2 text-sm text-text-muted">No states found</div>
189+
) : (
190+
filteredCodes.map((code) => {
191+
const isChecked = selected.includes(code);
192+
return (
193+
<label
194+
key={code}
195+
className="flex items-center gap-2 px-3 py-1.5 text-sm text-text-body cursor-pointer hover:bg-background-hover select-none"
196+
role="option"
197+
aria-selected={isChecked}
198+
>
199+
<input
200+
type="checkbox"
201+
checked={isChecked}
202+
onChange={() => toggle(code)}
203+
className="rounded border-border-default text-brand-primary focus:ring-brand-primary"
204+
/>
205+
{code}
206+
</label>
207+
);
208+
})
209+
)}
210+
</div>
211+
212+
{/* Footer */}
213+
{selected.length > 0 && (
214+
<div className="px-3 py-2 border-t border-border-default">
215+
<button
216+
type="button"
217+
onClick={clearAll}
218+
className="text-xs text-text-muted hover:text-text-body transition-colors"
219+
>
220+
Clear all ({selected.length})
221+
</button>
222+
</div>
223+
)}
224+
</div>
225+
)}
226+
</div>
227+
);
228+
}
229+
230+
// ---------------------------------------------------------------------------
231+
// Main panel
232+
// ---------------------------------------------------------------------------
233+
44234
export function UtilityListPanel() {
45-
const { state, setSearch, setSegment, navigateToDetail, navigateToLanding } = useExplorer();
235+
const { state, setSearch, setSegment, setJurisdictions, navigateToDetail, navigateToLanding } = useExplorer();
46236

47237
const allUtilities = useMemo(() => getAllUtilities(), []);
48238

@@ -54,9 +244,12 @@ export function UtilityListPanel() {
54244
if (state.segment !== "all") {
55245
result = result.filter((u) => u.segment === state.segment);
56246
}
247+
if (state.jurisdictions.length > 0) {
248+
result = result.filter((u) => matchesJurisdictions(u, state.jurisdictions));
249+
}
57250
result = sortByName(result, "asc");
58251
return result;
59-
}, [allUtilities, state.q, state.segment]);
252+
}, [allUtilities, state.q, state.segment, state.jurisdictions]);
60253

61254
const rows: UtilityRow[] = useMemo(
62255
() =>
@@ -141,17 +334,23 @@ export function UtilityListPanel() {
141334
onChange: () => {},
142335
}}
143336
customControls={
144-
<select
145-
value={state.segment}
146-
onChange={(e) => setSegment(e.target.value)}
147-
className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface px-2 text-base sm:text-sm text-text-body"
148-
>
149-
{segmentFilterOptions.map((opt) => (
150-
<option key={opt.id} value={opt.value}>
151-
{opt.label}
152-
</option>
153-
))}
154-
</select>
337+
<div className="flex items-center gap-2">
338+
<select
339+
value={state.segment}
340+
onChange={(e) => setSegment(e.target.value)}
341+
className="h-10 sm:h-8 rounded-md border border-border-default bg-background-surface px-2 text-base sm:text-sm text-text-body"
342+
>
343+
{segmentFilterOptions.map((opt) => (
344+
<option key={opt.id} value={opt.value}>
345+
{opt.label}
346+
</option>
347+
))}
348+
</select>
349+
<JurisdictionFilter
350+
selected={state.jurisdictions}
351+
onChange={setJurisdictions}
352+
/>
353+
</div>
155354
}
156355
sticky={true}
157356
/>
@@ -161,7 +360,7 @@ export function UtilityListPanel() {
161360
<EmptyState
162361
icon="Lightning"
163362
title="No utilities found"
164-
description={state.q ? "Try adjusting your search criteria." : "No utilities in the dataset."}
363+
description={state.q ? "Try adjusting your search criteria." : "No utilities match the selected filters."}
165364
fullHeight={true}
166365
/>
167366
) : (

0 commit comments

Comments
 (0)