fix(web): keep segmented timeline allocations actionable

This commit is contained in:
2026-04-01 08:54:15 +02:00
parent 6249f61ce1
commit 4edf3a32ac
5 changed files with 334 additions and 48 deletions
@@ -0,0 +1,96 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AllocationPopover } from "./AllocationPopover.js";
const mockUseQuery = vi.fn();
const mockUseMutation = vi.fn(() => ({ mutate: vi.fn() }));
const mockUseUtils = vi.fn(() => ({
allocation: {
getAssignmentById: { invalidate: vi.fn() },
listView: { invalidate: vi.fn() },
},
}));
vi.mock("~/lib/trpc/client.js", () => ({
trpc: {
useUtils: () => mockUseUtils(),
allocation: {
getAssignmentById: {
useQuery: (...args: unknown[]) => mockUseQuery(...args),
},
},
timeline: {
updateAllocationInline: {
useMutation: () => mockUseMutation(),
},
carveAllocationRange: {
useMutation: () => mockUseMutation(),
},
},
},
}));
vi.mock("~/hooks/useInvalidatePlanningViews.js", () => ({
useInvalidateTimeline: () => vi.fn(),
}));
vi.mock("~/hooks/useViewportPopover.js", () => ({
useViewportPopover: () => ({
ref: { current: null },
style: {},
}),
}));
describe("AllocationPopover", () => {
beforeEach(() => {
mockUseQuery.mockReset();
mockUseMutation.mockClear();
mockUseUtils.mockClear();
});
it("renders an error state when the allocation lookup fails", () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error("Assignment not found"),
});
const html = renderToStaticMarkup(
<AllocationPopover
allocationId="assignment_missing"
projectId="project_1"
anchorX={120}
anchorY={40}
onClose={() => {}}
onOpenPanel={() => {}}
/>,
);
expect(html).toContain("data-testid=\"timeline-allocation-popover-error\"");
expect(html).toContain("The selected booking could not be loaded right now.");
expect(html).toContain("Assignment not found");
});
it("renders an unavailable state when the lookup returns no allocation", () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: null,
});
const html = renderToStaticMarkup(
<AllocationPopover
allocationId="assignment_missing"
projectId="project_1"
anchorX={120}
anchorY={40}
onClose={() => {}}
onOpenPanel={() => {}}
/>,
);
expect(html).toContain("data-testid=\"timeline-allocation-popover-unavailable\"");
expect(html).toContain("The selected booking could not be resolved from the current timeline data.");
});
});
@@ -1,9 +1,10 @@
"use client";
import React from "react";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import type { AllocationLike, AllocationReadModel, Assignment } from "@capakraken/shared";
import type { AllocationLike, Assignment } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
@@ -43,16 +44,19 @@ export function AllocationPopover({
onClose,
});
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{ projectId },
{ staleTime: 10_000, enabled: !initialAllocation },
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
const allocation = initialAllocation ?? allocationView?.assignments.find((entry) => (
entry.id === allocationId
|| entry.entityId === allocationId
|| entry.sourceAllocationId === allocationId
|| getPlanningEntryMutationId(entry) === allocationId
)) as AllocationPopoverAssignment | undefined;
const shouldLoadAllocation = !initialAllocation;
const allocationQuery = trpc.allocation.getAssignmentById.useQuery(
{ id: allocationId },
{
staleTime: 10_000,
enabled: shouldLoadAllocation,
retry: false,
},
);
const fetchedAllocation = allocationQuery.data as AllocationPopoverAssignment | undefined;
const allocation = initialAllocation ?? fetchedAllocation;
const isLoading = shouldLoadAllocation && allocationQuery.isLoading;
const allocationError = shouldLoadAllocation ? allocationQuery.error : null;
const [hoursPerDay, setHoursPerDay] = useState<number | null>(null);
const [startDate, setStartDate] = useState<string>("");
@@ -79,6 +83,7 @@ export function AllocationPopover({
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
invalidateTimeline();
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
void utils.allocation.listView.invalidate();
onClose();
},
@@ -87,6 +92,7 @@ export function AllocationPopover({
const carveMutation = trpc.timeline.carveAllocationRange.useMutation({
onSuccess: () => {
invalidateTimeline();
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
void utils.allocation.listView.invalidate();
onClose();
},
@@ -122,18 +128,48 @@ export function AllocationPopover({
if (isLoading) {
const loadingPopover = (
<div ref={ref} style={style} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover-loading"
className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500"
>
Loading...
</div>
);
return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body);
}
if (allocationError) {
const errorPopover = (
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover-error"
className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-red-200 bg-white p-4 shadow-xl"
>
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
<p className="text-xs text-gray-500">
The selected booking could not be loaded right now.
</p>
<p className="text-xs text-red-600">{allocationError.message}</p>
<button
onClick={() => { onClose(); onOpenPanel(projectId); }}
className="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
Open Project Panel
</button>
</div>
);
return typeof document === "undefined" ? errorPopover : createPortal(errorPopover, document.body);
}
if (!allocation) {
const missingPopover = (
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover-unavailable"
className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-xl"
>
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
@@ -160,6 +196,8 @@ export function AllocationPopover({
<div
ref={ref}
style={style}
data-testid="timeline-allocation-popover"
data-allocation-id={allocationId}
className="flex max-h-[calc(100vh-32px)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl"
>
{/* Header */}
@@ -1146,7 +1146,10 @@ function renderProjectDragHandles(
e.stopPropagation();
if (suppressHoverInteractions) return;
onAllocationContextMenu(
{ allocationId: alloc.id, projectId: alloc.projectId },
{
allocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
},
e.clientX,
e.clientY,
);
@@ -812,7 +812,7 @@ function renderAllocBlocksFromData(
if (suppressHoverInteractions) return;
onAllocationContextMenu(
{
allocationId: alloc.id,
allocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
contextDate: resolveSegmentContextDate(
e.clientX,
@@ -1151,7 +1151,11 @@ function renderDailyBars(
e.stopPropagation();
if (suppressHoverInteractions) return;
onAllocationContextMenu(
{ allocationId: alloc.id, projectId: alloc.projectId, contextDate: new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) },
{
allocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
contextDate: new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())),
},
e.clientX,
e.clientY,
);