diff --git a/docs/ui.md b/docs/ui.md index a3497ff..93f6a68 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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. diff --git a/ui/src/lib/tags.ts b/ui/src/lib/tags.ts new file mode 100644 index 0000000..902d2b8 --- /dev/null +++ b/ui/src/lib/tags.ts @@ -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(); + 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 }; +} diff --git a/ui/src/pages/Flows.tsx b/ui/src/pages/Flows.tsx index 5deb622..e7e76a1 100644 --- a/ui/src/pages/Flows.tsx +++ b/ui/src/pages/Flows.tsx @@ -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"; @@ -28,6 +29,7 @@ export function FlowsPage() { const { data, error } = useLoad(() => api.flows(), [finished]); const [query, setQuery] = useState(""); const [tags, setTags] = useState([]); + const [allTagsShown, setAllTagsShown] = useState(false); const [dismissed, setDismissed] = useState(false); const running = useRunningFlows(); const now = useNow(30_000, true); @@ -35,7 +37,11 @@ export function FlowsPage() { if (error) return
Could not load flows: {error}
; if (!data) return
Loading flows
; - 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) => @@ -120,9 +126,9 @@ export function FlowsPage() { onChange={(e) => setQuery(e.target.value)} /> - {allTags.length > 0 && ( + {filters.shown.length > 0 && (
- {allTags.map((t) => ( + {filters.shown.map((t) => ( + )}
)} @@ -191,11 +207,20 @@ function FlowCard({
{plainFirstLine(flow.description) || No description}
- {flow.tags.map((t) => ( + {flow.tags.slice(0, CARD_TAG_LIMIT).map((t) => ( {t} ))} + {flow.tags.length > CARD_TAG_LIMIT && ( + + +{flow.tags.length - CARD_TAG_LIMIT} + + )}
{running ? ( diff --git a/ui/src/styles.css b/ui/src/styles.css index 831f607..538585c 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -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); @@ -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); diff --git a/ui/src/test/lib.test.ts b/ui/src/test/lib.test.ts index c71409f..c1f79dd 100644 --- a/ui/src/test/lib.test.ts +++ b/ui/src/test/lib.test.ts @@ -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"; @@ -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); + }); +});