Merge commit 'f9a457982' into feat/auto-mobile-boards

This commit is contained in:
Thomas Camlong committed 2026-08-10 13:21:29 +02:00
commit 9054dcad7a
6 files changed
+434 -140

No files matched your search

@@ -140,6 +140,14 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
pointer-events: none;
}
body[data-board-grid-interacting] .board-grid-resize-handle {
pointer-events: none;
}
body[data-board-grid-interacting] .board-grid-resize-handle::after {
opacity: 0;
}
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"] {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 4px;
@@ -1,7 +1,18 @@
"use client";
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
@@ -47,7 +58,10 @@ import {
previewGridResize,
} from "./dnd";
import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, TransactionalGridState } from "./dnd";
import type { DndGridEntryProps } from "./grid-entry-memo";
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
import { getGridDepth } from "./grid-depth";
import { createGridInteractionStore } from "./grid-interaction-store";
import { useBoardGridPortalHost } from "./grid-portal-host";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
@@ -124,8 +138,7 @@ interface ResizePreviewInput {
minHeight: number;
}
interface BoardGridEditorContextValue {
interaction: GridInteraction | null;
interface BoardGridEditorActionsContextValue {
registerGrid: (grid: RegisteredGrid) => () => void;
getDepth: (gridId: string) => number;
isDropTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean;
@@ -136,14 +149,35 @@ interface BoardGridEditorContextValue {
cancelResize: () => void;
}
const BoardGridEditorContext = createContext<BoardGridEditorContextValue | null>(null);
const BoardGridEditorActionsContext = createContext<BoardGridEditorActionsContextValue | null>(null);
const BoardGridEditorInteractionContext = createContext<ReturnType<
typeof createGridInteractionStore<GridInteraction>
> | null>(null);
const useBoardGridEditor = () => {
const value = useContext(BoardGridEditorContext);
const useBoardGridEditorActions = () => {
const value = useContext(BoardGridEditorActionsContext);
if (!value) throw new Error("BoardGridEditorProvider is required");
return value;
};
const useBoardGridEditorInteractionStore = () => {
const store = useContext(BoardGridEditorInteractionContext);
if (!store) throw new Error("BoardGridEditorProvider is required");
return store;
};
const useBoardGridEditorInteraction = () => {
const store = useBoardGridEditorInteractionStore();
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
};
const useBoardGridEditorGridInteraction = (sectionId: string) => {
const store = useBoardGridEditorInteractionStore();
const subscribe = useCallback((listener: () => void) => store.subscribeGrid(sectionId, listener), [sectionId, store]);
const getSnapshot = useCallback(() => store.getGridSnapshot(sectionId), [sectionId, store]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
/** One provider coordinates every root and nested grid on the board. */
export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
@@ -155,7 +189,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const activeRef = useRef<ActiveTransaction | null>(null);
const renderedInteractionRef = useRef<GridInteraction | null>(null);
const collisionFrameRef = useRef(0);
const [interaction, setInteraction] = useState<GridInteraction | null>(null);
const [interactionStore] = useState(() => createGridInteractionStore<GridInteraction>());
const [overlayZoom, setOverlayZoom] = useState(1);
const parentGridById = useMemo(
() =>
@@ -176,21 +210,9 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
collisionFrameRef.current = 0;
activeRef.current = null;
renderedInteractionRef.current = null;
setInteraction(null);
interactionStore.publish(null);
document.body.removeAttribute("data-board-grid-interacting");
}, []);
useLayoutEffect(() => {
const active = activeRef.current;
renderedInteractionRef.current =
interaction &&
active &&
interaction.mode === active.mode &&
interaction.activeId === active.transaction.activeId &&
interaction.sourceGridId === active.transaction.sourceGridId
? interaction
: null;
}, [interaction]);
}, [interactionStore]);
useEffect(() => clearInteraction, [clearInteraction, currentLayoutId]);
@@ -265,7 +287,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
previewRevision: 0,
};
document.body.setAttribute("data-board-grid-interacting", mode);
setInteraction({
interactionStore.publish({
activeId,
sourceGridId,
targetGridId: mode === "resize" ? sourceGridId : null,
@@ -282,7 +304,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
return false;
}
},
[announce, clearInteraction, getCurrentState, t],
[announce, clearInteraction, getCurrentState, interactionStore, t],
);
const updateDragPreview = useCallback(
@@ -297,7 +319,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const publishPreview = (preview: Omit<GridInteraction, "previewRevision">) => {
active.previewRevision += 1;
setInteraction({ ...preview, previewRevision: active.previewRevision });
interactionStore.publish({ ...preview, previewRevision: active.previewRevision });
};
const showInvalidPreview = (targetGridId: string | null, previewKey: string) => {
@@ -406,7 +428,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
valid: true,
});
},
[canAcceptDrop, isDropTargetEligible],
[canAcceptDrop, interactionStore, isDropTargetEligible],
);
const commitActiveTransaction = useCallback(
@@ -573,49 +595,52 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
[beginTransaction],
);
const applyResizePreview = useCallback((input: ResizePreviewInput) => {
const active = activeRef.current;
if (!active) return false;
active.mode = "resize";
active.targetGridId = active.transaction.sourceGridId;
const applyResizePreview = useCallback(
(input: ResizePreviewInput) => {
const active = activeRef.current;
if (!active) return false;
active.mode = "resize";
active.targetGridId = active.transaction.sourceGridId;
const key = `${input.direction}:${input.deltaColumns}:${input.deltaRows}:${input.minWidth}:${input.minHeight}`;
if (active.lastPreviewKey === key) return active.valid;
active.lastPreviewKey = key;
const result = previewGridResize(active.transaction, input);
active.valid = result.accepted;
if (!result.accepted) {
const key = `${input.direction}:${input.deltaColumns}:${input.deltaRows}:${input.minWidth}:${input.minHeight}`;
if (active.lastPreviewKey === key) return active.valid;
active.lastPreviewKey = key;
const result = previewGridResize(active.transaction, input);
active.valid = result.accepted;
if (!result.accepted) {
active.previewRevision += 1;
interactionStore.publish({
activeId: active.transaction.activeId,
sourceGridId: active.transaction.sourceGridId,
targetGridId: active.transaction.sourceGridId,
targetPlacement: null,
state: cancelGridTransaction(active.transaction),
mode: "resize",
valid: false,
previewRevision: active.previewRevision,
});
return false;
}
active.transaction = result.transaction;
active.previewRevision += 1;
setInteraction({
interactionStore.publish({
activeId: active.transaction.activeId,
sourceGridId: active.transaction.sourceGridId,
targetGridId: active.transaction.sourceGridId,
targetPlacement: null,
state: cancelGridTransaction(active.transaction),
targetPlacement:
result.transaction.preview.grids
.find((grid) => grid.id === active.transaction.sourceGridId)
?.placements.find((placement) => placement.id === active.transaction.activeId) ?? null,
state: result.transaction.preview,
mode: "resize",
valid: false,
valid: true,
previewRevision: active.previewRevision,
});
return false;
}
active.transaction = result.transaction;
active.previewRevision += 1;
setInteraction({
activeId: active.transaction.activeId,
sourceGridId: active.transaction.sourceGridId,
targetGridId: active.transaction.sourceGridId,
targetPlacement:
result.transaction.preview.grids
.find((grid) => grid.id === active.transaction.sourceGridId)
?.placements.find((placement) => placement.id === active.transaction.activeId) ?? null,
state: result.transaction.preview,
mode: "resize",
valid: true,
previewRevision: active.previewRevision,
});
return true;
}, []);
return true;
},
[interactionStore],
);
const previewResize = useCallback(
(input: ResizePreviewInput) => {
@@ -626,9 +651,8 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
[applyResizePreview],
);
const value = useMemo<BoardGridEditorContextValue>(
const actions = useMemo<BoardGridEditorActionsContextValue>(
() => ({
interaction,
registerGrid,
getDepth,
isDropTargetEligible,
@@ -643,13 +667,74 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
canAcceptDrop,
cancelActiveInteraction,
commitActiveTransaction,
interaction,
isDropTargetEligible,
previewResize,
getDepth,
registerGrid,
],
);
const syncRenderedInteraction = useCallback((interaction: GridInteraction | null) => {
const active = activeRef.current;
renderedInteractionRef.current =
interaction &&
active &&
interaction.mode === active.mode &&
interaction.activeId === active.transaction.activeId &&
interaction.sourceGridId === active.transaction.sourceGridId
? interaction
: null;
}, []);
return (
<BoardGridEditorActionsContext.Provider value={actions}>
<BoardGridEditorInteractionContext.Provider value={interactionStore}>
<DragDropProvider
plugins={configureEditorPlugins}
onCollision={handleCollision}
onBeforeDragStart={handleBeforeDragStart}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{children}
<DragOverlay
className="board-grid-drag-overlay"
dropAnimation={null}
style={
{
zoom: overlayZoom,
"--board-grid-overlay-content-zoom": 1 / overlayZoom,
} as CSSProperties
}
>
{(source) => {
const data = getGridEntryData(source);
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
}}
</DragOverlay>
<GridInteractionEffects
cancelActiveInteraction={cancelActiveInteraction}
syncRenderedInteraction={syncRenderedInteraction}
/>
</DragDropProvider>
</BoardGridEditorInteractionContext.Provider>
</BoardGridEditorActionsContext.Provider>
);
};
const GridInteractionEffects = ({
cancelActiveInteraction,
syncRenderedInteraction,
}: {
cancelActiveInteraction: () => void;
syncRenderedInteraction: (interaction: GridInteraction | null) => void;
}) => {
const interaction = useBoardGridEditorInteraction();
useLayoutEffect(() => syncRenderedInteraction(interaction), [interaction, syncRenderedInteraction]);
useEffect(() => {
if (interaction?.mode !== "resize") return;
const handleKeyDown = (event: KeyboardEvent) => {
@@ -661,48 +746,17 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [cancelActiveInteraction, interaction?.mode]);
return (
<BoardGridEditorContext.Provider value={value}>
<DragDropProvider
plugins={configureEditorPlugins}
onCollision={handleCollision}
onBeforeDragStart={handleBeforeDragStart}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{children}
<DragOverlay
className="board-grid-drag-overlay"
dropAnimation={null}
style={
{
zoom: overlayZoom,
"--board-grid-overlay-content-zoom": 1 / overlayZoom,
} as CSSProperties
}
>
{(source) => {
const data = getGridEntryData(source);
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
}}
</DragOverlay>
<GridCollisionSynchronizer interaction={interaction} />
</DragDropProvider>
</BoardGridEditorContext.Provider>
);
return <GridCollisionSynchronizer previewRevision={interaction?.previewRevision ?? null} />;
};
const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteraction | null }) => {
const GridCollisionSynchronizer = ({ previewRevision }: { previewRevision: number | null }) => {
const manager = useDragDropManager();
const geometryKey = interaction ? getGridGeometryKey(interaction.state) : null;
useLayoutEffect(() => {
if (!geometryKey || !manager) return;
if (previewRevision === null || !manager) return;
for (const target of manager.registry.droppables.value) target.refreshShape();
manager.collisionObserver.forceUpdate(true);
}, [geometryKey, manager]);
}, [manager, previewRevision]);
return null;
};
@@ -718,7 +772,8 @@ export default function GridEditor({
}: GridEditorProps) {
const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor();
const interaction = useBoardGridEditorGridInteraction(sectionId);
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
const gridRef = useRef<HTMLDivElement>(null);
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
@@ -738,9 +793,17 @@ export default function GridEditor({
}
return previewGrid.placements.filter((placement) => placement.id !== interaction.activeId);
}, [controlledById, interaction, placements, previewGrid, sectionId]);
const entryById = useMemo(
() => new Map([...items, ...innerSections].map((entry) => [entry.id, entry])),
[innerSections, items],
const labelById = useMemo(
() =>
new Map(
[...items, ...innerSections].map((entry) => [
entry.id,
entry.type === "item"
? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`)
: entry.options.title.trim() || t("section.container.untitled"),
]),
),
[innerSections, items, t],
);
const targetPlacement =
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
@@ -819,20 +882,15 @@ export default function GridEditor({
}
>
{renderPlacements.map((placement) => {
const entry = entryById.get(placement.id);
const label = entry
? entry.type === "item"
? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`)
: entry.options.title.trim() || t("section.container.untitled")
: placement.id;
return (
<DndGridEntry
key={placement.id}
sectionId={sectionId}
placement={placement}
label={label}
label={labelById.get(placement.id) ?? placement.id}
columnCount={columnCount}
maxRowCount={maxRowCount}
isActive={interaction?.activeId === placement.id}
/>
);
})}
@@ -859,17 +917,16 @@ export default function GridEditor({
);
}
interface DndGridEntryProps {
sectionId: string;
placement: SectionGridPlacement;
label: string;
columnCount: number;
maxRowCount: number | null;
}
const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: DndGridEntryProps) => {
const DndGridEntryComponent = ({
sectionId,
placement,
label,
columnCount,
maxRowCount,
isActive,
}: DndGridEntryProps) => {
const { acquireContainer } = useBoardGridPortalHost();
const { interaction, beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditor();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
const [continuousResize, setContinuousResize] = useState<{
placement: SectionGridPlacement;
@@ -963,7 +1020,6 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true });
}, [placement.type]);
const isActive = interaction?.activeId === placement.id;
const style = getLogicalItemStyle(placement) as CSSProperties;
const continuousResizeStyle =
continuousResize && isActive
@@ -1004,7 +1060,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
sectionId={sectionId}
placement={placement}
label={label}
disabled={interaction !== null || (placement.type === "section" && !isSectionChromeActive)}
disabled={placement.type === "section" && !isSectionChromeActive}
columnCount={columnCount}
maxRowCount={maxRowCount}
beginResize={beginResize}
@@ -1017,6 +1073,8 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
);
};
const DndGridEntry = memo(DndGridEntryComponent, areDndGridEntryPropsEqual);
interface GridResizeHandlesProps {
sectionId: string;
placement: SectionGridPlacement;
@@ -1024,10 +1082,10 @@ interface GridResizeHandlesProps {
disabled: boolean;
columnCount: number;
maxRowCount: number | null;
beginResize: BoardGridEditorContextValue["beginResize"];
previewResize: BoardGridEditorContextValue["previewResize"];
completeResize: BoardGridEditorContextValue["completeResize"];
cancelResize: BoardGridEditorContextValue["cancelResize"];
beginResize: BoardGridEditorActionsContextValue["beginResize"];
previewResize: BoardGridEditorActionsContextValue["previewResize"];
completeResize: BoardGridEditorActionsContextValue["completeResize"];
cancelResize: BoardGridEditorActionsContextValue["cancelResize"];
setContinuousResize: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
}
@@ -1355,18 +1413,3 @@ const getGridTargetData = (target: { data: Record<string, unknown> }): GridTarge
const data = target.data as Partial<GridTargetData>;
return data.kind === GRID_TARGET_TYPE && data.sectionId ? (data as GridTargetData) : null;
};
const getGridGeometryKey = (state: TransactionalGridState<SectionGridPlacement>) =>
JSON.stringify(
state.grids
.map((grid) => ({
id: grid.id,
columnCount: grid.columnCount,
maxRowCount: grid.maxRowCount,
parentGridId: grid.parentGridId,
placements: grid.placements
.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }))
.toSorted((first, second) => first.id.localeCompare(second.id)),
}))
.toSorted((first, second) => first.id.localeCompare(second.id)),
);
@@ -0,0 +1,25 @@
import type { SectionGridPlacement } from "./use-grid-layout-actions";
export interface DndGridEntryProps {
sectionId: string;
placement: SectionGridPlacement;
label: string;
columnCount: number;
maxRowCount: number | null;
isActive: boolean;
}
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
previous.sectionId === next.sectionId &&
previous.label === next.label &&
previous.columnCount === next.columnCount &&
previous.maxRowCount === next.maxRowCount &&
previous.isActive === next.isActive &&
previous.placement.id === next.placement.id &&
previous.placement.type === next.placement.type &&
previous.placement.x === next.placement.x &&
previous.placement.y === next.placement.y &&
previous.placement.w === next.placement.w &&
previous.placement.h === next.placement.h &&
previous.placement.minW === next.placement.minW &&
previous.placement.minH === next.placement.minH;
@@ -0,0 +1,58 @@
interface GridScopedInteraction {
sourceGridId: string;
targetGridId: string | null;
}
export interface GridInteractionStore<TInteraction extends GridScopedInteraction> {
getSnapshot: () => TInteraction | null;
getGridSnapshot: (sectionId: string) => TInteraction | null;
publish: (interaction: TInteraction | null) => void;
subscribe: (listener: () => void) => () => void;
subscribeGrid: (sectionId: string, listener: () => void) => () => void;
}
const getAffectedGridIds = (interaction: GridScopedInteraction | null) => {
const ids = new Set<string>();
if (interaction) {
ids.add(interaction.sourceGridId);
if (interaction.targetGridId) ids.add(interaction.targetGridId);
}
return ids;
};
export const createGridInteractionStore = <
TInteraction extends GridScopedInteraction,
>(): GridInteractionStore<TInteraction> => {
let snapshot: TInteraction | null = null;
const listeners = new Set<() => void>();
const gridListeners = new Map<string, Set<() => void>>();
return {
getSnapshot: () => snapshot,
getGridSnapshot: (sectionId) =>
snapshot && (snapshot.sourceGridId === sectionId || snapshot.targetGridId === sectionId) ? snapshot : null,
publish: (interaction) => {
if (Object.is(snapshot, interaction)) return;
const affectedGridIds = getAffectedGridIds(snapshot);
for (const sectionId of getAffectedGridIds(interaction)) affectedGridIds.add(sectionId);
snapshot = interaction;
for (const listener of listeners) listener();
for (const sectionId of affectedGridIds) {
for (const listener of gridListeners.get(sectionId) ?? []) listener();
}
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeGrid: (sectionId, listener) => {
const sectionListeners = gridListeners.get(sectionId) ?? new Set<() => void>();
sectionListeners.add(listener);
gridListeners.set(sectionId, sectionListeners);
return () => {
sectionListeners.delete(listener);
if (sectionListeners.size === 0) gridListeners.delete(sectionId);
};
},
};
};
@@ -0,0 +1,106 @@
import { act, createElement, memo } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { DndGridEntryProps } from "../grid-entry-memo";
import { areDndGridEntryPropsEqual } from "../grid-entry-memo";
describe("grid entry memoization", () => {
let container: HTMLDivElement;
let root: Root;
let renders: number;
const Probe = memo((props: DndGridEntryProps) => {
renders += 1;
return createElement("span", { "data-x": props.placement.x });
}, areDndGridEntryPropsEqual);
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
renders = 0;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test("skips cloned unchanged preview placements and renders changed entries", () => {
const props = createProps();
render(props);
expect(renders).toBe(1);
render({ ...props, placement: { ...props.placement } });
expect(renders).toBe(1);
render({ ...props, placement: { ...props.placement, x: 2 } });
expect(renders).toBe(2);
});
test("rerenders only the changed entry on a 100 item preview", () => {
const entries = Array.from({ length: 100 }, (_, index) => ({
...createProps(),
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
}));
renderBoard(entries);
expect(renders).toBe(100);
renderBoard(entries.map((entry) => ({ ...entry, placement: { ...entry.placement } })));
expect(renders).toBe(100);
renderBoard(
entries.map((entry, index) =>
index === 42 ? { ...entry, placement: { ...entry.placement, y: entry.placement.y + 1 } } : entry,
),
);
expect(renders).toBe(101);
});
test.each([
["active state", (props: DndGridEntryProps) => ({ ...props, isActive: true })],
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })],
[
"minimum size",
(props: DndGridEntryProps) => ({
...props,
placement: { ...props.placement, minW: 2 },
}),
],
])("renders when %s changes", (_name, update) => {
const props = createProps();
render(props);
render(update(props));
expect(renders).toBe(2);
});
const render = (props: DndGridEntryProps) => {
act(() => root.render(createElement(Probe, props)));
};
const renderBoard = (entries: DndGridEntryProps[]) => {
act(() =>
root.render(
createElement(
"div",
null,
entries.map((entry) => createElement(Probe, { ...entry, key: entry.placement.id })),
),
),
);
};
});
const createProps = (): DndGridEntryProps => ({
sectionId: "root",
placement: { id: "weather", type: "item", x: 0, y: 0, w: 1, h: 1 },
label: "Weather",
columnCount: 12,
maxRowCount: null,
isActive: false,
});
@@ -0,0 +1,54 @@
import { describe, expect, test, vi } from "vitest";
import { createGridInteractionStore } from "../grid-interaction-store";
describe("grid interaction store", () => {
test("notifies only source, current target and previous target grids", () => {
const store = createGridInteractionStore<TestInteraction>();
const globalListener = vi.fn();
const sourceListener = vi.fn();
const firstTargetListener = vi.fn();
const secondTargetListener = vi.fn();
const unrelatedListener = vi.fn();
store.subscribe(globalListener);
store.subscribeGrid("source", sourceListener);
store.subscribeGrid("target-a", firstTargetListener);
store.subscribeGrid("target-b", secondTargetListener);
store.subscribeGrid("unrelated", unrelatedListener);
store.publish(createInteraction("target-a", 1));
expect(sourceListener).toHaveBeenCalledTimes(1);
expect(firstTargetListener).toHaveBeenCalledTimes(1);
expect(secondTargetListener).not.toHaveBeenCalled();
expect(unrelatedListener).not.toHaveBeenCalled();
expect(store.getGridSnapshot("target-a")?.previewRevision).toBe(1);
store.publish(createInteraction("target-b", 2));
expect(sourceListener).toHaveBeenCalledTimes(2);
expect(firstTargetListener).toHaveBeenCalledTimes(2);
expect(secondTargetListener).toHaveBeenCalledTimes(1);
expect(unrelatedListener).not.toHaveBeenCalled();
expect(store.getGridSnapshot("target-a")).toBeNull();
expect(store.getGridSnapshot("target-b")?.previewRevision).toBe(2);
store.publish(null);
expect(sourceListener).toHaveBeenCalledTimes(3);
expect(firstTargetListener).toHaveBeenCalledTimes(2);
expect(secondTargetListener).toHaveBeenCalledTimes(2);
expect(unrelatedListener).not.toHaveBeenCalled();
expect(globalListener).toHaveBeenCalledTimes(3);
});
});
interface TestInteraction {
sourceGridId: "source";
targetGridId: string;
previewRevision: number;
}
const createInteraction = (targetGridId: string, previewRevision: number): TestInteraction => ({
sourceGridId: "source",
targetGridId,
previewRevision,
});