mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
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)
This commit is contained in:
@@ -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<string, string | undefined>;
|
||||
|
||||
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<SkeletonProps> = ({ children, ...skeletonProps }) => {
|
||||
return (
|
||||
<Skeleton
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseFilterQuery, stringifyFilter } from "./filterQuery";
|
||||
|
||||
describe("stringifyFilter", () => {
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export type FilterValues = Record<string, string | undefined>;
|
||||
|
||||
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();
|
||||
};
|
||||
Reference in New Issue
Block a user