Skip to content
Open
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
111 changes: 111 additions & 0 deletions desktop/src/features/projects/ui/ProjectIssueKanbanBoard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { MessageSquare } from "lucide-react";
import type * as React from "react";

import type { ProjectIssue } from "@/features/projects/hooks";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import { projectTaskCategoryLabel } from "@/features/projects/projectTaskCategories";
import { cn } from "@/shared/lib/cn";

type KanbanIssueItem = {
issue: ProjectIssue;
onOpen: () => void;
};

type KanbanIssueGroup = {
icon: React.ReactNode;
items: KanbanIssueItem[];
status: ProjectIssue["status"];
};

function ProjectIssueKanbanCard({ issue, onOpen }: KanbanIssueItem) {
return (
<button
className="group w-full rounded-lg border border-border/70 bg-background/65 p-3 text-left shadow-xs transition-colors hover:border-border hover:bg-muted/35 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-project-event-id={issue.id}
data-testid="project-issue-kanban-card"
onClick={onOpen}
type="button"
>
<span className="line-clamp-2 text-sm font-medium leading-5 text-foreground group-hover:text-primary">
{issue.title}
</span>
{issue.content ? (
<span className="mt-1.5 line-clamp-2 text-xs leading-4 text-muted-foreground/75">
{issue.content}
</span>
) : null}
<span className="mt-3 flex items-center gap-2 text-2xs text-muted-foreground/65">
<span className="font-medium tabular-nums">
#{issue.id.slice(0, 8)}
</span>
<span aria-hidden="true">/</span>
<span className="truncate">
{projectTaskCategoryLabel(issue.category)}
</span>
<span className="ml-auto flex shrink-0 items-center gap-1">
<MessageSquare aria-hidden="true" className="h-3 w-3" />
{issue.comments.length}
</span>
</span>
<span
className="mt-2 block text-2xs text-muted-foreground/50"
title={new Date(issue.updatedAt * 1_000).toLocaleString()}
>
Updated {relativeTime(issue.updatedAt)}
</span>
</button>
);
}

