feat(KB-038): add steps toggle to TaskCard

- Add steps toggle state and expand/collapse UI to TaskCard
- Add expand/collapse all steps button for better task navigation
- Add comprehensive tests for steps toggle functionality
- Update styles.css with toggle button and step list styling
- Include changeset for patch release
This commit is contained in:
gsxdsm
2026-03-29 19:40:11 -07:00
parent e06198c6aa
commit 34be4bf479
4 changed files with 345 additions and 12 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useState, useRef, useEffect } from "react";
import { Link, Clock, Layers, GitPullRequest, Pencil } from "lucide-react";
import { Link, Clock, Layers, GitPullRequest, Pencil, ChevronDown } from "lucide-react";
import type { Task, TaskDetail, Column } from "@kb/core";
import { fetchTaskDetail, uploadAttachment } from "../api";
import type { ToastType } from "../hooks/useToast";
@@ -52,6 +52,7 @@ export function TaskCard({
const [editTitle, setEditTitle] = useState(task.title || "");
const [editDescription, setEditDescription] = useState(task.description || "");
const [isSaving, setIsSaving] = useState(false);
const [showSteps, setShowSteps] = useState(false);
const titleInputRef = useRef<HTMLInputElement>(null);
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
@@ -376,18 +377,51 @@ export function TaskCard({
const completedSteps = task.steps.filter(s => s.status === "done").length;
const totalSteps = task.steps.length;
return (
<div className="card-progress">
<div className="card-progress-bar">
<div
className="card-progress-fill"
style={{
width: `${(completedSteps / totalSteps) * 100}%`,
backgroundColor: COLUMN_TEXT_COLOR_MAP[task.column],
}}
/>
<>
<div className="card-progress">
<div className="card-progress-bar">
<div
className="card-progress-fill"
style={{
width: `${(completedSteps / totalSteps) * 100}%`,
backgroundColor: COLUMN_TEXT_COLOR_MAP[task.column],
}}
/>
</div>
<span className="card-progress-label">{completedSteps}/{totalSteps}</span>
</div>
<span className="card-progress-label">{completedSteps}/{totalSteps}</span>
</div>
<button
type="button"
className="card-steps-toggle"
onClick={(e) => {
e.stopPropagation();
setShowSteps(!showSteps);
}}
aria-expanded={showSteps}
aria-label={showSteps ? "Hide steps" : "Show steps"}
>
<span>{totalSteps} steps</span>
<ChevronDown
size={14}
className={`card-steps-toggle-icon${showSteps ? " expanded" : ""}`}
/>
</button>
{showSteps && (
<div className="card-steps-list">
{task.steps.map((step, index) => (
<div key={index} className="card-step-item">
<span
className={`card-step-dot card-step-dot--${step.status}`}
aria-hidden="true"
/>
<span className={`card-step-name${step.status === "done" ? " completed" : ""}`}>
{step.name}
</span>
</div>
))}
</div>
)}
</>
);
})()}
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (

View File

@@ -1028,3 +1028,213 @@ describe("TaskCard inline editing", () => {
expect(editingCard).toBeDefined();
});
});
/**
* Tests for collapsible steps toggle in TaskCard.
*/
describe("TaskCard steps toggle", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("does not show steps toggle when task has no steps", () => {
const task = makeTask({ steps: [] });
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const toggle = screen.queryByRole("button", { name: /steps/i });
expect(toggle).toBeNull();
});
it("shows steps toggle with count when task has steps", () => {
const task = makeTask({
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "pending" },
],
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
expect(toggle).toBeDefined();
expect(toggle.textContent).toContain("2 steps");
});
it("clicking toggle expands and shows step list", () => {
const task = makeTask({
steps: [
{ name: "First step", status: "done" },
{ name: "Second step", status: "in-progress" },
],
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
fireEvent.click(toggle);
// Step list should be visible
expect(screen.getByText("First step")).toBeDefined();
expect(screen.getByText("Second step")).toBeDefined();
});
it("clicking toggle again collapses step list", () => {
const task = makeTask({
steps: [{ name: "Single step", status: "pending" }],
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
// Expand
fireEvent.click(toggle);
expect(screen.getByText("Single step")).toBeDefined();
// Collapse
fireEvent.click(toggle);
expect(screen.queryByText("Single step")).toBeNull();
});
it("step list renders correct number of steps", () => {
const task = makeTask({
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "in-progress" },
{ name: "Step 3", status: "pending" },
{ name: "Step 4", status: "skipped" },
],
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
fireEvent.click(toggle);
// Should show all 4 steps
const stepItems = document.querySelectorAll(".card-step-item");
expect(stepItems.length).toBe(4);
});
it("completed steps have strikethrough style", () => {
const task = makeTask({
steps: [
{ name: "Done step", status: "done" },
{ name: "Pending step", status: "pending" },
],
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
fireEvent.click(toggle);
const doneStepName = screen.getByText("Done step");
expect(doneStepName.classList.contains("completed")).toBe(true);
const pendingStepName = screen.getByText("Pending step");
expect(pendingStepName.classList.contains("completed")).toBe(false);
});
it("toggle does not trigger card click when clicked", () => {
const task = makeTask({
steps: [{ name: "Test step", status: "pending" }],
});
const onOpenDetail = vi.fn();
render(
<TaskCard
task={task}
onOpenDetail={onOpenDetail}
addToast={noopToast}
/>
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
fireEvent.click(toggle);
// Card click should not be triggered
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("aria-expanded reflects toggle state", () => {
const task = makeTask({
steps: [{ name: "Test step", status: "pending" }],
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
it("chevron icon rotates when expanded", () => {
const task = makeTask({
steps: [{ name: "Test step", status: "pending" }],
});
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
const chevron = toggle.querySelector(".card-steps-toggle-icon");
expect(chevron).toBeDefined();
expect(chevron?.classList.contains("expanded")).toBe(false);
fireEvent.click(toggle);
expect(chevron?.classList.contains("expanded")).toBe(true);
});
});

View File

@@ -610,6 +610,90 @@ body {
flex-shrink: 0;
}
/* Steps toggle and list */
.card-steps-toggle {
display: flex;
align-items: center;
gap: 4px;
margin-top: 6px;
padding: 0;
background: none;
border: none;
cursor: pointer;
font-size: 11px;
color: var(--text-muted);
transition: color 0.15s ease;
}
.card-steps-toggle:hover {
color: var(--text);
}
.card-steps-toggle:focus {
outline: 1px solid var(--todo);
outline-offset: 1px;
border-radius: 2px;
}
.card-steps-toggle-icon {
transition: transform 0.2s ease;
}
.card-steps-toggle-icon.expanded {
transform: rotate(180deg);
}
.card-steps-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
max-height: 200px;
overflow-y: auto;
}
.card-step-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.card-step-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.card-step-dot--pending {
background: var(--border, #30363d);
}
.card-step-dot--in-progress {
background: var(--todo, #58a6ff);
}
.card-step-dot--done {
background: var(--color-success, #3fb950);
}
.card-step-dot--skipped {
background: var(--text-dim, #484f58);
}
.card-step-name {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-step-name.completed {
text-decoration: line-through;
opacity: 0.6;
}
/* === Modals === */
.modal-overlay {
display: none;