feat(KB-653): add board title truncation with tooltip
- Truncate long board titles to 140 characters with ellipsis in TaskCard\n- Add hover tooltip to display full title when truncated\n- Add comprehensive test cases for 140 character truncation edge cases\n- Include changeset for board title truncation fix
This commit is contained in:
7
.changeset/truncate-board-titles.md
Normal file
7
.changeset/truncate-board-titles.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Truncate board titles to 140 characters with ellipsis and tooltip
|
||||||
|
|
||||||
|
Task card titles in the board view are now truncated to a maximum of 140 characters. When a title exceeds this limit, an ellipsis (…) is appended and the full title is shown in a tooltip on hover. This prevents overly long titles from breaking the board layout while preserving full accessibility.
|
||||||
@@ -637,9 +637,16 @@ function TaskCardComponent({
|
|||||||
<span className="card-error-text">{task.error.length > 60 ? task.error.slice(0, 60) + "…" : task.error}</span>
|
<span className="card-error-text">{task.error.length > 60 ? task.error.slice(0, 60) + "…" : task.error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="card-title">
|
{/* Truncate title/description to 140 chars with ellipsis; full text in tooltip */}
|
||||||
{task.title || task.description || task.id}
|
{(() => {
|
||||||
</div>
|
const displayText = task.title || task.description || task.id;
|
||||||
|
const truncatedText = truncate(displayText, 140);
|
||||||
|
return (
|
||||||
|
<div className="card-title" title={displayText}>
|
||||||
|
{truncatedText}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{task.steps.length > 0 && (() => {
|
{task.steps.length > 0 && (() => {
|
||||||
const completedSteps = task.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
const completedSteps = task.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
||||||
const totalSteps = task.steps.length;
|
const totalSteps = task.steps.length;
|
||||||
@@ -735,5 +742,9 @@ function TaskCardComponent({
|
|||||||
const TOUCH_MOVE_THRESHOLD = 10; // pixels
|
const TOUCH_MOVE_THRESHOLD = 10; // pixels
|
||||||
const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
|
const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||||
|
}
|
||||||
|
|
||||||
export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual);
|
export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual);
|
||||||
TaskCard.displayName = "TaskCard";
|
TaskCard.displayName = "TaskCard";
|
||||||
|
|||||||
@@ -2564,3 +2564,148 @@ describe("TaskCard title display", () => {
|
|||||||
expect(cardTitle).toBeDefined();
|
expect(cardTitle).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for TaskCard title truncation to 140 characters.
|
||||||
|
*/
|
||||||
|
describe("TaskCard title truncation", () => {
|
||||||
|
const noopToast = vi.fn();
|
||||||
|
|
||||||
|
const makeTask = (overrides: Partial<Task> = {}): Task => ({
|
||||||
|
id: "FN-001",
|
||||||
|
description: "Test task",
|
||||||
|
column: "todo",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00Z",
|
||||||
|
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
} as Task);
|
||||||
|
|
||||||
|
it("displays short title unchanged (under 140 characters)", () => {
|
||||||
|
const shortTitle = "This is a short title";
|
||||||
|
const task = makeTask({ title: shortTitle });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskCard
|
||||||
|
task={task}
|
||||||
|
onOpenDetail={vi.fn()}
|
||||||
|
addToast={noopToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardTitle = screen.getByText(shortTitle);
|
||||||
|
expect(cardTitle).toBeDefined();
|
||||||
|
expect(cardTitle.textContent).toBe(shortTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays title exactly 140 characters unchanged", () => {
|
||||||
|
const exactTitle = "A".repeat(140);
|
||||||
|
const task = makeTask({ title: exactTitle });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskCard
|
||||||
|
task={task}
|
||||||
|
onOpenDetail={vi.fn()}
|
||||||
|
addToast={noopToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardTitle = screen.getByText(exactTitle);
|
||||||
|
expect(cardTitle).toBeDefined();
|
||||||
|
expect(cardTitle.textContent?.length).toBe(140);
|
||||||
|
expect(cardTitle.textContent).toBe(exactTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates title over 140 characters with ellipsis", () => {
|
||||||
|
const longTitle = "B".repeat(150);
|
||||||
|
const task = makeTask({ title: longTitle });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskCard
|
||||||
|
task={task}
|
||||||
|
onOpenDetail={vi.fn()}
|
||||||
|
addToast={noopToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show truncated text with ellipsis (140 chars + "…")
|
||||||
|
const expectedTruncated = "B".repeat(140) + "…";
|
||||||
|
const cardTitle = screen.getByText(expectedTruncated);
|
||||||
|
expect(cardTitle).toBeDefined();
|
||||||
|
expect(cardTitle.textContent?.length).toBe(141); // 140 + ellipsis
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates description fallback when no title and description is over 140 chars", () => {
|
||||||
|
const longDescription = "C".repeat(200);
|
||||||
|
const task = makeTask({ title: undefined, description: longDescription });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskCard
|
||||||
|
task={task}
|
||||||
|
onOpenDetail={vi.fn()}
|
||||||
|
addToast={noopToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show truncated description with ellipsis
|
||||||
|
const expectedTruncated = "C".repeat(140) + "…";
|
||||||
|
const cardTitle = screen.getByText(expectedTruncated);
|
||||||
|
expect(cardTitle).toBeDefined();
|
||||||
|
expect(cardTitle.textContent?.length).toBe(141);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes full untruncated text in title attribute for tooltip", () => {
|
||||||
|
const longTitle = "D".repeat(200);
|
||||||
|
const task = makeTask({ title: longTitle });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskCard
|
||||||
|
task={task}
|
||||||
|
onOpenDetail={vi.fn()}
|
||||||
|
addToast={noopToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The title attribute should contain the full untruncated text
|
||||||
|
const cardTitleElement = document.querySelector(".card-title");
|
||||||
|
expect(cardTitleElement).toBeDefined();
|
||||||
|
expect(cardTitleElement?.getAttribute("title")).toBe(longTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes full description in title attribute when using description fallback", () => {
|
||||||
|
const longDescription = "E".repeat(200);
|
||||||
|
const task = makeTask({ title: undefined, description: longDescription });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskCard
|
||||||
|
task={task}
|
||||||
|
onOpenDetail={vi.fn()}
|
||||||
|
addToast={noopToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardTitleElement = document.querySelector(".card-title");
|
||||||
|
expect(cardTitleElement).toBeDefined();
|
||||||
|
expect(cardTitleElement?.getAttribute("title")).toBe(longDescription);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes task id in title attribute when falling back to id", () => {
|
||||||
|
const task = makeTask({ title: undefined, description: "" });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskCard
|
||||||
|
task={task}
|
||||||
|
onOpenDetail={vi.fn()}
|
||||||
|
addToast={noopToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardTitleElement = document.querySelector(".card-title");
|
||||||
|
expect(cardTitleElement).toBeDefined();
|
||||||
|
expect(cardTitleElement?.getAttribute("title")).toBe("FN-001");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user