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" });
|
||||
}
|
||||
|
||||
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) {
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(120);
|
||||
@@ -416,15 +424,43 @@ async function findVisibleTimelineEntryId(
|
||||
}, 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) => {
|
||||
const candidates: Array<{ id: string; score: number }> = [];
|
||||
let fallbackId: string | null = null;
|
||||
const candidates: Array<{
|
||||
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) {
|
||||
if (!(element instanceof HTMLElement)) continue;
|
||||
const id = element.dataset.allocationId;
|
||||
if (!id) continue;
|
||||
const allocationId = element.dataset.allocationId;
|
||||
if (!allocationId) continue;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const isVisible =
|
||||
@@ -439,7 +475,11 @@ async function findVisibleAllocationIdForResize(page: Page, selector: string) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fallbackId ??= id;
|
||||
fallback ??= {
|
||||
allocationId,
|
||||
segmentStart: element.dataset.allocationSegmentStart ?? null,
|
||||
segmentEnd: element.dataset.allocationSegmentEnd ?? null,
|
||||
};
|
||||
|
||||
const isPreferred =
|
||||
rect.width >= 36 &&
|
||||
@@ -454,11 +494,16 @@ async function findVisibleAllocationIdForResize(page: Page, selector: string) {
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const widthPenalty = Math.abs(rect.width - 96);
|
||||
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);
|
||||
return candidates[0]?.id ?? fallbackId;
|
||||
return candidates[0] ?? fallback;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -782,9 +827,13 @@ test.describe("Timeline", () => {
|
||||
.first();
|
||||
await allocation.click({ button: "right" });
|
||||
|
||||
await expect(page.getByText("Loading...")).not.toBeVisible({ timeout: 2000 });
|
||||
await expect(page.getByText("Hours / day")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Open Project Panel →" })).toBeVisible();
|
||||
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, { timeout: 2_000 });
|
||||
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();
|
||||
});
|
||||
|
||||
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(),
|
||||
).toBeVisible();
|
||||
|
||||
const allocationId = await findVisibleAllocationIdForResize(
|
||||
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||
page,
|
||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||
);
|
||||
expect(allocationId).toBeTruthy();
|
||||
expect(allocationSegment).toBeTruthy();
|
||||
|
||||
const allocation = page.locator(
|
||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||||
);
|
||||
await allocation.click({ button: "right" });
|
||||
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
|
||||
await openContextMenuAtCenter(page, allocation);
|
||||
|
||||
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
||||
await expect(cancelButton).toBeVisible();
|
||||
@@ -905,16 +952,14 @@ test.describe("Timeline", () => {
|
||||
await page.mouse.move(8, 8);
|
||||
await expect(hoverCard).not.toBeVisible();
|
||||
|
||||
const allocationId = await findVisibleAllocationIdForResize(
|
||||
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||
page,
|
||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||
);
|
||||
expect(allocationId).toBeTruthy();
|
||||
expect(allocationSegment).toBeTruthy();
|
||||
|
||||
const allocation = page.locator(
|
||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||||
);
|
||||
await allocation.click({ button: "right" });
|
||||
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
|
||||
await openContextMenuAtCenter(page, allocation);
|
||||
|
||||
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
||||
await expect(cancelButton).toBeVisible();
|
||||
@@ -956,15 +1001,15 @@ test.describe("Timeline", () => {
|
||||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const allocationId = await findVisibleAllocationIdForResize(
|
||||
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||
page,
|
||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||
);
|
||||
expect(allocationId).toBeTruthy();
|
||||
expect(allocationSegment).toBeTruthy();
|
||||
|
||||
const result = await measureAllocationDragGap(
|
||||
page,
|
||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||||
allocationSegmentSelector(allocationSegment!),
|
||||
);
|
||||
|
||||
expect(result.movedDistance).toBeGreaterThan(56);
|
||||
@@ -981,15 +1026,15 @@ test.describe("Timeline", () => {
|
||||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const allocationId = await findVisibleAllocationIdForResize(
|
||||
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||
page,
|
||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||
);
|
||||
expect(allocationId).toBeTruthy();
|
||||
expect(allocationSegment).toBeTruthy();
|
||||
|
||||
const result = await measureAllocationResizeGap(
|
||||
page,
|
||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||||
allocationSegmentSelector(allocationSegment!),
|
||||
);
|
||||
|
||||
expect(result.widthGain).toBeGreaterThan(64);
|
||||
@@ -1006,15 +1051,15 @@ test.describe("Timeline", () => {
|
||||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const allocationId = await findVisibleAllocationIdForResize(
|
||||
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||
page,
|
||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||
);
|
||||
expect(allocationId).toBeTruthy();
|
||||
expect(allocationSegment).toBeTruthy();
|
||||
|
||||
const result = await measureAllocationResizeStartGap(
|
||||
page,
|
||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||||
allocationSegmentSelector(allocationSegment!),
|
||||
);
|
||||
|
||||
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 }) => {
|
||||
await page.getByText("Project view").click();
|
||||
await expect(page.getByText(/projects/)).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user