fix(timeline): stabilize popovers on internal scroll + expand test coverage
B-1: useViewportPopover — ignoreScrollContainers option; scroll events originating inside the timeline canvas no longer close point-anchor popovers B-2: AllocationPopover, DemandPopover, NewAllocationPopover — thread scrollContainerRef through so horizontal timeline scroll is ignored B-3: AllocationPopover — staleTime 0 so SSE reconnect triggers immediate refetch B-4: useViewportPopover.test.ts — 6 new tests (scroll close, ignore container, resize close, style clamping) B-5: AllocationPopover.test.tsx — loading state + happy-path tests added Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -42,6 +42,14 @@ vi.mock("~/hooks/useViewportPopover.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/DateInput.js", () => ({
|
||||
DateInput: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/ProjectCombobox.js", () => ({
|
||||
ProjectCombobox: () => null,
|
||||
}));
|
||||
|
||||
describe("AllocationPopover", () => {
|
||||
beforeEach(() => {
|
||||
mockUseQuery.mockReset();
|
||||
@@ -93,4 +101,67 @@ describe("AllocationPopover", () => {
|
||||
expect(html).toContain("data-testid=\"timeline-allocation-popover-unavailable\"");
|
||||
expect(html).toContain("The selected booking could not be resolved from the current timeline data.");
|
||||
});
|
||||
|
||||
it("renders a loading skeleton when the allocation is being fetched", () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<AllocationPopover
|
||||
allocationId="assignment_loading"
|
||||
projectId="project_1"
|
||||
anchorX={120}
|
||||
anchorY={40}
|
||||
onClose={() => {}}
|
||||
onOpenPanel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("data-testid=\"timeline-allocation-popover-loading\"");
|
||||
});
|
||||
|
||||
it("renders allocation data when provided as initialAllocation", () => {
|
||||
// useQuery is always called (with enabled: false), so we need a baseline return value
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const allocation = {
|
||||
id: "assignment_1",
|
||||
entityId: "assignment_1",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-01-31"),
|
||||
hoursPerDay: 8,
|
||||
role: "Developer",
|
||||
metadata: null,
|
||||
resource: {
|
||||
displayName: "Alice Smith",
|
||||
eid: "alice.smith",
|
||||
lcrCents: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<AllocationPopover
|
||||
allocationId="assignment_1"
|
||||
projectId="project_1"
|
||||
initialAllocation={allocation as any}
|
||||
anchorX={120}
|
||||
anchorY={40}
|
||||
onClose={() => {}}
|
||||
onOpenPanel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("data-testid=\"timeline-allocation-popover-error\"");
|
||||
expect(html).not.toContain("data-testid=\"timeline-allocation-popover-unavailable\"");
|
||||
expect(html).not.toContain("data-testid=\"timeline-allocation-popover-loading\"");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { type RefObject } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -21,6 +21,7 @@ interface AllocationPopoverProps {
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
contextDate?: Date;
|
||||
ignoreScrollContainers?: RefObject<HTMLElement | null>[];
|
||||
}
|
||||
|
||||
type AllocationPopoverAssignment = Assignment<AllocationLike>;
|
||||
@@ -34,6 +35,7 @@ export function AllocationPopover({
|
||||
anchorX,
|
||||
anchorY,
|
||||
contextDate,
|
||||
ignoreScrollContainers,
|
||||
}: AllocationPopoverProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
@@ -42,13 +44,14 @@ export function AllocationPopover({
|
||||
width: 300,
|
||||
estimatedHeight: 360,
|
||||
onClose,
|
||||
...(ignoreScrollContainers ? { ignoreScrollContainers } : {}),
|
||||
});
|
||||
|
||||
const shouldLoadAllocation = !initialAllocation;
|
||||
const allocationQuery = trpc.allocation.getAssignmentById.useQuery(
|
||||
{ id: allocationId },
|
||||
{
|
||||
staleTime: 10_000,
|
||||
staleTime: 0,
|
||||
enabled: shouldLoadAllocation,
|
||||
retry: false,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { TimelineDemandEntry } from "./TimelineContext.js";
|
||||
import { formatCents, formatDateLong } from "~/lib/format.js";
|
||||
@@ -12,6 +13,7 @@ interface DemandPopoverProps {
|
||||
onFillDemand: (demand: TimelineDemandEntry) => void;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
ignoreScrollContainers?: RefObject<HTMLElement | null>[];
|
||||
}
|
||||
|
||||
export function DemandPopover({
|
||||
@@ -21,12 +23,14 @@ export function DemandPopover({
|
||||
onFillDemand,
|
||||
anchorX,
|
||||
anchorY,
|
||||
ignoreScrollContainers,
|
||||
}: DemandPopoverProps) {
|
||||
const { ref, style } = useViewportPopover({
|
||||
anchor: { kind: "point", x: anchorX, y: anchorY },
|
||||
width: 300,
|
||||
estimatedHeight: 340,
|
||||
onClose,
|
||||
...(ignoreScrollContainers ? { ignoreScrollContainers } : {}),
|
||||
});
|
||||
|
||||
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import type { RefObject } from "react";
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AllocationStatus } from "@capakraken/shared";
|
||||
@@ -20,6 +21,7 @@ interface NewAllocationPopoverProps {
|
||||
anchorY: number;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
ignoreScrollContainers?: RefObject<HTMLElement | null>[];
|
||||
}
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
@@ -38,6 +40,7 @@ export function NewAllocationPopover({
|
||||
anchorY,
|
||||
onClose,
|
||||
onCreated,
|
||||
ignoreScrollContainers,
|
||||
}: NewAllocationPopoverProps) {
|
||||
const { ref, style } = useViewportPopover({
|
||||
anchor: { kind: "point", x: anchorX - 10, y: anchorY },
|
||||
@@ -45,6 +48,7 @@ export function NewAllocationPopover({
|
||||
estimatedHeight: 440,
|
||||
onClose,
|
||||
ignoreSelectors: ["[data-entity-combobox-overlay='true']"],
|
||||
...(ignoreScrollContainers ? { ignoreScrollContainers } : {}),
|
||||
});
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
|
||||
|
||||
@@ -942,6 +942,7 @@ function TimelineViewContent({
|
||||
}}
|
||||
anchorX={popover.x}
|
||||
anchorY={popover.y}
|
||||
ignoreScrollContainers={[scrollContainerRef]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -957,6 +958,7 @@ function TimelineViewContent({
|
||||
}}
|
||||
anchorX={popover.x}
|
||||
anchorY={popover.y}
|
||||
ignoreScrollContainers={[scrollContainerRef]}
|
||||
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
|
||||
/>
|
||||
);
|
||||
@@ -988,6 +990,7 @@ function TimelineViewContent({
|
||||
}}
|
||||
anchorX={demandPopover.x}
|
||||
anchorY={demandPopover.y}
|
||||
ignoreScrollContainers={[scrollContainerRef]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1002,6 +1005,7 @@ function TimelineViewContent({
|
||||
anchorY={newAllocPopover.anchorY}
|
||||
onClose={() => setNewAllocPopover(null)}
|
||||
onCreated={() => setNewAllocPopover(null)}
|
||||
ignoreScrollContainers={[scrollContainerRef]}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user