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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"test:watch": "vitest --config vitest.config.ts",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"test:e2e": "playwright test"
"test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test"
},
"dependencies": {
"@octokit/core": "^7.0.6",
Expand Down
8 changes: 5 additions & 3 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { defineConfig, devices } from "@playwright/test";

const port = Number(process.env.E2E_PORT) || 5173;

export default defineConfig({
testDir: "./e2e",
reporter: [["html", { open: "never" }]],
use: {
baseURL: "http://localhost:5173",
baseURL: `http://localhost:${port}`,
trace: "on-first-retry",
},
projects: [
Expand All @@ -14,8 +16,8 @@ export default defineConfig({
},
],
webServer: {
command: "pnpm dev",
port: 5173,
command: `pnpm exec vite dev --port ${port} --strictPort`,
url: `http://localhost:${port}`,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
},
Expand Down
57 changes: 11 additions & 46 deletions src/app/components/dashboard/ActionsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { createMemo, For, Show } from "solid-js";
import { createStore } from "solid-js/store";
import type { WorkflowRun, ApiError } from "../../services/api";
import { config } from "../../stores/config";
import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, type ActionsFilterField } from "../../stores/view";
Expand All @@ -8,25 +9,7 @@ import ErrorBannerList from "../shared/ErrorBannerList";
import SkeletonRows from "../shared/SkeletonRows";
import FilterChips from "../shared/FilterChips";
import type { FilterChipGroupDef } from "../shared/FilterChips";

function ChevronIcon(props: { size: "sm" | "md"; rotated: boolean }) {
const sizeClass = () => (props.size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5");
return (
<svg
xmlns="http://www.w3.org/2000/svg"
class={`${sizeClass()} text-gray-400 transition-transform ${props.rotated ? "-rotate-90" : ""}`}
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
);
}
import ChevronIcon from "../shared/ChevronIcon";

interface ActionsTabProps {
workflowRuns: WorkflowRun[];
Expand Down Expand Up @@ -122,35 +105,15 @@ const actionsFilterGroups: FilterChipGroupDef[] = [
];

export default function ActionsTab(props: ActionsTabProps) {
const [collapsedRepos, setCollapsedRepos] = createSignal<Set<string>>(
new Set()
);
const [collapsedWorkflows, setCollapsedWorkflows] = createSignal<Set<string>>(
new Set()
);
const [collapsedRepos, setCollapsedRepos] = createStore<Record<string, boolean>>({});
const [collapsedWorkflows, setCollapsedWorkflows] = createStore<Record<string, boolean>>({});

function toggleRepo(repoFullName: string) {
setCollapsedRepos((prev) => {
const next = new Set(prev);
if (next.has(repoFullName)) {
next.delete(repoFullName);
} else {
next.add(repoFullName);
}
return next;
});
setCollapsedRepos(repoFullName, (v) => !v);
}

function toggleWorkflow(key: string) {
setCollapsedWorkflows((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
setCollapsedWorkflows(key, (v) => !v);
}

function handleIgnore(run: WorkflowRun) {
Expand Down Expand Up @@ -255,13 +218,14 @@ export default function ActionsTab(props: ActionsTabProps) {
<For each={repoGroups()}>
{(repoGroup) => {
const isRepoCollapsed = () =>
collapsedRepos().has(repoGroup.repoFullName);
collapsedRepos[repoGroup.repoFullName];

return (
<div class="bg-white dark:bg-gray-900">
{/* Repo header */}
<button
onClick={() => toggleRepo(repoGroup.repoFullName)}
aria-expanded={!isRepoCollapsed()}
class="w-full flex items-center gap-2 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<ChevronIcon size="md" rotated={isRepoCollapsed()} />
Expand All @@ -274,13 +238,14 @@ export default function ActionsTab(props: ActionsTabProps) {
{(wfGroup) => {
const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`;
const isWfCollapsed = () =>
collapsedWorkflows().has(wfKey);
collapsedWorkflows[wfKey];

return (
<div class="border-l-2 border-gray-100 dark:border-gray-800 ml-4">
{/* Workflow header */}
<button
onClick={() => toggleWorkflow(wfKey)}
aria-expanded={!isWfCollapsed()}
class="w-full flex items-center gap-2 px-4 py-1.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<ChevronIcon size="sm" rotated={isWfCollapsed()} />
Expand Down
95 changes: 62 additions & 33 deletions src/app/components/dashboard/IssuesTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { config } from "../../stores/config";
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, type IssueFilterField } from "../../stores/view";
import type { Issue, ApiError } from "../../services/api";
Expand All @@ -11,7 +12,9 @@ import FilterChips from "../shared/FilterChips";
import type { FilterChipGroupDef } from "../shared/FilterChips";
import RoleBadge from "../shared/RoleBadge";
import SkeletonRows from "../shared/SkeletonRows";
import ChevronIcon from "../shared/ChevronIcon";
import { deriveInvolvementRoles } from "../../lib/format";
import { groupByRepo, computePageLayout, slicePageGroups } from "../../lib/grouping";

export interface IssuesTabProps {
issues: Issue[];
Expand Down Expand Up @@ -43,6 +46,11 @@ const issueFilterGroups: FilterChipGroupDef[] = [

export default function IssuesTab(props: IssuesTabProps) {
const [page, setPage] = createSignal(0);
const [collapsedRepos, setCollapsedRepos] = createStore<Record<string, boolean>>({});

function toggleRepo(repoFullName: string) {
setCollapsedRepos(repoFullName, (v) => !v);
}

const sortPref = createMemo(() => {
const pref = viewState.sortPreferences["issues"];
Expand Down Expand Up @@ -110,20 +118,19 @@ export default function IssuesTab(props: IssuesTabProps) {
return { items, meta };
});

const filteredSorted = () => filteredSortedWithMeta().items;
const issueMeta = () => filteredSortedWithMeta().meta;

const pageSize = createMemo(() => config.itemsPerPage);
const filteredSorted = createMemo(() => filteredSortedWithMeta().items);
const issueMeta = createMemo(() => filteredSortedWithMeta().meta);

const pageCount = createMemo(() =>
Math.max(1, Math.ceil(filteredSorted().length / pageSize()))
const repoGroups = createMemo(() => groupByRepo(filteredSorted()));
const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage));
const pageCount = createMemo(() => pageLayout().pageCount);
const pageGroups = createMemo(() =>
slicePageGroups(repoGroups(), pageLayout().boundaries, pageCount(), page())
);

// Reset to first page when filters/sort change
const pagedItems = createMemo(() => {
const p = Math.min(page(), pageCount() - 1);
const start = p * pageSize();
return filteredSorted().slice(start, start + pageSize());
createEffect(() => {
const max = pageCount() - 1;
if (page() > max) setPage(max);
});

function handleSort(field: SortField) {
Expand Down Expand Up @@ -213,7 +220,7 @@ export default function IssuesTab(props: IssuesTabProps) {
{/* Issue rows */}
<Show when={!props.loading || props.issues.length > 0}>
<Show
when={pagedItems().length > 0}
when={pageGroups().length > 0}
fallback={
<div class="flex flex-col items-center justify-center gap-2 py-16 text-gray-500 dark:text-gray-400">
<svg
Expand All @@ -237,26 +244,48 @@ export default function IssuesTab(props: IssuesTabProps) {
</div>
}
>
<div role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={pagedItems()}>
{(issue) => (
<div role="listitem">
<ItemRow
repo={issue.repoFullName}
number={issue.number}
title={issue.title}
author={issue.userLogin}
createdAt={issue.createdAt}
url={issue.htmlUrl}
labels={issue.labels}
onIgnore={() => handleIgnore(issue)}
density={config.viewDensity}
commentCount={issue.comments}
>
<RoleBadge roles={issueMeta().get(issue.id)?.roles ?? []} />
</ItemRow>
</div>
)}
<div class="divide-y divide-gray-100 dark:divide-gray-800">
<For each={pageGroups()}>
{(repoGroup) => {
const isRepoCollapsed = () => collapsedRepos[repoGroup.repoFullName];
return (
<div class="bg-white dark:bg-gray-900">
<button
onClick={() => toggleRepo(repoGroup.repoFullName)}
aria-expanded={!isRepoCollapsed()}
class="w-full flex items-center gap-2 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<ChevronIcon size="md" rotated={isRepoCollapsed()} />
{repoGroup.repoFullName}
</button>
<Show when={!isRepoCollapsed()}>
<div role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={repoGroup.items}>
{(issue) => (
<div role="listitem">
<ItemRow
hideRepo={true}
repo={issue.repoFullName}
number={issue.number}
title={issue.title}
author={issue.userLogin}
createdAt={issue.createdAt}
url={issue.htmlUrl}
labels={issue.labels}
onIgnore={() => handleIgnore(issue)}
density={config.viewDensity}
commentCount={issue.comments}
>
<RoleBadge roles={issueMeta().get(issue.id)?.roles ?? []} />
</ItemRow>
</div>
)}
</For>
</div>
</Show>
</div>
);
}}
</For>
</div>
</Show>
Expand Down
19 changes: 11 additions & 8 deletions src/app/components/dashboard/ItemRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface ItemRowProps {
onIgnore: () => void;
density: "compact" | "comfortable";
commentCount?: number;
hideRepo?: boolean;
}

export default function ItemRow(props: ItemRowProps) {
Expand Down Expand Up @@ -43,14 +44,16 @@ export default function ItemRow(props: ItemRowProps) {
${isCompact() ? "px-4 py-2" : "px-4 py-3"}`}
>
{/* Repo badge */}
<span
class={`shrink-0 inline-flex items-center rounded-full font-mono font-medium
bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200
${isCompact() ? "text-xs px-2 py-0.5" : "text-xs px-2.5 py-1"}`}
title={props.repo}
>
{props.repo}
</span>
<Show when={!props.hideRepo}>
<span
class={`shrink-0 inline-flex items-center rounded-full font-mono font-medium
bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200
${isCompact() ? "text-xs px-2 py-0.5" : "text-xs px-2.5 py-1"}`}
title={props.repo}
>
{props.repo}
</span>
</Show>

{/* Main content */}
<div class="flex-1 min-w-0">
Expand Down
Loading
Loading