Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ name. It collapses to icons; the choice is remembered in the browser.

Every flow in the project as a card, grouped by folder. A card shows the last
run's result as a dot (hollow when the flow never ran), the first line of the
description, tags, and how long ago the last run started and how long it took.
Search matches names, ids and tags; tag chips filter. Validation problems from
description, its first two tags with a count of the rest, and how long ago the
last run started and how long it took. Search matches names, ids and tags; tag
chips filter. The row shows the twelve most used tags and any selected one,
and expands to every tag. Validation problems from
the flow files appear in a banner above the cards, and the affected cards carry
a warning mark.

Expand Down
24 changes: 24 additions & 0 deletions ui/src/lib/tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** Tags offered as filters before the list is expanded. */
export const TAG_FILTER_LIMIT = 12;
/** Tags a flow card shows before the rest fold into a count. */
export const CARD_TAG_LIMIT = 2;

/**
* The tag filters to show: the most used first, then by name. Collapsed, only
* the first `limit` and any selected tag; `hidden` counts the rest.
*/
export function tagFilters(
flowTags: string[][],
selected: string[],
expanded: boolean,
limit = TAG_FILTER_LIMIT,
): { shown: string[]; hidden: number } {
const counts = new Map<string, number>();
for (const tags of flowTags) for (const t of new Set(tags)) counts.set(t, (counts.get(t) ?? 0) + 1);
// A selected tag no flow has any more stays, so its filter can be cleared.
for (const t of selected) if (!counts.has(t)) counts.set(t, 0);
const all = [...counts.keys()].sort((a, b) => counts.get(b)! - counts.get(a)! || a.localeCompare(b));
if (expanded || all.length <= limit) return { shown: all, hidden: 0 };
const shown = all.filter((t, i) => i < limit || selected.includes(t));
return { shown, hidden: all.length - shown.length };
}
33 changes: 29 additions & 4 deletions ui/src/pages/Flows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { StatusDot, type AnyStatus } from "../components/status";
import { useNow } from "../components/ui";
import { formatDuration, timeAgo } from "../lib/format";
import { plainFirstLine } from "../lib/markdown";
import { CARD_TAG_LIMIT, tagFilters } from "../lib/tags";
import { useLoad } from "../lib/project";
import { linkHandler } from "../lib/router";
import { getRun, useFinishedCount, useLiveRunIds } from "../store/store";
Expand All @@ -28,14 +29,19 @@ export function FlowsPage() {
const { data, error } = useLoad(() => api.flows(), [finished]);
const [query, setQuery] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [allTagsShown, setAllTagsShown] = useState(false);
const [dismissed, setDismissed] = useState(false);
const running = useRunningFlows();
const now = useNow(30_000, true);

if (error) return <div className="page"><div className="banner banner-error">Could not load flows: {error}</div></div>;
if (!data) return <div className="page"><div className="loading">Loading flows</div></div>;

const allTags = [...new Set(data.flows.flatMap((f) => f.tags))].sort();
const filters = tagFilters(
data.flows.map((f) => f.tags),
tags,
allTagsShown,
);
const q = query.trim().toLowerCase();
const visible = data.flows.filter(
(f) =>
Expand Down Expand Up @@ -120,9 +126,9 @@ export function FlowsPage() {
onChange={(e) => setQuery(e.target.value)}
/>
</label>
{allTags.length > 0 && (
{filters.shown.length > 0 && (
<div className="chips" role="group" aria-label="Filter by tag">
{allTags.map((t) => (
{filters.shown.map((t) => (
<button
key={t}
type="button"
Expand All @@ -133,6 +139,16 @@ export function FlowsPage() {
{t}
</button>
))}
{(filters.hidden > 0 || allTagsShown) && (
<button
type="button"
className="chip chip-more"
aria-expanded={allTagsShown}
onClick={() => setAllTagsShown(!allTagsShown)}
>
{allTagsShown ? "Fewer tags" : `${filters.hidden} more`}
</button>
)}
</div>
)}
</div>
Expand Down Expand Up @@ -191,11 +207,20 @@ function FlowCard({
<div className="card-desc">{plainFirstLine(flow.description) || <span className="muted">No description</span>}</div>
<div className="card-foot">
<div className="tags">
{flow.tags.map((t) => (
{flow.tags.slice(0, CARD_TAG_LIMIT).map((t) => (
<span key={t} className="chip">
{t}
</span>
))}
{flow.tags.length > CARD_TAG_LIMIT && (
<span
className="chip chip-count"
title={flow.tags.slice(CARD_TAG_LIMIT).join(", ")}
aria-label={`Also tagged ${flow.tags.slice(CARD_TAG_LIMIT).join(", ")}`}
>
+{flow.tags.length - CARD_TAG_LIMIT}
</span>
)}
</div>
<div className="meta">
{running ? (
Expand Down
14 changes: 14 additions & 0 deletions ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,10 @@ button.chip {
button.chip:hover {
border-color: var(--border-strong);
}
button.chip-more {
color: var(--text-3);
border-style: dashed;
}
button.chip[aria-pressed="true"] {
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
Expand Down Expand Up @@ -870,6 +874,16 @@ a.card:hover {
overflow: hidden;
min-width: 0;
}
.card-foot .tags .chip {
display: block;
line-height: 18px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.card-foot .tags .chip-count {
flex-shrink: 0;
}
.card-foot .meta {
margin-left: auto;
color: var(--text-3);
Expand Down
18 changes: 18 additions & 0 deletions ui/src/test/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { buildContext, rerunCommand } from "../lib/context";
import { formatDuration, shellQuote, timeAgo } from "../lib/format";
import { parseBlocks, parseInline, plainFirstLine } from "../lib/markdown";
import { tagFilters } from "../lib/tags";
import type { RunEvent, RunReport, StepReport } from "../types";
import wrongPassword from "./fixtures/wrong-password.json";

Expand Down Expand Up @@ -97,3 +98,20 @@ describe("copy context", () => {
);
});
});

describe("tagFilters", () => {
const flows = [["api", "smoke"], ["api", "slow"], ["api", "smoke", "ui"], ["zeta"]];
it("orders by use, then name, and folds the rest", () => {
expect(tagFilters(flows, [], false, 2)).toEqual({ shown: ["api", "smoke"], hidden: 3 });
});
it("keeps a selected tag visible when folded", () => {
expect(tagFilters(flows, ["zeta"], false, 2)).toEqual({ shown: ["api", "smoke", "zeta"], hidden: 2 });
});
it("keeps a selected tag no flow has any more", () => {
expect(tagFilters(flows, ["gone"], false, 2).shown).toEqual(["api", "smoke", "gone"]);
});
it("shows everything when expanded or under the limit", () => {
expect(tagFilters(flows, [], true, 2).shown).toEqual(["api", "smoke", "slow", "ui", "zeta"]);
expect(tagFilters(flows, [], false, 10).hidden).toBe(0);
});
});
Loading