Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-trustlab-list-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"trustlab": patch
---

Fix list filters across opportunities, reports, toolkits and playbooks.

Multi-value filters (location, report, year/month) are now sent as repeated query params instead of comma-joined strings, which corrupted backend queries when a value contained a comma (e.g. "Nairobi, Kenya"). Date-range and query-param handling are consolidated into shared utilities, filter params are standardised to singular names (`year`, `month`, `report`) end to end, the previously broken toolkit/playbook date filtering now works, and filter results restore from bookmarked URLs.
7 changes: 4 additions & 3 deletions apps/trustlab/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ const nextConfig = {
output: "standalone",
outputFileTracingRoot,
// TODO(kilemensi): There is an upstream bug on this @ https://github.com/vercel/next.js/issues/51478
// `js`, `ts`, `tsx` are just to make sure there is more than one item;
// we SHOULDN'T use them in pages router!
pageExtensions: ["page.js", "js", "ts", "tsx"],
// the extra `page.*` entries just make sure there is more than
// one item; using `page.` infixes (instead of bare js/ts/tsx)
// keeps co-located *.test.js files from being collected as routes.
pageExtensions: ["page.js", "page.jsx", "page.ts", "page.tsx"],
reactStrictMode: true,
webpack: (config) => {
config.module.rules.push({
Expand Down
2 changes: 1 addition & 1 deletion apps/trustlab/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "trustlab",
"version": "0.0.22",
"version": "0.0.23",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import useOpportunities from "./useOpportunities";
import Filters from "@/trustlab/components/Filters";
import OpportunityCard from "@/trustlab/components/OpportunityCard";
import Pagination from "@/trustlab/components/Pagination";
import { parseQueryParams, setSearchParam } from "@/trustlab/utils/queryParams";

const OpportunitiesList = forwardRef(function OpportunitiesList(props, ref) {
const {
Expand Down Expand Up @@ -62,14 +63,7 @@ const OpportunitiesList = forwardRef(function OpportunitiesList(props, ref) {
limit: itemsPerPage,
...(defaultSort ? { sort: defaultSort } : {}),
// Parse query params to restore filter state (may override defaultSort)
...Object.entries(queryParams).reduce((acc, [key, value]) => {
if (typeof value === "string" && value.includes(",")) {
acc[key] = value.split(",");
} else if (value) {
acc[key] = value;
}
return acc;
}, {}),
...parseQueryParams(queryParams),
}));

const listRef = useRef(null);
Expand Down Expand Up @@ -97,14 +91,7 @@ const OpportunitiesList = forwardRef(function OpportunitiesList(props, ref) {
type: itemsType,
limit: itemsPerPage,
...(defaultSort ? { sort: defaultSort } : {}),
...Object.entries(currentQueryParams).reduce((acc, [key, value]) => {
if (typeof value === "string" && value.includes(",")) {
acc[key] = value.split(",");
} else if (value) {
acc[key] = value;
}
return acc;
}, {}),
...parseQueryParams(currentQueryParams),
};
setParams(newParams);
}, [router.isReady]);
Expand Down Expand Up @@ -156,11 +143,7 @@ const OpportunitiesList = forwardRef(function OpportunitiesList(props, ref) {
searchParams.delete(k),
);
Object.entries(filterParams).forEach(([key, value]) => {
if (Array.isArray(value) && value.length > 0) {
searchParams.set(key, value.join(","));
} else if (value && typeof value === "string") {
searchParams.set(key, value);
}
setSearchParam(searchParams, key, value);
});
searchParams.delete("page");

Expand Down
47 changes: 20 additions & 27 deletions apps/trustlab/src/components/OpportunitiesList/useOpportunities.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
import useSWR from "swr";

import { setSearchParam } from "@/trustlab/utils/queryParams";

const fetcher = (url) => fetch(url).then((res) => res.json());

// Allowlist of params forwarded to the API. Intentionally excludes `page`
// (set separately) and keeps arbitrary URL params from being proxied through.
// Keep in sync with the /api/v1/opportunities handler.
const QUERY_PARAM_KEYS = [
"limit",
"type",
"location",
"date",
"search",
"sort",
"opportunity",
"year",
"month",
];

function useOpportunities(
page,
params,
Expand All @@ -13,33 +30,9 @@ function useOpportunities(
const searchParams = new URLSearchParams();
searchParams.set("page", page);

if (params?.limit) {
searchParams.set("limit", params.limit);
}
if (params?.type) {
searchParams.set("type", params.type);
}
if (params?.location) {
searchParams.set("location", params.location);
}
if (params?.date) {
searchParams.set("date", params.date);
}
if (params?.search) {
searchParams.set("search", params.search);
}
if (params?.sort) {
searchParams.set("sort", params.sort);
}
if (params?.opportunity) {
searchParams.set("opportunity", params.opportunity);
}
if (params?.year) {
searchParams.set("year", params.year);
}
if (params?.month) {
searchParams.set("month", params.month);
}
QUERY_PARAM_KEYS.forEach((key) => {
setSearchParam(searchParams, key, params?.[key]);
});

const { data } = useSWR(
skip ? null : `${apiEndpoint}?${searchParams.toString()}`,
Expand Down
27 changes: 20 additions & 7 deletions apps/trustlab/src/components/PlaybooksList/PlaybooksList.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import usePlaybooks from "./usePlaybooks";
import Filters from "@/trustlab/components/Filters";
import Pagination from "@/trustlab/components/Pagination";
import RowCard from "@/trustlab/components/RowCard";
import { parseQueryParams, setSearchParam } from "@/trustlab/utils/queryParams";

const PlaybooksList = forwardRef(function PlaybooksList(props, ref) {
const {
Expand Down Expand Up @@ -40,6 +41,22 @@ const PlaybooksList = forwardRef(function PlaybooksList(props, ref) {
}
}, [initialPage]);

// Restore filter results from a bookmarked/shared URL once the router is
// ready (the filter UI controls are not restored — out of scope).
useEffect(() => {
if (!router.isReady) {
return;
}
const { year, month, sort } = query;
if (!year && !month && !sort) {
return;
}
setParams((prev) => ({
...prev,
...parseQueryParams({ year, month, sort }),
}));
}, [router.isReady]);

const { playbooks = [], pagination = p } = usePlaybooks(
page,
params,
Expand All @@ -50,7 +67,7 @@ const PlaybooksList = forwardRef(function PlaybooksList(props, ref) {

const handlePageChange = (value) => {
setPage(value);
const urlParams = new URLSearchParams(router.query);
const urlParams = new URLSearchParams(window.location.search);
if (value === 1) {
urlParams.delete("page");
} else {
Expand All @@ -69,13 +86,9 @@ const PlaybooksList = forwardRef(function PlaybooksList(props, ref) {
const handleApplyFilters = (filterParams) => {
setParams((prev) => ({ ...prev, ...filterParams }));
setPage(1);
const urlParams = new URLSearchParams(router.query);
const urlParams = new URLSearchParams(window.location.search);
Object.entries(filterParams).forEach(([key, value]) => {
if (Array.isArray(value)) {
urlParams.set(key, value.join(","));
} else {
urlParams.set(key, value);
}
setSearchParam(urlParams, key, value);
});
urlParams.delete("page");
router.push(
Expand Down
17 changes: 3 additions & 14 deletions apps/trustlab/src/components/PlaybooksList/usePlaybooks.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,11 @@
import useSWR from "swr";

import { setSearchParam } from "@/trustlab/utils/queryParams";

export const buildQueryString = (params) => {
const query = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
if (!value.length) {
return;
}
query.set(key, value.join(","));
return;
}
const str = String(value).trim();
if (str) {
query.set(key, str);
}
setSearchParam(query, key, value);
});
return query.toString();
};
Expand Down
48 changes: 14 additions & 34 deletions apps/trustlab/src/components/ReportsList/ReportsList.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import ErrorPageIcon from "@/trustlab/assets/error-page-icon.svg?url";
import Filters from "@/trustlab/components/Filters";
import Pagination from "@/trustlab/components/Pagination";
import ReportCard from "@/trustlab/components/ReportCard";
import { parseQueryParams, setSearchParam } from "@/trustlab/utils/queryParams";

const ReportsList = forwardRef(function ReportsList(props, ref) {
const {
Expand Down Expand Up @@ -95,16 +96,10 @@ const ReportsList = forwardRef(function ReportsList(props, ref) {
};

const handleApplyFilters = (filterParams) => {
// filter keys are singular (year/month/report); API expects plural
const keyMap = { year: "years", month: "months", report: "reports" };
const mappedParams = Object.fromEntries(
Object.entries(filterParams).map(([k, v]) => [keyMap[k] ?? k, v]),
);

setParams((prev) => ({
reportsType,
limit: reportsPerPage,
...mappedParams,
...filterParams,
// preserve sort and search across filter changes
...(prev.sort ? { sort: prev.sort } : {}),
...(prev.search ? { search: prev.search } : {}),
Expand All @@ -113,13 +108,9 @@ const ReportsList = forwardRef(function ReportsList(props, ref) {

const searchParams = new URLSearchParams(window.location.search);
// clear existing filter params before setting new ones
["years", "months", "reports"].forEach((k) => searchParams.delete(k));
Object.entries(mappedParams).forEach(([key, value]) => {
if (Array.isArray(value) && value.length > 0) {
searchParams.set(key, value.join(","));
} else if (value && typeof value === "string") {
searchParams.set(key, value);
}
["year", "month", "report"].forEach((k) => searchParams.delete(k));
Object.entries(filterParams).forEach(([key, value]) => {
setSearchParam(searchParams, key, value);
});
searchParams.delete("page");

Expand Down Expand Up @@ -205,34 +196,23 @@ const ReportsList = forwardRef(function ReportsList(props, ref) {
if (!router.isReady) {
return;
}
const { years, months, reports: reportsFilter, sort, search } = query;
if (!years && !months && !reportsFilter && !sort && !search) {
const { year, month, report, sort, search } = query;
if (!year && !month && !report && !sort && !search) {
return;
}

const parseParam = (v) =>
typeof v === "string" && v.includes(",") ? v.split(",") : v;

const newParams = {
reportsType,
limit: reportsPerPage,
...(defaultSort ? { sort: defaultSort } : {}),
...parseQueryParams({
year,
month,
report,
sort,
search,
}),
};
if (years) {
newParams.years = parseParam(years);
}
if (months) {
newParams.months = parseParam(months);
}
if (reportsFilter) {
newParams.reports = parseParam(reportsFilter);
}
if (sort) {
newParams.sort = sort;
}
if (search) {
newParams.search = search;
}

setParams(newParams);
}, [router.isReady]);
Expand Down
35 changes: 17 additions & 18 deletions apps/trustlab/src/components/ReportsList/useReports.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,28 @@
import useSWR from "swr";

import { setSearchParam } from "@/trustlab/utils/queryParams";

const fetcher = (url) => fetch(url).then((res) => res.json());

// Allowlist of params forwarded to the API. Intentionally excludes `page`
// (set separately) and keeps arbitrary URL params from being proxied through.
// Keep in sync with the /api/v1/reports handler.
const QUERY_PARAM_KEYS = [
"limit",
"reportsType",
"sort",
"search",
"year",
"month",
"report",
];

function useReports(page, params, initialReports, initialCount, skip) {
const searchParams = new URLSearchParams();
searchParams.set("page", page);

if (params?.limit) {
searchParams.set("limit", params.limit);
}
if (params?.reportsType) {
searchParams.set("reportsType", params.reportsType);
}
if (params?.sort) {
searchParams.set("sort", params.sort);
}
if (params?.search) {
searchParams.set("search", params.search);
}

["years", "months", "reports"].forEach((key) => {
if (params?.[key]) {
const v = params[key];
searchParams.set(key, Array.isArray(v) ? v.join(",") : v);
}
QUERY_PARAM_KEYS.forEach((key) => {
setSearchParam(searchParams, key, params?.[key]);
});

const { data } = useSWR(
Expand Down
Loading