fix(web): keep segmented timeline allocations actionable
This commit is contained in:
+177
-32
@@ -307,6 +307,14 @@ async function openAllocationContextMenuAtOffset(
|
|||||||
await page.mouse.click(box.x + xOffset, box.y + box.height / 2, { button: "right" });
|
await page.mouse.click(box.x + xOffset, box.y + box.height / 2, { button: "right" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openContextMenuAtCenter(
|
||||||
|
page: Page,
|
||||||
|
locator: ReturnType<Page["locator"]>,
|
||||||
|
) {
|
||||||
|
const box = await readBoundingBox(locator);
|
||||||
|
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
|
||||||
|
}
|
||||||
|
|
||||||
async function releaseMouse(page: Page) {
|
async function releaseMouse(page: Page) {
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await page.waitForTimeout(120);
|
await page.waitForTimeout(120);
|
||||||
@@ -416,15 +424,43 @@ async function findVisibleTimelineEntryId(
|
|||||||
}, minimumWidth);
|
}, minimumWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findVisibleAllocationIdForResize(page: Page, selector: string) {
|
type VisibleAllocationSegment = {
|
||||||
|
allocationId: string;
|
||||||
|
segmentStart: string | null;
|
||||||
|
segmentEnd: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function allocationSegmentSelector(segment: VisibleAllocationSegment) {
|
||||||
|
const parts = [
|
||||||
|
`[data-timeline-entry-type='allocation'][data-allocation-id='${segment.allocationId}']`,
|
||||||
|
];
|
||||||
|
if (segment.segmentStart) {
|
||||||
|
parts.push(`[data-allocation-segment-start='${segment.segmentStart}']`);
|
||||||
|
}
|
||||||
|
if (segment.segmentEnd) {
|
||||||
|
parts.push(`[data-allocation-segment-end='${segment.segmentEnd}']`);
|
||||||
|
}
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findVisibleAllocationSegmentForResize(
|
||||||
|
page: Page,
|
||||||
|
selector: string,
|
||||||
|
): Promise<VisibleAllocationSegment | null> {
|
||||||
return page.locator(selector).evaluateAll((elements) => {
|
return page.locator(selector).evaluateAll((elements) => {
|
||||||
const candidates: Array<{ id: string; score: number }> = [];
|
const candidates: Array<{
|
||||||
let fallbackId: string | null = null;
|
allocationId: string;
|
||||||
|
segmentStart: string | null;
|
||||||
|
segmentEnd: string | null;
|
||||||
|
score: number;
|
||||||
|
}> = [];
|
||||||
|
let fallback: { allocationId: string; segmentStart: string | null; segmentEnd: string | null } | null =
|
||||||
|
null;
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
if (!(element instanceof HTMLElement)) continue;
|
if (!(element instanceof HTMLElement)) continue;
|
||||||
const id = element.dataset.allocationId;
|
const allocationId = element.dataset.allocationId;
|
||||||
if (!id) continue;
|
if (!allocationId) continue;
|
||||||
|
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const isVisible =
|
const isVisible =
|
||||||
@@ -439,7 +475,11 @@ async function findVisibleAllocationIdForResize(page: Page, selector: string) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
fallbackId ??= id;
|
fallback ??= {
|
||||||
|
allocationId,
|
||||||
|
segmentStart: element.dataset.allocationSegmentStart ?? null,
|
||||||
|
segmentEnd: element.dataset.allocationSegmentEnd ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
const isPreferred =
|
const isPreferred =
|
||||||
rect.width >= 36 &&
|
rect.width >= 36 &&
|
||||||
@@ -454,11 +494,16 @@ async function findVisibleAllocationIdForResize(page: Page, selector: string) {
|
|||||||
const centerX = rect.left + rect.width / 2;
|
const centerX = rect.left + rect.width / 2;
|
||||||
const widthPenalty = Math.abs(rect.width - 96);
|
const widthPenalty = Math.abs(rect.width - 96);
|
||||||
const centerPenalty = Math.abs(centerX - window.innerWidth / 2) / 4;
|
const centerPenalty = Math.abs(centerX - window.innerWidth / 2) / 4;
|
||||||
candidates.push({ id, score: widthPenalty + centerPenalty });
|
candidates.push({
|
||||||
|
allocationId,
|
||||||
|
segmentStart: element.dataset.allocationSegmentStart ?? null,
|
||||||
|
segmentEnd: element.dataset.allocationSegmentEnd ?? null,
|
||||||
|
score: widthPenalty + centerPenalty,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates.sort((a, b) => a.score - b.score);
|
candidates.sort((a, b) => a.score - b.score);
|
||||||
return candidates[0]?.id ?? fallbackId;
|
return candidates[0] ?? fallback;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,9 +827,13 @@ test.describe("Timeline", () => {
|
|||||||
.first();
|
.first();
|
||||||
await allocation.click({ button: "right" });
|
await allocation.click({ button: "right" });
|
||||||
|
|
||||||
await expect(page.getByText("Loading...")).not.toBeVisible({ timeout: 2000 });
|
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, { timeout: 2_000 });
|
||||||
await expect(page.getByText("Hours / day")).toBeVisible();
|
const popover = page.getByTestId("timeline-allocation-popover");
|
||||||
await expect(page.getByRole("button", { name: "Open Project Panel →" })).toBeVisible();
|
await expect(popover).toBeVisible();
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0);
|
||||||
|
await expect(popover.getByText("Hours / day")).toBeVisible();
|
||||||
|
await expect(popover.getByRole("button", { name: "Open Project Panel →" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("right clicking a project header strip opens the project panel", async ({ page }) => {
|
test("right clicking a project header strip opens the project panel", async ({ page }) => {
|
||||||
@@ -852,16 +901,14 @@ test.describe("Timeline", () => {
|
|||||||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
const allocationId = await findVisibleAllocationIdForResize(
|
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||||
page,
|
page,
|
||||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||||
);
|
);
|
||||||
expect(allocationId).toBeTruthy();
|
expect(allocationSegment).toBeTruthy();
|
||||||
|
|
||||||
const allocation = page.locator(
|
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
|
||||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
await openContextMenuAtCenter(page, allocation);
|
||||||
);
|
|
||||||
await allocation.click({ button: "right" });
|
|
||||||
|
|
||||||
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
||||||
await expect(cancelButton).toBeVisible();
|
await expect(cancelButton).toBeVisible();
|
||||||
@@ -905,16 +952,14 @@ test.describe("Timeline", () => {
|
|||||||
await page.mouse.move(8, 8);
|
await page.mouse.move(8, 8);
|
||||||
await expect(hoverCard).not.toBeVisible();
|
await expect(hoverCard).not.toBeVisible();
|
||||||
|
|
||||||
const allocationId = await findVisibleAllocationIdForResize(
|
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||||
page,
|
page,
|
||||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||||
);
|
);
|
||||||
expect(allocationId).toBeTruthy();
|
expect(allocationSegment).toBeTruthy();
|
||||||
|
|
||||||
const allocation = page.locator(
|
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
|
||||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
await openContextMenuAtCenter(page, allocation);
|
||||||
);
|
|
||||||
await allocation.click({ button: "right" });
|
|
||||||
|
|
||||||
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
||||||
await expect(cancelButton).toBeVisible();
|
await expect(cancelButton).toBeVisible();
|
||||||
@@ -956,15 +1001,15 @@ test.describe("Timeline", () => {
|
|||||||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
const allocationId = await findVisibleAllocationIdForResize(
|
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||||
page,
|
page,
|
||||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||||
);
|
);
|
||||||
expect(allocationId).toBeTruthy();
|
expect(allocationSegment).toBeTruthy();
|
||||||
|
|
||||||
const result = await measureAllocationDragGap(
|
const result = await measureAllocationDragGap(
|
||||||
page,
|
page,
|
||||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
allocationSegmentSelector(allocationSegment!),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.movedDistance).toBeGreaterThan(56);
|
expect(result.movedDistance).toBeGreaterThan(56);
|
||||||
@@ -981,15 +1026,15 @@ test.describe("Timeline", () => {
|
|||||||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
const allocationId = await findVisibleAllocationIdForResize(
|
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||||
page,
|
page,
|
||||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||||
);
|
);
|
||||||
expect(allocationId).toBeTruthy();
|
expect(allocationSegment).toBeTruthy();
|
||||||
|
|
||||||
const result = await measureAllocationResizeGap(
|
const result = await measureAllocationResizeGap(
|
||||||
page,
|
page,
|
||||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
allocationSegmentSelector(allocationSegment!),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.widthGain).toBeGreaterThan(64);
|
expect(result.widthGain).toBeGreaterThan(64);
|
||||||
@@ -1006,15 +1051,15 @@ test.describe("Timeline", () => {
|
|||||||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
const allocationId = await findVisibleAllocationIdForResize(
|
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||||
page,
|
page,
|
||||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||||
);
|
);
|
||||||
expect(allocationId).toBeTruthy();
|
expect(allocationSegment).toBeTruthy();
|
||||||
|
|
||||||
const result = await measureAllocationResizeStartGap(
|
const result = await measureAllocationResizeStartGap(
|
||||||
page,
|
page,
|
||||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
allocationSegmentSelector(allocationSegment!),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.widthGain).toBeGreaterThan(48);
|
expect(result.widthGain).toBeGreaterThan(48);
|
||||||
@@ -1447,6 +1492,106 @@ test.describe("Timeline", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("segmented allocations stay actionable after switching to project view and reloading", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const scenario = createTimelineSegmentScenario(suffix);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourceRow = page
|
||||||
|
.locator(
|
||||||
|
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`,
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
await expect(resourceRow).toBeVisible();
|
||||||
|
|
||||||
|
const baseSegment = resourceRow.locator(
|
||||||
|
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`,
|
||||||
|
);
|
||||||
|
await expect(baseSegment).toBeVisible();
|
||||||
|
|
||||||
|
const baseSegmentBox = await readBoundingBox(baseSegment);
|
||||||
|
const dayWidth = Math.round((baseSegmentBox.width + 4) / 5);
|
||||||
|
expect(dayWidth).toBeGreaterThan(8);
|
||||||
|
|
||||||
|
await openAllocationContextMenuAtOffset(page, baseSegment, dayWidth * 2.5);
|
||||||
|
const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
||||||
|
await carveDateInputs.nth(2).fill("08/04/2026");
|
||||||
|
await carveDateInputs.nth(3).fill("08/04/2026");
|
||||||
|
await page.getByRole("button", { name: "Remove Selected Range" }).click();
|
||||||
|
|
||||||
|
await waitForScenarioAssignments(scenario.projectId, [
|
||||||
|
{ startDate: "2026-04-06", endDate: "2026-04-07" },
|
||||||
|
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mondaySegment = resourceRow.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
|
).first();
|
||||||
|
await expect(mondaySegment).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByText("Project view").click();
|
||||||
|
await expect(page.getByText(/projects/)).toBeVisible();
|
||||||
|
|
||||||
|
let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null;
|
||||||
|
await expect
|
||||||
|
.poll(() => {
|
||||||
|
mondayAssignment =
|
||||||
|
listScenarioAssignments(scenario.projectId).find(
|
||||||
|
(entry) => entry.startDate === "2026-04-09" && entry.endDate === "2026-04-17",
|
||||||
|
) ?? null;
|
||||||
|
return mondayAssignment?.id ?? null;
|
||||||
|
})
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
const projectRow = page.locator(
|
||||||
|
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`,
|
||||||
|
).first();
|
||||||
|
await expect(projectRow).toBeVisible();
|
||||||
|
|
||||||
|
const projectAllocation = projectRow.locator(
|
||||||
|
`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`,
|
||||||
|
);
|
||||||
|
await expect(projectAllocation).toBeVisible();
|
||||||
|
await openContextMenuAtCenter(page, projectAllocation);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0);
|
||||||
|
const popover = page.getByTestId("timeline-allocation-popover");
|
||||||
|
await expect(popover).toBeVisible();
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0);
|
||||||
|
await expect(popover.getByText("Hours / day")).toBeVisible();
|
||||||
|
await expect(popover.getByRole("button", { name: "Open Project Panel →" })).toBeVisible();
|
||||||
|
await popover.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByText("Project view").click();
|
||||||
|
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
||||||
|
|
||||||
|
const projectAllocationAfterReload = page
|
||||||
|
.locator(`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`)
|
||||||
|
.first();
|
||||||
|
await expect(projectAllocationAfterReload).toBeVisible();
|
||||||
|
await openContextMenuAtCenter(page, projectAllocationAfterReload);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0);
|
||||||
|
const popoverAfterReload = page.getByTestId("timeline-allocation-popover");
|
||||||
|
await expect(popoverAfterReload).toBeVisible();
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0);
|
||||||
|
await expect(popoverAfterReload.getByText("Hours / day")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("project view demand bars expose hover details", async ({ page }) => {
|
test("project view demand bars expose hover details", async ({ page }) => {
|
||||||
await page.getByText("Project view").click();
|
await page.getByText("Project view").click();
|
||||||
await expect(page.getByText(/projects/)).toBeVisible();
|
await expect(page.getByText(/projects/)).toBeVisible();
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 { trpc } from "~/lib/trpc/client.js";
|
||||||
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
||||||
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
||||||
@@ -43,16 +44,19 @@ export function AllocationPopover({
|
|||||||
onClose,
|
onClose,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
|
const shouldLoadAllocation = !initialAllocation;
|
||||||
{ projectId },
|
const allocationQuery = trpc.allocation.getAssignmentById.useQuery(
|
||||||
{ staleTime: 10_000, enabled: !initialAllocation },
|
{ id: allocationId },
|
||||||
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
|
{
|
||||||
const allocation = initialAllocation ?? allocationView?.assignments.find((entry) => (
|
staleTime: 10_000,
|
||||||
entry.id === allocationId
|
enabled: shouldLoadAllocation,
|
||||||
|| entry.entityId === allocationId
|
retry: false,
|
||||||
|| entry.sourceAllocationId === allocationId
|
},
|
||||||
|| getPlanningEntryMutationId(entry) === allocationId
|
);
|
||||||
)) as AllocationPopoverAssignment | undefined;
|
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 [hoursPerDay, setHoursPerDay] = useState<number | null>(null);
|
||||||
const [startDate, setStartDate] = useState<string>("");
|
const [startDate, setStartDate] = useState<string>("");
|
||||||
@@ -79,6 +83,7 @@ export function AllocationPopover({
|
|||||||
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
|
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateTimeline();
|
invalidateTimeline();
|
||||||
|
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
|
||||||
void utils.allocation.listView.invalidate();
|
void utils.allocation.listView.invalidate();
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
@@ -87,6 +92,7 @@ export function AllocationPopover({
|
|||||||
const carveMutation = trpc.timeline.carveAllocationRange.useMutation({
|
const carveMutation = trpc.timeline.carveAllocationRange.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateTimeline();
|
invalidateTimeline();
|
||||||
|
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
|
||||||
void utils.allocation.listView.invalidate();
|
void utils.allocation.listView.invalidate();
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
@@ -122,18 +128,48 @@ export function AllocationPopover({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
const loadingPopover = (
|
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...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body);
|
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) {
|
if (!allocation) {
|
||||||
const missingPopover = (
|
const missingPopover = (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={style}
|
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"
|
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>
|
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
|
||||||
@@ -160,6 +196,8 @@ export function AllocationPopover({
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={style}
|
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"
|
className="flex max-h-[calc(100vh-32px)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -1146,7 +1146,10 @@ function renderProjectDragHandles(
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (suppressHoverInteractions) return;
|
if (suppressHoverInteractions) return;
|
||||||
onAllocationContextMenu(
|
onAllocationContextMenu(
|
||||||
{ allocationId: alloc.id, projectId: alloc.projectId },
|
{
|
||||||
|
allocationId: getPlanningEntryMutationId(alloc),
|
||||||
|
projectId: alloc.projectId,
|
||||||
|
},
|
||||||
e.clientX,
|
e.clientX,
|
||||||
e.clientY,
|
e.clientY,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -812,7 +812,7 @@ function renderAllocBlocksFromData(
|
|||||||
if (suppressHoverInteractions) return;
|
if (suppressHoverInteractions) return;
|
||||||
onAllocationContextMenu(
|
onAllocationContextMenu(
|
||||||
{
|
{
|
||||||
allocationId: alloc.id,
|
allocationId: getPlanningEntryMutationId(alloc),
|
||||||
projectId: alloc.projectId,
|
projectId: alloc.projectId,
|
||||||
contextDate: resolveSegmentContextDate(
|
contextDate: resolveSegmentContextDate(
|
||||||
e.clientX,
|
e.clientX,
|
||||||
@@ -1151,7 +1151,11 @@ function renderDailyBars(
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (suppressHoverInteractions) return;
|
if (suppressHoverInteractions) return;
|
||||||
onAllocationContextMenu(
|
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.clientX,
|
||||||
e.clientY,
|
e.clientY,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user