mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
feat(web): upgrade Children sub-tab to use the runs list view
The /runs/:id/children tab now gets server-side pagination, sortable
columns, column picker, search/time/archived filters, and bulk
archive/unarchive/delete — same affordances as the main runs list view.
Preferences persist to localStorage under a dedicated key so they don't
collide with the /runs page.
Repo and Workflow filter buttons are intentionally omitted (children
typically share these with the parent), but those columns remain visible
for the cases where workflows fan out across repos.
- New childRunsListPreferences in components/runs-list/preferences.ts
- run-children.tsx fetches via useRunsPage({parentId, ...}) with all
list controls wired up
- Empty state retains the existing "Learn about parent links" CTA
- useChildRuns + queryKeys.runs.children removed (replaced by the
generalized useRunsPage)
- useRetryRun broadcasts via mutateRunListCaches now that the dedicated
children cache key is gone
- Revert board-cache children matcher added in the previous commit
(no longer needed)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d59f97d6a0
commit
4dfcbc0e03
6 changed files with 312 additions and 61 deletions
|
|
@ -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<Storage, "getItem"> | 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<Storage, "setItem"> | 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) {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,16 +199,6 @@ export function useRunFiles(
|
|||
);
|
||||
}
|
||||
|
||||
export function useChildRuns(parentId: string | undefined) {
|
||||
return useSWR<PaginatedRunList | null>(
|
||||
parentId ? queryKeys.runs.children(parentId) : null,
|
||||
() =>
|
||||
apiNullableData(() =>
|
||||
runsApi.listRuns(undefined, undefined, false, parentId!),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRunCommits(id: string | undefined) {
|
||||
return useSWR<PaginatedRunCommitList | null>(
|
||||
id ? queryKeys.runs.commits(id) : null,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ToggleableColumn>) =>
|
||||
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<number | null>(null);
|
||||
const [now, setNow] = useState<number>(() => 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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-fg-3">Runs spawned from this run.</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-64">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
aria-label="Search child runs"
|
||||
placeholder="Search child runs…"
|
||||
value={query}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FilterButton
|
||||
label="Time"
|
||||
value={createdFilter}
|
||||
allValue="all"
|
||||
options={createdFilterOptions}
|
||||
onChange={setCreatedFilter}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIncludeArchived(!includeArchived)}
|
||||
aria-pressed={includeArchived}
|
||||
title={includeArchived ? "Hide archived runs" : "Show archived runs"}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-3 py-2 text-xs font-medium transition-colors ${includeArchived ? "text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
>
|
||||
<ArchiveBoxIcon className="size-4" aria-hidden="true" />
|
||||
<span>Show archived</span>
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{updatedAt != null ? (
|
||||
<span className="font-mono text-xs text-fg-muted">
|
||||
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"
|
||||
>
|
||||
<ArrowPathIcon
|
||||
className={`size-3.5 ${childRunsQuery.isValidating ? "animate-spin [animation-duration:450ms]" : ""}`}
|
||||
className={`size-4 ${childRunsQuery.isValidating ? "animate-spin [animation-duration:450ms]" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<ColumnPickerButton hidden={hiddenColumns} onChange={setHiddenColumns} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No child runs"
|
||||
description="When you launch another run with this run as its parent, it will appear here."
|
||||
action={
|
||||
<a
|
||||
href="https://docs.fabro.sh/reference/cli#fabro-parent-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
Learn about parent links
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: RUNS_LIST_GRID_TEMPLATE }}
|
||||
>
|
||||
{children.map((child) => (
|
||||
<RunRow key={child.id} run={toRunWithStatus(child)} />
|
||||
))}
|
||||
</div>
|
||||
{hasMore ? (
|
||||
<p className="text-xs text-fg-muted">
|
||||
Showing the first {children.length} child runs — more exist.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<RunsListView
|
||||
data={childRunsQuery.data ?? undefined}
|
||||
isLoading={childRunsQuery.data == null && childRunsQuery.isLoading}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
title="No child runs"
|
||||
description="When you launch another run with this run as its parent, it will appear here."
|
||||
action={
|
||||
<a
|
||||
href="https://docs.fabro.sh/reference/cli#fabro-parent-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
Learn about parent links
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
}
|
||||
sort={sort}
|
||||
direction={direction}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
hiddenColumns={hiddenColumns}
|
||||
onSortClick={handleSortClick}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
query={lowerQuery}
|
||||
repoFilter="all"
|
||||
workflowFilter="all"
|
||||
createdCutoffMs={createdCutoffMs}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue