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
172 changes: 129 additions & 43 deletions dashboard/src/v2/ProjectsPage.tsx

Large diffs are not rendered by default.

20 changes: 13 additions & 7 deletions dashboard/src/v2/components/projects/AddProjectCard.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import type { FunctionComponent } from "preact";
import { Plus } from "lucide-preact";
import { useDashboardI18n } from "../../i18n/context.js";
import { projectMessages } from "../../i18n/messages/projects.js";

export interface AddProjectCardProps {
onClick: () => void;
}

export const AddProjectCard: FunctionComponent<AddProjectCardProps> = ({ onClick }) => (
<button
export const AddProjectCard: FunctionComponent<AddProjectCardProps> = ({ onClick }) => {
const { translate } = useDashboardI18n();

return (
<button
type="button"
aria-label="Add Project"
aria-label={translate(projectMessages, "addProject")}
onClick={onClick}
className="group flex h-full min-h-[390px] w-full min-w-0 flex-col items-center justify-center gap-4 rounded-[1.5rem] border border-dashed border-black/[0.12] bg-white/55 p-5 text-center shadow-sm backdrop-blur-xl motion-safe:transition-colors motion-safe:duration-150 hover:border-signal-500/45 hover:bg-signal-500/[0.035] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500 focus-visible:ring-offset-2 dark:border-white/[0.12] dark:bg-void-800/50 dark:focus-visible:ring-offset-void-900"
>
<span className="grid h-12 w-12 place-items-center rounded-2xl border border-black/[0.08] bg-white/60 text-slate-500 motion-safe:transition-colors motion-safe:duration-150 group-hover:border-signal-500/30 group-hover:text-signal-600 dark:border-white/[0.09] dark:bg-white/[0.04] dark:text-slate-400 dark:group-hover:text-signal-300">
<Plus className="h-5 w-5" aria-hidden="true" />
</span>
<span className="font-display text-base font-semibold text-slate-800 dark:text-slate-100">Add Project</span>
<span className="font-display text-base font-semibold text-slate-800 dark:text-slate-100">{translate(projectMessages, "addProject")}</span>
<span className="max-w-48 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
Connect a local folder or Git repository
{translate(projectMessages, "connectProjectDescription")}
</span>
</button>
);
</button>
);
};
72 changes: 42 additions & 30 deletions dashboard/src/v2/components/projects/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
import type { Source, SourceStatus } from "../../types.js";
import type { ProjectCardDisplayValue } from "../../types.js";
import { buildProjectCardViewModel } from "../../lib/project-card-view-model.js";
import { useDashboardI18n } from "../../i18n/context.js";
import { projectMessages } from "../../i18n/messages/projects.js";
import { StatusDot } from "../ui/StatusDot.js";

