feat(dashboard): list-view count/border cleanup, overview buttons match Stop AI Engine, center graph on load
- List view: remove the 'X of Y tasks' count from the desktop sidebar toolbar and the border between the controls row and the quick-add box (mobile count kept). Count-display tests now verify the filter via rendered rows. - Command Center Overview: View Board / View Agents are btn btn-secondary (taller, centered) matching the Stop AI Engine button. - Dependency graph: fit/center on load — the initial fit ran before the viewport was measured + before nodes were positioned (async), and never re-fit. Now fits only when fittable (nodes + measured viewport) and re-fits when the node set changes (covers async load + re-entering the view). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,12 +17,12 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* FNXC:ListView 2026-06-23-00:00: No border below the controls — the toolbar (Bulk Edit / View options / New Task) flows straight into the quick-add box with no divider between them. */
|
||||
.list-sidebar-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
|
||||
@@ -1985,15 +1985,8 @@ export function ListView({
|
||||
*/}
|
||||
<div className="list-sidebar-controls__header">
|
||||
{renderWorkflowSelector()}
|
||||
{/* FNXC:ListView 2026-06-23-00:00: The "X of Y tasks" count is removed from the sidebar toolbar per user request — the Bulk Edit / View options / New Task actions are the sole content of this row. */}
|
||||
<div className="list-sidebar-controls__toolbar">
|
||||
<p className="list-stats list-stats--compact">
|
||||
{selectedColumn
|
||||
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: getListColumnLabel(selectedColumn) })
|
||||
: t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
|
||||
{hiddenCompletedCount > 0 && !selectedColumn && (
|
||||
<span className="list-stats-hidden"> ({t("listView.hidden", "{{count}} hidden", { count: hiddenCompletedCount })})</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="list-sidebar-controls__actions">
|
||||
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
|
||||
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
|
||||
|
||||
@@ -1558,7 +1558,8 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
|
||||
// FNXC:ListView 2026-06-23-00:00: the "X of Y tasks" count was removed from the desktop sidebar; verify the filter result via the rendered task rows instead.
|
||||
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("displays filtered task count in stats", () => {
|
||||
@@ -1570,7 +1571,8 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks, searchQuery: "Alpha" });
|
||||
|
||||
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
|
||||
// FNXC:ListView 2026-06-23-00:00: count removed from sidebar; assert the filtered rows.
|
||||
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("calls onNewTask when + New Task button is clicked", () => {
|
||||
@@ -2096,7 +2098,7 @@ describe("ListView Column Filtering", () => {
|
||||
fireEvent.click(triageZone);
|
||||
|
||||
// Stats should show filtered count with column name
|
||||
expect(screen.getByText("2 of 3 tasks in Planning")).toBeDefined();
|
||||
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("applies text filter within column filter", () => {
|
||||
@@ -2118,7 +2120,7 @@ describe("ListView Column Filtering", () => {
|
||||
expect(screen.queryByText("FN-003")).toBeNull();
|
||||
|
||||
// Stats should reflect combined filtering
|
||||
expect(screen.getByText("1 of 3 tasks in Planning")).toBeDefined();
|
||||
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("applies active class to selected column drop zone", () => {
|
||||
@@ -2525,16 +2527,15 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Initial stats should show all tasks
|
||||
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
|
||||
// Initial: all 3 tasks visible
|
||||
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(3);
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Stats should show filtered count with hidden indicator
|
||||
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
|
||||
expect(screen.getByText(/2 hidden/)).toBeDefined();
|
||||
// FNXC:ListView 2026-06-23-00:00: the count + "(N hidden)" indicator were removed from the sidebar; assert hiding done leaves only the 1 non-done task visible.
|
||||
expect(screen.getAllByRole("row").filter((r) => r.getAttribute("data-id"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hides done and archived column section headers when hide done is active", () => {
|
||||
|
||||
@@ -214,26 +214,57 @@ export function DependencyGraph({
|
||||
setGraphBounds,
|
||||
} = useGraphInteraction();
|
||||
|
||||
/*
|
||||
FNXC:Graph 2026-06-23-00:10:
|
||||
The Graph view must load CENTERED/fit in the viewport. This is a custom transform-based
|
||||
canvas (not React Flow), so "fit" = compute zoom/pan from node positions via fitToGraph.
|
||||
Two failure modes were producing an off-center / not-fit initial load:
|
||||
1. Async nodes: tasks (and their computed `positions`) arrive after first paint. The old
|
||||
guard latched `initialFitDoneRef` on the FIRST run, so it fit an empty/half-laid-out
|
||||
graph (positions still stale, viewport unmeasured) and never re-fit once real nodes existed.
|
||||
2. Re-entering the view: this component stays mounted when the user navigates away and back,
|
||||
so a latched ref meant no re-fit on re-activation.
|
||||
Fix: only fit once the graph is actually fittable — viewport measured (width/height > 0) AND
|
||||
`positions` populated — and re-fit whenever the set of node ids changes (fitNodeKey) so a
|
||||
fresh node set (re)centers. Guard against fitting an empty graph. User-saved positions still
|
||||
opt out of auto-fit (respect manual layout).
|
||||
*/
|
||||
// Stable signature of the current node set; changes when nodes (re)load so we re-center.
|
||||
const fitNodeKey = useMemo(
|
||||
() => Array.from(positions.keys()).sort().join("|"),
|
||||
[positions],
|
||||
);
|
||||
const lastFittedNodeKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFitDoneRef.current) return;
|
||||
if (filteredTasks.length === 0) return;
|
||||
if (positions.size === 0) return;
|
||||
|
||||
const hasSavedPositions = Boolean(savedPositions && Object.keys(savedPositions).length > 0);
|
||||
if (hasSavedPositions) {
|
||||
initialFitDoneRef.current = true;
|
||||
lastFittedNodeKeyRef.current = fitNodeKey;
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
// Viewport not yet measured: a 0-sized fit would mis-center. Wait for ResizeObserver.
|
||||
const viewportWidth = viewport.clientWidth || viewportSize.width;
|
||||
const viewportHeight = viewport.clientHeight || viewportSize.height;
|
||||
if (viewportWidth === 0 || viewportHeight === 0) return;
|
||||
|
||||
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, {
|
||||
// Re-fit on initial load AND whenever the node set changes (async (re)load / view re-entry).
|
||||
if (initialFitDoneRef.current && lastFittedNodeKeyRef.current === fitNodeKey) return;
|
||||
|
||||
fitToGraph(positions, viewportWidth, viewportHeight, {
|
||||
nodeWidth: NODE_WIDTH,
|
||||
nodeHeight: NODE_HEIGHT,
|
||||
measuredHeights,
|
||||
});
|
||||
initialFitDoneRef.current = true;
|
||||
}, [filteredTasks.length, fitToGraph, measuredHeights, positions, savedPositions]);
|
||||
lastFittedNodeKeyRef.current = fitNodeKey;
|
||||
}, [filteredTasks.length, fitNodeKey, fitToGraph, measuredHeights, positions, savedPositions, viewportSize.height, viewportSize.width]);
|
||||
|
||||
const bounds = useMemo(() => {
|
||||
const values = Array.from(positions.values());
|
||||
|
||||
@@ -190,9 +190,12 @@ describe("DependencyGraph", () => {
|
||||
expect(screen.queryByTestId("graph-task-node-F")).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-fits on initial load with active tasks", () => {
|
||||
it("auto-fits on initial load with active tasks", async () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
expect(fitToGraph).toHaveBeenCalled();
|
||||
// FNXC:Graph 2026-06-23-00:10: auto-fit only fires once the viewport is measured
|
||||
// (width/height > 0); a 0-sized fit would mis-center, so the guard waits for layout.
|
||||
setViewportSize(1200, 800);
|
||||
await waitFor(() => expect(fitToGraph).toHaveBeenCalled());
|
||||
expect(setGraphBounds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user