From 962366ffc6c4cc47dd93f47bd9437c19a746f296 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 18 Aug 2026 17:01:42 +0100 Subject: [PATCH] fix(site): quote colon-containing values in filter serialization (#28254) `stringifyFilter` in the shared `Filter` component only quoted values containing spaces. Filter values like RFC 3339 timestamps (`2026-08-16T20:42:00Z`) contain colons but no spaces, so when any filter was edited and the whole query re-serialized, the timestamp went out unquoted and the backend `searchTerms` parser rejected it (`Query element ... can only contain 1 ':'`). Quote values containing colons as well; they were never valid unquoted because the backend parser already rejects them. Extract `parseFilterQuery`/`stringifyFilter` into `filterQuery.ts` with unit tests, including a round-trip of quoted timestamps. Part of [AIGOV-580](https://linear.app/codercom/issue/AIGOV-580/ai-gateway-sessions-page-takes-5-10-seconds-to-load) --- _Generated by Coder Agents on behalf of @johnstcn._$ --- **Stack:** #28254 (filterQuery fix) \u2192 #28255 (component) \u2192 #28256 (sessions page) --- 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 3f8972af69..065a7fe6f0 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 0000000000..8376a351c9 --- /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(); +};