Skip to content

Commit b7a4f0f

Browse files
authored
Merge pull request #32 from CodeForAfrica/implement-fact-check-filters
Implement fact check filters
2 parents 6cfa64d + 8552417 commit b7a4f0f

14 files changed

Lines changed: 609 additions & 221 deletions

File tree

app/fact-checks/[desk]/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { FactChecksHero } from "@/components/fact-checks/FactChecksHero";
77
import { ARTICLES, getArticleBySlug } from "@/lib/article-content";
88
import { CONTENT_DESKS, deskBySlug } from "@/lib/content-desks";
99
import { getArticle } from "@/lib/data/article";
10+
import { EMPTY_FILTERS } from "@/lib/data/fact-check-filters";
1011
import { FEATURE, FEATURE_SECONDARY, STORIES } from "@/lib/fact-checks-content";
1112

1213
type Params = Promise<{ desk: string }>;
@@ -71,6 +72,7 @@ export default async function ContentDeskOrArticlePage({
7172
stories={[FEATURE, FEATURE_SECONDARY, ...STORIES]}
7273
page={1}
7374
totalPages={1}
75+
filters={EMPTY_FILTERS}
7476
/>
7577
<FactChecksContentDesks activeSlug={desk.slug} />
7678
</>

app/fact-checks/page.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from "next";
22
import { FactChecksContentDesks } from "@/components/fact-checks/FactChecksContentDesks";
33
import { FactChecksExplorer } from "@/components/fact-checks/FactChecksExplorer";
4+
import { parseFilterParams } from "@/lib/data/fact-check-filters";
45
import {
56
clampPage,
67
pageOffset,
@@ -37,19 +38,24 @@ function staticPage(page: number): FactCheckListing {
3738
export default async function FactChecksPage({
3839
searchParams,
3940
}: {
40-
searchParams: Promise<{ page?: string | string[] }>;
41+
searchParams: Promise<Record<string, string | string[] | undefined>>;
4142
}) {
42-
const page = parsePageParam((await searchParams).page);
43+
const params = await searchParams;
44+
const page = parsePageParam(params.page);
45+
const filters = parseFilterParams(params);
4346

47+
// Filters narrow the grid server-side; the static fallback ignores them (it's
48+
// a degraded mode for when Hasura is unreachable) and just pages the design pool.
4449
const listing =
45-
(await getFactChecks(page).catch(() => null)) ?? staticPage(page);
50+
(await getFactChecks(page, filters).catch(() => null)) ?? staticPage(page);
4651

4752
return (
4853
<>
4954
<FactChecksExplorer
5055
stories={listing.stories}
5156
page={listing.page}
5257
totalPages={listing.totalPages}
58+
filters={filters}
5359
/>
5460
<FactChecksContentDesks />
5561
</>

components/fact-checks/FactChecksExplorer.tsx

Lines changed: 64 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,124 @@
11
"use client";
22

33
import { usePathname, useRouter } from "next/navigation";
4-
import { useMemo, useState } from "react";
4+
import { useOptimistic, useState, useTransition } from "react";
55
import { Pagination } from "@/components/ui/Pagination";
66
import { Container } from "@/components/ui/SectionHeading";
77
import { StoryCard } from "@/components/ui/StoryCard";
88
import {
9-
DEFAULT_FILTERS,
10-
FILTERS,
11-
type FilterDimension,
12-
} from "@/lib/fact-checks-content";
9+
EMPTY_FILTERS,
10+
type FilterSelection,
11+
filtersToQuery,
12+
} from "@/lib/data/fact-check-filters";
13+
import { FILTERS, type FilterDimension } from "@/lib/fact-checks-content";
1314
import type { Story } from "@/lib/home-content";
14-
import { FilterBar, type Selection } from "./FilterBar";
15+
import { FilterBar } from "./FilterBar";
1516

16-
const EMPTY: Selection = { region: [], language: [], topic: [] };
17-
18-
function clone(sel: Selection): Selection {
17+
function clone(sel: FilterSelection): FilterSelection {
1918
return {
2019
region: [...sel.region],
2120
language: [...sel.language],
2221
topic: [...sel.topic],
2322
};
2423
}
2524

26-
function matches(story: Story, applied: Selection): boolean {
27-
return FILTERS.every(({ dimension }) => {
28-
const wanted = applied[dimension];
29-
if (wanted.length === 0) return true;
30-
const value = story[dimension];
31-
return value != null && wanted.includes(value);
32-
});
33-
}
34-
25+
/**
26+
* The fact-checks grid + filter bar. Filtering and pagination are both
27+
* **server-side and URL-driven**: `stories` arrive already filtered/paged, and
28+
* every control navigates by mutating the query string (`?region=…&topic=…&page=N`),
29+
* which re-runs the server fetch.
30+
*
31+
* Filters **auto-apply** — there's no "Apply" step. The URL (`filters` prop) is
32+
* the single source of truth; toggling an option, removing a chip, or clearing
33+
* navigates straight away. `useOptimistic` mirrors the change so the checkboxes
34+
* and chips update instantly while the server re-fetches the grid in a
35+
* transition. Filter changes use `replace` (so a burst of toggles doesn't spam
36+
* history); pagination uses `push`.
37+
*/
3538
export function FactChecksExplorer({
3639
stories,
3740
page,
3841
totalPages,
42+
filters,
3943
}: {
4044
stories: Story[];
4145
page: number;
4246
totalPages: number;
47+
filters: FilterSelection;
4348
}) {
4449
const router = useRouter();
4550
const pathname = usePathname();
51+
const [, startTransition] = useTransition();
4652

47-
// `selected` is the staged set reflected by the dropdowns + chips; `applied`
48-
// is what actually filters the listing (committed via "Apply Filters"). The
49-
// page loads showing the design's chips staged but the full listing visible —
50-
// filtering kicks in once the reader clicks "Apply Filters".
51-
const [selected, setSelected] = useState<Selection>(() =>
52-
clone(DEFAULT_FILTERS),
53-
);
54-
const [applied, setApplied] = useState<Selection>(() => clone(EMPTY));
53+
// Optimistic mirror of the applied (URL) filters — instant checkbox/chip
54+
// feedback; rebases to `filters` once the navigation transition resolves.
55+
const [optimistic, setOptimistic] = useOptimistic(filters);
5556
const [openDropdown, setOpenDropdown] = useState<FilterDimension | null>(
5657
null,
5758
);
5859

59-
const chips = useMemo(
60-
() =>
61-
FILTERS.flatMap(({ dimension }) =>
62-
selected[dimension].map((value) => ({ dimension, value })),
63-
),
64-
[selected],
65-
);
66-
67-
const results = useMemo(
68-
() => stories.filter((s) => matches(s, applied)),
69-
[stories, applied],
60+
const chips = FILTERS.flatMap(({ dimension }) =>
61+
optimistic[dimension].map((value) => ({ dimension, value })),
7062
);
7163

72-
const toggleOption = (dimension: FilterDimension, value: string) => {
73-
setSelected((prev) => {
74-
const next = clone(prev);
75-
next[dimension] = next[dimension].includes(value)
76-
? next[dimension].filter((v) => v !== value)
77-
: [...next[dimension], value];
78-
return next;
79-
});
80-
};
81-
82-
const removeChip = (dimension: FilterDimension, value: string) => {
83-
setSelected((prev) => {
84-
const next = clone(prev);
85-
next[dimension] = next[dimension].filter((v) => v !== value);
86-
return next;
64+
// Commit a filter/page change to the URL; the server re-fetches that slice.
65+
const navigate = (
66+
next: FilterSelection,
67+
nextPage: number,
68+
mode: "push" | "replace",
69+
) => {
70+
const params = new URLSearchParams(filtersToQuery(next));
71+
if (nextPage > 1) params.set("page", String(nextPage));
72+
const qs = params.toString();
73+
const url = qs ? `${pathname}?${qs}` : pathname;
74+
startTransition(() => {
75+
setOptimistic(next);
76+
router[mode](url);
8777
});
8878
};
8979

90-
const toggleDropdown = (dimension: FilterDimension) =>
91-
setOpenDropdown((cur) => (cur === dimension ? null : dimension));
80+
// Any filter change auto-applies and resets to page 1.
81+
const commitFilters = (next: FilterSelection) => navigate(next, 1, "replace");
9282

93-
const goToPage = (next: number) => {
94-
router.push(next <= 1 ? pathname : `${pathname}?page=${next}`);
83+
const toggleOption = (dimension: FilterDimension, value: string) => {
84+
const next = clone(optimistic);
85+
next[dimension] = next[dimension].includes(value)
86+
? next[dimension].filter((v) => v !== value)
87+
: [...next[dimension], value];
88+
commitFilters(next);
9589
};
9690

97-
const apply = () => {
98-
setApplied(clone(selected));
99-
setOpenDropdown(null);
100-
goToPage(1);
91+
const removeChip = (dimension: FilterDimension, value: string) => {
92+
const next = clone(optimistic);
93+
next[dimension] = next[dimension].filter((v) => v !== value);
94+
commitFilters(next);
10195
};
10296

10397
const clear = () => {
104-
setSelected(clone(EMPTY));
105-
setApplied(clone(EMPTY));
10698
setOpenDropdown(null);
107-
goToPage(1);
99+
commitFilters(EMPTY_FILTERS);
108100
};
109101

110-
const [feature, secondary, ...grid] = results;
102+
const toggleDropdown = (dimension: FilterDimension) =>
103+
setOpenDropdown((cur) => (cur === dimension ? null : dimension));
104+
105+
// Pagination keeps the applied filters; only the page changes.
106+
const goToPage = (next: number) => navigate(filters, next, "push");
107+
108+
const [feature, secondary, ...grid] = stories;
111109

112110
return (
113111
<>
114112
{/* Filter panel — its own band below the hero, not overlapping it. */}
115113
<section className="bg-neutral-50">
116114
<Container className="py-14 lg:py-16">
117115
<FilterBar
118-
selected={selected}
116+
selected={optimistic}
119117
openDropdown={openDropdown}
120118
chips={chips}
121119
onToggleDropdown={toggleDropdown}
122120
onToggleOption={toggleOption}
123121
onRemoveChip={removeChip}
124-
onApply={apply}
125122
onClear={clear}
126123
/>
127124
</Container>
@@ -130,7 +127,7 @@ export function FactChecksExplorer({
130127
{/* Listing */}
131128
<section className="py-14 lg:py-20">
132129
<Container>
133-
{results.length === 0 ? (
130+
{stories.length === 0 ? (
134131
<p className="py-16 text-center text-base font-medium text-neutral-500">
135132
No fact-checks match your filters. Try removing some.
136133
</p>

0 commit comments

Comments
 (0)