/** Status-column view of a project's tasks. Cards open the existing task detail. */
export function ProjectIssueKanbanBoard({
groups,
}: {
groups: KanbanIssueGroup[];
}) {
return (
<section
aria-label="Project task board"
className="grid auto-cols-[minmax(17rem,1fr)] grid-flow-col gap-3 overflow-x-auto px-3 pb-4 pt-2"
data-testid="project-issue-kanban-board"
>
{groups.map(({ icon, items, status }) => (
<section
aria-labelledby={`project-kanban-${status.replaceAll(" ", "-").toLowerCase()}`}
className="min-h-48 rounded-xl border border-border/60 bg-muted/15"
data-testid="project-issue-kanban-column"
key={status}
>
<header className="sticky top-0 z-10 flex items-center gap-2 rounded-t-xl border-b border-border/50 bg-background/90 px-3 py-2.5 backdrop-blur">
{icon}
<h3
className="text-xs font-semibold text-foreground"
id={`project-kanban-${status.replaceAll(" ", "-").toLowerCase()}`}
>
{status}
</h3>
<span
className={cn(
"ml-auto min-w-5 rounded-full bg-muted px-1.5 py-0.5 text-center text-2xs font-medium tabular-nums text-muted-foreground",
items.length === 0 && "opacity-60",
)}
>
{items.length}
</span>
</header>
<div className="space-y-2 p-2">
{items.length > 0 ? (
items.map((item) => (
<ProjectIssueKanbanCard key={item.issue.id} {...item} />
))
) : (
<p className="px-2 py-5 text-center text-xs text-muted-foreground/55">
No tasks
</p>
)}
</div>
</section>
))}
</section>
);
}
57 changes: 55 additions & 2 deletions desktop/src/features/projects/ui/ProjectIssuesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
CircleDashed,
CircleDot,
CircleX,
Columns3,
List,
MessageSquare,
Tag,
type LucideIcon,
Expand Down Expand Up @@ -57,6 +59,8 @@ import {
import { ProjectWorkItemGroup } from "./ProjectWorkItemGroup";
import { ProjectWorkItemRow } from "./ProjectWorkItemRow";
import { ProjectPanelState } from "./ProjectPanelState";
import { ProjectIssueKanbanBoard } from "./ProjectIssueKanbanBoard";
import { SegmentedControl } from "@/shared/ui/segmented-control";

export function issueStatusClassName(status: ProjectIssue["status"]) {
if (status === "Triage" || status === "In Progress") return "text-amber-500";
Expand Down Expand Up @@ -116,6 +120,11 @@ const ISSUE_STATUS_ORDER: readonly ProjectIssue["status"][] = [
"Closed",
];

const ISSUE_VIEW_OPTIONS = [
{ value: "list", label: "List", Icon: List },
{ value: "board", label: "Board", Icon: Columns3 },
] as const;

export type ProjectIssuePanelItem = {
issue: ProjectIssue;
project: Project;
Expand Down Expand Up @@ -436,6 +445,7 @@ export function ProjectIssuesPanel({
project: Project;
selectedIssueId: string | null;
}) {
const [viewMode, setViewMode] = React.useState<"list" | "board">("list");
const issuesQuery = useProjectIssuesQuery(
issueItems === undefined ? project : null,
);
Expand Down Expand Up @@ -476,16 +486,59 @@ export function ProjectIssuesPanel({
);
}

const groups = ISSUE_STATUS_ORDER.map((status) => ({
const allGroups = ISSUE_STATUS_ORDER.map((status) => ({
items: resolvedItems.filter(({ issue }) => issue.status === status),
status,
})).filter((group) => group.items.length > 0);
}));
const groups = allGroups.filter((group) => group.items.length > 0);
const rangeItems = resolvedItems.map(({ issue, project: itemProject }) =>
issueSelectionItem(itemProject, issue),
);

const viewControl = (
<div className="flex justify-end border-b border-border/50 px-3 py-2">
<SegmentedControl
className="w-40"
legend="Task layout"
onValueChange={setViewMode}
optionTestIdPrefix="project-task-view"
options={ISSUE_VIEW_OPTIONS}
size="compact"
testId="project-task-view-control"
value={viewMode}
/>
</div>
);

if (viewMode === "board") {
return (
<div>
{viewControl}
<ProjectIssueKanbanBoard
groups={allGroups.map(({ items, status }) => {
const visual = issueStatusVisual(status);
return {
icon: (
<ProjectStatusProgressIcon
className={`h-4 w-4 ${visual.className}`}
state={visual.progress}
/>
),
items: items.map(({ issue }) => ({
issue,
onOpen: () => onSelectedIssueIdChange(issue.id),
})),
status,
};
})}
/>
</div>
);
}

return (
<div>
{viewControl}
{groups.map(({ items, status }) => {
const visual = issueStatusVisual(status);
return (
Expand Down
26 changes: 26 additions & 0 deletions desktop/tests/e2e/project-issue-comments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,32 @@ async function openBuzzProject(page: import("@playwright/test").Page) {
await page.getByTestId("project-home-context-repo-buzz").click();
}

test("project tasks can switch to a kanban board and open task activity", async ({
page,
}) => {
await installMockBridge(page);
await openBuzzProject(page);

await page.getByRole("tab", { name: "Tasks", exact: true }).click();
await page.getByTestId("project-task-view-board").click();

const board = page.getByTestId("project-issue-kanban-board");
await expect(board).toBeVisible({ timeout: 10_000 });
await expect(board.getByTestId("project-issue-kanban-column")).toHaveCount(6);

const card = board.getByTestId("project-issue-kanban-card").first();
await expect(card).toBeVisible();
await card.click();

await expect(page.getByTestId("project-issue-detail")).toBeVisible();
await expect(
page.getByRole("button", { name: "Activity", exact: true }),
).toBeVisible();
await expect(
page.getByTestId("project-issue-comment-composer"),
).toBeVisible();
});

test("issue detail can open agent chat or seed a channel question", async ({
page,
}) => {
Expand Down