From 6cd49d5f5bba080353e63ae9e4e224bb555819b5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 17 Aug 2026 21:04:17 +0000 Subject: [PATCH] fix(site): quote colon-containing values in filter serialization stringifyFilter only quoted values containing spaces, so filter values like RFC 3339 timestamps (2026-08-16T20:42:00Z) were emitted unquoted and the backend query parser rejected their colons. Quote values containing colons too; they were never valid unquoted. Extract parseFilterQuery/stringifyFilter into filterQuery.ts with unit tests. --- site/src/components/Filter/Filter.tsx | 40 ++-------------- .../src/components/Filter/filterQuery.test.ts | 48 +++++++++++++++++++ site/src/components/Filter/filterQuery.ts | 38 +++++++++++++++ 3 files changed, 91 insertions(+), 35 deletions(-) create mode 100644 site/src/components/Filter/filterQuery.test.ts create mode 100644 site/src/components/Filter/filterQuery.ts diff --git a/site/src/components/Filter/Filter.tsx b/site/src/components/Filter/Filter.tsx index 3f8972af69e..065a7fe6f08 100644 --- a/site/src/components/Filter/Filter.tsx +++ b/site/src/components/Filter/Filter.tsx @@ -26,14 +26,17 @@ import { SearchField } from "#/components/SearchField/SearchField"; import { Skeleton, type SkeletonProps } from "#/components/Skeleton/Skeleton"; import { useDebouncedFunction } from "#/hooks/debounce"; import { cn } from "#/utils/cn"; +import { + type FilterValues, + parseFilterQuery, + stringifyFilter, +} from "./filterQuery"; type PresetFilter = { name: string; query: string; }; -type FilterValues = Record; - type UseFilterConfig = { /** * The fallback value to use in the event that no filter params can be @@ -102,39 +105,6 @@ export const useFilter = ({ }; }; -const parseFilterQuery = (filterQuery: string): FilterValues => { - if (filterQuery === "") { - return {}; - } - - const result: FilterValues = {}; - const keyValuePair = /(\w+):"([^"]+)"|(\w+):(\S+)/g; - - for (const match of filterQuery.matchAll(keyValuePair)) { - const key = match[1] ?? match[3]; - const value = match[2] ?? match[4]; - if (key && value) { - result[key] = value; - } - } - - return result; -}; - -const stringifyFilter = (filterValue: FilterValues): string => { - let result = ""; - - for (const key in filterValue) { - const value = filterValue[key]; - if (value) { - const needsQuotes = value.includes(" "); - result += needsQuotes ? `${key}:"${value}" ` : `${key}:${value} `; - } - } - - return result.trim(); -}; - const BaseSkeleton: FC = ({ children, ...skeletonProps }) => { return ( { + it("leaves simple values unquoted", () => { + expect(stringifyFilter({ initiator: "me" })).toBe("initiator:me"); + }); + + it("quotes values containing spaces", () => { + expect(stringifyFilter({ session_id: "abc def" })).toBe( + 'session_id:"abc def"', + ); + }); + + it("quotes values containing colons so the backend parser accepts them", () => { + expect(stringifyFilter({ started_after: "2026-08-16T20:42:00Z" })).toBe( + 'started_after:"2026-08-16T20:42:00Z"', + ); + }); + + it("drops empty values", () => { + expect(stringifyFilter({ initiator: undefined, model: "gpt-4" })).toBe( + "model:gpt-4", + ); + }); +}); + +describe("parseFilterQuery", () => { + it("round-trips quoted timestamp values", () => { + const values = { + initiator: "me", + started_after: "2026-08-16T20:42:00Z", + started_before: "2026-08-17T20:42:00Z", + }; + expect(parseFilterQuery(stringifyFilter(values))).toEqual(values); + }); + + it("parses unquoted and quoted pairs", () => { + expect(parseFilterQuery('initiator:me session_id:"abc def"')).toEqual({ + initiator: "me", + session_id: "abc def", + }); + }); + + it("returns an empty object for an empty query", () => { + expect(parseFilterQuery("")).toEqual({}); + }); +}); diff --git a/site/src/components/Filter/filterQuery.ts b/site/src/components/Filter/filterQuery.ts new file mode 100644 index 00000000000..8376a351c9e --- /dev/null +++ b/site/src/components/Filter/filterQuery.ts @@ -0,0 +1,38 @@ +export type FilterValues = Record; + +export const parseFilterQuery = (filterQuery: string): FilterValues => { + if (filterQuery === "") { + return {}; + } + + const result: FilterValues = {}; + const keyValuePair = /(\w+):"([^"]+)"|(\w+):(\S+)/g; + + for (const match of filterQuery.matchAll(keyValuePair)) { + const key = match[1] ?? match[3]; + const value = match[2] ?? match[4]; + if (key && value) { + result[key] = value; + } + } + + return result; +}; + +// Values containing spaces or colons must be quoted: the backend query +// parser splits unquoted elements on ':' and rejects more than one colon. +const needsQuotes = (value: string): boolean => + value.includes(" ") || value.includes(":"); + +export const stringifyFilter = (filterValue: FilterValues): string => { + let result = ""; + + for (const key in filterValue) { + const value = filterValue[key]; + if (value) { + result += needsQuotes(value) ? `${key}:"${value}" ` : `${key}:${value} `; + } + } + + return result.trim(); +};