Skip to content

Commit 90cf7c6

Browse files
committed
Hook up filters to API where availiable
1 parent 4f8ef54 commit 90cf7c6

6 files changed

Lines changed: 215 additions & 71 deletions

File tree

src/components/Home/RunSection/RunSection.tsx

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import {
2222
import { useBackend } from "@/providers/BackendProvider";
2323
import { getBackendStatusString } from "@/utils/backend";
2424
import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling";
25+
import {
26+
filtersToApiString,
27+
parseFilterParam,
28+
} from "@/utils/pipelineRunFilterUtils";
2529

2630
import RunRow from "./RunRow";
2731

@@ -42,25 +46,12 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => {
4246
const isCreatedByMeDefault = useFlagValue("created-by-me-default");
4347
const dataVersion = useRef(0);
4448

45-
// Parse filter into a dictionary
46-
const parseFilter = (filter?: string): Record<string, string> => {
47-
if (!filter) return {};
48-
49-
const filterDict: Record<string, string> = {};
50-
const parts = filter.split(",");
51-
52-
for (const part of parts) {
53-
const [key, value] = part.split(":");
54-
if (key && value) {
55-
filterDict[key.trim()] = value.trim();
56-
}
57-
}
49+
// Parse filter from URL - supports both JSON (new) and key:value (legacy) formats
50+
const filters = parseFilterParam(search.filter);
51+
const createdByValue = filters.created_by;
5852

59-
return filterDict;
60-
};
61-
62-
const filterDict = parseFilter(search.filter);
63-
const createdByValue = filterDict.created_by;
53+
// Convert filters to API format (only includes supported filters)
54+
const apiFilterString = filtersToApiString(filters);
6455

6556
const [searchUser, setSearchUser] = useState(createdByValue ?? "");
6657

@@ -75,13 +66,15 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => {
7566

7667
const { data, isLoading, isFetching, error, isFetched } =
7768
useQuery<ListPipelineJobsResponse>({
78-
queryKey: ["runs", backendUrl, pageToken, search.filter],
69+
queryKey: ["runs", backendUrl, pageToken, apiFilterString],
7970
refetchOnWindowFocus: false,
8071
enabled: configured && available,
8172
queryFn: async () => {
8273
const u = new URL(PIPELINE_RUNS_QUERY_URL, backendUrl);
8374
if (pageToken) u.searchParams.set(PAGE_TOKEN_QUERY_KEY, pageToken);
84-
if (search.filter) u.searchParams.set(FILTER_QUERY_KEY, search.filter);
75+
// Use translated filter string (only includes API-supported filters)
76+
if (apiFilterString)
77+
u.searchParams.set(FILTER_QUERY_KEY, apiFilterString);
8578

8679
u.searchParams.set(INCLUDE_PIPELINE_NAME_QUERY_KEY, "true");
8780
u.searchParams.set(INCLUDE_EXECUTION_STATS_QUERY_KEY, "true");
@@ -108,22 +101,25 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => {
108101

109102
if (value) {
110103
// If there's already a created_by filter, keep it; otherwise use "created_by:me"
111-
if (!filterDict.created_by) {
104+
if (!filters.created_by) {
112105
nextSearch.filter = CREATED_BY_ME_FILTER;
113106
setSearchUser("");
114107
}
115108
} else {
116109
// Remove created_by from filter, but keep other filters
117-
const updatedFilterDict = { ...filterDict };
118-
delete updatedFilterDict.created_by;
119-
120-
// Convert back to string format
121-
const remainingFilters = Object.entries(updatedFilterDict)
122-
.map(([key, value]) => `${key}:${value}`)
123-
.join(",");
110+
const updatedFilters = { ...filters };
111+
delete updatedFilters.created_by;
112+
113+
// Check if any other filters remain
114+
const hasRemainingFilters = Object.keys(updatedFilters).some((key) => {
115+
const val = updatedFilters[key as keyof typeof updatedFilters];
116+
if (val === undefined || val === null || val === "") return false;
117+
if (Array.isArray(val) && val.length === 0) return false;
118+
return true;
119+
});
124120

125-
if (remainingFilters) {
126-
nextSearch.filter = remainingFilters;
121+
if (hasRemainingFilters) {
122+
nextSearch.filter = JSON.stringify(updatedFilters);
127123
} else {
128124
if (isCreatedByMeDefault) {
129125
nextSearch.filter = "";
@@ -143,16 +139,9 @@ export const RunSection = ({ onEmptyList }: { onEmptyList?: () => void }) => {
143139
const nextSearch: RunSectionSearch = { ...search };
144140
delete nextSearch.page_token;
145141

146-
// Create or update the created_by filter
147-
const updatedFilterDict = { ...filterDict };
148-
updatedFilterDict.created_by = searchUser.trim();
149-
150-
// Convert back to string format
151-
const newFilter = Object.entries(updatedFilterDict)
152-
.map(([key, value]) => `${key}:${value}`)
153-
.join(",");
154-
155-
nextSearch.filter = newFilter;
142+
// Create or update the created_by filter, preserving other filters
143+
const updatedFilters = { ...filters, created_by: searchUser.trim() };
144+
nextSearch.filter = JSON.stringify(updatedFilters);
156145

157146
setPreviousPageTokens([]);
158147
navigate({ to: pathname, search: nextSearch });

src/components/shared/CreatedByFilter/CreatedByFilter.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { useEffect, useState } from "react";
22

33
import { Button } from "@/components/ui/button";
44
import { Input } from "@/components/ui/input";
@@ -20,6 +20,16 @@ interface CreatedByFilterProps {
2020
export function CreatedByFilter({ value, onChange }: CreatedByFilterProps) {
2121
const [searchUser, setSearchUser] = useState(value ?? "");
2222

23+
// Sync internal state when value prop changes externally (e.g., URL navigation, badge removal)
24+
useEffect(() => {
25+
// Only sync if value is different and not "me" (don't populate input with "me")
26+
if (value !== undefined && value !== "me") {
27+
setSearchUser(value);
28+
} else if (value === undefined) {
29+
setSearchUser("");
30+
}
31+
}, [value]);
32+
2333
const isFilterActive = value !== undefined;
2434
const toggleText = value ? `Created by ${value}` : "Created by me";
2535

src/components/shared/PipelineRunFiltersBar/PipelineRunFiltersBar.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,13 @@ export function PipelineRunFiltersBar({
8383
const dateRange: DateRange | undefined =
8484
filters.created_after || filters.created_before
8585
? {
86-
from: filters.created_after
87-
? new Date(filters.created_after)
88-
: undefined,
89-
to: filters.created_before
90-
? new Date(filters.created_before)
91-
: undefined,
92-
}
86+
from: filters.created_after
87+
? new Date(filters.created_after)
88+
: undefined,
89+
to: filters.created_before
90+
? new Date(filters.created_before)
91+
: undefined,
92+
}
9393
: undefined;
9494

9595
const handleDateRangeChange = (range: DateRange | undefined) => {

src/flags.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ export const ExistingFlags: ConfigFlags = {
6767
},
6868

6969
["pipeline-run-filters-bar"]: {
70-
name: "Pipeline run filters bar (UI only)",
70+
name: "Pipeline run filters bar",
7171
description:
72-
"Non-functional UI preview. This filter bar is not connected to the API and is for testing/development purposes only.",
72+
"Enable the advanced pipeline run filters bar. Note: Only 'Created by' filter is currently functional. Other filters are UI previews.",
7373
default: false,
7474
category: "beta",
7575
},

src/hooks/useRunSearchParams.ts

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
22
import { useRef } from "react";
33

44
import type { PipelineRunFilters } from "@/types/pipelineRunFilters";
5+
import { parseFilterParam } from "@/utils/pipelineRunFilterUtils";
56

67
const DEBOUNCE_MS = 300;
78

@@ -13,26 +14,6 @@ function getStringOrUndefined(value: unknown): string | undefined {
1314
return typeof value === "string" ? value : undefined;
1415
}
1516

16-
/**
17-
* Parse the filter query param from URL into PipelineRunFilters object.
18-
*/
19-
function parseFiltersFromUrl(
20-
filterParam: string | undefined,
21-
): PipelineRunFilters {
22-
if (!filterParam) return {};
23-
24-
try {
25-
const parsed: unknown = JSON.parse(filterParam);
26-
if (isRecord(parsed)) {
27-
return parsed as PipelineRunFilters;
28-
}
29-
} catch {
30-
// Invalid JSON
31-
}
32-
33-
return {};
34-
}
35-
3617
/**
3718
* Serialize PipelineRunFilters to a JSON string for URL query param.
3819
*/
@@ -95,7 +76,8 @@ export function useRunSearchParams(): UseRunSearchParamsReturn {
9576
const filterParam = isRecord(search)
9677
? getStringOrUndefined(search.filter)
9778
: undefined;
98-
const filters = parseFiltersFromUrl(filterParam);
79+
// Use shared parser that handles both JSON (new) and key:value (legacy) formats
80+
const filters = parseFilterParam(filterParam);
9981

10082
const updateUrl = (newFilters: PipelineRunFilters) => {
10183
const serialized = serializeFiltersToUrl(newFilters);
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import type { PipelineRunFilters } from "@/types/pipelineRunFilters";
2+
3+
/**
4+
* List of filter keys that the API currently supports.
5+
* Add keys here as the backend adds support for more filters.
6+
*/
7+
const SUPPORTED_API_FILTERS: (keyof PipelineRunFilters)[] = ["created_by"];
8+
9+
function isRecord(value: unknown): value is Record<string, unknown> {
10+
return typeof value === "object" && value !== null && !Array.isArray(value);
11+
}
12+
13+
/**
14+
* Type guard to validate a parsed object has the expected PipelineRunFilters shape.
15+
* Performs runtime validation instead of unsafe type assertion.
16+
*/
17+
function isPipelineRunFilters(value: unknown): value is PipelineRunFilters {
18+
if (!isRecord(value)) return false;
19+
20+
// Validate known fields have correct types if present
21+
if (value.status !== undefined && typeof value.status !== "string") {
22+
return false;
23+
}
24+
if (value.created_by !== undefined && typeof value.created_by !== "string") {
25+
return false;
26+
}
27+
if (
28+
value.created_after !== undefined &&
29+
typeof value.created_after !== "string"
30+
) {
31+
return false;
32+
}
33+
if (
34+
value.created_before !== undefined &&
35+
typeof value.created_before !== "string"
36+
) {
37+
return false;
38+
}
39+
if (
40+
value.pipeline_name !== undefined &&
41+
typeof value.pipeline_name !== "string"
42+
) {
43+
return false;
44+
}
45+
if (value.annotations !== undefined && !Array.isArray(value.annotations)) {
46+
return false;
47+
}
48+
49+
return true;
50+
}
51+
52+
/**
53+
* Converts PipelineRunFilters to the API's key:value string format.
54+
* Only includes filters that the API actually supports.
55+
*
56+
* @example
57+
* // Input: { created_by: "me", status: "FAILED", annotations: [...] }
58+
* // Output: "created_by:me" (status and annotations are stripped as unsupported)
59+
*/
60+
export function filtersToApiString(
61+
filters: PipelineRunFilters,
62+
): string | undefined {
63+
const parts: string[] = [];
64+
65+
for (const key of SUPPORTED_API_FILTERS) {
66+
const value = filters[key];
67+
if (value !== undefined && value !== null && value !== "") {
68+
// Handle simple string values
69+
if (typeof value === "string") {
70+
parts.push(`${key}:${value}`);
71+
}
72+
// Future: handle arrays or complex values when API supports them
73+
}
74+
}
75+
76+
return parts.length > 0 ? parts.join(",") : undefined;
77+
}
78+
79+
/**
80+
* Parses the legacy key:value,key2:value2 format into a filter object.
81+
* Used for backwards compatibility with existing URLs.
82+
*/
83+
export function parseApiStringToFilters(
84+
filterString: string | undefined,
85+
): PipelineRunFilters {
86+
if (!filterString) return {};
87+
88+
const filters: PipelineRunFilters = {};
89+
const parts = filterString.split(",");
90+
91+
for (const part of parts) {
92+
const colonIndex = part.indexOf(":");
93+
if (colonIndex === -1) continue;
94+
95+
const key = part.slice(0, colonIndex).trim();
96+
const value = part.slice(colonIndex + 1).trim();
97+
98+
if (key && value) {
99+
if (key === "created_by") {
100+
filters.created_by = value;
101+
}
102+
// Add more cases as API supports more filters
103+
}
104+
}
105+
106+
return filters;
107+
}
108+
109+
/**
110+
* Parses the filter URL param, handling both JSON format (new) and key:value format (legacy).
111+
*/
112+
export function parseFilterParam(
113+
filterParam: string | undefined,
114+
): PipelineRunFilters {
115+
if (!filterParam) return {};
116+
117+
// Try JSON format first (new format from PipelineRunFiltersBar)
118+
try {
119+
const parsed: unknown = JSON.parse(filterParam);
120+
if (isPipelineRunFilters(parsed)) {
121+
return parsed;
122+
}
123+
} catch {
124+
// Not JSON, fall through to legacy format
125+
}
126+
127+
// Fall back to legacy key:value format
128+
return parseApiStringToFilters(filterParam);
129+
}
130+
131+
/**
132+
* Returns the list of filter keys that are set but not supported by the API.
133+
* Useful for showing users which filters are UI-only.
134+
*/
135+
export function getUnsupportedActiveFilters(
136+
filters: PipelineRunFilters,
137+
): (keyof PipelineRunFilters)[] {
138+
const unsupported: (keyof PipelineRunFilters)[] = [];
139+
140+
const allFilterKeys: (keyof PipelineRunFilters)[] = [
141+
"status",
142+
"created_by",
143+
"created_after",
144+
"created_before",
145+
"pipeline_name",
146+
"annotations",
147+
];
148+
149+
for (const key of allFilterKeys) {
150+
const value = filters[key];
151+
const hasValue =
152+
value !== undefined &&
153+
value !== null &&
154+
value !== "" &&
155+
!(Array.isArray(value) && value.length === 0);
156+
157+
if (hasValue && !SUPPORTED_API_FILTERS.includes(key)) {
158+
unsupported.push(key);
159+
}
160+
}
161+
162+
return unsupported;
163+
}

0 commit comments

Comments
 (0)