export interface ProjectCardProps {
Expand All @@ -29,11 +31,11 @@ export interface ProjectCardProps {
onSettings: () => void;
}

const STATUS_LABELS: Record<SourceStatus, string> = {
running: "Running",
failed: "Failed",
intervention: "Needs review",
idle: "Idle",
const STATUS_MESSAGE_KEYS: Record<SourceStatus, "statusRunning" | "statusFailed" | "statusNeedsReview" | "statusIdle"> = {
running: "statusRunning",
failed: "statusFailed",
intervention: "statusNeedsReview",
idle: "statusIdle",
};

const STATUS_TEXT_CLASSES: Record<SourceStatus, string> = {
Expand Down Expand Up @@ -123,16 +125,24 @@ export const ProjectCard: FunctionComponent<ProjectCardProps> = ({
onOpenInvocation,
onSettings,
}) => {
const viewModel = useMemo(() => buildProjectCardViewModel(source), [source]);
const { locale, formatNumber, translate } = useDashboardI18n();
const viewModel = useMemo(() => buildProjectCardViewModel(source, locale), [locale, source]);
const location = viewModel.gitUrl.isEmpty ? viewModel.localDirectory : viewModel.gitUrl;
const locationLabel = viewModel.gitUrl.isEmpty ? "Path" : "Repository";
const lastRunStatus = viewModel.lastRunStatus.isEmpty ? "No runs yet" : viewModel.lastRunStatus.value;
const locationLabel = translate(projectMessages, viewModel.gitUrl.isEmpty ? "path" : "repository");
const lastRunStatus = viewModel.lastRunStatus.isEmpty
? translate(projectMessages, "noRunsYet")
: viewModel.lastRunStatus.value;
const completion = viewModel.taskCompletion.percentage ?? 0;
const selectionLabel = isSelected ? `Selected project: ${source.name}` : `Select project: ${source.name}`;
const selectionLabel = translate(
projectMessages,
isSelected ? "selectedProject" : "selectProject",
{ name: source.name },
);
const statusLabel = translate(projectMessages, STATUS_MESSAGE_KEYS[source.status]);

return (
<article
aria-label={`Project: ${source.name}`}
aria-label={translate(projectMessages, "projectArticle", { name: source.name })}
data-selected={isSelected ? "true" : "false"}
data-running={source.status === "running" ? "true" : "false"}
className={`flex h-full min-h-[390px] min-w-0 flex-col overflow-hidden rounded-[1.5rem] border bg-white/70 p-5 shadow-sm backdrop-blur-xl dark:bg-void-800/65 ${
Expand Down Expand Up @@ -166,19 +176,19 @@ export const ProjectCard: FunctionComponent<ProjectCardProps> = ({
{isSelected ? (
<span
role="status"
aria-label={`${source.name} is selected`}
aria-label={translate(projectMessages, "projectIsSelected", { name: source.name })}
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-signal-500/[0.12] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-signal-700 dark:text-signal-300"
>
<Check className="h-3 w-3" aria-hidden="true" />
Selected
{translate(projectMessages, "selected")}
</span>
) : null}
</span>

<span className="mt-4 flex items-center justify-between gap-3 border-y border-black/[0.06] py-3 dark:border-white/[0.07]">
<span className={`inline-flex items-center gap-2 text-xs font-semibold ${STATUS_TEXT_CLASSES[source.status]}`}>
<StatusDot status={source.status} />
{STATUS_LABELS[source.status]}
<span aria-hidden="true"><StatusDot status={source.status} /></span>
<span aria-label={translate(projectMessages, "statusLabel", { status: statusLabel })}>{statusLabel}</span>
</span>
<span className="max-w-[50%] truncate rounded-full border border-black/[0.07] bg-black/[0.025] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-slate-300">
{viewModel.sourceBadge.label}
Expand All @@ -190,12 +200,12 @@ export const ProjectCard: FunctionComponent<ProjectCardProps> = ({
icon={viewModel.gitUrl.isEmpty ? <MapPin className="h-3 w-3" aria-hidden="true" /> : <Link className="h-3 w-3" aria-hidden="true" />}
label={locationLabel}
value={location}
fallback="Not set"
fallback={translate(projectMessages, "notSet")}
mono
testId="project-location"
/>
<DetailRow icon={<GitBranch className="h-3 w-3" aria-hidden="true" />} label="Branch" value={viewModel.branch} fallback="Not set" mono testId="project-branch" />
<DetailRow icon={<Clock3 className="h-3 w-3" aria-hidden="true" />} label="Last run" value={viewModel.lastRunAt} fallback="No runs yet" testId="project-last-run" />
<DetailRow icon={<GitBranch className="h-3 w-3" aria-hidden="true" />} label={translate(projectMessages, "branch")} value={viewModel.branch} fallback={translate(projectMessages, "notSet")} mono testId="project-branch" />
<DetailRow icon={<Clock3 className="h-3 w-3" aria-hidden="true" />} label={translate(projectMessages, "lastRun")} value={viewModel.lastRunAt} fallback={translate(projectMessages, "noRunsYet")} testId="project-last-run" />
<span className="block truncate text-right text-[11px] text-slate-400 dark:text-slate-500" title={lastRunStatus}>
{lastRunStatus}
</span>
Expand All @@ -206,7 +216,7 @@ export const ProjectCard: FunctionComponent<ProjectCardProps> = ({
<button
type="button"
disabled={!setupInvocationId}
aria-label={setupInvocationId ? "Open setup invocation" : "Project setup invocation is starting"}
aria-label={translate(projectMessages, setupInvocationId ? "openSetupInvocation" : "setupInvocationStarting")}
aria-busy="true"
onClick={(event) => {
event.stopPropagation();
Expand All @@ -216,25 +226,25 @@ export const ProjectCard: FunctionComponent<ProjectCardProps> = ({
>
<span className="inline-flex min-w-0 items-center gap-2 text-xs font-semibold">
<Loader2 className="h-3.5 w-3.5 shrink-0 motion-safe:animate-spin" aria-hidden="true" />
<span className="truncate">Project setup running</span>
<span className="truncate">{translate(projectMessages, "projectSetupRunning")}</span>
</span>
{setupInvocationId ? <ExternalLink className="h-3.5 w-3.5 shrink-0" aria-hidden="true" /> : null}
</button>
) : null}

<div className="mt-auto pt-5">
<div className="grid grid-cols-3 divide-x divide-black/[0.06] rounded-xl bg-black/[0.025] py-2 dark:divide-white/[0.07] dark:bg-white/[0.035]">
<Stat label="Sprints" value={source.sprintsCount} />
<Stat label="Open" value={viewModel.taskCompletion.openTasks} />
<Stat label="Done" value={viewModel.taskCompletion.completedTasks} />
<Stat label={translate(projectMessages, "sprints")} value={formatNumber(source.sprintsCount)} />
<Stat label={translate(projectMessages, "open")} value={formatNumber(viewModel.taskCompletion.openTasks)} />
<Stat label={translate(projectMessages, "done")} value={formatNumber(viewModel.taskCompletion.completedTasks)} />
</div>
<div className="mt-3 flex items-center justify-between text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">
<span>Completion</span>
<span>{translate(projectMessages, "completion")}</span>
<span className="font-mono text-slate-600 dark:text-slate-300">{viewModel.taskCompletion.value}</span>
</div>
<div
role="progressbar"
aria-label={`${source.name} task completion`}
aria-label={translate(projectMessages, "taskCompletion", { name: source.name })}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={completion}
Expand All @@ -247,7 +257,9 @@ export const ProjectCard: FunctionComponent<ProjectCardProps> = ({
<button
type="button"
aria-pressed={isSelected}
aria-label={isSelected ? `${source.name} is selected` : `Select ${source.name}`}
aria-label={isSelected
? translate(projectMessages, "projectIsSelected", { name: source.name })
: translate(projectMessages, "selectNamedProject", { name: source.name })}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
Expand All @@ -259,18 +271,18 @@ export const ProjectCard: FunctionComponent<ProjectCardProps> = ({
: "border border-black/[0.09] bg-white/55 text-slate-700 hover:border-signal-500/35 hover:text-signal-700 dark:border-white/[0.1] dark:bg-white/[0.04] dark:text-slate-200 dark:hover:text-signal-300"
}`}
>
{isSelected ? "Selected" : "Select project"}
{translate(projectMessages, isSelected ? "selected" : "selectProjectAction")}
</button>
<ActionButton label={isSettingUp ? "Project setup is already running" : "Setup project"} icon={<Bot className="h-4 w-4" aria-hidden="true" />} onClick={onSetup} disabled={isSettingUp} busy={isSettingUp} />
<ActionButton label="Project settings" icon={<Settings className="h-4 w-4" aria-hidden="true" />} onClick={onSettings} />
<ActionButton label="Delete project" icon={<Trash2 className="h-4 w-4" aria-hidden="true" />} onClick={onDelete} danger />
<ActionButton label={translate(projectMessages, isSettingUp ? "setupAlreadyRunning" : "setupProject")} icon={<Bot className="h-4 w-4" aria-hidden="true" />} onClick={onSetup} disabled={isSettingUp} busy={isSettingUp} />
<ActionButton label={translate(projectMessages, "projectSettings")} icon={<Settings className="h-4 w-4" aria-hidden="true" />} onClick={onSettings} />
<ActionButton label={translate(projectMessages, "deleteProject")} icon={<Trash2 className="h-4 w-4" aria-hidden="true" />} onClick={onDelete} danger />
</div>
</div>
</article>
);
};

const Stat: FunctionComponent<{ label: string; value: number }> = ({ label, value }) => (
const Stat: FunctionComponent<{ label: string; value: string }> = ({ label, value }) => (
<span className="flex min-w-0 flex-col items-center gap-1">
<span className="font-display text-base font-semibold tabular-nums text-slate-800 dark:text-slate-100">{value}</span>
<span className="text-[9px] font-semibold uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{label}</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import type { ComponentChildren } from "preact";
import * as matchers from "@testing-library/jest-dom/matchers";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Source } from "../../../types.js";
import { AddProjectCard } from "../AddProjectCard.js";
import { ProjectCard, type ProjectCardProps } from "../ProjectCard.js";
import { DashboardI18nProvider } from "../../../i18n/context.js";
import type { DashboardLocale } from "../../../i18n/locales.js";

expect.extend(matchers);

Expand Down Expand Up @@ -52,6 +55,12 @@ function createProps(overrides: Partial<ProjectCardProps> = {}): ProjectCardProp
};
}

const withLocale = (children: ComponentChildren, locale: DashboardLocale = "en") => (
<DashboardI18nProvider initialLocale={locale} storage={null}>
{children}
</DashboardI18nProvider>
);

afterEach(() => {
cleanup();
vi.clearAllMocks();
Expand All @@ -60,7 +69,7 @@ afterEach(() => {
describe("ProjectCard", () => {
it("selects the project from the card selection surface", () => {
const props = createProps();
render(<ProjectCard {...props} />);
render(withLocale(<ProjectCard {...props} />));

fireEvent.click(screen.getByRole("button", { name: "Select project: Project One" }));

Expand All @@ -70,7 +79,7 @@ describe("ProjectCard", () => {
it("supports native keyboard activation", async () => {
const user = userEvent.setup();
const props = createProps();
render(<ProjectCard {...props} />);
render(withLocale(<ProjectCard {...props} />));

const selectSurface = screen.getByRole("button", { name: "Select project: Project One" });
selectSurface.focus();
Expand All @@ -82,7 +91,7 @@ describe("ProjectCard", () => {

it("isolates setup, settings, and delete actions from selection", () => {
const props = createProps();
render(<ProjectCard {...props} />);
render(withLocale(<ProjectCard {...props} />));

fireEvent.click(screen.getByRole("button", { name: "Setup project" }));
fireEvent.click(screen.getByRole("button", { name: "Project settings" }));
Expand All @@ -96,7 +105,7 @@ describe("ProjectCard", () => {

it("opens an available setup invocation without selecting the project", () => {
const props = createProps({ isSettingUp: true, setupInvocationId: "invocation-123" });
render(<ProjectCard {...props} />);
render(withLocale(<ProjectCard {...props} />));

fireEvent.click(screen.getByRole("button", { name: "Open setup invocation" }));

Expand All @@ -109,13 +118,13 @@ describe("ProjectCard", () => {
const longName = "A project name long enough to overflow a narrow mobile project card surface";
const longRepository = "https://example.com/organization/with-a-very-long-name/repository-with-a-very-long-name.git";
const longBranch = "feature/a-branch-name-that-must-never-force-horizontal-page-overflow";
const { rerender } = render(
const { rerender } = render(withLocale(
<ProjectCard
{...createProps({
source: createSource({ name: longName, sourceType: "git", repoUrl: longRepository, sourceRef: longRepository, defaultBranch: longBranch }),
})}
/>,
);
));

expect(screen.getByTestId("project-name")).toHaveClass("truncate");
expect(screen.getByTestId("project-name")).toHaveAttribute("title", longName);
Expand All @@ -125,38 +134,49 @@ describe("ProjectCard", () => {
expect(screen.getByTestId("project-branch")).toHaveAttribute("title", longBranch);

const longPath = "/workspace/a/local/path/with/many/nested/directories/that-must-remain-inside-the-card";
rerender(<ProjectCard {...createProps({ source: createSource({ name: longName, baseDir: longPath, sourceRef: longPath }) })} />);
rerender(withLocale(<ProjectCard {...createProps({ source: createSource({ name: longName, baseDir: longPath, sourceRef: longPath }) })} />));
expect(screen.getByTestId("project-location")).toHaveAttribute("title", longPath);
});

it("exposes stable selected and running states with static visual cues", () => {
render(<ProjectCard {...createProps({ source: createSource({ status: "running", isRunning: true }), isSelected: true })} />);
render(withLocale(<ProjectCard {...createProps({ source: createSource({ status: "running", isRunning: true }), isSelected: true })} />));

const card = screen.getByRole("article", { name: "Project: Project One" });
expect(card).toHaveAttribute("data-selected", "true");
expect(card).toHaveAttribute("data-running", "true");
expect(card).toHaveClass("border-signal-500/55");
expect(screen.getByRole("button", { name: "Selected project: Project One" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("status", { name: "Project One is selected" })).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Status: running" })).toBeInTheDocument();
expect(screen.getByLabelText("Status: Running")).toBeInTheDocument();
expect(screen.getByText("Running")).toBeInTheDocument();
});

it("renders view-model task counts and completion", () => {
render(<ProjectCard {...createProps()} />);
render(withLocale(<ProjectCard {...createProps()} />));

expect(screen.getByText("75%")).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "Project One task completion" })).toHaveAttribute("aria-valuenow", "75");
expect(screen.getByText("Open").previousElementSibling).toHaveTextContent("2");
expect(screen.getByText("Done").previousElementSibling).toHaveTextContent("6");
});

it("localizes card chrome and metadata while preserving project values", () => {
render(withLocale(<ProjectCard {...createProps()} />, "de"));

expect(screen.getByRole("article", { name: "Projekt: Project One" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Projekt auswählen: Project One" })).toBeInTheDocument();
expect(screen.getByText("Inaktiv")).toBeInTheDocument();
expect(screen.getByText("4. Jan. 2026, 5:06")).toBeInTheDocument();
expect(screen.getByText("/workspace/project-one")).toBeInTheDocument();
expect(screen.getByText("completed")).toBeInTheDocument();
});
});

describe("AddProjectCard", () => {
it("provides a full-height keyboard-reachable Add Project entry point", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<AddProjectCard onClick={onClick} />);
render(withLocale(<AddProjectCard onClick={onClick} />));

const addProject = screen.getByRole("button", { name: "Add Project" });
expect(addProject).toHaveClass("h-full", "min-h-[390px]");
Expand Down
Loading
Loading