fix(web): keep segmented timeline allocations actionable
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user