feat(web): add collapsible sections to changes panel (#457)
* feat(web): add collapsible sections to changes panel
Sections (Unstaged, Staged, Commits, PR Changes, PR Commits) can now
be collapsed/expanded by clicking the section header or chevron arrow.
Includes Playwright E2E test validating collapse and expand behavior.
* fix(web): add aria-expanded to collapsible section toggles
Addresses CodeRabbit review: adds aria-expanded attribute to section
toggle buttons for screen reader support, and asserts the attribute
in the E2E test.
* fix(web): render section toggle as button only when collapsible
Non-collapsible sections keep a plain <span> label to avoid
ambiguous getByRole("button") matches in E2E tests (e.g. "Commit"
action button vs "Commits (0)" section header).
* fix(web): use exact match for Commit button in pre-hooks E2E test
The collapsible "Commits" section toggle button now matches
getByRole("button", { name: "Commit" }) via substring. Use exact
matching to only target the action button.
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
IconGitCommit,
|
||||
IconGitPullRequest,
|
||||
IconCloudUpload,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconLoader2,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
@@ -54,6 +56,7 @@ function TimelineSection({
|
||||
action,
|
||||
isLast,
|
||||
children,
|
||||
collapsible = true,
|
||||
"data-testid": testId,
|
||||
}: {
|
||||
dotColor: string;
|
||||
@@ -62,8 +65,12 @@ function TimelineSection({
|
||||
action?: React.ReactNode;
|
||||
isLast?: boolean;
|
||||
children?: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
"data-testid"?: string;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const canCollapse = collapsible && !!label;
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-2.5" data-testid={testId}>
|
||||
{/* Vertical line + dot */}
|
||||
@@ -77,18 +84,38 @@ function TimelineSection({
|
||||
{/* Header */}
|
||||
{label && (
|
||||
<div className="flex items-center justify-between gap-2 -mt-0.5 mb-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-foreground/70">
|
||||
{label}
|
||||
{typeof count === "number" && (
|
||||
<span className="ml-1 text-muted-foreground/50 font-normal">({count})</span>
|
||||
)}
|
||||
</span>
|
||||
{canCollapse ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-foreground/70 cursor-pointer hover:text-foreground/90"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
aria-expanded={!collapsed}
|
||||
data-testid={`${testId ?? label.toLowerCase()}-collapse-toggle`}
|
||||
>
|
||||
{label}
|
||||
{typeof count === "number" && (
|
||||
<span className="text-muted-foreground/50 font-normal">({count})</span>
|
||||
)}
|
||||
{collapsed ? (
|
||||
<IconChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
) : (
|
||||
<IconChevronDown className="h-3 w-3 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-foreground/70">
|
||||
{label}
|
||||
{typeof count === "number" && (
|
||||
<span className="ml-1 text-muted-foreground/50 font-normal">({count})</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Children (file list, buttons, etc.) */}
|
||||
{children}
|
||||
{!collapsed && children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -359,6 +386,7 @@ export function PRFilesSection({ files, isLast, onOpenDiff }: PRFilesSectionProp
|
||||
label="PR Changes"
|
||||
count={files.length}
|
||||
isLast={isLast}
|
||||
data-testid="pr-changes-section"
|
||||
>
|
||||
{files.length > 0 && (
|
||||
<ul className="space-y-0.5">
|
||||
@@ -428,6 +456,7 @@ export function PRCommitsSection({ commits, isLast, onOpenCommitDetail }: PRComm
|
||||
label="PR Commits"
|
||||
count={commits.length}
|
||||
isLast={isLast}
|
||||
data-testid="pr-commits-timeline-section"
|
||||
>
|
||||
<ul className="space-y-0.5">
|
||||
{commits.map((commit, index) => (
|
||||
|
||||
@@ -1001,4 +1001,108 @@ test.describe("Git Changes Panel", () => {
|
||||
// The cumulative diff should NOT show "No changes"
|
||||
await expect(testPage.locator("text=No changes")).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
/**
|
||||
* Verifies that sections in the changes panel can be collapsed and expanded.
|
||||
* Clicking the section header (label + count + chevron) toggles visibility of the section content.
|
||||
*/
|
||||
test("sections can be collapsed and expanded", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
const profile = await createStandardProfile(apiClient, "Git Collapse Profile");
|
||||
|
||||
await apiClient.createTaskWithAgent(seedData.workspaceId, "Git Collapse Test", profile.id, {
|
||||
description: "Testing section collapse",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
|
||||
const session = await openTaskSession(testPage, "Git Collapse Test");
|
||||
|
||||
// Set up git helper
|
||||
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
HOME: backend.tmpDir,
|
||||
GIT_AUTHOR_NAME: "E2E Test",
|
||||
GIT_AUTHOR_EMAIL: "e2e@test.local",
|
||||
GIT_COMMITTER_NAME: "E2E Test",
|
||||
GIT_COMMITTER_EMAIL: "e2e@test.local",
|
||||
};
|
||||
const git = new GitHelper(repoDir, gitEnv);
|
||||
|
||||
// Create an unstaged file and a committed file so both sections appear
|
||||
git.createFile("collapse-committed.txt", "committed content");
|
||||
git.stageFile("collapse-committed.txt");
|
||||
git.commit("Collapse test commit");
|
||||
|
||||
git.createFile("collapse-unstaged.txt", "unstaged content");
|
||||
|
||||
// Click the Changes tab
|
||||
await session.clickTab("Changes");
|
||||
await expect(session.changes).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for both sections to appear
|
||||
await expect(testPage.getByTestId("unstaged-files-section")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(testPage.getByTestId("commits-section")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Verify the unstaged file is visible
|
||||
await expect(session.changes.getByText("collapse-unstaged.txt")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Verify the commit is visible
|
||||
await expect(session.changes.getByText("Collapse test commit")).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// --- Collapse the unstaged section ---
|
||||
const unstagedToggle = testPage.getByTestId("unstaged-files-section-collapse-toggle");
|
||||
await expect(unstagedToggle).toBeVisible({ timeout: 5_000 });
|
||||
await expect(unstagedToggle).toHaveAttribute("aria-expanded", "true");
|
||||
await unstagedToggle.click();
|
||||
|
||||
// The unstaged file should now be hidden and toggle reflects collapsed state
|
||||
await expect(session.changes.getByText("collapse-unstaged.txt")).not.toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(unstagedToggle).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
// The section header should still be visible (with count)
|
||||
await expect(unstagedToggle).toBeVisible();
|
||||
await expect(unstagedToggle).toContainText("Unstaged");
|
||||
|
||||
// --- Collapse the commits section ---
|
||||
const commitsToggle = testPage.getByTestId("commits-section-collapse-toggle");
|
||||
await expect(commitsToggle).toBeVisible({ timeout: 5_000 });
|
||||
await expect(commitsToggle).toHaveAttribute("aria-expanded", "true");
|
||||
await commitsToggle.click();
|
||||
|
||||
// The commit should now be hidden and toggle reflects collapsed state
|
||||
await expect(session.changes.getByText("Collapse test commit")).not.toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(commitsToggle).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
// --- Expand the unstaged section back ---
|
||||
await unstagedToggle.click();
|
||||
|
||||
// The unstaged file should be visible again
|
||||
await expect(session.changes.getByText("collapse-unstaged.txt")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(unstagedToggle).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// --- Expand the commits section back ---
|
||||
await commitsToggle.click();
|
||||
|
||||
// The commit should be visible again
|
||||
await expect(session.changes.getByText("Collapse test commit")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(commitsToggle).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// Clean up
|
||||
git.deleteFile("collapse-unstaged.txt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,7 +125,8 @@ test.describe("Git commit pre-hooks", () => {
|
||||
await session.changes.getByRole("button", { name: "Stage all" }).click();
|
||||
|
||||
// Wait for the "Commit" button to appear (indicates staged section is ready)
|
||||
const commitButton = session.changes.getByRole("button", { name: "Commit" });
|
||||
// Use exact match to avoid matching the "Commits" section toggle
|
||||
const commitButton = session.changes.getByRole("button", { name: "Commit", exact: true });
|
||||
await expect(commitButton).toBeVisible({ timeout: 15_000 });
|
||||
await commitButton.click();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user