feat(web): add multi-select run status filter to /runs toolbar

Status filter operates on the eight non-archived BoardColumn lanes and
filters both the board (hides whole lanes) and the list (hides rows).
Show archived remains a standalone toggle alongside it; an `archived`
token in a previously-saved status string is migrated into the toggle on
read so the two controls stay independent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-26 00:16:23 -04:00
parent 7148e37bbe
commit 7c69807f1d
No known key found for this signature in database
8 changed files with 217 additions and 13 deletions

View file

@ -1,8 +1,41 @@
import type { ListRunsDirectionEnum, ListRunsSortEnum } from "@qltysh/fabro-api-client";
import type {
BoardColumn,
ListRunsDirectionEnum,
ListRunsSortEnum,
} from "@qltysh/fabro-api-client";
import { columnStatuses } from "../../data/runs";
import { parseHiddenColumns, serializeHiddenColumns } from "./toggleable-column";
import type { ToggleableColumn } from "./toggleable-column";
// The Status filter is independent of the "Show archived" toggle; archived
// runs are gated by that toggle and not by selecting "archived" here.
export const STATUS_FILTER_OPTIONS: readonly BoardColumn[] = columnStatuses.filter(
(c) => c !== "archived",
);
const STATUS_FILTER_OPTION_SET = new Set<string>(STATUS_FILTER_OPTIONS);
export function parseStatusFilter(raw: string): Set<BoardColumn> {
if (raw === "") return new Set<BoardColumn>();
const filter = new Set<BoardColumn>();
for (const value of raw.split(",")) {
const trimmed = value.trim();
if (STATUS_FILTER_OPTION_SET.has(trimmed)) filter.add(trimmed as BoardColumn);
}
return filter;
}
function serializeStatusFilter(filter: Set<BoardColumn>): string {
return STATUS_FILTER_OPTIONS.filter((c) => filter.has(c)).join(",");
}
// Empty set and a full selection both mean "no filter is narrowing results" —
// canonicalize to empty in the URL so back-button history stays clean.
function statusFilterIsTrivial(filter: Set<BoardColumn>): boolean {
return filter.size === 0 || filter.size === STATUS_FILTER_OPTIONS.length;
}
// Columns hidden by default in both the main runs list and the Children
// sub-tab. Users can still reveal them via the column picker.
export const DEFAULT_HIDDEN_RUN_LIST_COLUMNS: readonly ToggleableColumn[] = [
@ -105,6 +138,7 @@ const RUNS_WORKSPACE_PARAM_KEYS = [
"repo",
"workflow",
"created",
"status",
"archived",
"sort",
"direction",
@ -121,6 +155,7 @@ export interface RunsWorkspacePreferences {
repo: string;
workflow: string;
created: CreatedFilter;
status: Set<BoardColumn>;
archived: boolean;
sort: ListRunsSortEnum;
direction: ListRunsDirectionEnum;
@ -138,6 +173,7 @@ export function defaultRunsWorkspacePreferences(): RunsWorkspacePreferences {
repo: "all",
workflow: "all",
created: "all",
status: new Set<BoardColumn>(),
archived: false,
sort: "created_at",
direction: "desc",
@ -177,6 +213,11 @@ function normalizeStoredRunsWorkspacePreferences(value: unknown): RunsWorkspaceP
const size = record.size;
const { status, archived } = normalizeStoredStatusAndArchived(
record.status,
record.archived,
);
return {
version: RUNS_PREFERENCES_VERSION,
view: parseView(stringValue(record.view)),
@ -184,7 +225,8 @@ function normalizeStoredRunsWorkspacePreferences(value: unknown): RunsWorkspaceP
repo: filterPreference(stringValue(record.repo)),
workflow: filterPreference(stringValue(record.workflow)),
created: parseCreatedFilter(stringValue(record.created)),
archived: record.archived === true || record.archived === "1",
status,
archived,
sort: parseSort(stringValue(record.sort)),
direction: parseDirection(stringValue(record.direction)),
size: parsePageSize(typeof size === "number" || typeof size === "string" ? String(size) : null),
@ -193,6 +235,24 @@ function normalizeStoredRunsWorkspacePreferences(value: unknown): RunsWorkspaceP
};
}
// Some intermediate records folded "archived" into the status string. Strip
// that token out and flip the archived toggle instead, since "Show archived"
// is owned by its own preference now.
function normalizeStoredStatusAndArchived(
storedStatus: unknown,
storedArchived: unknown,
): { status: Set<BoardColumn>; archived: boolean } {
let archived = storedArchived === true || storedArchived === "1";
let status = new Set<BoardColumn>();
if (typeof storedStatus === "string") {
status = parseStatusFilter(storedStatus);
if ((storedStatus.split(",").map((s) => s.trim())).includes("archived")) {
archived = true;
}
}
return { status, archived };
}
// Stored records from before the default-hidden-columns change have no `hide`
// field. Treat the absence as "use the new defaults" so existing users pick up
// the new default automatically. An explicit empty string means the user
@ -205,6 +265,7 @@ function normalizeStoredHide(stored: string | null): string {
export function runsWorkspacePreferencesFromSearchParams(
searchParams: URLSearchParams,
): RunsWorkspacePreferences {
const rawStatus = searchParams.get("status");
return {
version: RUNS_PREFERENCES_VERSION,
view: parseView(searchParams.get("view")),
@ -212,6 +273,7 @@ export function runsWorkspacePreferencesFromSearchParams(
repo: filterPreference(searchParams.get("repo")),
workflow: filterPreference(searchParams.get("workflow")),
created: parseCreatedFilter(searchParams.get("created")),
status: rawStatus == null ? new Set<BoardColumn>() : parseStatusFilter(rawStatus),
archived: searchParams.get("archived") === "1",
sort: parseSort(searchParams.get("sort")),
direction: parseDirection(searchParams.get("direction")),
@ -230,6 +292,9 @@ export function runsWorkspacePreferencesToSearchParams(
if (preferences.repo !== "all") params.set("repo", preferences.repo);
if (preferences.workflow !== "all") params.set("workflow", preferences.workflow);
if (preferences.created !== "all") params.set("created", preferences.created);
if (!statusFilterIsTrivial(preferences.status)) {
params.set("status", serializeStatusFilter(preferences.status));
}
if (preferences.archived) params.set("archived", "1");
if (preferences.sort !== "created_at") params.set("sort", preferences.sort);
if (preferences.direction === "asc") params.set("direction", "asc");
@ -278,7 +343,10 @@ export function persistRunsWorkspacePreferences(
) {
if (storage == null) return;
// `page` is URL-only ephemeral view state; strip it before persisting.
const { page: _page, ...storable } = preferences;
// `status` is a Set, which doesn't survive JSON.stringify — flatten to the
// same comma-separated form we use in the URL.
const { page: _page, status, ...rest } = preferences;
const storable = { ...rest, status: serializeStatusFilter(status) };
try {
storage.setItem(RUNS_PREFERENCES_STORAGE_KEY, JSON.stringify(storable));
} catch {

View file

@ -1,6 +1,7 @@
import { useCallback, useMemo, useState } from "react";
import type { ReactNode } from "react";
import type {
BoardColumn,
ListRunsDirectionEnum,
ListRunsSortEnum,
PaginatedRunList,
@ -17,6 +18,7 @@ import { SortHeader } from "./sort-header";
import type { ToggleableColumn } from "./toggleable-column";
const EMPTY_SELECTION = new Set<string>();
const EMPTY_STATUS_FILTER: ReadonlySet<BoardColumn> = new Set();
export type RunsListViewProps = {
data: PaginatedRunList | undefined;
@ -33,6 +35,7 @@ export type RunsListViewProps = {
query: string;
repoFilter: string;
workflowFilter: string;
statusFilter?: ReadonlySet<BoardColumn>;
createdCutoffMs: number | null;
};
@ -51,15 +54,18 @@ export function RunsListView({
query,
repoFilter,
workflowFilter,
statusFilter = EMPTY_STATUS_FILTER,
createdCutoffMs,
}: RunsListViewProps) {
const show = (col: ToggleableColumn) => !hiddenColumns.has(col);
const rows: RunWithStatus[] = useMemo(() => {
const apiRuns = data?.data ?? [];
const next: RunWithStatus[] = [];
const filterStatuses = statusFilter.size > 0;
for (const run of apiRuns) {
const item = toRunWithStatus(run);
if (
(!filterStatuses || statusFilter.has(item.status)) &&
(repoFilter === "all" || item.repo === repoFilter) &&
(workflowFilter === "all" || item.workflow === workflowFilter) &&
(createdCutoffMs == null ||
@ -74,7 +80,7 @@ export function RunsListView({
}
}
return next;
}, [data, repoFilter, workflowFilter, createdCutoffMs, query]);
}, [data, repoFilter, workflowFilter, statusFilter, createdCutoffMs, query]);
const hasMore = data?.meta.has_more ?? false;
const total = data?.meta.total ?? null;
@ -83,7 +89,8 @@ export function RunsListView({
const apiRunCount = data?.data.length ?? 0;
const isEmptyServerSide = data !== undefined && apiRunCount === 0 && page === 1;
const selectionScopeKey = `${page}:${sort}:${direction}:${query}:${repoFilter}:${workflowFilter}:${createdCutoffMs ?? ""}`;
const statusScopeKey = [...statusFilter].sort().join(",");
const selectionScopeKey = `${page}:${sort}:${direction}:${query}:${repoFilter}:${workflowFilter}:${statusScopeKey}:${createdCutoffMs ?? ""}`;
const [selection, setSelection] = useState<{
scopeKey: string;
ids: Set<string>;

View file

@ -0,0 +1,80 @@
import { CheckIcon, PlusCircleIcon } from "@heroicons/react/24/outline";
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react";
import type { BoardColumn } from "@qltysh/fabro-api-client";
import { columnStatusDisplay } from "../../data/runs";
import { STATUS_FILTER_OPTIONS } from "./preferences";
export function StatusFilterButton({
value,
onChange,
}: {
value: Set<BoardColumn>;
onChange: (next: Set<BoardColumn>) => void;
}) {
// Empty set and a full selection both mean "no filter is narrowing the
// result set", so leave the button looking inactive in both cases.
const active =
value.size > 0 && value.size < STATUS_FILTER_OPTIONS.length;
const selectedList = STATUS_FILTER_OPTIONS.filter((c) => value.has(c));
const buttonLabel = (() => {
if (!active) return "Status";
if (selectedList.length === 1) {
return `Status: ${columnStatusDisplay[selectedList[0]].label}`;
}
return `Status: ${selectedList.length}`;
})();
const selectedArray = [...value];
return (
<Listbox
value={selectedArray}
onChange={(next: BoardColumn[]) => onChange(new Set<BoardColumn>(next))}
multiple
>
<ListboxButton
className={`inline-flex items-center gap-1.5 rounded-md border px-3 py-2 text-xs font-medium transition-colors ${
active
? "border-line-strong bg-panel text-fg-2"
: "border-line bg-panel/80 text-fg-muted hover:text-fg-3"
}`}
>
<PlusCircleIcon className="size-4" aria-hidden="true" />
<span>{buttonLabel}</span>
</ListboxButton>
<ListboxOptions
anchor="bottom start"
className="z-20 mt-1 min-w-[12rem] rounded-md border border-line bg-panel py-1 text-xs shadow-lg focus:outline-none"
>
{STATUS_FILTER_OPTIONS.map((status) => {
const display = columnStatusDisplay[status];
return (
<ListboxOption
key={status}
value={status}
className={({ focus }) =>
`flex cursor-pointer items-center justify-between gap-3 px-3 py-1.5 text-fg-2 ${focus ? "bg-overlay" : ""}`
}
>
{({ selected }) => (
<>
<span className="flex items-center gap-2">
<span className={`size-2 shrink-0 rounded-full ${display.dot}`} aria-hidden="true" />
<span className={selected ? display.text : "text-fg-2"}>{display.label}</span>
</span>
{selected ? (
<CheckIcon className="size-4 shrink-0 text-teal-500" aria-hidden="true" />
) : (
<span className="size-4 shrink-0" aria-hidden="true" />
)}
</>
)}
</ListboxOption>
);
})}
</ListboxOptions>
</Listbox>
);
}

View file

@ -283,6 +283,9 @@ describe("Runs workspace preference restoration", () => {
await act(async () => {
compositeByName(renderer, "FilterButton", (props) => props.label === "Time").props.onChange("7d");
});
await act(async () => {
compositeByName(renderer, "StatusFilterButton").props.onChange(new Set(["running", "blocked"]));
});
await act(async () => {
renderer.root.findByProps({ title: "Show archived runs" }).props.onClick();
});
@ -296,6 +299,7 @@ describe("Runs workspace preference restoration", () => {
repo: "qlty/docs",
workflow: "docs",
created: "7d",
status: "running,blocked",
archived: true,
hide: "repo,workflow",
});

View file

@ -263,6 +263,7 @@ describe("runs route workspace preferences", () => {
repo: "qlty/fabro",
workflow: "release",
created: "7d",
status: "running,blocked",
archived: true,
sort: "updated_at",
direction: "asc",
@ -273,10 +274,25 @@ describe("runs route workspace preferences", () => {
);
expect(loadStoredRunsWorkspaceSearchParams(storage).toString()).toBe(
"view=list&search=retry+failures&repo=qlty%2Ffabro&workflow=release&created=7d&archived=1&sort=updated_at&direction=asc&size=50&hide=repo%2Cchanges",
"view=list&search=retry+failures&repo=qlty%2Ffabro&workflow=release&created=7d&status=running%2Cblocked&archived=1&sort=updated_at&direction=asc&size=50&hide=repo%2Cchanges",
);
});
test("stored archived in a status string migrates into the standalone archived toggle", () => {
const storage = new MemoryStorage();
storage.setItem(
RUNS_PREFERENCES_STORAGE_KEY,
JSON.stringify({ version: 1, view: "list", status: "running,archived" }),
);
const params = loadStoredRunsWorkspaceSearchParams(storage);
expect(params.get("view")).toBe("list");
// The "archived" token is stripped out of the status filter and flipped
// into the separate archived toggle so the two controls don't entangle.
expect(params.get("status")).toBe("running");
expect(params.get("archived")).toBe("1");
});
test("persisting preferences omits page and stores canonical values", () => {
const storage = new MemoryStorage();
@ -288,6 +304,7 @@ describe("runs route workspace preferences", () => {
repo: "all",
workflow: "all",
created: "1d",
status: new Set<BoardColumn>(["running", "blocked"]),
archived: false,
sort: "created_at",
direction: "asc",
@ -305,6 +322,7 @@ describe("runs route workspace preferences", () => {
repo: "all",
workflow: "all",
created: "1d",
status: "running,blocked",
archived: false,
sort: "created_at",
direction: "asc",

View file

@ -721,6 +721,7 @@ export default function Runs() {
repoFilter,
workflowFilter,
createdFilter,
statusFilter,
includeArchived,
view,
sort,
@ -732,6 +733,7 @@ export default function Runs() {
setRepoFilter,
setWorkflowFilter,
setCreatedFilter,
setStatusFilter,
setIncludeArchived,
setView,
setPage,
@ -827,7 +829,13 @@ export default function Runs() {
(sum, col) => sum + col.items.length,
0,
);
const visibleColumns = placeArchivedColumnLast(filteredColumns, includeArchived).filter(
// Empty status filter means "show all"; otherwise only render the lanes
// whose status the user explicitly selected.
const statusVisibleColumns =
statusFilter.size === 0
? filteredColumns
: filteredColumns.filter((col) => statusFilter.has(col.id));
const visibleColumns = placeArchivedColumnLast(statusVisibleColumns, includeArchived).filter(
(col) => col.id !== "pending" || col.items.length > 0,
);
@ -839,6 +847,7 @@ export default function Runs() {
repoFilter={repoFilter}
workflowFilter={workflowFilter}
createdFilter={createdFilter}
statusFilter={statusFilter}
includeArchived={includeArchived}
view={view}
hiddenColumns={hiddenColumns}
@ -848,6 +857,7 @@ export default function Runs() {
onRepoFilterChange={setRepoFilter}
onWorkflowFilterChange={setWorkflowFilter}
onCreatedFilterChange={setCreatedFilter}
onStatusFilterChange={setStatusFilter}
onIncludeArchivedChange={setIncludeArchived}
onViewChange={setView}
onHiddenColumnsChange={setHiddenColumns}
@ -894,6 +904,7 @@ export default function Runs() {
query={lowerQuery}
repoFilter={repoFilter}
workflowFilter={workflowFilter}
statusFilter={statusFilter}
createdCutoffMs={createdCutoffMs}
/>
)}

View file

@ -1,4 +1,5 @@
import { ArchiveBoxIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import type { BoardColumn } from "@qltysh/fabro-api-client";
import { ColumnPickerButton } from "../../components/runs-list/column-picker-button";
import { FilterButton } from "../../components/runs-list/filter-button";
@ -7,6 +8,7 @@ import {
type CreatedFilter,
type ViewMode,
} from "../../components/runs-list/preferences";
import { StatusFilterButton } from "../../components/runs-list/status-filter-button";
import type { ToggleableColumn } from "../../components/runs-list/toggleable-column";
interface RunsToolbarProps {
@ -14,6 +16,7 @@ interface RunsToolbarProps {
repoFilter: string;
workflowFilter: string;
createdFilter: CreatedFilter;
statusFilter: Set<BoardColumn>;
includeArchived: boolean;
view: ViewMode;
hiddenColumns: Set<ToggleableColumn>;
@ -23,6 +26,7 @@ interface RunsToolbarProps {
onRepoFilterChange: (value: string) => void;
onWorkflowFilterChange: (value: string) => void;
onCreatedFilterChange: (value: CreatedFilter) => void;
onStatusFilterChange: (value: Set<BoardColumn>) => void;
onIncludeArchivedChange: (value: boolean) => void;
onViewChange: (value: ViewMode) => void;
onHiddenColumnsChange: (value: Set<ToggleableColumn>) => void;
@ -33,6 +37,7 @@ export function RunsToolbar({
repoFilter,
workflowFilter,
createdFilter,
statusFilter,
includeArchived,
view,
hiddenColumns,
@ -42,6 +47,7 @@ export function RunsToolbar({
onRepoFilterChange,
onWorkflowFilterChange,
onCreatedFilterChange,
onStatusFilterChange,
onIncludeArchivedChange,
onViewChange,
onHiddenColumnsChange,
@ -61,6 +67,7 @@ export function RunsToolbar({
/>
</div>
<StatusFilterButton value={statusFilter} onChange={onStatusFilterChange} />
<FilterButton
label="Time"
value={createdFilter}

View file

@ -5,7 +5,7 @@ import {
useRef,
} from "react";
import { useSearchParams } from "react-router";
import type { ListRunsSortEnum } from "@qltysh/fabro-api-client";
import type { BoardColumn, ListRunsSortEnum } from "@qltysh/fabro-api-client";
import {
hiddenColumnsFromSearchParams,
@ -32,11 +32,16 @@ export function useRunsWorkspacePreferences() {
() => resolveRunsWorkspaceSearchParams(urlSearchParams),
[urlSearchParams],
);
const query = searchParams.get("search") ?? "";
const repoFilter = searchParams.get("repo") ?? "all";
const workflowFilter = searchParams.get("workflow") ?? "all";
const createdFilter = parseCreatedFilter(searchParams.get("created"));
const includeArchived = searchParams.get("archived") === "1";
const preferences = useMemo(
() => runsWorkspacePreferencesFromSearchParams(searchParams),
[searchParams],
);
const query = preferences.search;
const repoFilter = preferences.repo;
const workflowFilter = preferences.workflow;
const createdFilter = preferences.created;
const statusFilter = preferences.status;
const includeArchived = preferences.archived;
const view = parseView(searchParams.get("view"));
const sort = parseSort(searchParams.get("sort"));
const direction = parseDirection(searchParams.get("direction"));
@ -69,6 +74,8 @@ export function useRunsWorkspacePreferences() {
updatePreferences((prev) => ({ ...prev, workflow: value }));
const setCreatedFilter = (value: CreatedFilter) =>
updatePreferences((prev) => ({ ...prev, created: value }));
const setStatusFilter = (value: Set<BoardColumn>) =>
updatePreferences((prev) => ({ ...prev, status: value }));
const setIncludeArchived = (value: boolean) =>
updatePreferences((prev) => ({ ...prev, archived: value }));
const setView = (value: ViewMode) =>
@ -109,6 +116,7 @@ export function useRunsWorkspacePreferences() {
repoFilter,
workflowFilter,
createdFilter,
statusFilter,
includeArchived,
view,
sort,
@ -120,6 +128,7 @@ export function useRunsWorkspacePreferences() {
setRepoFilter,
setWorkflowFilter,
setCreatedFilter,
setStatusFilter,
setIncludeArchived,
setView,
setPage,