FN-6387: clamp long task detail titles
Clamp long task-detail headings to two lines with an explicit expand control. - Replace character-count title truncation with measured two-line CSS clamping. - Add overflow detection so the Show more/Show less toggle only appears when the heading actually wraps past two lines. - Update task detail rendering tests for triage, non-triage, editing, summarize, chat-expanded, desktop, and mobile title surfaces. Files changed: .../dashboard/app/components/TaskDetailModal.css | 10 + .../dashboard/app/components/TaskDetailModal.tsx | 111 ++++- .../__tests__/TaskDetailModal.rendering.test.tsx | 441 +++++++++------------ 3 files changed, 261 insertions(+), 301 deletions(-) Fusion-Task-Id: FN-6387 Fusion-Task-Lineage: 8c1b860c-8fe7-4f91-89e1-b387dbd7320e
This commit is contained in:
@@ -107,6 +107,16 @@
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-md);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-title--collapsed {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-heading-row {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./TaskDetailModal.css";
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles } from "lucide-react";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
@@ -531,8 +531,6 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt
|
||||
}
|
||||
}
|
||||
|
||||
const DESCRIPTION_TRUNCATE_LENGTH = 200;
|
||||
|
||||
// #1403: widened to ColumnId so `.has(task.column)` accepts custom column ids
|
||||
// (non-members correctly resolve to false → not editable).
|
||||
const EDITABLE_COLUMNS: Set<ColumnId> = new Set<ColumnId>(["triage", "todo"]);
|
||||
@@ -672,12 +670,15 @@ export function TaskDetailContent({
|
||||
|
||||
// Reset description expanded state when task changes
|
||||
useEffect(() => {
|
||||
setDescriptionExpanded(task.column === "triage");
|
||||
setDescriptionExpanded(false);
|
||||
}, [task.column, task.id]);
|
||||
|
||||
const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity");
|
||||
const [highlightStallCode, setHighlightStallCode] = useState<string | null>(null);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(() => task.column === "triage");
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [titleOverflows, setTitleOverflows] = useState(false);
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null);
|
||||
const displayTitleText = task.title || task.description || task.id;
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
@@ -695,6 +696,43 @@ export function TaskDetailContent({
|
||||
const [showRefineModal, setShowRefineModal] = useState(false);
|
||||
const [prCreateOpen, setPrCreateOpen] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const titleElement = titleRef.current;
|
||||
if (!titleElement) {
|
||||
setTitleOverflows(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const measureTitleOverflow = () => {
|
||||
let addedCollapsedClass = false;
|
||||
if (descriptionExpanded && !titleElement.classList.contains("detail-title--collapsed")) {
|
||||
titleElement.classList.add("detail-title--collapsed");
|
||||
addedCollapsedClass = true;
|
||||
}
|
||||
|
||||
const overflows = titleElement.scrollHeight > titleElement.clientHeight + 1;
|
||||
|
||||
if (addedCollapsedClass) {
|
||||
titleElement.classList.remove("detail-title--collapsed");
|
||||
}
|
||||
|
||||
setTitleOverflows(overflows);
|
||||
};
|
||||
|
||||
measureTitleOverflow();
|
||||
|
||||
const resizeObserver = typeof ResizeObserver !== "undefined"
|
||||
? new ResizeObserver(measureTitleOverflow)
|
||||
: null;
|
||||
resizeObserver?.observe(titleElement);
|
||||
window.addEventListener("resize", measureTitleOverflow);
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", measureTitleOverflow);
|
||||
};
|
||||
}, [descriptionExpanded, displayTitleText, task.id]);
|
||||
|
||||
// Custom field definitions (U13/KTD-14). Resolved for this task's workflow
|
||||
// from the board-workflows payload; absent when the workflow declares none,
|
||||
// in which case the fields section renders nothing (today's UI byte-identical).
|
||||
@@ -2783,39 +2821,36 @@ export function TaskDetailContent({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
const displayText = task.title || task.description || task.id;
|
||||
const shouldTruncate = !descriptionExpanded && displayText.length > DESCRIPTION_TRUNCATE_LENGTH;
|
||||
return (
|
||||
<>
|
||||
<div className="detail-heading-row">
|
||||
<h2 className="detail-title">
|
||||
{shouldTruncate ? displayText.slice(0, DESCRIPTION_TRUNCATE_LENGTH) + "…" : displayText}
|
||||
</h2>
|
||||
{showSummarizeTitleButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="detail-summarize-title-btn"
|
||||
onClick={() => void handleSummarizeTitle()}
|
||||
disabled={isSummarizingTitle || isSaving}
|
||||
data-testid="summarize-title-btn"
|
||||
>
|
||||
{isSummarizingTitle ? <Loader2 size={14} className="spinner" /> : <Sparkles size={14} />}
|
||||
<span>{t("taskDetail.title.summarize", "Summarize as title")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{displayText.length > DESCRIPTION_TRUNCATE_LENGTH && (
|
||||
<button
|
||||
className="detail-description-toggle"
|
||||
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
|
||||
>
|
||||
{descriptionExpanded ? t("taskDetail.description.showLess", "Show less") : t("taskDetail.description.showMore", "Show more")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<>
|
||||
<div className="detail-heading-row">
|
||||
<h2
|
||||
ref={titleRef}
|
||||
className={`detail-title${descriptionExpanded ? "" : " detail-title--collapsed"}`}
|
||||
>
|
||||
{displayTitleText}
|
||||
</h2>
|
||||
{showSummarizeTitleButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="detail-summarize-title-btn"
|
||||
onClick={() => void handleSummarizeTitle()}
|
||||
disabled={isSummarizingTitle || isSaving}
|
||||
data-testid="summarize-title-btn"
|
||||
>
|
||||
{isSummarizingTitle ? <Loader2 size={14} className="spinner" /> : <Sparkles size={14} />}
|
||||
<span>{t("taskDetail.title.summarize", "Summarize as title")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(titleOverflows || descriptionExpanded) && (
|
||||
<button
|
||||
className="detail-description-toggle"
|
||||
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
|
||||
>
|
||||
{descriptionExpanded ? t("taskDetail.description.showLess", "Show less") : t("taskDetail.description.showMore", "Show more")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
{customFieldDefs && customFieldDefs.length > 0 ? (
|
||||
<TaskFieldsSection
|
||||
fieldDefs={customFieldDefs}
|
||||
|
||||
@@ -1300,264 +1300,168 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
|
||||
describe("description truncation", () => {
|
||||
it("expands long triage title by default with Show less button", () => {
|
||||
let titleScrollHeight = 0;
|
||||
let titleClientHeight = 0;
|
||||
const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
|
||||
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
||||
|
||||
const setTitleLayout = ({ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number }) => {
|
||||
titleScrollHeight = scrollHeight;
|
||||
titleClientHeight = clientHeight;
|
||||
};
|
||||
|
||||
const renderDetail = (taskOverrides: Parameters<typeof makeTask>[0] = {}) => render(
|
||||
<TaskDetailModal
|
||||
task={makeTask(taskOverrides)}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
setTitleLayout({ scrollHeight: 120, clientHeight: 40 });
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this instanceof HTMLElement && this.classList.contains("detail-title") ? titleScrollHeight : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this instanceof HTMLElement && this.classList.contains("detail-title") ? titleClientHeight : 0;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalScrollHeight) {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollHeight", originalScrollHeight);
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "scrollHeight");
|
||||
}
|
||||
if (originalClientHeight) {
|
||||
Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "clientHeight");
|
||||
}
|
||||
});
|
||||
|
||||
it("collapses long triage title by default with Show more button and expands on demand", async () => {
|
||||
const longTitle = "Triage title ".repeat(25);
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
column: "triage",
|
||||
title: longTitle,
|
||||
description: "Triage planning context",
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
const { container } = renderDetail({
|
||||
column: "triage",
|
||||
title: longTitle,
|
||||
description: "Triage planning context",
|
||||
});
|
||||
|
||||
const h2 = container.querySelector("h2.detail-title");
|
||||
expect(h2?.textContent).toBe(longTitle);
|
||||
const toggle = container.querySelector(".detail-description-toggle");
|
||||
expect(toggle?.textContent).toBe("Show less");
|
||||
expect(h2).toHaveClass("detail-title--collapsed");
|
||||
const toggle = await screen.findByRole("button", { name: "Show more" });
|
||||
expect(toggle).toHaveClass("detail-description-toggle");
|
||||
|
||||
await userEvent.click(toggle);
|
||||
|
||||
expect(container.querySelector("h2.detail-title")?.textContent).toBe(longTitle);
|
||||
expect(container.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
|
||||
expect(screen.getByRole("button", { name: "Show less" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands long triage description by default when title is missing", () => {
|
||||
it("collapses long triage description by default when title is missing", async () => {
|
||||
const longDescription = "Triage description ".repeat(20);
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
column: "triage",
|
||||
title: undefined,
|
||||
description: longDescription,
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
const { container } = renderDetail({
|
||||
column: "triage",
|
||||
title: undefined,
|
||||
description: longDescription,
|
||||
});
|
||||
|
||||
const h2 = container.querySelector("h2.detail-title");
|
||||
expect(h2?.textContent).toBe(longDescription);
|
||||
const toggle = container.querySelector(".detail-description-toggle");
|
||||
expect(toggle?.textContent).toBe("Show less");
|
||||
expect(h2).toHaveClass("detail-title--collapsed");
|
||||
expect(await screen.findByRole("button", { name: "Show more" })).toHaveClass("detail-description-toggle");
|
||||
});
|
||||
|
||||
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);
|
||||
it("uses the title, description, and id fallback chain for the clamped heading", async () => {
|
||||
const { container: withTitle } = renderDetail({
|
||||
title: "Title wins",
|
||||
description: "Description loses",
|
||||
});
|
||||
expect(withTitle.querySelector("h2.detail-title")?.textContent).toBe("Title wins");
|
||||
expect(withTitle.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
|
||||
expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument();
|
||||
|
||||
const h2 = container.querySelector("h2.detail-title");
|
||||
expect(h2?.textContent).toBe("B".repeat(250));
|
||||
expect(toggle.textContent).toBe("Show less");
|
||||
});
|
||||
|
||||
it("lets Show less and Show more override the triage default for the current task", async () => {
|
||||
const longDescription = "C".repeat(250);
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
column: "triage",
|
||||
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;
|
||||
expect(container.querySelector("h2.detail-title")?.textContent).toBe("C".repeat(250));
|
||||
expect(toggle.textContent).toBe("Show less");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
setTitleLayout({ scrollHeight: 40, clientHeight: 40 });
|
||||
const { container: withDescription } = renderDetail({
|
||||
title: undefined,
|
||||
description: "Description fallback",
|
||||
});
|
||||
expect(withDescription.querySelector("h2.detail-title")?.textContent).toBe("Description fallback");
|
||||
expect(withDescription.querySelector(".detail-description-toggle")).toBeNull();
|
||||
|
||||
expect(container.querySelector("h2.detail-title")?.textContent).toBe("C".repeat(200) + "…");
|
||||
expect(toggle.textContent).toBe("Show more");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
const { container: withId } = renderDetail({
|
||||
id: "FN-FALLBACK",
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
|
||||
expect(container.querySelector("h2.detail-title")?.textContent).toBe("C".repeat(250));
|
||||
expect(toggle.textContent).toBe("Show less");
|
||||
expect(withId.querySelector("h2.detail-title")?.textContent).toBe("FN-FALLBACK");
|
||||
expect(withId.querySelector(".detail-description-toggle")).toBeNull();
|
||||
});
|
||||
|
||||
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}
|
||||
/>,
|
||||
);
|
||||
it.each(["todo", "in-progress", "in-review", "done", "archived"] as const)(
|
||||
"collapses overflowing non-triage %s title by default",
|
||||
async (column) => {
|
||||
const longTitle = `${column} title `.repeat(25);
|
||||
const { container } = renderDetail({
|
||||
column,
|
||||
title: longTitle,
|
||||
});
|
||||
|
||||
// First expand
|
||||
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(longTitle);
|
||||
expect(h2).toHaveClass("detail-title--collapsed");
|
||||
expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not render an empty toggle shell when the title fits within two lines", () => {
|
||||
setTitleLayout({ scrollHeight: 40, clientHeight: 40 });
|
||||
const { container } = renderDetail({
|
||||
title: "Short title",
|
||||
description: "This is a longer description that is not shown as the heading while title is present",
|
||||
});
|
||||
|
||||
// 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 empty title and description fallback to task id", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
id: "FN-EMPTY",
|
||||
column: "triage",
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const h2 = container.querySelector("h2.detail-title");
|
||||
expect(h2?.textContent).toBe("FN-EMPTY");
|
||||
expect(container.querySelector(".detail-description-toggle")).toBeNull();
|
||||
});
|
||||
|
||||
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(h2).toHaveClass("detail-title--collapsed");
|
||||
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}
|
||||
/>,
|
||||
);
|
||||
it("collapses again when Show less is clicked", async () => {
|
||||
const longDescription = "C".repeat(250);
|
||||
const { container } = renderDetail({
|
||||
title: undefined,
|
||||
description: longDescription,
|
||||
});
|
||||
|
||||
const toggle = await screen.findByRole("button", { name: "Show more" });
|
||||
await userEvent.click(toggle);
|
||||
expect(container.querySelector("h2.detail-title")?.textContent).toBe(longDescription);
|
||||
expect(container.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Show less" }));
|
||||
|
||||
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");
|
||||
expect(h2?.textContent).toBe(longDescription);
|
||||
expect(h2).toHaveClass("detail-title--collapsed");
|
||||
expect(screen.getByRole("button", { name: "Show more" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets to expanded when switching from a non-triage task to a triage task", async () => {
|
||||
it("resets to collapsed when switching from a non-triage task to a triage task", async () => {
|
||||
const todoDescription = "G".repeat(250);
|
||||
const triageDescription = "H".repeat(250);
|
||||
const { container, rerender } = render(
|
||||
@@ -1577,8 +1481,8 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("h2.detail-title")?.textContent).toBe("G".repeat(200) + "…");
|
||||
expect(container.querySelector(".detail-description-toggle")?.textContent).toBe("Show more");
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Show more" }));
|
||||
expect(container.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
|
||||
|
||||
rerender(
|
||||
<TaskDetailModal
|
||||
@@ -1598,60 +1502,71 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("h2.detail-title")?.textContent).toBe("H".repeat(250));
|
||||
expect(container.querySelector("h2.detail-title")?.textContent).toBe(triageDescription);
|
||||
});
|
||||
expect(container.querySelector(".detail-description-toggle")?.textContent).toBe("Show less");
|
||||
expect(container.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
|
||||
expect(screen.getByRole("button", { name: "Show more" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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);
|
||||
it("keeps the editing title form unaffected by the read-only clamp", async () => {
|
||||
const longTitle = "Editable title ".repeat(25);
|
||||
const { container } = renderDetail({
|
||||
column: "todo",
|
||||
title: longTitle,
|
||||
description: "Editable description",
|
||||
});
|
||||
|
||||
// Verify expanded
|
||||
const h2Before = container.querySelector("h2.detail-title");
|
||||
expect(h2Before?.textContent).toBe("E".repeat(250));
|
||||
expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit task" }));
|
||||
|
||||
// Change to a different task
|
||||
rerender(
|
||||
<TaskDetailModal
|
||||
expect(container.querySelector("h2.detail-title")).toBeNull();
|
||||
expect(container.querySelector(".detail-description-toggle")).toBeNull();
|
||||
expect(screen.getByLabelText("Title")).toHaveValue(longTitle);
|
||||
});
|
||||
|
||||
it("keeps the summarize-title affordance aligned next to the clamped title", async () => {
|
||||
const { container } = renderDetail({
|
||||
column: "todo",
|
||||
title: "Summarize me ".repeat(25),
|
||||
description: "Description available for summarization",
|
||||
});
|
||||
|
||||
expect(container.querySelector(".detail-heading-row h2.detail-title--collapsed")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("summarize-title-btn")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the clamp available in chat-expanded layout", async () => {
|
||||
const { container } = render(
|
||||
<TaskDetailContent
|
||||
task={makeTask({
|
||||
id: "FN-002",
|
||||
title: undefined,
|
||||
description: longDescription2,
|
||||
column: "todo",
|
||||
title: "Chat expanded title ".repeat(25),
|
||||
description: "Description",
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
initialTab="chat"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should be collapsed again
|
||||
const h2After = container.querySelector("h2.detail-title");
|
||||
expect(h2After?.textContent).toBe("F".repeat(200) + "…");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Expand chat to full modal" }));
|
||||
|
||||
expect(container.querySelector(".task-detail-content--chat-expanded")).toBeInTheDocument();
|
||||
expect(container.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
|
||||
expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has desktop and mobile CSS rules that preserve the two-line title clamp", () => {
|
||||
const css = readDashboardStylesSource();
|
||||
expect(css).toContain(".detail-title--collapsed");
|
||||
expectBaseRule(css, ".detail-title--collapsed", "-webkit-line-clamp: 2");
|
||||
expectBaseRule(css, ".detail-title--collapsed", "line-clamp: 2");
|
||||
expect(css).toContain("@media (max-width: 768px)");
|
||||
expectBaseRule(css, ".detail-title", "font-size: 16px");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user