refactor(web): extract preview target setup
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAllocationPreviewSession, createProjectPreviewSession } from "./timelinePreviewSession.js";
|
||||
|
||||
function createFakeElement({
|
||||
left = "0px",
|
||||
width = "0px",
|
||||
transform = "",
|
||||
closest = () => null,
|
||||
}: {
|
||||
left?: string;
|
||||
width?: string;
|
||||
transform?: string;
|
||||
closest?: () => HTMLElement | null;
|
||||
} = {}): HTMLElement {
|
||||
return {
|
||||
style: { left, width, transform },
|
||||
closest,
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
function isFakeElement(value: EventTarget | null | undefined): value is HTMLElement {
|
||||
return Boolean(value && typeof value === "object" && "style" in value);
|
||||
}
|
||||
|
||||
describe("timelinePreviewSession", () => {
|
||||
it("returns null when project preview lookup finds nothing and no HTMLElement fallback exists", () => {
|
||||
const session = createProjectPreviewSession({
|
||||
projectId: "proj_1",
|
||||
currentTarget: null,
|
||||
cellWidth: 32,
|
||||
queryProjectTargets: () => [],
|
||||
isElement: isFakeElement,
|
||||
});
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the current target when project preview lookup returns no matching elements", () => {
|
||||
const currentTarget = createFakeElement({ left: "12px", width: "48px" });
|
||||
|
||||
const session = createProjectPreviewSession({
|
||||
projectId: "proj_1",
|
||||
currentTarget,
|
||||
cellWidth: 32,
|
||||
queryProjectTargets: () => [],
|
||||
isElement: isFakeElement,
|
||||
});
|
||||
|
||||
expect(session).toMatchObject({
|
||||
mode: "move",
|
||||
cellWidth: 32,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
frame: null,
|
||||
});
|
||||
expect(session?.targets).toHaveLength(1);
|
||||
expect(session?.targets[0]).toMatchObject({
|
||||
element: currentTarget,
|
||||
baseLeft: 12,
|
||||
baseWidth: 48,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps queried project preview targets authoritative when they exist", () => {
|
||||
const queriedTarget = createFakeElement({ left: "20px", width: "64px" });
|
||||
const fallbackTarget = createFakeElement({ left: "99px", width: "99px" });
|
||||
|
||||
const session = createProjectPreviewSession({
|
||||
projectId: "proj_1",
|
||||
currentTarget: fallbackTarget,
|
||||
cellWidth: 16,
|
||||
queryProjectTargets: () => [queriedTarget],
|
||||
isElement: isFakeElement,
|
||||
});
|
||||
|
||||
expect(session?.targets).toHaveLength(1);
|
||||
expect(session?.targets[0]).toMatchObject({
|
||||
element: queriedTarget,
|
||||
baseLeft: 20,
|
||||
baseWidth: 64,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when allocation preview root cannot be resolved", () => {
|
||||
const currentTarget = createFakeElement();
|
||||
|
||||
expect(
|
||||
createAllocationPreviewSession({
|
||||
currentTarget,
|
||||
cellWidth: 24,
|
||||
resolveAllocationRoot: () => null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("captures the nearest allocation preview root for allocation sessions", () => {
|
||||
const root = createFakeElement({ left: "8px", width: "40px" });
|
||||
const child = createFakeElement();
|
||||
|
||||
const session = createAllocationPreviewSession({
|
||||
currentTarget: child,
|
||||
mode: "resize-end",
|
||||
cellWidth: 24,
|
||||
resolveAllocationRoot: () => root,
|
||||
});
|
||||
|
||||
expect(session).toMatchObject({
|
||||
mode: "resize-end",
|
||||
cellWidth: 24,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
frame: null,
|
||||
});
|
||||
expect(session?.targets).toHaveLength(1);
|
||||
expect(session?.targets[0]).toMatchObject({
|
||||
element: root,
|
||||
baseLeft: 8,
|
||||
baseWidth: 40,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
captureLivePreviewTargets,
|
||||
type LivePreviewMode,
|
||||
type LivePreviewSession,
|
||||
type LivePreviewTarget,
|
||||
} from "./timelineLivePreview.js";
|
||||
|
||||
function isHTMLElement(value: EventTarget | null | undefined): value is HTMLElement {
|
||||
return typeof HTMLElement !== "undefined" && value instanceof HTMLElement;
|
||||
}
|
||||
|
||||
function resolveAllocationPreviewRoot(currentTarget: EventTarget | null | undefined): HTMLElement | null {
|
||||
return isHTMLElement(currentTarget)
|
||||
? currentTarget.closest<HTMLElement>('[data-timeline-drag-preview~="allocation"]')
|
||||
: null;
|
||||
}
|
||||
|
||||
function createLivePreviewSession(
|
||||
mode: LivePreviewMode,
|
||||
cellWidth: number,
|
||||
targets: LivePreviewTarget[],
|
||||
): LivePreviewSession | null {
|
||||
if (targets.length === 0) return null;
|
||||
|
||||
return {
|
||||
mode,
|
||||
cellWidth,
|
||||
targets,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
frame: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectPreviewSession({
|
||||
projectId,
|
||||
currentTarget,
|
||||
cellWidth,
|
||||
queryProjectTargets = (nextProjectId: string) =>
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
`[data-timeline-drag-preview~="project-shift"][data-timeline-project-id="${nextProjectId}"]`,
|
||||
),
|
||||
isElement = isHTMLElement,
|
||||
}: {
|
||||
projectId: string;
|
||||
currentTarget?: EventTarget | null | undefined;
|
||||
cellWidth: number;
|
||||
queryProjectTargets?: (projectId: string) => Iterable<HTMLElement>;
|
||||
isElement?: (value: EventTarget | null | undefined) => value is HTMLElement;
|
||||
}): LivePreviewSession | null {
|
||||
const targets = captureLivePreviewTargets(queryProjectTargets(projectId));
|
||||
if (targets.length === 0 && isElement(currentTarget)) {
|
||||
targets.push(...captureLivePreviewTargets([currentTarget]));
|
||||
}
|
||||
|
||||
return createLivePreviewSession("move", cellWidth, targets);
|
||||
}
|
||||
|
||||
export function createAllocationPreviewSession({
|
||||
currentTarget,
|
||||
mode = "move",
|
||||
cellWidth,
|
||||
resolveAllocationRoot = resolveAllocationPreviewRoot,
|
||||
}: {
|
||||
currentTarget?: EventTarget | null | undefined;
|
||||
mode?: LivePreviewMode;
|
||||
cellWidth: number;
|
||||
resolveAllocationRoot?: (currentTarget: EventTarget | null | undefined) => HTMLElement | null;
|
||||
}): LivePreviewSession | null {
|
||||
const root = resolveAllocationRoot(currentTarget);
|
||||
const targets = root ? captureLivePreviewTargets([root]) : [];
|
||||
return createLivePreviewSession(mode, cellWidth, targets);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
|
||||
import { pixelsToDays } from "~/components/timeline/dragMath.js";
|
||||
import {
|
||||
captureLivePreviewTargets,
|
||||
clearLivePreview,
|
||||
preserveLivePreview,
|
||||
scheduleLivePreview,
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
} from "./timelineMultiSelect.js";
|
||||
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
|
||||
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
|
||||
import { createAllocationPreviewSession, createProjectPreviewSession } from "./timelinePreviewSession.js";
|
||||
import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js";
|
||||
import { createRangeSelectionState, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
|
||||
import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js";
|
||||
@@ -283,50 +283,20 @@ export function useTimelineDrag({
|
||||
|
||||
const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => {
|
||||
clearLivePreview(projectPreviewRef.current);
|
||||
|
||||
const projectTargets = captureLivePreviewTargets(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
`[data-timeline-drag-preview~="project-shift"][data-timeline-project-id="${projectId}"]`,
|
||||
),
|
||||
);
|
||||
|
||||
if (projectTargets.length === 0 && currentTarget instanceof HTMLElement) {
|
||||
projectTargets.push(...captureLivePreviewTargets([currentTarget]));
|
||||
}
|
||||
|
||||
projectPreviewRef.current =
|
||||
projectTargets.length > 0
|
||||
? {
|
||||
mode: "move",
|
||||
cellWidth: cellWidthRef.current,
|
||||
targets: projectTargets,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
frame: null,
|
||||
}
|
||||
: null;
|
||||
projectPreviewRef.current = createProjectPreviewSession({
|
||||
projectId,
|
||||
currentTarget,
|
||||
cellWidth: cellWidthRef.current,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
|
||||
clearLivePreview(allocPreviewRef.current);
|
||||
|
||||
const root =
|
||||
currentTarget instanceof HTMLElement
|
||||
? currentTarget.closest<HTMLElement>('[data-timeline-drag-preview~="allocation"]')
|
||||
: null;
|
||||
const targets = root ? captureLivePreviewTargets([root]) : [];
|
||||
|
||||
allocPreviewRef.current =
|
||||
targets.length > 0
|
||||
? {
|
||||
mode,
|
||||
cellWidth: cellWidthRef.current,
|
||||
targets,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
frame: null,
|
||||
}
|
||||
: null;
|
||||
allocPreviewRef.current = createAllocationPreviewSession({
|
||||
currentTarget,
|
||||
mode,
|
||||
cellWidth: cellWidthRef.current,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateLivePreview = useCallback(
|
||||
|
||||
@@ -255,6 +255,25 @@ export const rules = [
|
||||
],
|
||||
forbidden: [],
|
||||
},
|
||||
{
|
||||
file: "apps/web/src/hooks/timelinePreviewSession.ts",
|
||||
maxLines: 80,
|
||||
required: [
|
||||
{
|
||||
pattern: /\bexport function createProjectPreviewSession\b/,
|
||||
message: "timeline preview session helpers must keep project preview target resolution centralized",
|
||||
},
|
||||
{
|
||||
pattern: /\bexport function createAllocationPreviewSession\b/,
|
||||
message: "timeline preview session helpers must keep allocation preview target resolution centralized",
|
||||
},
|
||||
{
|
||||
pattern: /from "\.\/timelineLivePreview\.js"/,
|
||||
message: "timeline preview session helpers must keep target capture delegated to the live preview helper module",
|
||||
},
|
||||
],
|
||||
forbidden: [],
|
||||
},
|
||||
{
|
||||
file: "apps/web/src/hooks/timelineAllocationFinalize.ts",
|
||||
maxLines: 100,
|
||||
@@ -492,6 +511,10 @@ export const rules = [
|
||||
pattern: /from "\.\/timelineOptimisticAllocations\.js"/,
|
||||
message: "timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module",
|
||||
},
|
||||
{
|
||||
pattern: /from "\.\/timelinePreviewSession\.js"/,
|
||||
message: "timeline drag must keep preview target setup delegated to the extracted helper module",
|
||||
},
|
||||
{
|
||||
pattern: /from "\.\/timelineDragCleanup\.js"/,
|
||||
message: "timeline drag must keep unmount teardown delegated to the extracted helper module",
|
||||
@@ -594,6 +617,14 @@ export const rules = [
|
||||
pattern: /\bconst mutationInput = buildProjectShiftMutationInput\(finalDrag\)\b[\s\S]*applyShiftMutation\.(?:mutate|mutateAsync)\(/,
|
||||
message: "timeline drag must not re-inline extracted project drag finalize flow",
|
||||
},
|
||||
{
|
||||
pattern: /\bdocument\.querySelectorAll<HTMLElement>\([\s\S]*data-timeline-project-id/,
|
||||
message: "timeline drag must not re-inline extracted project preview target lookup",
|
||||
},
|
||||
{
|
||||
pattern: /\bcurrentTarget\.closest<HTMLElement>\('\[data-timeline-drag-preview~=\"allocation\"\]'\)/,
|
||||
message: "timeline drag must not re-inline extracted allocation preview target lookup",
|
||||
},
|
||||
{
|
||||
pattern: /\bconst selection = finalizeRangeSelection\(/,
|
||||
message: "timeline drag must not re-inline extracted range release resolution",
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("architecture guardrails", () => {
|
||||
const rangeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineRangeSelection.ts");
|
||||
const rangeReleaseRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineRangeRelease.ts");
|
||||
const optimisticRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineOptimisticAllocations.ts");
|
||||
const previewSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelinePreviewSession.ts");
|
||||
const allocationFinalizeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationFinalize.ts");
|
||||
const allocationMultiDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationMultiDrag.ts");
|
||||
const allocationMultiDragSessionRule = rules.find(
|
||||
@@ -110,6 +111,7 @@ describe("architecture guardrails", () => {
|
||||
assert.ok(rangeRule);
|
||||
assert.ok(rangeReleaseRule);
|
||||
assert.ok(optimisticRule);
|
||||
assert.ok(previewSessionRule);
|
||||
assert.ok(allocationFinalizeRule);
|
||||
assert.ok(allocationMultiDragRule);
|
||||
assert.ok(allocationMultiDragSessionRule);
|
||||
@@ -133,6 +135,7 @@ describe("architecture guardrails", () => {
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range preview and finalization delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range release and cancel delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep preview target setup delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep unmount teardown delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project and allocation drag position derivation delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep document mouse listener lifecycle delegated to the extracted helper module",
|
||||
@@ -192,6 +195,11 @@ describe("architecture guardrails", () => {
|
||||
"apps/web/src/hooks/timelineOptimisticAllocations.ts: missing guardrail anchor: timeline optimistic helpers must keep server-reconciliation logic centralized",
|
||||
]);
|
||||
|
||||
assert.deepEqual(evaluateRule(previewSessionRule, "export function createProjectPreviewSession() {}\n"), [
|
||||
"apps/web/src/hooks/timelinePreviewSession.ts: missing guardrail anchor: timeline preview session helpers must keep allocation preview target resolution centralized",
|
||||
"apps/web/src/hooks/timelinePreviewSession.ts: missing guardrail anchor: timeline preview session helpers must keep target capture delegated to the live preview helper module",
|
||||
]);
|
||||
|
||||
assert.deepEqual(evaluateRule(allocationFinalizeRule, "export function hasAllocationDateChange() {}\n"), [
|
||||
"apps/web/src/hooks/timelineAllocationFinalize.ts: missing guardrail anchor: timeline allocation finalize helpers must keep click-vs-drag classification centralized",
|
||||
"apps/web/src/hooks/timelineAllocationFinalize.ts: missing guardrail anchor: timeline allocation finalize helpers must keep segment extraction rules centralized",
|
||||
@@ -269,6 +277,7 @@ describe("architecture guardrails", () => {
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range preview and finalization delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range release and cancel delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep preview target setup delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep unmount teardown delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project and allocation drag position derivation delegated to the extracted helper module",
|
||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep document mouse listener lifecycle delegated to the extracted helper module",
|
||||
|
||||
Reference in New Issue
Block a user