From 239b2069907b28dc62c7dc362d9bb682297abf81 Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 25 Mar 2026 16:12:46 -0400 Subject: [PATCH 01/38] feat(actions): redesigns to workflow summary cards with failure emphasis --- .../dashboard/WorkflowSummaryCard.tsx | 95 ++++++++++ tests/components/WorkflowSummaryCard.test.tsx | 178 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 src/app/components/dashboard/WorkflowSummaryCard.tsx create mode 100644 tests/components/WorkflowSummaryCard.test.tsx diff --git a/src/app/components/dashboard/WorkflowSummaryCard.tsx b/src/app/components/dashboard/WorkflowSummaryCard.tsx new file mode 100644 index 0000000..b720921 --- /dev/null +++ b/src/app/components/dashboard/WorkflowSummaryCard.tsx @@ -0,0 +1,95 @@ +import { For, Show } from "solid-js"; +import type { WorkflowRun } from "../../services/api"; +import WorkflowRunRow from "./WorkflowRunRow"; + +interface WorkflowSummaryCardProps { + workflowName: string; + runs: WorkflowRun[]; + expanded: boolean; + onToggle: () => void; + onIgnoreRun: (run: WorkflowRun) => void; + density: "compact" | "comfortable"; +} + +function getAccentColor(runs: WorkflowRun[]): "green" | "red" | "yellow" | "gray" { + const hasFailure = runs.some((r) => r.conclusion === "failure"); + const hasRunning = runs.some((r) => r.status === "in_progress"); + const allSuccess = runs.every((r) => r.conclusion === "success"); + + if (hasFailure) return "red"; + if (hasRunning) return "yellow"; + if (allSuccess) return "green"; + return "gray"; +} + +export default function WorkflowSummaryCard(props: WorkflowSummaryCardProps) { + const successCount = () => props.runs.filter((r) => r.conclusion === "success").length; + const failureCount = () => props.runs.filter((r) => r.conclusion === "failure").length; + const runningCount = () => props.runs.filter((r) => r.status === "in_progress").length; + + const accentColor = () => getAccentColor(props.runs); + + const hasFailure = () => failureCount() > 0; + + const borderLeftClass = () => { + const color = accentColor(); + if (color === "red") return "border-l-4 border-l-red-500"; + if (color === "yellow") return "border-l-4 border-l-yellow-500"; + if (color === "green") return "border-l-4 border-l-green-500"; + return "border-l-4 border-l-gray-300 dark:border-l-gray-600"; + }; + + const cardBgClass = () => + hasFailure() + ? "border-red-300 dark:border-red-700 bg-red-50/50 dark:bg-red-900/10" + : "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"; + + return ( +
+ {/* Card header */} +
+ + {props.workflowName} + +
+ 0}> + + {successCount()} + + + 0}> + + {failureCount()} + + + 0}> + + {runningCount()} + + +
+
+ + {/* Expanded run list */} + +
e.stopPropagation()} + > + + {(run) => ( + + )} + +
+
+
+ ); +} diff --git a/tests/components/WorkflowSummaryCard.test.tsx b/tests/components/WorkflowSummaryCard.test.tsx new file mode 100644 index 0000000..8778b02 --- /dev/null +++ b/tests/components/WorkflowSummaryCard.test.tsx @@ -0,0 +1,178 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import userEvent from "@testing-library/user-event"; +import WorkflowSummaryCard from "../../src/app/components/dashboard/WorkflowSummaryCard"; +import { makeWorkflowRun } from "../helpers/index"; + +describe("WorkflowSummaryCard", () => { + it("renders workflow name", () => { + const runs = [makeWorkflowRun({ name: "My Workflow", conclusion: "success" })]; + render(() => ( + {}} + onIgnoreRun={() => {}} + density="comfortable" + /> + )); + screen.getByText("My Workflow"); + }); + + it("shows correct counts: success, failure, running", () => { + const runs = [ + makeWorkflowRun({ conclusion: "success", status: "completed" }), + makeWorkflowRun({ conclusion: "success", status: "completed" }), + makeWorkflowRun({ conclusion: "failure", status: "completed" }), + makeWorkflowRun({ conclusion: null, status: "in_progress" }), + ]; + const { container } = render(() => ( + {}} + onIgnoreRun={() => {}} + density="comfortable" + /> + )); + // 2 successes shown as green count + expect(screen.getByText("2").className).toContain("green"); + // 1 failure shown as red count — use container query to target by color class + const redCount = container.querySelector('[class*="text-red"]'); + expect(redCount).not.toBeNull(); + expect(redCount!.textContent).toBe("1"); + // 1 running shown as yellow count + const yellowCount = container.querySelector('[class*="text-yellow"]'); + expect(yellowCount).not.toBeNull(); + expect(yellowCount!.textContent).toBe("1"); + }); + + it("does not show zero counts", () => { + const runs = [makeWorkflowRun({ conclusion: "success", status: "completed" })]; + const { container } = render(() => ( + {}} + onIgnoreRun={() => {}} + density="comfortable" + /> + )); + // Only green count visible, no red or yellow elements + const redSpans = container.querySelectorAll('[class*="red"]'); + const yellowSpans = container.querySelectorAll('[class*="yellow"]'); + expect(redSpans.length).toBe(0); + expect(yellowSpans.length).toBe(0); + }); + + it("collapsed: does not render WorkflowRunRow components", () => { + const runs = [ + makeWorkflowRun({ displayTitle: "run-unique-title", conclusion: "success" }), + ]; + render(() => ( + {}} + onIgnoreRun={() => {}} + density="comfortable" + /> + )); + expect(screen.queryByText("run-unique-title")).toBeNull(); + }); + + it("expanded: renders WorkflowRunRow for each run", () => { + const runs = [ + makeWorkflowRun({ displayTitle: "run-alpha", conclusion: "success" }), + makeWorkflowRun({ displayTitle: "run-beta", conclusion: "failure" }), + ]; + render(() => ( + {}} + onIgnoreRun={() => {}} + density="comfortable" + /> + )); + screen.getByText("run-alpha"); + screen.getByText("run-beta"); + }); + + it("clicking card calls onToggle", async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + const runs = [makeWorkflowRun({ conclusion: "success" })]; + const { container } = render(() => ( + {}} + density="comfortable" + /> + )); + const card = container.firstElementChild as HTMLElement; + await user.click(card); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("card with failures has red border classes", () => { + const runs = [makeWorkflowRun({ conclusion: "failure", status: "completed" })]; + const { container } = render(() => ( + {}} + onIgnoreRun={() => {}} + density="comfortable" + /> + )); + const card = container.firstElementChild as HTMLElement; + expect(card.className).toContain("border-red"); + }); + + it("card with all successes has green border accent", () => { + const runs = [ + makeWorkflowRun({ conclusion: "success", status: "completed" }), + makeWorkflowRun({ conclusion: "success", status: "completed" }), + ]; + const { container } = render(() => ( + {}} + onIgnoreRun={() => {}} + density="comfortable" + /> + )); + const card = container.firstElementChild as HTMLElement; + expect(card.className).toContain("border-l-green"); + }); + + it("card with running workflows has yellow border accent", () => { + const runs = [ + makeWorkflowRun({ conclusion: null, status: "in_progress" }), + ]; + const { container } = render(() => ( + {}} + onIgnoreRun={() => {}} + density="comfortable" + /> + )); + const card = container.firstElementChild as HTMLElement; + expect(card.className).toContain("border-l-yellow"); + }); +}); From 9d00632d004ceab66025919e95a8a59a6d6c8dc2 Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 25 Mar 2026 16:13:30 -0400 Subject: [PATCH 02/38] feat(shared): adds SortDropdown component for toolbar sort controls --- src/app/components/shared/SortDropdown.tsx | 54 ++++++++ tests/components/shared/SortDropdown.test.tsx | 130 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/app/components/shared/SortDropdown.tsx create mode 100644 tests/components/shared/SortDropdown.test.tsx diff --git a/src/app/components/shared/SortDropdown.tsx b/src/app/components/shared/SortDropdown.tsx new file mode 100644 index 0000000..a18698d --- /dev/null +++ b/src/app/components/shared/SortDropdown.tsx @@ -0,0 +1,54 @@ +import { For } from "solid-js"; + +export interface SortOption { + label: string; + field: string; + type: "date" | "text" | "number"; +} + +interface SortDropdownProps { + options: SortOption[]; + value: string; + direction: "asc" | "desc"; + onChange: (field: string, direction: "asc" | "desc") => void; +} + +function suffixFor(type: SortOption["type"], dir: "asc" | "desc"): string { + if (type === "date") return dir === "desc" ? "(newest first)" : "(oldest first)"; + if (type === "text") return dir === "asc" ? "(A-Z)" : "(Z-A)"; + return dir === "desc" ? "(most)" : "(fewest)"; +} + +export default function SortDropdown(props: SortDropdownProps) { + const selected = () => `${props.value}:${props.direction}`; + + function handleChange(e: Event) { + const val = (e.currentTarget as HTMLSelectElement).value; + const lastColon = val.lastIndexOf(":"); + const field = val.slice(0, lastColon); + const dir = val.slice(lastColon + 1) as "asc" | "desc"; + props.onChange(field, dir); + } + + return ( + + ); +} diff --git a/tests/components/shared/SortDropdown.test.tsx b/tests/components/shared/SortDropdown.test.tsx new file mode 100644 index 0000000..911b1a6 --- /dev/null +++ b/tests/components/shared/SortDropdown.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import SortDropdown from "../../../src/app/components/shared/SortDropdown"; +import type { SortOption } from "../../../src/app/components/shared/SortDropdown"; + +const options: SortOption[] = [ + { label: "Updated", field: "updated", type: "date" }, + { label: "Title", field: "title", type: "text" }, + { label: "Comments", field: "comments", type: "number" }, +]; + +describe("SortDropdown", () => { + it("renders two options per field (asc and desc)", () => { + render(() => ( + + )); + const select = screen.getByRole("combobox", { name: "Sort by" }); + const opts = select.querySelectorAll("option"); + expect(opts.length).toBe(6); // 3 fields × 2 directions + }); + + it("shows current selection as selected option", () => { + render(() => ( + + )); + const select = screen.getByRole("combobox", { name: "Sort by" }) as HTMLSelectElement; + expect(select.value).toBe("title:asc"); + }); + + it("calls onChange with new field and direction when selecting a different option", () => { + const onChange = vi.fn(); + render(() => ( + + )); + const select = screen.getByRole("combobox", { name: "Sort by" }); + fireEvent.change(select, { target: { value: "comments:asc" } }); + expect(onChange).toHaveBeenCalledWith("comments", "asc"); + }); + + it("calls onChange when selecting opposite direction for current field", () => { + const onChange = vi.fn(); + render(() => ( + + )); + const select = screen.getByRole("combobox", { name: "Sort by" }); + fireEvent.change(select, { target: { value: "updated:asc" } }); + expect(onChange).toHaveBeenCalledWith("updated", "asc"); + }); + + it("applies correct styling classes", () => { + render(() => ( + + )); + const select = screen.getByRole("combobox", { name: "Sort by" }); + expect(select.className).toContain("text-sm"); + expect(select.className).toContain("rounded-md"); + expect(select.className).toContain("border"); + expect(select.className).toContain("focus:ring-blue-500"); + }); + + it("renders correct suffix labels for date type", () => { + render(() => ( + + )); + const select = screen.getByRole("combobox", { name: "Sort by" }); + const opts = Array.from(select.querySelectorAll("option")).map((o) => o.textContent ?? ""); + expect(opts.some((t) => t.includes("(newest first)"))).toBe(true); + expect(opts.some((t) => t.includes("(oldest first)"))).toBe(true); + }); + + it("renders correct suffix labels for text type", () => { + render(() => ( + + )); + const select = screen.getByRole("combobox", { name: "Sort by" }); + const opts = Array.from(select.querySelectorAll("option")).map((o) => o.textContent ?? ""); + expect(opts.some((t) => t.includes("(A-Z)"))).toBe(true); + expect(opts.some((t) => t.includes("(Z-A)"))).toBe(true); + }); + + it("renders correct suffix labels for number type", () => { + render(() => ( + + )); + const select = screen.getByRole("combobox", { name: "Sort by" }); + const opts = Array.from(select.querySelectorAll("option")).map((o) => o.textContent ?? ""); + expect(opts.some((t) => t.includes("(most)"))).toBe(true); + expect(opts.some((t) => t.includes("(fewest)"))).toBe(true); + }); +}); From 6eb021208f683d1b66f0d7d8a61f34ec9b011f0c Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 25 Mar 2026 16:25:18 -0400 Subject: [PATCH 03/38] feat(dashboard): constrains content area to max-w-6xl for readability --- src/app/components/dashboard/ActionsTab.tsx | 116 +++++---- .../components/dashboard/DashboardPage.tsx | 80 +++--- src/app/components/layout/FilterBar.tsx | 4 +- src/app/components/layout/Header.tsx | 4 +- src/app/components/layout/TabBar.tsx | 4 +- tests/components/ActionsTab.test.tsx | 234 +++++++++++------- tests/components/DashboardPage.test.tsx | 8 +- 7 files changed, 271 insertions(+), 179 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 3a486c8..a76d7d1 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -3,7 +3,7 @@ import { createStore } from "solid-js/store"; import type { WorkflowRun } from "../../services/api"; import { config } from "../../stores/config"; import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, type ActionsFilterField } from "../../stores/view"; -import WorkflowRunRow from "./WorkflowRunRow"; +import WorkflowSummaryCard from "./WorkflowSummaryCard"; import IgnoreBadge from "./IgnoreBadge"; import SkeletonRows from "../shared/SkeletonRows"; import FilterChips from "../shared/FilterChips"; @@ -75,6 +75,19 @@ function groupRuns(runs: WorkflowRun[]): RepoGroup[] { return result; } +function sortWorkflowsByStatus(workflows: WorkflowGroup[]): WorkflowGroup[] { + return [...workflows].sort((a, b) => { + const priorityOf = (wf: WorkflowGroup): number => { + const latest = wf.runs[0]; + if (!latest) return 2; + if (latest.conclusion === "failure") return 0; + if (latest.status === "in_progress") return 1; + return 2; + }; + return priorityOf(a) - priorityOf(b); + }); +} + const KNOWN_CONCLUSIONS = ["success", "failure", "cancelled"]; const KNOWN_EVENTS = ["push", "pull_request", "schedule", "workflow_dispatch"]; @@ -103,15 +116,15 @@ const actionsFilterGroups: FilterChipGroupDef[] = [ ]; export default function ActionsTab(props: ActionsTabProps) { - const [collapsedRepos, setCollapsedRepos] = createStore>({}); - const [collapsedWorkflows, setCollapsedWorkflows] = createStore>({}); + const [expandedRepos, setExpandedRepos] = createStore>({}); + const [expandedWorkflows, setExpandedWorkflows] = createStore>({}); function toggleRepo(repoFullName: string) { - setCollapsedRepos(repoFullName, (v) => !v); + setExpandedRepos(repoFullName, (v) => !v); } function toggleWorkflow(key: string) { - setCollapsedWorkflows(key, (v) => !v); + setExpandedWorkflows(key, (v) => !v); } function handleIgnore(run: WorkflowRun) { @@ -212,59 +225,68 @@ export default function ActionsTab(props: ActionsTabProps) { 0}> {(repoGroup) => { - const isRepoCollapsed = () => - collapsedRepos[repoGroup.repoFullName]; + const isExpanded = () => !!expandedRepos[repoGroup.repoFullName]; + + const sortedWorkflows = createMemo(() => + sortWorkflowsByStatus(repoGroup.workflows) + ); + + const collapsedSummary = createMemo(() => { + const wfs = repoGroup.workflows; + const total = wfs.length; + let passed = 0; + let failed = 0; + for (const wf of wfs) { + const latest = wf.runs[0]; + if (latest?.conclusion === "success") passed++; + else if (latest?.conclusion === "failure") failed++; + } + const parts: string[] = []; + if (passed > 0) parts.push(`${passed} passed`); + if (failed > 0) parts.push(`${failed} failed`); + return `${total} workflow${total !== 1 ? "s" : ""}: ${parts.join(", ")}`; + }); return (
{/* Repo header */} - {/* Workflow groups */} - - - {(wfGroup) => { - const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`; - const isWfCollapsed = () => - collapsedWorkflows[wfKey]; - - return ( -
- {/* Workflow header */} - - - {/* Runs */} - -
- - {(run) => ( - - )} - -
-
-
- ); - }} -
+ {/* Workflow cards grid */} + +
+ + {(wfGroup) => { + const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`; + const isWfExpanded = () => !!expandedWorkflows[wfKey]; + + return ( +
+ toggleWorkflow(wfKey)} + onIgnoreRun={handleIgnore} + density={config.viewDensity} + /> +
+ ); + }} +
+
); diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 74e9522..29cba97 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -186,46 +186,50 @@ export default function DashboardPage() { />
- - - - - - - - - - - +
+ + + + + + + + + + + +
-