feat(FN-3190): fix ListView split-pane UX follow-up issues
Fixes FN-3190 addresses follow-up UX feedback on the split-pane component in ListView, updating the layout styles and component logic with accompanying tests. Fusion-Task-Id: FN-3190
This commit is contained in:
@@ -38,6 +38,10 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.list-clear-column-filter-btn {
|
||||
margin-left: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Selection stats */
|
||||
.list-selection-stats {
|
||||
display: flex;
|
||||
@@ -262,14 +266,14 @@
|
||||
|
||||
.list-drop-zone.active {
|
||||
border-color: var(--todo);
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
background: color-mix(in srgb, var(--todo) 15%, transparent);
|
||||
box-shadow: 0 0 0 1px var(--todo);
|
||||
}
|
||||
|
||||
.list-drop-zone.drag-over {
|
||||
border-color: var(--todo);
|
||||
box-shadow: 0 0 0 1px var(--todo);
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
background: color-mix(in srgb, var(--todo) 10%, transparent);
|
||||
}
|
||||
|
||||
.drop-zone-dot {
|
||||
@@ -302,6 +306,52 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.list-split-layout {
|
||||
display: grid;
|
||||
grid-template-columns: auto var(--space-xs) minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.list-split-sidebar {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.list-split-resize-handle {
|
||||
cursor: col-resize;
|
||||
background: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.list-split-resize-handle:hover,
|
||||
.list-split-resize-handle:focus-visible {
|
||||
background: color-mix(in srgb, var(--todo) 35%, transparent);
|
||||
}
|
||||
|
||||
.list-split-resize-handle:focus-visible {
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.list-split-detail {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
padding: var(--space-xl);
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list-split-detail p {
|
||||
margin-block: 0;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -332,7 +382,7 @@
|
||||
|
||||
/* Reduce padding on ID column header to match data cells */
|
||||
.list-table th:first-child.list-header-cell {
|
||||
padding-right: 8px;
|
||||
padding-right: var(--space-sm);
|
||||
}
|
||||
|
||||
.list-header-cell:hover {
|
||||
@@ -361,6 +411,11 @@
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.list-row--selected {
|
||||
background: color-mix(in srgb, var(--todo) 12%, transparent);
|
||||
box-shadow: inset 0 0 0 1px var(--todo);
|
||||
}
|
||||
|
||||
.list-row.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -653,7 +708,7 @@
|
||||
@keyframes section-expand {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(calc(var(--space-xs) * -1));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -662,7 +717,7 @@
|
||||
}
|
||||
|
||||
.list-section-header ~ tr {
|
||||
animation: section-expand 0.2s ease-out;
|
||||
animation: section-expand var(--transition-normal) ease-out;
|
||||
}
|
||||
|
||||
/* Empty section row */
|
||||
@@ -716,6 +771,22 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.list-split-layout {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.list-split-sidebar,
|
||||
.list-split-detail {
|
||||
overflow: visible;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.list-split-resize-handle,
|
||||
.list-split-detail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.list-table {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { isTaskStuck } from "../utils/taskStuck";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
@@ -111,6 +111,48 @@ function readSelectedTaskIds(projectId?: string): Set<string> {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
function readSelectedTaskId(projectId?: string): string | null {
|
||||
try {
|
||||
const saved = getScopedItem("kb-dashboard-list-selected-task", projectId);
|
||||
if (typeof saved === "string" && saved.trim().length > 0) {
|
||||
return saved;
|
||||
}
|
||||
} catch {
|
||||
// Invalid localStorage data - fall through to default
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readSidebarWidth(projectId?: string): number {
|
||||
const fallbackWidth = 400;
|
||||
try {
|
||||
const saved = getScopedItem("kb-dashboard-list-sidebar-width", projectId);
|
||||
if (!saved) return fallbackWidth;
|
||||
const parsed = Number(saved);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Invalid localStorage data - fall through to default
|
||||
}
|
||||
|
||||
return fallbackWidth;
|
||||
}
|
||||
|
||||
const LIST_SIDEBAR_MIN_WIDTH = 280;
|
||||
const LIST_SIDEBAR_MAX_RATIO = 0.65;
|
||||
const LIST_SIDEBAR_KEYBOARD_STEP = 16;
|
||||
|
||||
function getSidebarMaxWidth(containerWidth: number): number {
|
||||
return Math.max(LIST_SIDEBAR_MIN_WIDTH, containerWidth * LIST_SIDEBAR_MAX_RATIO);
|
||||
}
|
||||
|
||||
function clampSidebarWidth(width: number, containerWidth: number): number {
|
||||
const maxWidth = getSidebarMaxWidth(containerWidth);
|
||||
return Math.min(Math.max(width, LIST_SIDEBAR_MIN_WIDTH), maxWidth);
|
||||
}
|
||||
|
||||
interface ListViewProps {
|
||||
tasks: Task[];
|
||||
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
||||
@@ -235,12 +277,18 @@ export function ListView({
|
||||
|
||||
// Selection state - initialize from localStorage
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(() => readSelectedTaskIds(projectId));
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(() => readSelectedTaskId(projectId));
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readSidebarWidth(projectId));
|
||||
const splitLayoutRef = useRef<HTMLDivElement>(null);
|
||||
const splitSidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleColumns(readVisibleColumns(projectId));
|
||||
setHideDoneTasks(readHideDoneTasks(projectId));
|
||||
setCollapsedSections(readCollapsedSections(projectId));
|
||||
setSelectedTaskIds(readSelectedTaskIds(projectId));
|
||||
setSelectedTaskId(readSelectedTaskId(projectId));
|
||||
setSidebarWidth(readSidebarWidth(projectId));
|
||||
}, [projectId]);
|
||||
|
||||
// Persist selection to localStorage
|
||||
@@ -250,6 +298,72 @@ export function ListView({
|
||||
}
|
||||
}, [projectId, selectedTaskIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (selectedTaskId) {
|
||||
setScopedItem("kb-dashboard-list-selected-task", selectedTaskId, projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
removeScopedItem("kb-dashboard-list-selected-task", projectId);
|
||||
}, [projectId, selectedTaskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTaskId) return;
|
||||
if (!tasks.some((task) => task.id === selectedTaskId)) {
|
||||
setSelectedTaskId(null);
|
||||
}
|
||||
}, [selectedTaskId, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || typeof ResizeObserver === "undefined") return;
|
||||
const container = splitLayoutRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const applyClamp = () => {
|
||||
// Keep width valid when viewport/container size changes.
|
||||
const clamped = clampSidebarWidth(sidebarWidth, container.clientWidth);
|
||||
if (clamped !== sidebarWidth) {
|
||||
setSidebarWidth(clamped);
|
||||
}
|
||||
};
|
||||
|
||||
applyClamp();
|
||||
const observer = new ResizeObserver(applyClamp);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile, sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || typeof ResizeObserver === "undefined") return;
|
||||
const sidebar = splitSidebarRef.current;
|
||||
const container = splitLayoutRef.current;
|
||||
if (!sidebar || !container) return;
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastSavedWidth = sidebar.offsetWidth;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextWidth = clampSidebarWidth(sidebar.offsetWidth, container.clientWidth);
|
||||
if (nextWidth === lastSavedWidth) return;
|
||||
lastSavedWidth = nextWidth;
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
try {
|
||||
setScopedItem("kb-dashboard-list-sidebar-width", String(nextWidth), projectId);
|
||||
} catch {
|
||||
// localStorage persistence is best-effort.
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
|
||||
observer.observe(sidebar);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
};
|
||||
}, [isMobile, projectId]);
|
||||
|
||||
// Toggle task selection
|
||||
const toggleTaskSelection = useCallback((taskId: string) => {
|
||||
setSelectedTaskIds((prev) => {
|
||||
@@ -601,9 +715,14 @@ export function ListView({
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(task: Task) => {
|
||||
onOpenDetail(task);
|
||||
if (isMobile) {
|
||||
onOpenDetail(task);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTaskId(task.id);
|
||||
},
|
||||
[onOpenDetail]
|
||||
[isMobile, onOpenDetail]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
@@ -624,6 +743,54 @@ export function ListView({
|
||||
setDragOverColumn(null);
|
||||
}, []);
|
||||
|
||||
const handleSplitResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isMobile) return;
|
||||
event.preventDefault();
|
||||
const container = splitLayoutRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const proposedWidth = moveEvent.clientX - rect.left;
|
||||
setSidebarWidth(clampSidebarWidth(proposedWidth, rect.width));
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
}, [isMobile]);
|
||||
|
||||
const handleSplitResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isMobile) return;
|
||||
const measuredWidth = splitLayoutRef.current?.clientWidth ?? 0;
|
||||
const fallbackWidth = sidebarWidth / LIST_SIDEBAR_MAX_RATIO + LIST_SIDEBAR_KEYBOARD_STEP;
|
||||
const containerWidth = Math.max(measuredWidth, fallbackWidth);
|
||||
|
||||
const maxWidth = getSidebarMaxWidth(containerWidth);
|
||||
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowLeft" ? -LIST_SIDEBAR_KEYBOARD_STEP : LIST_SIDEBAR_KEYBOARD_STEP;
|
||||
setSidebarWidth((current) => clampSidebarWidth(current + delta, containerWidth));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setSidebarWidth(LIST_SIDEBAR_MIN_WIDTH);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setSidebarWidth(maxWidth);
|
||||
}
|
||||
}, [isMobile, sidebarWidth]);
|
||||
|
||||
const handleColumnDragOver = useCallback(
|
||||
(e: React.DragEvent, column: Column) => {
|
||||
e.preventDefault();
|
||||
@@ -724,10 +891,9 @@ export function ListView({
|
||||
)}
|
||||
{selectedColumn && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
className="btn btn-sm list-clear-column-filter-btn"
|
||||
onClick={clearColumnFilter}
|
||||
aria-label="Clear column filter"
|
||||
style={{ marginLeft: "8px" }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
@@ -835,22 +1001,29 @@ export function ListView({
|
||||
</div>
|
||||
|
||||
<div className="list-table-container">
|
||||
<div className="list-quick-entry-above-table">
|
||||
<QuickEntryBox
|
||||
onCreate={onQuickCreate ?? (async () => addToast("Task creation not available", "error"))}
|
||||
addToast={addToast}
|
||||
tasks={tasks}
|
||||
availableModels={availableModels}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
projectId={projectId}
|
||||
autoExpand={false}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
<div className={isMobile ? "" : "list-split-layout"} data-testid={isMobile ? undefined : "list-split-layout"} ref={splitLayoutRef}>
|
||||
<div
|
||||
className={isMobile ? "" : "list-split-sidebar"}
|
||||
data-testid={isMobile ? undefined : "list-split-sidebar"}
|
||||
ref={splitSidebarRef}
|
||||
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<div className="list-quick-entry-above-table">
|
||||
<QuickEntryBox
|
||||
onCreate={onQuickCreate ?? (async () => addToast("Task creation not available", "error"))}
|
||||
addToast={addToast}
|
||||
tasks={tasks}
|
||||
availableModels={availableModels}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
projectId={projectId}
|
||||
autoExpand={false}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
{filteredCount === 0 ? (
|
||||
<div className="list-empty">
|
||||
{searchQuery ? "No tasks match your filter" : "No tasks yet"}
|
||||
@@ -1101,7 +1274,7 @@ export function ListView({
|
||||
isStuckState ? " stuck" : ""
|
||||
}${isAgentActive ? " agent-active" : ""}${
|
||||
isDragging ? " dragging" : ""
|
||||
}`}
|
||||
}${selectedTaskId === task.id ? " list-row--selected" : ""}`}
|
||||
onClick={() => handleRowClick(task)}
|
||||
draggable={!isPaused}
|
||||
onDragStart={(e) => handleDragStart(e, task)}
|
||||
@@ -1218,6 +1391,37 @@ export function ListView({
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<div
|
||||
className="list-split-resize-handle"
|
||||
data-testid="list-split-resize-handle"
|
||||
onMouseDown={handleSplitResizeStart}
|
||||
onKeyDown={handleSplitResizeKeyDown}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize task list sidebar"
|
||||
aria-valuemin={LIST_SIDEBAR_MIN_WIDTH}
|
||||
aria-valuemax={Math.round(
|
||||
getSidebarMaxWidth(
|
||||
splitLayoutRef.current?.clientWidth ??
|
||||
(sidebarWidth / LIST_SIDEBAR_MAX_RATIO + LIST_SIDEBAR_KEYBOARD_STEP)
|
||||
)
|
||||
)}
|
||||
aria-valuenow={Math.round(sidebarWidth)}
|
||||
/>
|
||||
<div className="list-split-detail" data-testid="list-split-detail">
|
||||
{!selectedTaskId ? (
|
||||
<p>Select a task to view details</p>
|
||||
) : (
|
||||
<p>Task selected. Detail view coming soon.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -101,6 +101,18 @@ function mockDesktopViewport() {
|
||||
describe("ListView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
ensureMatchMedia();
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
@@ -211,7 +223,8 @@ describe("ListView", () => {
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenDetail synchronously with Task when row is clicked", async () => {
|
||||
it("updates selectedTaskId on desktop row click without opening detail", () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
|
||||
const mockOnOpenDetail = vi.fn();
|
||||
|
||||
@@ -220,21 +233,135 @@ describe("ListView", () => {
|
||||
const row = screen.getByText("FN-001").closest("tr");
|
||||
fireEvent.click(row!);
|
||||
|
||||
// Should call onOpenDetail synchronously with the Task object (no fetch)
|
||||
expect(mockOnOpenDetail).toHaveBeenCalledWith(tasks[0]);
|
||||
expect(mockOnOpenDetail).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenDetail).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001");
|
||||
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
||||
expect(row?.className).toContain("list-row--selected");
|
||||
expect(screen.getByText("Task selected. Detail view coming soon.")).toBeInTheDocument();
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not call fetchTaskDetail on row click", () => {
|
||||
it("calls onOpenDetail on mobile row click", () => {
|
||||
const viewportSpy = mockMobileViewport();
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
|
||||
const mockOnOpenDetail = vi.fn();
|
||||
|
||||
renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr");
|
||||
fireEvent.click(row!);
|
||||
const card = document.querySelector('.list-card[data-id="FN-001"]');
|
||||
fireEvent.click(card!);
|
||||
|
||||
expect(mockOnOpenDetail).toHaveBeenCalledWith(tasks[0]);
|
||||
expect(mockOnOpenDetail).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps selectedTaskIds and selectedTaskId as separate persisted state", async () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr")!;
|
||||
const checkbox = within(row).getByRole("checkbox", { name: "Select FN-001" });
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(row);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-selected-tasks"))).toContain("FN-001");
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001");
|
||||
});
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("initializes selectedTaskId from persisted project storage", () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-list-selected-task"), "FN-001");
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Persisted task" })];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001");
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renders desktop split-pane shell with resize handle and empty detail state", () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
expect(screen.getByTestId("list-split-layout")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("list-split-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("list-split-resize-handle")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("list-split-detail")).toBeInTheDocument();
|
||||
expect(screen.getByText("Select a task to view details")).toBeInTheDocument();
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("reloads persisted sidebar width when projectId changes", () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
localStorage.setItem(scopedKey("kb-dashboard-list-sidebar-width", "project-a"), "300");
|
||||
localStorage.setItem(scopedKey("kb-dashboard-list-sidebar-width", "project-b"), "460");
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
|
||||
|
||||
const { rerender } = render(
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
onMoveTask={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={mockAddToast}
|
||||
projectId="project-a"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "300px" });
|
||||
|
||||
rerender(
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
onMoveTask={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={mockAddToast}
|
||||
projectId="project-b"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("list-split-sidebar")).toHaveStyle({ width: "460px" });
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("supports keyboard resizing on the desktop split-pane handle", () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
const handle = screen.getByTestId("list-split-resize-handle");
|
||||
const startWidth = Number(handle.getAttribute("aria-valuenow"));
|
||||
|
||||
expect(handle).toHaveAttribute("tabindex", "0");
|
||||
expect(handle).toHaveAttribute("aria-valuemin", "280");
|
||||
expect(Number(handle.getAttribute("aria-valuemax"))).toBeGreaterThanOrEqual(280);
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
||||
|
||||
expect(Number(handle.getAttribute("aria-valuenow"))).toBeGreaterThan(startWidth);
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not render split-pane structure on mobile", () => {
|
||||
const viewportSpy = mockMobileViewport();
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Task" })];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
expect(screen.queryByTestId("list-split-layout")).toBeNull();
|
||||
expect(screen.queryByTestId("list-split-resize-handle")).toBeNull();
|
||||
expect(screen.queryByTestId("list-split-detail")).toBeNull();
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sorts tasks by ID when ID header is clicked", () => {
|
||||
|
||||
@@ -79,6 +79,8 @@ describe("projectStorage", () => {
|
||||
"kb-dashboard-hide-done",
|
||||
"kb-dashboard-list-collapsed",
|
||||
"kb-dashboard-selected-tasks",
|
||||
"kb-dashboard-list-selected-task",
|
||||
"kb-dashboard-list-sidebar-width",
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"fn-agent-view",
|
||||
@@ -93,7 +95,7 @@ describe("projectStorage", () => {
|
||||
"kb-chat-active-session",
|
||||
]),
|
||||
);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(17);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(19);
|
||||
});
|
||||
|
||||
it("has no overlap between global and project-scoped keys", () => {
|
||||
|
||||
@@ -12,6 +12,8 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
||||
"kb-dashboard-hide-done",
|
||||
"kb-dashboard-list-collapsed",
|
||||
"kb-dashboard-selected-tasks",
|
||||
"kb-dashboard-list-selected-task",
|
||||
"kb-dashboard-list-sidebar-width",
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"fn-agent-view",
|
||||
|
||||
Reference in New Issue
Block a user