feat(FN-1853): add description truncation with expand toggle to TaskDetailModal
- Add truncation state and expand/collapse toggle logic to TaskDetailModal - Truncate descriptions at 4 lines with a 'Show more' button to expand - Add CSS styles for description toggle button using design tokens - Add comprehensive tests for truncation behavior and toggle interactions
This commit is contained in:
@@ -191,6 +191,8 @@ function truncate(s: string, max: number): string {
|
|||||||
return s.length > max ? s.slice(0, max) + "…" : s;
|
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DESCRIPTION_TRUNCATE_LENGTH = 200;
|
||||||
|
|
||||||
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
||||||
|
|
||||||
export function TaskDetailModal({
|
export function TaskDetailModal({
|
||||||
@@ -267,7 +269,13 @@ export function TaskDetailModal({
|
|||||||
setActiveTab(initialTab);
|
setActiveTab(initialTab);
|
||||||
}, [initialTab]);
|
}, [initialTab]);
|
||||||
|
|
||||||
|
// Reset description expanded state when task changes
|
||||||
|
useEffect(() => {
|
||||||
|
setDescriptionExpanded(false);
|
||||||
|
}, [task.id]);
|
||||||
|
|
||||||
const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity");
|
const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity");
|
||||||
|
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||||
@@ -1099,7 +1107,25 @@ export function TaskDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="detail-title">{task.title || task.description}</h2>
|
{(() => {
|
||||||
|
const displayText = task.title || task.description || task.id;
|
||||||
|
const shouldTruncate = !descriptionExpanded && displayText.length > DESCRIPTION_TRUNCATE_LENGTH;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="detail-title">
|
||||||
|
{shouldTruncate ? displayText.slice(0, DESCRIPTION_TRUNCATE_LENGTH) + "…" : displayText}
|
||||||
|
</h2>
|
||||||
|
{displayText.length > DESCRIPTION_TRUNCATE_LENGTH && (
|
||||||
|
<button
|
||||||
|
className="detail-description-toggle"
|
||||||
|
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
|
||||||
|
>
|
||||||
|
{descriptionExpanded ? "Show less" : "Show more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div className="detail-meta">
|
<div className="detail-meta">
|
||||||
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
|
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
|
||||||
{new Date(task.updatedAt).toLocaleDateString()}
|
{new Date(task.updatedAt).toLocaleDateString()}
|
||||||
|
|||||||
@@ -433,6 +433,208 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(h2?.textContent).toBe("Implement dark mode");
|
expect(h2?.textContent).toBe("Implement dark mode");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("description truncation", () => {
|
||||||
|
it("truncates description over 200 characters with Show more button", () => {
|
||||||
|
const longDescription = "A".repeat(250);
|
||||||
|
const { container } = render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({
|
||||||
|
title: undefined,
|
||||||
|
description: longDescription,
|
||||||
|
})}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const h2 = container.querySelector("h2.detail-title");
|
||||||
|
expect(h2?.textContent).toBe("A".repeat(200) + "…");
|
||||||
|
const toggle = container.querySelector(".detail-description-toggle");
|
||||||
|
expect(toggle?.textContent).toBe("Show more");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands full description when Show more is clicked", async () => {
|
||||||
|
const longDescription = "B".repeat(250);
|
||||||
|
const { container } = render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({
|
||||||
|
title: undefined,
|
||||||
|
description: longDescription,
|
||||||
|
})}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
});
|
||||||
|
|
||||||
|
const h2 = container.querySelector("h2.detail-title");
|
||||||
|
expect(h2?.textContent).toBe("B".repeat(250));
|
||||||
|
expect(toggle.textContent).toBe("Show less");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses description when Show less is clicked", async () => {
|
||||||
|
const longDescription = "C".repeat(250);
|
||||||
|
const { container } = render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({
|
||||||
|
title: undefined,
|
||||||
|
description: longDescription,
|
||||||
|
})}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// First expand
|
||||||
|
const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then collapse
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
});
|
||||||
|
|
||||||
|
const h2 = container.querySelector("h2.detail-title");
|
||||||
|
expect(h2?.textContent).toBe("C".repeat(200) + "…");
|
||||||
|
expect(toggle.textContent).toBe("Show more");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show toggle for description under 200 characters", () => {
|
||||||
|
const shortDescription = "Short description";
|
||||||
|
const { container } = render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({
|
||||||
|
title: undefined,
|
||||||
|
description: shortDescription,
|
||||||
|
})}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const h2 = container.querySelector("h2.detail-title");
|
||||||
|
expect(h2?.textContent).toBe(shortDescription);
|
||||||
|
expect(container.querySelector(".detail-description-toggle")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show toggle when title is present and short", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({
|
||||||
|
title: "Short title",
|
||||||
|
description: "This is a longer description that would be truncated if it were shown as the main text",
|
||||||
|
})}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const h2 = container.querySelector("h2.detail-title");
|
||||||
|
expect(h2?.textContent).toBe("Short title");
|
||||||
|
expect(container.querySelector(".detail-description-toggle")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows toggle when title exceeds 200 characters", () => {
|
||||||
|
const longTitle = "D".repeat(250);
|
||||||
|
const { container } = render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({
|
||||||
|
title: longTitle,
|
||||||
|
description: "Short description",
|
||||||
|
})}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const h2 = container.querySelector("h2.detail-title");
|
||||||
|
expect(h2?.textContent).toBe("D".repeat(200) + "…");
|
||||||
|
const toggle = container.querySelector(".detail-description-toggle");
|
||||||
|
expect(toggle?.textContent).toBe("Show more");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets expanded state when task changes", async () => {
|
||||||
|
const longDescription1 = "E".repeat(250);
|
||||||
|
const longDescription2 = "F".repeat(250);
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({
|
||||||
|
id: "FN-001",
|
||||||
|
title: undefined,
|
||||||
|
description: longDescription1,
|
||||||
|
})}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expand the first task
|
||||||
|
const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify expanded
|
||||||
|
const h2Before = container.querySelector("h2.detail-title");
|
||||||
|
expect(h2Before?.textContent).toBe("E".repeat(250));
|
||||||
|
|
||||||
|
// Change to a different task
|
||||||
|
rerender(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({
|
||||||
|
id: "FN-002",
|
||||||
|
title: undefined,
|
||||||
|
description: longDescription2,
|
||||||
|
})}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should be collapsed again
|
||||||
|
const h2After = container.querySelector("h2.detail-title");
|
||||||
|
expect(h2After?.textContent).toBe("F".repeat(200) + "…");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("always shows task.id in the detail-id badge regardless of title", () => {
|
it("always shows task.id in the detail-id badge regardless of title", () => {
|
||||||
// With title
|
// With title
|
||||||
const { container: withTitle } = render(
|
const { container: withTitle } = render(
|
||||||
|
|||||||
@@ -3883,6 +3883,22 @@ body {
|
|||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-description-toggle {
|
||||||
|
display: block;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px 0;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-description-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.detail-meta {
|
.detail-meta {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user