Skip to content

Commit 957fb1e

Browse files
committed
[Docs site] Add sidebar filters to ResourcesBySelector
1 parent 7bc4e5d commit 957fb1e

File tree

4 files changed

+249
-66
lines changed

4 files changed

+249
-66
lines changed

src/components/ResourcesBySelector.astro

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const props = z.object({
1919
columns: z.union([z.literal(2), z.literal(3)]).default(2),
2020
showDescriptions: z.boolean().default(true),
2121
showLastUpdated: z.boolean().default(false),
22+
filterPlacement: z.string().default("top"),
2223
});
2324
2425
const {
@@ -30,6 +31,7 @@ const {
3031
columns,
3132
showDescriptions,
3233
showLastUpdated,
34+
filterPlacement,
3335
} = props.parse(Astro.props);
3436
3537
const docs = await getCollection("docs");
@@ -46,15 +48,15 @@ const resources: Array<CollectionEntry<"docs"> | CollectionEntry<"stream">> = [
4648
(tags ? data.tags?.some((v: string) => tags.includes(v)) : true) &&
4749
(products
4850
? data.products?.some((v) =>
49-
products.includes(typeof v === "object" ? v.id : v),
51+
products.includes(typeof v === "object" ? v.id : v)
5052
)
5153
: true)
5254
);
5355
});
5456
5557
if (resources.length === 0) {
5658
throw new Error(
57-
`[ResourcesBySelector] Couldn't resources related to your filtered options`,
59+
`[ResourcesBySelector] Couldn't resources related to your filtered options`
5860
);
5961
}
6062
@@ -75,7 +77,7 @@ const facets = resources.reduce(
7577
7678
return acc;
7779
},
78-
{} as Record<string, string[]>,
80+
{} as Record<string, string[]>
7981
);
8082
---
8183

