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:
@@ -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) && (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user