diff --git a/apps/fabro-web/app/components/runs-list/preferences.ts b/apps/fabro-web/app/components/runs-list/preferences.ts index 4235d7d4e..4e4e594f9 100644 --- a/apps/fabro-web/app/components/runs-list/preferences.ts +++ b/apps/fabro-web/app/components/runs-list/preferences.ts @@ -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(STATUS_FILTER_OPTIONS); + +export function parseStatusFilter(raw: string): Set { + if (raw === "") return new Set(); + const filter = new Set(); + 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): 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): 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; archived: boolean; sort: ListRunsSortEnum; direction: ListRunsDirectionEnum; @@ -138,6 +173,7 @@ export function defaultRunsWorkspacePreferences(): RunsWorkspacePreferences { repo: "all", workflow: "all", created: "all", + status: new Set(), 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; archived: boolean } { + let archived = storedArchived === true || storedArchived === "1"; + let status = new Set(); + 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() : 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 { diff --git a/apps/fabro-web/app/components/runs-list/runs-list-view.tsx b/apps/fabro-web/app/components/runs-list/runs-list-view.tsx index 3b653e8ca..b15e216e4 100644 --- a/apps/fabro-web/app/components/runs-list/runs-list-view.tsx +++ b/apps/fabro-web/app/components/runs-list/runs-list-view.tsx @@ -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(); +const EMPTY_STATUS_FILTER: ReadonlySet = new Set(); export type RunsListViewProps = { data: PaginatedRunList | undefined; @@ -33,6 +35,7 @@ export type RunsListViewProps = { query: string; repoFilter: string; workflowFilter: string; + statusFilter?: ReadonlySet; 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; diff --git a/apps/fabro-web/app/components/runs-list/status-filter-button.tsx b/apps/fabro-web/app/components/runs-list/status-filter-button.tsx new file mode 100644 index 000000000..c51148a02 --- /dev/null +++ b/apps/fabro-web/app/components/runs-list/status-filter-button.tsx @@ -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; + onChange: (next: Set) => 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 ( + onChange(new Set(next))} + multiple + > + + + + {STATUS_FILTER_OPTIONS.map((status) => { + const display = columnStatusDisplay[status]; + return ( + + `flex cursor-pointer items-center justify-between gap-3 px-3 py-1.5 text-fg-2 ${focus ? "bg-overlay" : ""}` + } + > + {({ selected }) => ( + <> + + + {selected ? ( + + ); + })} + + + ); +} diff --git a/apps/fabro-web/app/routes/runs.preferences.test.tsx b/apps/fabro-web/app/routes/runs.preferences.test.tsx index d3ad1cc1f..648d8e35a 100644 --- a/apps/fabro-web/app/routes/runs.preferences.test.tsx +++ b/apps/fabro-web/app/routes/runs.preferences.test.tsx @@ -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", }); diff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx index 3d03fe5a8..51483cff2 100644 --- a/apps/fabro-web/app/routes/runs.test.tsx +++ b/apps/fabro-web/app/routes/runs.test.tsx @@ -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(["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", diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 43793daa6..28bc4a9d6 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -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} /> )} diff --git a/apps/fabro-web/app/routes/runs/toolbar.tsx b/apps/fabro-web/app/routes/runs/toolbar.tsx index 8cc72b9ca..64725240d 100644 --- a/apps/fabro-web/app/routes/runs/toolbar.tsx +++ b/apps/fabro-web/app/routes/runs/toolbar.tsx @@ -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; includeArchived: boolean; view: ViewMode; hiddenColumns: Set; @@ -23,6 +26,7 @@ interface RunsToolbarProps { onRepoFilterChange: (value: string) => void; onWorkflowFilterChange: (value: string) => void; onCreatedFilterChange: (value: CreatedFilter) => void; + onStatusFilterChange: (value: Set) => void; onIncludeArchivedChange: (value: boolean) => void; onViewChange: (value: ViewMode) => void; onHiddenColumnsChange: (value: Set) => 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({ /> + 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) => + 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,