diff --git a/apps/fabro-web/app/components/runs-list/preferences.ts b/apps/fabro-web/app/components/runs-list/preferences.ts index 3de2945ea..b544209a2 100644 --- a/apps/fabro-web/app/components/runs-list/preferences.ts +++ b/apps/fabro-web/app/components/runs-list/preferences.ts @@ -247,6 +247,139 @@ export function persistRunsWorkspacePreferences( } } +const CHILD_RUNS_LIST_PREFERENCES_VERSION = 1; +export const CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY = "fabro:run-children-preferences:v1"; +const CHILD_RUNS_LIST_PARAM_KEYS = [ + "search", + "created", + "archived", + "sort", + "direction", + "size", + "hide", +] as const; + +export interface ChildRunsListPreferences { + version: typeof CHILD_RUNS_LIST_PREFERENCES_VERSION; + search: string; + created: CreatedFilter; + archived: boolean; + sort: ListRunsSortEnum; + direction: ListRunsDirectionEnum; + size: number; + hide: string; + // URL-only: never persisted to localStorage. + page: number; +} + +export function defaultChildRunsListPreferences(): ChildRunsListPreferences { + return { + version: CHILD_RUNS_LIST_PREFERENCES_VERSION, + search: "", + created: "all", + archived: false, + sort: "created_at", + direction: "desc", + size: DEFAULT_LIST_PAGE_SIZE, + hide: "", + page: 1, + }; +} + +function normalizeStoredChildRunsListPreferences(value: unknown): ChildRunsListPreferences { + const record = storageRecord(value); + if (record == null || record.version !== CHILD_RUNS_LIST_PREFERENCES_VERSION) { + return defaultChildRunsListPreferences(); + } + + const hiddenColumns = parseHiddenColumns(stringValue(record.hide)); + const size = record.size; + + return { + version: CHILD_RUNS_LIST_PREFERENCES_VERSION, + search: stringValue(record.search) ?? "", + created: parseCreatedFilter(stringValue(record.created)), + archived: record.archived === true || record.archived === "1", + sort: parseSort(stringValue(record.sort)), + direction: parseDirection(stringValue(record.direction)), + size: parsePageSize(typeof size === "number" || typeof size === "string" ? String(size) : null), + hide: serializeHiddenColumns(hiddenColumns) ?? "", + page: 1, + }; +} + +export function childRunsListPreferencesFromSearchParams( + searchParams: URLSearchParams, +): ChildRunsListPreferences { + return { + version: CHILD_RUNS_LIST_PREFERENCES_VERSION, + search: searchParams.get("search") ?? "", + created: parseCreatedFilter(searchParams.get("created")), + archived: searchParams.get("archived") === "1", + sort: parseSort(searchParams.get("sort")), + direction: parseDirection(searchParams.get("direction")), + size: parsePageSize(searchParams.get("size")), + hide: serializeHiddenColumns(parseHiddenColumns(searchParams.get("hide"))) ?? "", + page: parsePage(searchParams.get("page")), + }; +} + +export function childRunsListPreferencesToSearchParams( + preferences: ChildRunsListPreferences, +): URLSearchParams { + const params = new URLSearchParams(); + if (preferences.search !== "") params.set("search", preferences.search); + if (preferences.created !== "all") params.set("created", preferences.created); + 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"); + if (preferences.size !== DEFAULT_LIST_PAGE_SIZE) params.set("size", String(preferences.size)); + if (preferences.hide !== "") params.set("hide", preferences.hide); + if (preferences.page > 1) params.set("page", String(preferences.page)); + return params; +} + +function hasChildRunsListParams(searchParams: URLSearchParams): boolean { + return CHILD_RUNS_LIST_PARAM_KEYS.some((key) => searchParams.has(key)); +} + +export function loadStoredChildRunsListSearchParams( + storage: Pick | null = runsPreferencesStorage(), +): URLSearchParams { + if (storage == null) return new URLSearchParams(); + try { + const raw = storage.getItem(CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY); + if (raw == null) return new URLSearchParams(); + return childRunsListPreferencesToSearchParams( + normalizeStoredChildRunsListPreferences(JSON.parse(raw)), + ); + } catch { + return new URLSearchParams(); + } +} + +export function resolveChildRunsListSearchParams( + urlSearchParams: URLSearchParams, +): URLSearchParams { + if (hasChildRunsListParams(urlSearchParams)) return urlSearchParams; + const stored = loadStoredChildRunsListSearchParams(); + return stored.toString() === "" ? urlSearchParams : stored; +} + +export function persistChildRunsListPreferences( + preferences: ChildRunsListPreferences, + storage: Pick | null = runsPreferencesStorage(), +) { + if (storage == null) return; + // `page` is URL-only ephemeral view state; strip it before persisting. + const { page: _page, ...storable } = preferences; + try { + storage.setItem(CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY, JSON.stringify(storable)); + } catch { + // localStorage persistence is best effort only. + } +} + export function createdCutoffMsFor(filter: CreatedFilter): number | null { const now = Date.now(); switch (filter) { diff --git a/apps/fabro-web/app/lib/board-cache.ts b/apps/fabro-web/app/lib/board-cache.ts index 1ef359dc2..b143a3266 100644 --- a/apps/fabro-web/app/lib/board-cache.ts +++ b/apps/fabro-web/app/lib/board-cache.ts @@ -5,7 +5,7 @@ type Mutator = (key: KeyOrMatcher) => unknown; const isRunListKey: KeyMatcher = (key) => Array.isArray(key) && key[0] === "runs" && - (key[1] === "all" || key[1] === "page" || key[1] === "children"); + (key[1] === "all" || key[1] === "page"); export function runListCacheMatchers(): KeyOrMatcher[] { return [isRunListKey]; diff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts index 0fb4db240..9b15c06fc 100644 --- a/apps/fabro-web/app/lib/mutations.ts +++ b/apps/fabro-web/app/lib/mutations.ts @@ -86,7 +86,7 @@ export function useRetryRun(id: string | undefined) { return useLifecycleMutation(id, "retry", retryRun, (run, mutate) => { void mutate(queryKeys.runs.detail(run.id), run, { revalidate: false }); if (run.parent_id) { - void mutate(queryKeys.runs.children(run.parent_id)); + mutateRunListCaches(mutate); } }); } diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index 2cf03d7d2..beab0f3ba 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -199,16 +199,6 @@ export function useRunFiles( ); } -export function useChildRuns(parentId: string | undefined) { - return useSWR( - parentId ? queryKeys.runs.children(parentId) : null, - () => - apiNullableData(() => - runsApi.listRuns(undefined, undefined, false, parentId!), - ), - ); -} - export function useRunCommits(id: string | undefined) { return useSWR( id ? queryKeys.runs.commits(id) : null, diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index b5f068190..31b2d69c1 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -52,7 +52,6 @@ export const queryKeys = { queryKeys.runs.files(id, runFileScopeSelection(scope)), ), commits: (id: string) => ["runs", "commits", id] as const, - children: (parentId: string) => ["runs", "children", parentId] as const, stages: (id: string) => ["runs", "stages", id] as const, graph: (id: string, direction?: RunGraphDirection) => ["runs", "graph", id, direction ?? null] as const, diff --git a/apps/fabro-web/app/routes/run-children.tsx b/apps/fabro-web/app/routes/run-children.tsx index 5c4be7b4e..ca6b83fb5 100644 --- a/apps/fabro-web/app/routes/run-children.tsx +++ b/apps/fabro-web/app/routes/run-children.tsx @@ -1,21 +1,119 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useParams } from "react-router"; -import { ArrowPathIcon } from "@heroicons/react/20/solid"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams, useSearchParams } from "react-router"; +import { ArchiveBoxIcon, ArrowPathIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import type { ListRunsSortEnum } from "@qltysh/fabro-api-client"; import { EmptyState, ErrorState, LoadingState } from "../components/state"; +import { ColumnPickerButton } from "../components/runs-list/column-picker-button"; +import { FilterButton } from "../components/runs-list/filter-button"; +import { + childRunsListPreferencesFromSearchParams, + childRunsListPreferencesToSearchParams, + createdCutoffMsFor, + createdFilterOptions, + parseCreatedFilter, + parseDirection, + parsePage, + parsePageSize, + parseSort, + persistChildRunsListPreferences, + resolveChildRunsListSearchParams, +} from "../components/runs-list/preferences"; +import type { ChildRunsListPreferences, CreatedFilter } from "../components/runs-list/preferences"; +import { RunsListView } from "../components/runs-list/runs-list-view"; +import { parseHiddenColumns, serializeHiddenColumns } from "../components/runs-list/toggleable-column"; +import type { ToggleableColumn } from "../components/runs-list/toggleable-column"; import { SECONDARY_BUTTON_CLASS } from "../components/ui"; -import { toRunWithStatus } from "../data/runs"; import { ApiError } from "../lib/api-client"; import { formatRelativeTime } from "../lib/format"; -import { useChildRuns, useRun } from "../lib/queries"; -import { RUNS_LIST_GRID_TEMPLATE, RunRow } from "./runs"; +import { useRun, useRunsPage } from "../lib/queries"; export const handle = { wide: true }; export default function RunChildren() { const { id } = useParams(); const runQuery = useRun(id); - const childRunsQuery = useChildRuns(id); + + const [urlSearchParams, setSearchParams] = useSearchParams(); + const searchParams = useMemo( + () => resolveChildRunsListSearchParams(urlSearchParams), + [urlSearchParams], + ); + + const query = searchParams.get("search") ?? ""; + const createdFilter = parseCreatedFilter(searchParams.get("created")); + const includeArchived = searchParams.get("archived") === "1"; + const sort = parseSort(searchParams.get("sort")); + const direction = parseDirection(searchParams.get("direction")); + const page = parsePage(searchParams.get("page")); + const pageSize = parsePageSize(searchParams.get("size")); + const hiddenColumns = useMemo( + () => parseHiddenColumns(searchParams.get("hide")), + [searchParams], + ); + + const updatePreferences = useCallback( + (updater: (prev: ChildRunsListPreferences) => ChildRunsListPreferences) => { + setSearchParams( + (prevParams) => { + const next = updater(childRunsListPreferencesFromSearchParams(prevParams)); + persistChildRunsListPreferences(next); + return childRunsListPreferencesToSearchParams(next); + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const setQuery = (value: string) => + updatePreferences((prev) => ({ ...prev, search: value })); + const setCreatedFilter = (value: CreatedFilter) => + updatePreferences((prev) => ({ ...prev, created: value })); + const setIncludeArchived = (value: boolean) => + updatePreferences((prev) => ({ ...prev, archived: value })); + const setPage = useCallback( + (next: number) => updatePreferences((prev) => ({ ...prev, page: next })), + [updatePreferences], + ); + const setPageSize = useCallback( + (next: number) => updatePreferences((prev) => ({ ...prev, size: next, page: 1 })), + [updatePreferences], + ); + const setHiddenColumns = useCallback( + (next: Set) => + updatePreferences((prev) => ({ ...prev, hide: serializeHiddenColumns(next) ?? "" })), + [updatePreferences], + ); + const handleSortClick = useCallback( + (key: ListRunsSortEnum) => + updatePreferences((prev) => + prev.sort === key + ? { ...prev, direction: prev.direction === "asc" ? "desc" : "asc", page: 1 } + : { ...prev, sort: key, direction: "desc", page: 1 }, + ), + [updatePreferences], + ); + + const hydratedFromStorage = useRef(false); + useEffect(() => { + if (hydratedFromStorage.current) return; + hydratedFromStorage.current = true; + if (searchParams === urlSearchParams) return; + setSearchParams(searchParams, { replace: true }); + }, [searchParams, urlSearchParams, setSearchParams]); + + const childRunsQuery = useRunsPage( + { + parentId: id, + includeArchived, + sort, + direction, + limit: pageSize, + offset: (page - 1) * pageSize, + }, + id != null, + ); const lastFetchedAtRef = useRef(null); const [now, setNow] = useState(() => Date.now()); @@ -53,16 +151,46 @@ export default function RunChildren() { ); } - const data = childRunsQuery.data; - const children = data?.data ?? []; - const hasMore = data?.meta.has_more ?? false; const updatedAt = lastFetchedAtRef.current; + const lowerQuery = query.toLowerCase(); + const createdCutoffMs = createdCutoffMsFor(createdFilter); return ( -
-
-

Runs spawned from this run.

-
+
+
+
+ + setQuery(e.target.value)} + className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0" + /> +
+ + + + + +
{updatedAt != null ? ( Updated{" "} @@ -79,48 +207,49 @@ export default function RunChildren() { : "Refresh child runs" } title="Refresh" - className="inline-flex size-7 items-center justify-center rounded-md border border-line bg-panel text-fg-3 transition-colors hover:bg-overlay hover:text-fg disabled:cursor-default disabled:opacity-60 disabled:hover:bg-panel disabled:hover:text-fg-3" + className="inline-flex size-9 items-center justify-center rounded-md border border-line bg-panel/80 text-fg-3 transition-colors hover:bg-panel hover:text-fg disabled:cursor-default disabled:opacity-60 disabled:hover:bg-panel/80 disabled:hover:text-fg-3" >
- {children.length === 0 ? ( - - Learn about parent links - - } - /> - ) : ( - <> -
- {children.map((child) => ( - - ))} -
- {hasMore ? ( -

- Showing the first {children.length} child runs — more exist. -

- ) : null} - - )} + + Learn about parent links + + } + /> + } + sort={sort} + direction={direction} + page={page} + pageSize={pageSize} + hiddenColumns={hiddenColumns} + onSortClick={handleSortClick} + onPageChange={setPage} + onPageSizeChange={setPageSize} + query={lowerQuery} + repoFilter="all" + workflowFilter="all" + createdCutoffMs={createdCutoffMs} + />
); }