diff --git a/.changeset/fix-trustlab-list-filters.md b/.changeset/fix-trustlab-list-filters.md new file mode 100644 index 0000000000..01c5a4a6f8 --- /dev/null +++ b/.changeset/fix-trustlab-list-filters.md @@ -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. diff --git a/apps/trustlab/next.config.mjs b/apps/trustlab/next.config.mjs index c3d9bee31d..50b7416248 100644 --- a/apps/trustlab/next.config.mjs +++ b/apps/trustlab/next.config.mjs @@ -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({ diff --git a/apps/trustlab/package.json b/apps/trustlab/package.json index d77da84b1e..290ba7ce79 100644 --- a/apps/trustlab/package.json +++ b/apps/trustlab/package.json @@ -1,6 +1,6 @@ { "name": "trustlab", - "version": "0.0.22", + "version": "0.0.23", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/trustlab/src/app/(payload)/admin/[[...segments]]/not-found.tsx b/apps/trustlab/src/app/(payload)/admin/[[...segments]]/not-found.page.tsx similarity index 100% rename from apps/trustlab/src/app/(payload)/admin/[[...segments]]/not-found.tsx rename to apps/trustlab/src/app/(payload)/admin/[[...segments]]/not-found.page.tsx diff --git a/apps/trustlab/src/app/(payload)/admin/[[...segments]]/page.tsx b/apps/trustlab/src/app/(payload)/admin/[[...segments]]/page.page.tsx similarity index 100% rename from apps/trustlab/src/app/(payload)/admin/[[...segments]]/page.tsx rename to apps/trustlab/src/app/(payload)/admin/[[...segments]]/page.page.tsx diff --git a/apps/trustlab/src/app/(payload)/api/[...slug]/route.ts b/apps/trustlab/src/app/(payload)/api/[...slug]/route.page.ts similarity index 100% rename from apps/trustlab/src/app/(payload)/api/[...slug]/route.ts rename to apps/trustlab/src/app/(payload)/api/[...slug]/route.page.ts diff --git a/apps/trustlab/src/app/(payload)/layout.tsx b/apps/trustlab/src/app/(payload)/layout.page.tsx similarity index 100% rename from apps/trustlab/src/app/(payload)/layout.tsx rename to apps/trustlab/src/app/(payload)/layout.page.tsx diff --git a/apps/trustlab/src/app/exit-preview/route.ts b/apps/trustlab/src/app/exit-preview/route.page.ts similarity index 100% rename from apps/trustlab/src/app/exit-preview/route.ts rename to apps/trustlab/src/app/exit-preview/route.page.ts diff --git a/apps/trustlab/src/app/global-error.tsx b/apps/trustlab/src/app/global-error.page.tsx similarity index 100% rename from apps/trustlab/src/app/global-error.tsx rename to apps/trustlab/src/app/global-error.page.tsx diff --git a/apps/trustlab/src/app/preview/route.ts b/apps/trustlab/src/app/preview/route.page.ts similarity index 100% rename from apps/trustlab/src/app/preview/route.ts rename to apps/trustlab/src/app/preview/route.page.ts diff --git a/apps/trustlab/src/components/OpportunitiesList/OpportunitiesList.js b/apps/trustlab/src/components/OpportunitiesList/OpportunitiesList.js index 11e2916bdc..a8a03eca9e 100644 --- a/apps/trustlab/src/components/OpportunitiesList/OpportunitiesList.js +++ b/apps/trustlab/src/components/OpportunitiesList/OpportunitiesList.js @@ -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 { @@ -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); @@ -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]); @@ -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"); diff --git a/apps/trustlab/src/components/OpportunitiesList/useOpportunities.js b/apps/trustlab/src/components/OpportunitiesList/useOpportunities.js index de8fac7a26..b0089bd298 100644 --- a/apps/trustlab/src/components/OpportunitiesList/useOpportunities.js +++ b/apps/trustlab/src/components/OpportunitiesList/useOpportunities.js @@ -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, @@ -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()}`, diff --git a/apps/trustlab/src/components/PlaybooksList/PlaybooksList.js b/apps/trustlab/src/components/PlaybooksList/PlaybooksList.js index 3cec31a97a..2a21daf5c6 100644 --- a/apps/trustlab/src/components/PlaybooksList/PlaybooksList.js +++ b/apps/trustlab/src/components/PlaybooksList/PlaybooksList.js @@ -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 { @@ -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, @@ -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 { @@ -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( diff --git a/apps/trustlab/src/components/PlaybooksList/usePlaybooks.js b/apps/trustlab/src/components/PlaybooksList/usePlaybooks.js index 728d0082bb..6d4fe723ec 100644 --- a/apps/trustlab/src/components/PlaybooksList/usePlaybooks.js +++ b/apps/trustlab/src/components/PlaybooksList/usePlaybooks.js @@ -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(); }; diff --git a/apps/trustlab/src/components/ReportsList/ReportsList.js b/apps/trustlab/src/components/ReportsList/ReportsList.js index 20962d41e5..b024db538e 100644 --- a/apps/trustlab/src/components/ReportsList/ReportsList.js +++ b/apps/trustlab/src/components/ReportsList/ReportsList.js @@ -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 { @@ -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 } : {}), @@ -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"); @@ -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]); diff --git a/apps/trustlab/src/components/ReportsList/useReports.js b/apps/trustlab/src/components/ReportsList/useReports.js index 540b18bcc0..db6108a8cf 100644 --- a/apps/trustlab/src/components/ReportsList/useReports.js +++ b/apps/trustlab/src/components/ReportsList/useReports.js @@ -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( diff --git a/apps/trustlab/src/components/ToolkitList/ToolkitList.js b/apps/trustlab/src/components/ToolkitList/ToolkitList.js index 6ddbc31a3b..ec14cf0ef6 100644 --- a/apps/trustlab/src/components/ToolkitList/ToolkitList.js +++ b/apps/trustlab/src/components/ToolkitList/ToolkitList.js @@ -9,6 +9,7 @@ import useToolkits from "./useToolkits"; import Filters from "@/trustlab/components/Filters"; import Pagination from "@/trustlab/components/Pagination"; +import { parseQueryParams, setSearchParam } from "@/trustlab/utils/queryParams"; const ToolkitList = forwardRef(function ToolkitList(props, ref) { const { @@ -43,6 +44,22 @@ const ToolkitList = forwardRef(function ToolkitList(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 { toolkits = [], pagination = p } = useToolkits( page, params, @@ -53,7 +70,7 @@ const ToolkitList = forwardRef(function ToolkitList(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 { @@ -72,13 +89,9 @@ const ToolkitList = forwardRef(function ToolkitList(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( diff --git a/apps/trustlab/src/components/ToolkitList/useToolkits.js b/apps/trustlab/src/components/ToolkitList/useToolkits.js index 7fb948ac9e..49eb94593d 100644 --- a/apps/trustlab/src/components/ToolkitList/useToolkits.js +++ b/apps/trustlab/src/components/ToolkitList/useToolkits.js @@ -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(); }; diff --git a/apps/trustlab/src/lib/data/getOpportunities.js b/apps/trustlab/src/lib/data/getOpportunities.js index c422554197..d9d2bdd2c7 100644 --- a/apps/trustlab/src/lib/data/getOpportunities.js +++ b/apps/trustlab/src/lib/data/getOpportunities.js @@ -1,4 +1,6 @@ import formatDate from "@/trustlab/payload/utils/formatDate"; +import { buildDateRangeCondition } from "@/trustlab/utils/dateFilters"; +import { normalizeQueryList } from "@/trustlab/utils/queryParams"; function fullSlugFromParents(doc) { if (!doc) { @@ -11,6 +13,15 @@ function fullSlugFromParents(doc) { return `${fullSlugFromParents(parent)}/${slug}`; } +function addExactMatchCondition(andConditions, field, value) { + const values = normalizeQueryList(value); + if (values.length === 1) { + andConditions.push({ [field]: { equals: values[0] } }); + } else if (values.length > 1) { + andConditions.push({ [field]: { in: values } }); + } +} + async function getOpportunities(api, options = {}) { const { page = 1, @@ -32,36 +43,23 @@ async function getOpportunities(api, options = {}) { } if (search) { - andConditions.push({ title: { like: search } }); + // Fuzzy matching lives in search (the location filter itself is exact). + andConditions.push({ + or: [{ title: { like: search } }, { location: { like: search } }], + }); } if (id) { - andConditions.push({ id: { equals: id } }); + addExactMatchCondition(andConditions, "id", id); } if (location) { - andConditions.push({ location: { contains: location } }); - } - - if (year) { - const startOfYear = new Date(year, 0, 1).toISOString(); - const startOfNextYear = new Date(Number(year) + 1, 0, 1).toISOString(); - andConditions.push({ date: { greater_than_equal: startOfYear } }); - andConditions.push({ date: { less_than: startOfNextYear } }); + addExactMatchCondition(andConditions, "location", location); } - if (month) { - // month is 1-based (1 = January) - const monthIndex = parseInt(month, 10) - 1; - const targetYear = year || new Date().getFullYear(); - const startOfMonth = new Date(targetYear, monthIndex, 1).toISOString(); - const startOfNextMonth = new Date( - targetYear, - monthIndex + 1, - 1, - ).toISOString(); - andConditions.push({ date: { greater_than_equal: startOfMonth } }); - andConditions.push({ date: { less_than: startOfNextMonth } }); + const dateCondition = buildDateRangeCondition({ month, year }); + if (dateCondition) { + andConditions.push(dateCondition); } const where = andConditions.length ? { and: andConditions } : {}; diff --git a/apps/trustlab/src/lib/data/getOpportunities.test.js b/apps/trustlab/src/lib/data/getOpportunities.test.js new file mode 100644 index 0000000000..1d723a696c --- /dev/null +++ b/apps/trustlab/src/lib/data/getOpportunities.test.js @@ -0,0 +1,74 @@ +import getOpportunities from "./getOpportunities"; + +function createApi() { + return { + findPage: jest.fn().mockResolvedValue({ docs: [] }), + getCollection: jest.fn().mockResolvedValue({ + docs: [], + page: 1, + totalPages: 1, + totalDocs: 0, + hasNextPage: false, + hasPrevPage: false, + }), + }; +} + +describe("getOpportunities", () => { + it("builds exact-match filters for repeated query params", async () => { + const api = createApi(); + + await getOpportunities(api, { + opportunity: ["opportunity-1", "opportunity-2"], + location: ["Nairobi, Kenya", "Lagos, Nigeria"], + }); + + expect(api.getCollection).toHaveBeenCalledWith( + "opportunities", + expect.objectContaining({ + where: { + and: [ + { id: { in: ["opportunity-1", "opportunity-2"] } }, + { location: { in: ["Nairobi, Kenya", "Lagos, Nigeria"] } }, + ], + }, + }), + ); + }); + + it("builds date range filters for repeated year and month params", async () => { + const api = createApi(); + + await getOpportunities(api, { + year: ["2025", "2026"], + month: ["1", "2"], + }); + + const options = api.getCollection.mock.calls[0][1]; + expect(options.where.and).toHaveLength(1); + expect(options.where.and[0].or).toHaveLength(4); + expect(options.where.and[0].or[0]).toHaveProperty("and"); + }); + + it("searches title and location", async () => { + const api = createApi(); + + await getOpportunities(api, { search: "nairobi" }); + + expect(api.getCollection).toHaveBeenCalledWith( + "opportunities", + expect.objectContaining({ + where: { + and: [ + { + or: [ + { title: { like: "nairobi" } }, + { location: { like: "nairobi" } }, + ], + }, + ], + }, + }), + ); + }); +}); diff --git a/apps/trustlab/src/pages/api/v1/opportunities.page.js b/apps/trustlab/src/pages/api/v1/opportunities.page.js index c4d0384b85..6b61e1cf17 100644 --- a/apps/trustlab/src/pages/api/v1/opportunities.page.js +++ b/apps/trustlab/src/pages/api/v1/opportunities.page.js @@ -1,5 +1,6 @@ import getOpportunities from "@/trustlab/lib/data/getOpportunities"; import api from "@/trustlab/lib/payload"; +import { singleQueryValue } from "@/trustlab/utils/queryParams"; const ALLOWED_SORT = [ "-date", @@ -18,18 +19,16 @@ export default async function handler(req, res) { } try { - const { - page = 1, - type, - locale, - year, - month, - location, - opportunity, - search, - sort, - } = req.query; - const limit = parseInt(req.query?.limit, 10) || 12; + // Multi-value params (location, opportunity, year, month) arrive as arrays + // when repeated in the URL and are normalized downstream. Single-valued + // params are coerced to one value (the last, if repeated). + const { year, month, location, opportunity } = req.query; + const type = singleQueryValue(req.query.type); + const locale = singleQueryValue(req.query.locale); + const search = singleQueryValue(req.query.search); + const sort = singleQueryValue(req.query.sort); + const page = singleQueryValue(req.query.page) ?? 1; + const limit = parseInt(singleQueryValue(req.query.limit), 10) || 12; const validatedSort = sort && ALLOWED_SORT.includes(sort) ? sort : undefined; diff --git a/apps/trustlab/src/pages/api/v1/opportunities.test.js b/apps/trustlab/src/pages/api/v1/opportunities.test.js new file mode 100644 index 0000000000..974d01e665 --- /dev/null +++ b/apps/trustlab/src/pages/api/v1/opportunities.test.js @@ -0,0 +1,68 @@ +import querystring from "node:querystring"; + +import handler from "./opportunities.page"; + +import api from "@/trustlab/lib/payload"; +import { setSearchParam } from "@/trustlab/utils/queryParams"; + +jest.mock("@/trustlab/lib/payload", () => ({ + getCollection: jest.fn(), + findPage: jest.fn(), +})); + +function createResponse() { + return { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; +} + +describe("/api/v1/opportunities", () => { + beforeEach(() => { + api.getCollection.mockResolvedValue({ + docs: [], + page: 1, + totalPages: 1, + totalDocs: 0, + hasNextPage: false, + hasPrevPage: false, + }); + api.findPage.mockResolvedValue({ docs: [] }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("round-trips comma values and repeated filters through the query string", async () => { + // Client serializes filters exactly as useOpportunities does. + const searchParams = new URLSearchParams(); + setSearchParam(searchParams, "location", [ + "Nairobi, Kenya", + "Lagos, Nigeria", + ]); + setSearchParam(searchParams, "opportunity", [ + "opportunity-1", + "opportunity-2", + ]); + + // querystring mirrors Next's pages-router parsing of req.query: repeated + // keys become arrays, single keys stay strings. This is the boundary the + // comma-in-value bug used to break. + const query = querystring.parse(searchParams.toString()); + expect(query.location).toEqual(["Nairobi, Kenya", "Lagos, Nigeria"]); + expect(query.opportunity).toEqual(["opportunity-1", "opportunity-2"]); + + const res = createResponse(); + await handler({ method: "GET", query }, res); + + const { where } = api.getCollection.mock.calls[0][1]; + expect(where.and).toEqual( + expect.arrayContaining([ + { id: { in: ["opportunity-1", "opportunity-2"] } }, + { location: { in: ["Nairobi, Kenya", "Lagos, Nigeria"] } }, + ]), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/apps/trustlab/src/pages/api/v1/playbooks/index.page.js b/apps/trustlab/src/pages/api/v1/playbooks/index.page.js index a7152a075e..25dadca718 100644 --- a/apps/trustlab/src/pages/api/v1/playbooks/index.page.js +++ b/apps/trustlab/src/pages/api/v1/playbooks/index.page.js @@ -1,4 +1,6 @@ import api from "@/trustlab/lib/payload"; +import { buildDateRangeCondition } from "@/trustlab/utils/dateFilters"; +import { singleQueryValue } from "@/trustlab/utils/queryParams"; export default async function handler(req, res) { const { method } = req; @@ -7,74 +9,21 @@ export default async function handler(req, res) { return res.status(405).json({ error: "Method not allowed" }); } - const { page, sort, years, months, limit } = req.query; - - const monthRange = (year, monthNumber) => { - const mIdx = monthNumber - 1; - const start = new Date(Date.UTC(year, mIdx, 1, 0, 0, 0, 0)); - const end = - mIdx === 11 - ? new Date(Date.UTC(year + 1, 0, 1, 0, 0, 0, 0)) - : new Date(Date.UTC(year, mIdx + 1, 1, 0, 0, 0, 0)); - return { - and: [ - { createdAt: { greater_than_equal: start.toISOString() } }, - { createdAt: { less_than: end.toISOString() } }, - ], - }; - }; - - const yearRange = (year) => { - const start = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0)); - const end = new Date(Date.UTC(year + 1, 0, 1, 0, 0, 0, 0)); - return { - and: [ - { createdAt: { greater_than_equal: start.toISOString() } }, - { createdAt: { less_than: end.toISOString() } }, - ], - }; - }; + // year/month are multi-value (repeated in the URL) and normalized + // downstream; the rest are single-valued. + const { year, month } = req.query; + const sort = singleQueryValue(req.query.sort); + const page = singleQueryValue(req.query.page); + const limit = singleQueryValue(req.query.limit); const andConditions = []; - - const yearsArray = years - ? years - .split(",") - .map((y) => parseInt(y, 10)) - .filter((y) => !Number.isNaN(y)) - : []; - - const monthsArray = months - ? months - .split(",") - .map((m) => parseInt(m, 10)) - .filter((m) => !Number.isNaN(m) && m >= 1 && m <= 12) - : []; - - const dateOrConditions = []; - const currentYear = new Date().getFullYear(); - const defaultStartYear = 2000; - - if (yearsArray.length && monthsArray.length) { - yearsArray.forEach((y) => { - monthsArray.forEach((m) => { - dateOrConditions.push(monthRange(y, m)); - }); - }); - } else if (yearsArray.length) { - yearsArray.forEach((y) => { - dateOrConditions.push(yearRange(y)); - }); - } else if (monthsArray.length) { - for (let y = defaultStartYear; y <= currentYear; y += 1) { - monthsArray.forEach((m) => { - dateOrConditions.push(monthRange(y, m)); - }); - } - } - - if (dateOrConditions.length) { - andConditions.push({ or: dateOrConditions }); + const dateCondition = buildDateRangeCondition({ + field: "createdAt", + month, + year, + }); + if (dateCondition) { + andConditions.push(dateCondition); } const where = andConditions.length ? { and: andConditions } : {}; diff --git a/apps/trustlab/src/pages/api/v1/playbooks/index.test.js b/apps/trustlab/src/pages/api/v1/playbooks/index.test.js new file mode 100644 index 0000000000..0c64c4550e --- /dev/null +++ b/apps/trustlab/src/pages/api/v1/playbooks/index.test.js @@ -0,0 +1,46 @@ +import querystring from "node:querystring"; + +import handler from "./index.page"; + +import api from "@/trustlab/lib/payload"; +import { setSearchParam } from "@/trustlab/utils/queryParams"; + +jest.mock("@/trustlab/lib/payload", () => ({ + getCollection: jest.fn(), +})); + +function createResponse() { + return { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; +} + +describe("/api/v1/playbooks", () => { + beforeEach(() => { + api.getCollection.mockResolvedValue({ docs: [], page: 1, totalPages: 1 }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("builds a createdAt date filter from repeated year/month params", async () => { + // Client serializes filters as repeated params; querystring mirrors Next's + // pages-router parsing (repeated keys -> arrays). + const searchParams = new URLSearchParams(); + setSearchParam(searchParams, "year", ["2025", "2026"]); + setSearchParam(searchParams, "month", ["1", "2"]); + + const query = querystring.parse(searchParams.toString()); + expect(query.year).toEqual(["2025", "2026"]); + + const res = createResponse(); + await handler({ method: "GET", query }, res); + + const { where } = api.getCollection.mock.calls[0][1]; + expect(where.and[0].or).toHaveLength(4); + expect(where.and[0].or[0].and[0]).toHaveProperty("createdAt"); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/apps/trustlab/src/pages/api/v1/reports.page.js b/apps/trustlab/src/pages/api/v1/reports.page.js index c64ba4d0b0..3eb974907f 100644 --- a/apps/trustlab/src/pages/api/v1/reports.page.js +++ b/apps/trustlab/src/pages/api/v1/reports.page.js @@ -1,46 +1,24 @@ import api from "@/trustlab/lib/payload"; +import { buildDateRangeCondition } from "@/trustlab/utils/dateFilters"; +import { + normalizeQueryList, + singleQueryValue, +} from "@/trustlab/utils/queryParams"; import { getReports } from "@/trustlab/utils/reports"; export default async function handler(req, res) { const { method } = req; if (method === "GET") { - const { - page, - sort, - years, - months, - reports, - reportsType, - search, - limit = 12, - } = req.query; - - const monthRange = (year, monthNumber) => { - // monthNumber is 1-12 - const mIdx = monthNumber - 1; - const start = new Date(Date.UTC(year, mIdx, 1, 0, 0, 0, 0)); - const end = - mIdx === 11 - ? new Date(Date.UTC(year + 1, 0, 1, 0, 0, 0, 0)) - : new Date(Date.UTC(year, mIdx + 1, 1, 0, 0, 0, 0)); - return { - and: [ - { date: { greater_than_equal: start.toISOString() } }, - { date: { less_than: end.toISOString() } }, - ], - }; - }; - const yearRange = (year) => { - const start = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0)); - const end = new Date(Date.UTC(year + 1, 0, 1, 0, 0, 0, 0)); - return { - and: [ - { date: { greater_than_equal: start.toISOString() } }, - { date: { less_than: end.toISOString() } }, - ], - }; - }; + // Multi-value params (year, month, report) arrive as arrays when + // repeated in the URL and are normalized downstream. Single-valued + // params are coerced to one value (the last, if repeated). + const { year, month, report } = req.query; + const reportsType = singleQueryValue(req.query.reportsType); + const search = singleQueryValue(req.query.search); + const sort = singleQueryValue(req.query.sort); + const page = singleQueryValue(req.query.page); + const limit = singleQueryValue(req.query.limit) ?? 12; // Build filters const andConditions = []; @@ -54,58 +32,14 @@ export default async function handler(req, res) { } // Reports (slug) filter - if (reports) { - const reportsArray = reports - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - if (reportsArray.length) { - andConditions.push({ slug: { in: reportsArray } }); - } - } - - // Years/months on date - const yearsArray = years - ? years - .split(",") - .map((y) => parseInt(y, 10)) - .filter((y) => !Number.isNaN(y)) - : []; - - const monthsArray = months - ? months - .split(",") - .map((m) => parseInt(m, 10)) - .filter((m) => !Number.isNaN(m) && m >= 1 && m <= 12) - : []; - - const dateOrConditions = []; - const currentYear = new Date().getFullYear(); - const defaultStartYear = 2000; - - if (yearsArray.length && monthsArray.length) { - // Specific month(s) within specific year(s) - yearsArray.forEach((y) => { - monthsArray.forEach((m) => { - dateOrConditions.push(monthRange(y, m)); - }); - }); - } else if (yearsArray.length) { - // Whole year(s) - yearsArray.forEach((y) => { - dateOrConditions.push(yearRange(y)); - }); - } else if (monthsArray.length) { - // Month(s) across all years in range - for (let y = defaultStartYear; y <= currentYear; y += 1) { - monthsArray.forEach((m) => { - dateOrConditions.push(monthRange(y, m)); - }); - } + const reportSlugs = normalizeQueryList(report); + if (reportSlugs.length) { + andConditions.push({ slug: { in: reportSlugs } }); } - if (dateOrConditions.length) { - andConditions.push({ or: dateOrConditions }); + const dateCondition = buildDateRangeCondition({ month, year }); + if (dateCondition) { + andConditions.push(dateCondition); } const where = andConditions.length > 0 ? { and: andConditions } : {}; diff --git a/apps/trustlab/src/pages/api/v1/reports.test.js b/apps/trustlab/src/pages/api/v1/reports.test.js new file mode 100644 index 0000000000..484963a69e --- /dev/null +++ b/apps/trustlab/src/pages/api/v1/reports.test.js @@ -0,0 +1,93 @@ +import querystring from "node:querystring"; + +import handler from "./reports.page"; + +import { setSearchParam } from "@/trustlab/utils/queryParams"; +import { getReports } from "@/trustlab/utils/reports"; + +jest.mock("@/trustlab/lib/payload", () => ({})); +jest.mock("@/trustlab/utils/reports", () => ({ + getReports: jest.fn(), +})); + +function createResponse() { + return { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; +} + +describe("/api/v1/reports", () => { + beforeEach(() => { + getReports.mockResolvedValue({ + reports: [], + pagination: { page: 1, count: 1 }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("builds filters from repeated query params", async () => { + const res = createResponse(); + + await handler( + { + method: "GET", + query: { + report: ["report-one", "report-two"], + year: ["2025", "2026"], + month: ["1", "2"], + }, + }, + res, + ); + + const options = getReports.mock.calls[0][1]; + expect(options.where.and[0]).toEqual({ + slug: { in: ["report-one", "report-two"] }, + }); + expect(options.where.and[1].or).toHaveLength(4); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("coerces a duplicated single-valued param to its last value", async () => { + const res = createResponse(); + + await handler( + { + method: "GET", + query: { reportsType: ["briefing", "playbook"] }, + }, + res, + ); + + const { where } = getReports.mock.calls[0][1]; + expect(where.and[0]).toEqual({ reportType: { equals: "playbook" } }); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("round-trips comma values and repeated filters through the query string", async () => { + // Client serializes filters exactly as useReports does. + const searchParams = new URLSearchParams(); + setSearchParam(searchParams, "report", ["report, one", "report-two"]); + setSearchParam(searchParams, "year", ["2025", "2026"]); + + // querystring mirrors Next's pages-router parsing of req.query: repeated + // keys become arrays, single keys stay strings. This is the boundary the + // comma-in-value bug used to break. + const query = querystring.parse(searchParams.toString()); + expect(query.report).toEqual(["report, one", "report-two"]); + expect(query.year).toEqual(["2025", "2026"]); + + const res = createResponse(); + await handler({ method: "GET", query }, res); + + const options = getReports.mock.calls[0][1]; + expect(options.where.and[0]).toEqual({ + slug: { in: ["report, one", "report-two"] }, + }); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/apps/trustlab/src/pages/api/v1/toolkits/index.page.js b/apps/trustlab/src/pages/api/v1/toolkits/index.page.js index fd0beb2181..deba1144f5 100644 --- a/apps/trustlab/src/pages/api/v1/toolkits/index.page.js +++ b/apps/trustlab/src/pages/api/v1/toolkits/index.page.js @@ -1,4 +1,6 @@ import api from "@/trustlab/lib/payload"; +import { buildDateRangeCondition } from "@/trustlab/utils/dateFilters"; +import { singleQueryValue } from "@/trustlab/utils/queryParams"; export default async function handler(req, res) { const { method } = req; @@ -7,76 +9,21 @@ export default async function handler(req, res) { return res.status(405).json({ error: "Method not allowed" }); } - const { page, sort, years, months, limit = 12 } = req.query; + // year/month are multi-value (repeated in the URL) and normalized + // downstream; the rest are single-valued. + const { year, month } = req.query; + const sort = singleQueryValue(req.query.sort); + const page = singleQueryValue(req.query.page); + const limit = singleQueryValue(req.query.limit) ?? 12; - const monthRange = (year, monthNumber) => { - const mIdx = monthNumber - 1; - const start = new Date(Date.UTC(year, mIdx, 1, 0, 0, 0, 0)); - const end = - mIdx === 11 - ? new Date(Date.UTC(year + 1, 0, 1, 0, 0, 0, 0)) - : new Date(Date.UTC(year, mIdx + 1, 1, 0, 0, 0, 0)); - return { - and: [ - { createdAt: { greater_than_equal: start.toISOString() } }, - { createdAt: { less_than: end.toISOString() } }, - ], - }; - }; - - const yearRange = (year) => { - const start = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0)); - const end = new Date(Date.UTC(year + 1, 0, 1, 0, 0, 0, 0)); - return { - and: [ - { createdAt: { greater_than_equal: start.toISOString() } }, - { createdAt: { less_than: end.toISOString() } }, - ], - }; - }; - - // Build filters const andConditions = []; - - // Years/months on date - const yearsArray = years - ? years - .split(",") - .map((y) => parseInt(y, 10)) - .filter((y) => !Number.isNaN(y)) - : []; - - const monthsArray = months - ? months - .split(",") - .map((m) => parseInt(m, 10)) - .filter((m) => !Number.isNaN(m) && m >= 1 && m <= 12) - : []; - - const dateOrConditions = []; - const currentYear = new Date().getFullYear(); - const defaultStartYear = 2000; - - if (yearsArray.length && monthsArray.length) { - yearsArray.forEach((y) => { - monthsArray.forEach((m) => { - dateOrConditions.push(monthRange(y, m)); - }); - }); - } else if (yearsArray.length) { - yearsArray.forEach((y) => { - dateOrConditions.push(yearRange(y)); - }); - } else if (monthsArray.length) { - for (let y = defaultStartYear; y <= currentYear; y += 1) { - monthsArray.forEach((m) => { - dateOrConditions.push(monthRange(y, m)); - }); - } - } - - if (dateOrConditions.length) { - andConditions.push({ or: dateOrConditions }); + const dateCondition = buildDateRangeCondition({ + field: "createdAt", + month, + year, + }); + if (dateCondition) { + andConditions.push(dateCondition); } const where = andConditions.length > 0 ? { and: andConditions } : {}; diff --git a/apps/trustlab/src/pages/api/v1/toolkits/index.test.js b/apps/trustlab/src/pages/api/v1/toolkits/index.test.js new file mode 100644 index 0000000000..bb7fc52464 --- /dev/null +++ b/apps/trustlab/src/pages/api/v1/toolkits/index.test.js @@ -0,0 +1,46 @@ +import querystring from "node:querystring"; + +import handler from "./index.page"; + +import api from "@/trustlab/lib/payload"; +import { setSearchParam } from "@/trustlab/utils/queryParams"; + +jest.mock("@/trustlab/lib/payload", () => ({ + getCollection: jest.fn(), +})); + +function createResponse() { + return { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; +} + +describe("/api/v1/toolkits", () => { + beforeEach(() => { + api.getCollection.mockResolvedValue({ docs: [], page: 1, totalPages: 1 }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("builds a createdAt date filter from repeated year/month params", async () => { + // Client serializes filters as repeated params; querystring mirrors Next's + // pages-router parsing (repeated keys -> arrays). + const searchParams = new URLSearchParams(); + setSearchParam(searchParams, "year", ["2025", "2026"]); + setSearchParam(searchParams, "month", ["1", "2"]); + + const query = querystring.parse(searchParams.toString()); + expect(query.year).toEqual(["2025", "2026"]); + + const res = createResponse(); + await handler({ method: "GET", query }, res); + + const { where } = api.getCollection.mock.calls[0][1]; + expect(where.and[0].or).toHaveLength(4); + expect(where.and[0].or[0].and[0]).toHaveProperty("createdAt"); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/apps/trustlab/src/payload/collections/Opportunities.js b/apps/trustlab/src/payload/collections/Opportunities.js index 723f78c667..60071fd220 100644 --- a/apps/trustlab/src/payload/collections/Opportunities.js +++ b/apps/trustlab/src/payload/collections/Opportunities.js @@ -38,6 +38,7 @@ const Opportunities = { type: "text", required: true, localized: true, + index: true, }, image({ overrides: { @@ -71,6 +72,7 @@ const Opportunities = { name: "location", type: "text", localized: true, + index: true, }, { name: "date", diff --git a/apps/trustlab/src/payload/collections/Playbooks.js b/apps/trustlab/src/payload/collections/Playbooks.js index 6fc2659600..9a7e95fc90 100644 --- a/apps/trustlab/src/payload/collections/Playbooks.js +++ b/apps/trustlab/src/payload/collections/Playbooks.js @@ -21,6 +21,7 @@ const Playbooks = { type: "text", required: true, localized: true, + index: true, }, image({ name: "image" }), slug({ name: "slug" }), diff --git a/apps/trustlab/src/payload/collections/Reports.js b/apps/trustlab/src/payload/collections/Reports.js index baa0c1d719..ccd30d0e17 100644 --- a/apps/trustlab/src/payload/collections/Reports.js +++ b/apps/trustlab/src/payload/collections/Reports.js @@ -36,6 +36,7 @@ const Reports = { type: "text", required: true, localized: true, + index: true, }, image({ name: "image" }), slug({ name: "slug" }), diff --git a/apps/trustlab/src/payload/collections/Toolkits.js b/apps/trustlab/src/payload/collections/Toolkits.js index ce0dab6f88..3afc0dbd2c 100644 --- a/apps/trustlab/src/payload/collections/Toolkits.js +++ b/apps/trustlab/src/payload/collections/Toolkits.js @@ -21,6 +21,7 @@ const Toolkits = { type: "text", required: true, localized: true, + index: true, }, image({ name: "image" }), slug({ name: "slug" }), diff --git a/apps/trustlab/src/utils/dateFilters.js b/apps/trustlab/src/utils/dateFilters.js new file mode 100644 index 0000000000..6444e2565c --- /dev/null +++ b/apps/trustlab/src/utils/dateFilters.js @@ -0,0 +1,63 @@ +import { normalizeIntegerQueryList } from "./queryParams"; + +const DEFAULT_START_YEAR = 2000; + +function dateRangeCondition(field, start, end) { + return { + and: [ + { [field]: { greater_than_equal: start.toISOString() } }, + { [field]: { less_than: end.toISOString() } }, + ], + }; +} + +function monthRange(year, month, field) { + const monthIndex = month - 1; + return dateRangeCondition( + field, + new Date(Date.UTC(year, monthIndex, 1)), + new Date(Date.UTC(year, monthIndex + 1, 1)), + ); +} + +function yearRange(year, field) { + return dateRangeCondition( + field, + new Date(Date.UTC(year, 0, 1)), + new Date(Date.UTC(year + 1, 0, 1)), + ); +} + +export function buildDateRangeCondition({ + currentYear = new Date().getFullYear(), + defaultStartYear = DEFAULT_START_YEAR, + field = "date", + month, + year, +} = {}) { + const yearValues = normalizeIntegerQueryList(year); + const monthValues = normalizeIntegerQueryList(month).filter( + (m) => m >= 1 && m <= 12, + ); + + const ranges = []; + if (yearValues.length && monthValues.length) { + yearValues.forEach((y) => { + monthValues.forEach((m) => ranges.push(monthRange(y, m, field))); + }); + } else if (yearValues.length) { + yearValues.forEach((y) => ranges.push(yearRange(y, field))); + } else if (monthValues.length) { + for (let y = defaultStartYear; y <= currentYear; y += 1) { + monthValues.forEach((m) => ranges.push(monthRange(y, m, field))); + } + } + + if (!ranges.length) { + return null; + } + if (ranges.length === 1) { + return ranges[0]; + } + return { or: ranges }; +} diff --git a/apps/trustlab/src/utils/dateFilters.test.js b/apps/trustlab/src/utils/dateFilters.test.js new file mode 100644 index 0000000000..3379a401dd --- /dev/null +++ b/apps/trustlab/src/utils/dateFilters.test.js @@ -0,0 +1,41 @@ +import { buildDateRangeCondition } from "./dateFilters"; + +describe("date filters", () => { + it("builds year and month combinations", () => { + const condition = buildDateRangeCondition({ + year: ["2025", "2026"], + month: ["1", "2"], + }); + + expect(condition.or).toHaveLength(4); + }); + + it("builds month-only ranges across years", () => { + const condition = buildDateRangeCondition({ + currentYear: 2026, + defaultStartYear: 2024, + month: ["1"], + }); + + expect(condition.or).toHaveLength(3); + expect(condition.or[0].and[0]).toEqual({ + date: { greater_than_equal: "2024-01-01T00:00:00.000Z" }, + }); + expect(condition.or[2].and[1]).toEqual({ + date: { less_than: "2026-02-01T00:00:00.000Z" }, + }); + }); + + it("returns a single and-condition for one range", () => { + expect(buildDateRangeCondition({ year: ["2025"] })).toEqual({ + and: [ + { date: { greater_than_equal: "2025-01-01T00:00:00.000Z" } }, + { date: { less_than: "2026-01-01T00:00:00.000Z" } }, + ], + }); + }); + + it("returns null when no year or month is provided", () => { + expect(buildDateRangeCondition({})).toBeNull(); + }); +}); diff --git a/apps/trustlab/src/utils/queryParams.js b/apps/trustlab/src/utils/queryParams.js new file mode 100644 index 0000000000..482d408be1 --- /dev/null +++ b/apps/trustlab/src/utils/queryParams.js @@ -0,0 +1,45 @@ +function isValidValue(value) { + // Avoid Boolean because 0 can be a valid value + return value !== undefined && value !== null && value !== ""; +} + +function normalizeValue(value) { + return typeof value === "string" ? value.trim() : value; +} + +export function normalizeQueryList(value) { + const values = Array.isArray(value) ? value : [value]; + return values.map(normalizeValue).filter(isValidValue); +} + +// For params that are single-valued by design: coerce to one value, taking the +// last if the param is repeated in the URL (and dropping blanks/whitespace). +export function singleQueryValue(value) { + const values = normalizeQueryList(value); + return values[values.length - 1]; +} + +export function normalizeIntegerQueryList(value) { + return [ + ...new Set( + normalizeQueryList(value) + .map((item) => Number(item)) + .filter(Number.isInteger), + ), + ]; +} + +export function parseQueryParams(queryParams) { + return Object.entries(queryParams).reduce((acc, [key, value]) => { + const values = normalizeQueryList(value); + if (values.length) { + acc[key] = Array.isArray(value) ? values : values[0]; + } + return acc; + }, {}); +} + +export function setSearchParam(searchParams, key, value) { + searchParams.delete(key); + normalizeQueryList(value).forEach((v) => searchParams.append(key, v)); +} diff --git a/apps/trustlab/src/utils/queryParams.test.js b/apps/trustlab/src/utils/queryParams.test.js new file mode 100644 index 0000000000..4d72405251 --- /dev/null +++ b/apps/trustlab/src/utils/queryParams.test.js @@ -0,0 +1,53 @@ +import { + normalizeIntegerQueryList, + parseQueryParams, + setSearchParam, + singleQueryValue, +} from "./queryParams"; + +describe("query params", () => { + it("serializes arrays as repeated query params", () => { + const searchParams = new URLSearchParams(); + + setSearchParam(searchParams, "location", [ + "Nairobi, Kenya", + "Lagos, Nigeria", + ]); + + expect(searchParams.getAll("location")).toEqual([ + "Nairobi, Kenya", + "Lagos, Nigeria", + ]); + }); + + it("does not split comma-containing scalar query params", () => { + expect(parseQueryParams({ location: "Nairobi, Kenya" })).toEqual({ + location: "Nairobi, Kenya", + }); + }); + + it("preserves repeated params as arrays", () => { + expect(parseQueryParams({ year: ["2025", "2026"] })).toEqual({ + year: ["2025", "2026"], + }); + }); + + it("trims surrounding whitespace and drops blank values", () => { + expect(parseQueryParams({ reports: [" report-one ", " "] })).toEqual({ + reports: ["report-one"], + }); + }); + + it("coerces single-valued params to the last value, trimmed", () => { + expect(singleQueryValue(["first", "last"])).toBe("last"); + expect(singleQueryValue(" solo ")).toBe("solo"); + expect(singleQueryValue(["keep", " "])).toBe("keep"); + expect(singleQueryValue(undefined)).toBeUndefined(); + }); + + it("normalizes integer query values strictly", () => { + expect(normalizeIntegerQueryList(["2025", "2025abc", "2026"])).toEqual([ + 2025, 2026, + ]); + }); +});