@@ -87,6 +89,7 @@ const facets = resources.reduce(
8789
columns={columns}
8890
showDescriptions={showDescriptions}
8991
showLastUpdated={showLastUpdated}
92+
filterPlacement={filterPlacement}
9093
client:load
9194
/>
9295
</div>

src/components/ResourcesBySelector.tsx

Lines changed: 227 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import { useEffect, useState } from "react";
1+
import {
2+
useEffect,
3+
useState,
4+
type ChangeEvent,
5+
type KeyboardEvent,
6+
} from "react";
27
import ReactSelect from "./ReactSelect";
38
import type { CollectionEntry } from "astro:content";
49
import { formatDistance } from "date-fns";
10+
import { setSearchParams } from "~/util/url";
511

612
type DocsData = keyof CollectionEntry<"docs">["data"];
713
type VideosData = keyof CollectionEntry<"stream">["data"];
@@ -15,6 +21,7 @@ interface Props {
1521
columns: number;
1622
showDescriptions: boolean;
1723
showLastUpdated: boolean;
24+
filterPlacement: string;
1825
}
1926

2027
export default function ResourcesBySelector({
@@ -24,8 +31,16 @@ export default function ResourcesBySelector({
2431
columns,
2532
showDescriptions,
2633
showLastUpdated,
34+
filterPlacement,
2735
}: Props) {
2836
const [selectedFilter, setSelectedFilter] = useState<string | null>(null);
37+
const [leftFilters, setLeftFilters] = useState<{
38+
search: string;
39+
selectedValues: Record<string, string[]>;
40+
}>({
41+
search: "",
42+
selectedValues: {},
43+
});
2944

3045
const timeAgo = (date?: Date) => {
3146
if (!date) return undefined;
@@ -44,36 +59,176 @@ export default function ResourcesBySelector({
4459
})),
4560
}));
4661

62+
// Keep facets organized by filterable field for left sidebar
63+
4764
const visibleResources = resources.filter((resource) => {
48-
if (!selectedFilter || !filters) return true;
49-
50-
const filterableValues: string[] = [];
51-
for (const filter of filters) {
52-
const val = resource.data[filter as keyof typeof resource.data];
53-
if (val) {
54-
if (Array.isArray(val) && val.every((v) => typeof v === "string")) {
55-
filterableValues.push(...val);
56-
} else if (typeof val === "string") {
57-
filterableValues.push(val);
65+
// Handle top filter (ReactSelect)
66+
if (filterPlacement === "top" && selectedFilter && filters) {
67+
const filterableValues: string[] = [];
68+
for (const filter of filters) {
69+
const val = resource.data[filter as keyof typeof resource.data];
70+
if (val) {
71+
if (Array.isArray(val) && val.every((v) => typeof v === "string")) {
72+
filterableValues.push(...val);
73+
} else if (typeof val === "string") {
74+
filterableValues.push(val);
75+
}
76+
}
77+
}
78+
if (!filterableValues.includes(selectedFilter)) return false;
79+
}
80+
81+
// Handle left sidebar filters
82+
if (filterPlacement === "left" && filters) {
83+
// Check each filterable field separately
84+
for (const [filterField, selectedValues] of Object.entries(leftFilters.selectedValues)) {
85+
if (selectedValues.length > 0) {
86+
const resourceValues: string[] = [];
87+
const val = resource.data[filterField as keyof typeof resource.data];
88+
if (val) {
89+
if (Array.isArray(val) && val.every((v) => typeof v === "string")) {
90+
resourceValues.push(...val);
91+
} else if (typeof val === "string") {
92+
resourceValues.push(val);
93+
}
94+
}
95+
if (!resourceValues.some((v) => selectedValues.includes(v))) {
96+
return false;
97+
}
98+
}
99+
}
100+
101+
// Search filter
102+
if (leftFilters.search) {
103+
const searchTerm = leftFilters.search.toLowerCase();
104+
const title = resource.data.title?.toLowerCase() || "";
105+
const description = resource.data.description?.toLowerCase() || "";
106+
107+
if (!title.includes(searchTerm) && !description.includes(searchTerm)) {
108+
return false;
58109
}
59110
}
60111
}
61112

62-
return filterableValues.includes(selectedFilter);
113+
return true;
63114
});
64115

65116
useEffect(() => {
66117
const params = new URLSearchParams(window.location.search);
67-
const value = params.get("filters");
68118

69-
if (value) {
70-
setSelectedFilter(value);
119+
if (filterPlacement === "top") {
120+
const value = params.get("filters");
121+
if (value) {
122+
setSelectedFilter(value);
123+
}
124+
} else if (filterPlacement === "left") {
125+
// Handle left sidebar URL params
126+
const searchTerm = params.get("search-term") ?? "";
127+
const selectedValues: Record<string, string[]> = {};
128+
129+
// Get values for each filterable field from URL params
130+
if (filters) {
131+
for (const filter of filters) {
132+
const values = params.getAll(`filter-${filter}`);
133+
if (values.length > 0) {
134+
selectedValues[filter] = values;
135+
}
136+
}
137+
}
138+
139+
if (Object.keys(selectedValues).length > 0 || searchTerm) {
140+
setLeftFilters({
141+
search: searchTerm,
142+
selectedValues: selectedValues,
143+
});
144+
}
71145
}
72-
}, []);
146+
}, [filterPlacement]);
147+
148+
// Update URL params for left sidebar filters
149+
useEffect(() => {
150+
if (filterPlacement === "left") {
151+
const params = new URLSearchParams();
152+
153+
if (leftFilters.search) {
154+
params.set("search-term", leftFilters.search);
155+
}
156+
157+
// Add URL params for each filterable field
158+
for (const [filterField, selectedValues] of Object.entries(leftFilters.selectedValues)) {
159+
selectedValues.forEach((value) =>
160+
params.append(`filter-${filterField}`, value),
161+
);
162+
}
163+
164+
setSearchParams(params);
165+
}
166+
}, [leftFilters, filterPlacement]);
73167

74168
return (
75-
<div>
76-
{filters && (
169+
<div className={filterPlacement === "left" ? "md:flex" : ""}>
170+
{filterPlacement === "left" && filters && (
171+
<div className="mr-8 w-full md:w-1/4">
172+
<input
173+
type="text"
174+
className="mb-8 w-full rounded-md border-2 border-gray-200 bg-white px-2 py-2 dark:border-gray-700 dark:bg-gray-800"
175+
placeholder="Search resources"
176+
value={leftFilters.search}
177+
onChange={(e) =>
178+
setLeftFilters({ ...leftFilters, search: e.target.value })
179+
}
180+
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
181+
if (e.key === "Escape") {
182+
setLeftFilters({ ...leftFilters, search: "" });
183+
}
184+
}}
185+
/>
186+
187+
{Object.entries(facets).map(([filterField, values]) => (
188+
<div key={filterField} className="mb-8! hidden md:block">
189+
<span className="text-sm font-bold text-gray-600 uppercase dark:text-gray-200">
190+
{filterField.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
191+
</span>
192+
193+
{values.map((value) => (
194+
<label key={`${filterField}-${value}`} className="my-2! block">
195+
<input
196+
type="checkbox"
197+
className="mr-2"
198+
value={value}
199+
checked={leftFilters.selectedValues[filterField]?.includes(value) || false}
200+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
201+
const currentValues = leftFilters.selectedValues[filterField] || [];
202+
if (e.target.checked) {
203+
setLeftFilters({
204+
...leftFilters,
205+
selectedValues: {
206+
...leftFilters.selectedValues,
207+
[filterField]: [...currentValues, e.target.value],
208+
},
209+
});
210+
} else {
211+
setLeftFilters({
212+
...leftFilters,
213+
selectedValues: {
214+
...leftFilters.selectedValues,
215+
[filterField]: currentValues.filter(
216+
(v) => v !== e.target.value,
217+
),
218+
},
219+
});
220+
}
221+
}}
222+
/>{" "}
223+
{value}
224+
</label>
225+
))}
226+
</div>
227+
))}
228+
</div>
229+
)}
230+
231+
{filterPlacement === "top" && filters && (
77232
<div className="not-content">
78233
<ReactSelect
79234
className="mt-2"
@@ -91,48 +246,62 @@ export default function ResourcesBySelector({
91246
)}
92247

93248
<div
94-
className={`grid ${columns === 2 ? "md:grid-cols-2" : "md:grid-cols-3"} grid-cols-1 gap-4`}
249+
className={filterPlacement === "left" ? "mt-0! w-full md:w-3/4" : ""}
95250
>
96-
{visibleResources.map((page) => {
97-
const href =
98-
page.collection === "stream"
99-
? `/videos/${page.data.url}/`
100-
: `/${page.id}/`;
101-
102-
// title can either be set directly in title or added as a meta.title property when we want something different for sidebar and SEO titles
103-
let title;
104-
105-
if (page.collection === "docs") {
106-
const titleItem = page.data.head.find(
107-
(item) => item.tag === "title",
108-
);
109-
title = titleItem ? titleItem.content : page.data.title;
110-
} else {
111-
title = page.data.title;
112-
}
251+
{filterPlacement === "left" && visibleResources.length === 0 && (
252+
<div className="flex w-full flex-col justify-center rounded-md border bg-gray-50 py-6 text-center align-middle dark:border-gray-500 dark:bg-gray-800">
253+
<span className="text-lg font-bold!">No resources found</span>
254+
<p>
255+
Try a different search term, or broaden your search by removing
256+
filters.
257+
</p>
258+
</div>
259+
)}
260+
261+
<div
262+
className={`grid ${columns === 2 ? "md:grid-cols-2" : "md:grid-cols-3"} grid-cols-1 gap-4`}
263+
>
264+
{visibleResources.map((page) => {
265+
const href =
266+
page.collection === "stream"
267+
? `/videos/${page.data.url}/`
268+
: `/${page.id}/`;
113269

114-
return (
115-
<a
116-
key={page.id}
117-
href={href}
118-
className="flex flex-col gap-2 rounded-sm border border-solid border-gray-200 p-6 text-black no-underline hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
119-
>
120-
<p className="decoration-accent underline decoration-2 underline-offset-4">
121-
{title}
122-
</p>
123-
{showDescriptions && (
124-
<span className="line-clamp-3" title={page.data.description}>
125-
{page.data.description}
126-
</span>
127-
)}
128-
{showLastUpdated && "reviewed" in page.data && (
129-
<span className="line-clamp-3" title={page.data.description}>
130-
Updated {timeAgo(page.data.reviewed)}
131-
</span>
132-
)}
133-
</a>
134-
);
135-
})}
270+
// title can either be set directly in title or added as a meta.title property when we want something different for sidebar and SEO titles
271+
let title;
272+
273+
if (page.collection === "docs") {
274+
const titleItem = page.data.head.find(
275+
(item) => item.tag === "title",
276+
);
277+
title = titleItem ? titleItem.content : page.data.title;
278+
} else {
279+
title = page.data.title;
280+
}
281+
282+
return (
283+
<a
284+
key={page.id}
285+
href={href}
286+
className="flex flex-col gap-2 rounded-sm border border-solid border-gray-200 p-6 text-black no-underline hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
287+
>
288+
<p className="decoration-accent underline decoration-2 underline-offset-4">
289+
{title}
290+
</p>
291+
{showDescriptions && (
292+
<span className="line-clamp-3" title={page.data.description}>
293+
{page.data.description}
294+
</span>
295+
)}
296+
{showLastUpdated && "reviewed" in page.data && (
297+
<span className="line-clamp-3" title={page.data.description}>
298+
Updated {timeAgo(page.data.reviewed)}
299+
</span>
300+
)}
301+
</a>
302+
);
303+
})}
304+
</div>
136305
</div>
137306
</div>
138307
);

0 commit comments

Comments
 (0)