fix(timeline): clear multi-select on drag start and lock in SSE edge-case coverage
- useTimelineDrag: onProjectBarMouseDown and single-alloc drag path now reset multiSelectRef + multiSelectState before starting a new drag, so the FloatingActionBar is dismissed immediately when an unrelated drag begins - FloatingActionBar.test.tsx: 4 regression tests for the null-render guard (count=0) and all three label variants - useTimelineSSE.test.ts: 2 new tests — tab hides during pending reconnect timer (clears timer, resyncs on next open) and first-ever connection fails before any open (retry open still resyncs correctly) - assistant-tools-user-admin-inventory-read.test.ts: add isActive to expected findMany select shape (already in production, test was stale) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FloatingActionBar } from "./FloatingActionBar.js";
|
||||
|
||||
const noop = vi.fn();
|
||||
|
||||
describe("FloatingActionBar", () => {
|
||||
it("renders nothing when no items are selected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<FloatingActionBar
|
||||
selectedAllocationCount={0}
|
||||
selectedResourceCount={0}
|
||||
onDelete={noop}
|
||||
onAssign={noop}
|
||||
onClear={noop}
|
||||
isDeleting={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("shows allocation count label when allocations are selected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<FloatingActionBar
|
||||
selectedAllocationCount={2}
|
||||
selectedResourceCount={0}
|
||||
onDelete={noop}
|
||||
onAssign={noop}
|
||||
onClear={noop}
|
||||
isDeleting={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/2 allocations selected/i);
|
||||
});
|
||||
|
||||
it("shows resource count label when resources are selected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<FloatingActionBar
|
||||
selectedAllocationCount={0}
|
||||
selectedResourceCount={1}
|
||||
onDelete={noop}
|
||||
onAssign={noop}
|
||||
onClear={noop}
|
||||
isDeleting={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/1 resource selected/i);
|
||||
});
|
||||
|
||||
it("shows combined label when both allocations and resources are selected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<FloatingActionBar
|
||||
selectedAllocationCount={1}
|
||||
selectedResourceCount={1}
|
||||
onDelete={noop}
|
||||
onAssign={noop}
|
||||
onClear={noop}
|
||||
isDeleting={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/1 allocation.*1 resource/i);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface FloatingActionBarProps {
|
||||
|
||||
@@ -496,6 +496,8 @@ export function useTimelineDrag({
|
||||
},
|
||||
) => {
|
||||
if (e.button !== 0) return;
|
||||
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
||||
setMultiSelectState(INITIAL_MULTI_SELECT);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const state = createProjectDragState<DragState>({
|
||||
@@ -610,6 +612,8 @@ export function useTimelineDrag({
|
||||
|
||||
// ── Single allocation drag ────────────────────────────────────────────
|
||||
|
||||
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
||||
setMultiSelectState(INITIAL_MULTI_SELECT);
|
||||
const initial = createAllocationDragState<AllocDragState, AllocDragMode, AllocDragScope>({
|
||||
mode: opts.mode,
|
||||
scope: opts.scope,
|
||||
|
||||
@@ -240,4 +240,58 @@ describe("useTimelineSSE", () => {
|
||||
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides during pending reconnect timer — clears timer and resyncs on next open", () => {
|
||||
useTimelineSSE();
|
||||
|
||||
const firstConnection = MockEventSource.instances[0];
|
||||
expect(firstConnection).toBeDefined();
|
||||
|
||||
// Establish connection, then break it — schedules a 2s reconnect timer
|
||||
firstConnection?.emitOpen();
|
||||
firstConnection?.emitError(); // shouldResyncOnOpen = true, timer scheduled
|
||||
|
||||
// Tab hides while timer is still pending (no active `es`)
|
||||
mockDocument.setVisibility("hidden"); // clears timer, shouldResyncOnOpen stays true
|
||||
|
||||
// No timer should fire — still only one EventSource
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
|
||||
// Tab becomes visible — should connect fresh
|
||||
mockDocument.setVisibility("visible");
|
||||
expect(MockEventSource.instances).toHaveLength(2);
|
||||
|
||||
// Successful open on the new connection → resync
|
||||
const secondConnection = MockEventSource.instances[1];
|
||||
secondConnection?.emitOpen();
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
|
||||
expect(invalidateQueries.mock.calls).toEqual(
|
||||
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
|
||||
);
|
||||
});
|
||||
|
||||
it("resyncs after the first-ever connection attempt fails before any open", () => {
|
||||
useTimelineSSE();
|
||||
|
||||
const firstConnection = MockEventSource.instances[0];
|
||||
expect(firstConnection).toBeDefined();
|
||||
|
||||
// Error fires without any preceding open — shouldResyncOnOpen becomes true
|
||||
firstConnection?.emitError();
|
||||
|
||||
// Advance past the 2s backoff
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(MockEventSource.instances).toHaveLength(2);
|
||||
|
||||
// Retry succeeds
|
||||
const secondConnection = MockEventSource.instances[1];
|
||||
secondConnection?.emitOpen();
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
|
||||
expect(invalidateQueries.mock.calls).toEqual(
|
||||
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user