merge: stack assistant on mobile boards
This commit is contained in:
1326 files changed
+131355
-46380
No files matched your search
@@ -213,6 +213,7 @@ export const AssistantConfigureWidgetTool = ({
|
||||
openModal(
|
||||
{
|
||||
kind: args.kind,
|
||||
definition: widgetImports[args.kind].definition,
|
||||
value: configuration.value,
|
||||
integrationData: configuration.integrationData,
|
||||
integrationSupport: configuration.integrationSupport,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
.backdrop {
|
||||
pointer-events: none;
|
||||
animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.manualBackdrop {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.surface {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
pointer-events: auto;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
overscroll-behavior: contain;
|
||||
animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.manualSurface {
|
||||
position: fixed;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
overscroll-behavior: contain;
|
||||
animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.manualSurface > div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none !important;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.backdropClosing {
|
||||
animation: focus-backdrop-out 180ms ease-in forwards;
|
||||
}
|
||||
|
||||
.surfaceClosing {
|
||||
animation: focus-surface-out 180ms ease-in forwards;
|
||||
}
|
||||
|
||||
.sourcePlaceholder {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.surfaceHost {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.surfacePortalTarget,
|
||||
.manualSurfaceMount {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.manualSurfaceMount {
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.surfaceHostAdvanced {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--homarr-z-index-widget-focus);
|
||||
width: auto;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@keyframes focus-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes focus-surface-in {
|
||||
from {
|
||||
transform: translate(var(--focus-translate-x), var(--focus-translate-y))
|
||||
scale(var(--focus-scale-x), var(--focus-scale-y));
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes focus-backdrop-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes focus-surface-out {
|
||||
to {
|
||||
transform: translate(var(--focus-translate-x), var(--focus-translate-y))
|
||||
scale(var(--focus-scale-x), var(--focus-scale-y));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop,
|
||||
.surface,
|
||||
.manualSurface {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.backdrop {
|
||||
background: Canvas;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Overlay, Portal } from "@mantine/core";
|
||||
import { useIsomorphicEffect } from "@mantine/hooks";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import classes from "./advanced-focus.module.css";
|
||||
import { isEscapeOwnedByNestedOverlay } from "./escape";
|
||||
import type { FocusRect } from "./geometry";
|
||||
|
||||
interface ActiveFocus {
|
||||
itemId: string;
|
||||
source: HTMLElement;
|
||||
sourceRect: FocusRect;
|
||||
activation: "preview" | "manual";
|
||||
autofocusClose: boolean;
|
||||
restorePreviewFocus: boolean;
|
||||
phase: "visible" | "closing";
|
||||
}
|
||||
|
||||
interface OpenAdvancedFocusOptions {
|
||||
activation?: ActiveFocus["activation"];
|
||||
autofocusClose?: boolean;
|
||||
}
|
||||
|
||||
interface AdvancedFocusContextValue {
|
||||
active: ActiveFocus | null;
|
||||
open: (itemId: string, source: HTMLElement, options?: OpenAdvancedFocusOptions) => void;
|
||||
close: (restoreFocus?: boolean) => void;
|
||||
dismiss: (itemId: string) => void;
|
||||
hover: (itemId: string, source: HTMLElement) => void;
|
||||
leave: (itemId: string) => void;
|
||||
}
|
||||
|
||||
const AdvancedFocusContext = createContext<AdvancedFocusContextValue | null>(null);
|
||||
const HOVER_DELAY_MS = 150;
|
||||
const CLOSE_DURATION_MS = 180;
|
||||
const interactiveSelector =
|
||||
"a[href], button, input, textarea, select, [contenteditable='true'], [role='button'], [role='link'], [role='menuitem'], [role='option']";
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null) =>
|
||||
target instanceof HTMLElement &&
|
||||
(target.matches("input, textarea, select, [contenteditable='true']") || target.isContentEditable);
|
||||
|
||||
export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const [active, setActive] = useState<ActiveFocus | null>(null);
|
||||
const activeRef = useRef<ActiveFocus | null>(null);
|
||||
const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null);
|
||||
const shiftHeldRef = useRef(false);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelHoverTimer = useCallback(() => {
|
||||
if (hoverTimerRef.current === null) return;
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const updateActive = useCallback((next: ActiveFocus | null) => {
|
||||
activeRef.current = next;
|
||||
setActive(next);
|
||||
}, []);
|
||||
|
||||
const cancelCloseTimer = useCallback(() => {
|
||||
if (closeTimerRef.current === null) return;
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const open = useCallback(
|
||||
(itemId: string, source: HTMLElement, options: OpenAdvancedFocusOptions = {}) => {
|
||||
if (isEditMode) return;
|
||||
cancelHoverTimer();
|
||||
cancelCloseTimer();
|
||||
const rect = source.getBoundingClientRect();
|
||||
updateActive({
|
||||
itemId,
|
||||
source,
|
||||
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||
activation: options.activation ?? "manual",
|
||||
autofocusClose: options.autofocusClose ?? false,
|
||||
restorePreviewFocus: options.activation === "preview" && document.activeElement === source,
|
||||
phase: "visible",
|
||||
});
|
||||
},
|
||||
[cancelCloseTimer, cancelHoverTimer, isEditMode, updateActive],
|
||||
);
|
||||
|
||||
const close = useCallback(
|
||||
(restoreFocus = true) => {
|
||||
cancelHoverTimer();
|
||||
const current = activeRef.current;
|
||||
if (!current || current.phase === "closing") return;
|
||||
updateActive({ ...current, phase: "closing" });
|
||||
const closeDuration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : CLOSE_DURATION_MS;
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
closeTimerRef.current = null;
|
||||
updateActive(null);
|
||||
if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus());
|
||||
}, closeDuration);
|
||||
},
|
||||
[cancelHoverTimer, updateActive],
|
||||
);
|
||||
|
||||
const startHoverTimer = useCallback(() => {
|
||||
cancelHoverTimer();
|
||||
const hovered = hoveredRef.current;
|
||||
if (!hovered || !shiftHeldRef.current || activeRef.current) return;
|
||||
hoverTimerRef.current = setTimeout(
|
||||
() => open(hovered.itemId, hovered.source, { activation: "preview" }),
|
||||
HOVER_DELAY_MS,
|
||||
);
|
||||
}, [cancelHoverTimer, open]);
|
||||
|
||||
const hover = useCallback(
|
||||
(itemId: string, source: HTMLElement) => {
|
||||
hoveredRef.current = { itemId, source };
|
||||
const current = activeRef.current;
|
||||
if (current?.activation === "preview" && current.itemId !== itemId) {
|
||||
cancelCloseTimer();
|
||||
updateActive(null);
|
||||
if (current.restorePreviewFocus && current.source.isConnected) current.source.focus({ preventScroll: true });
|
||||
}
|
||||
startHoverTimer();
|
||||
},
|
||||
[cancelCloseTimer, startHoverTimer, updateActive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const current = activeRef.current;
|
||||
if (!shiftHeldRef.current || current?.activation !== "preview") return;
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest(interactiveSelector)?.closest("[data-advanced-focus-surface]")
|
||||
)
|
||||
return;
|
||||
|
||||
const underlyingSource = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) =>
|
||||
element instanceof HTMLElement
|
||||
? element.matches("[data-advanced-focus-source]")
|
||||
? element
|
||||
: element.closest<HTMLElement>("[data-advanced-focus-source]")
|
||||
: null,
|
||||
)
|
||||
.find((source) => source !== null && source !== current.source);
|
||||
const itemId = underlyingSource?.dataset.itemId;
|
||||
if (!underlyingSource || !itemId) return;
|
||||
|
||||
if (underlyingSource.hasAttribute("aria-keyshortcuts")) {
|
||||
hover(itemId, underlyingSource);
|
||||
return;
|
||||
}
|
||||
|
||||
hoveredRef.current = null;
|
||||
cancelHoverTimer();
|
||||
cancelCloseTimer();
|
||||
updateActive(null);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove, { capture: true });
|
||||
return () => window.removeEventListener("pointermove", handlePointerMove, { capture: true });
|
||||
}, [cancelCloseTimer, cancelHoverTimer, hover, updateActive]);
|
||||
|
||||
const leave = useCallback(
|
||||
(itemId: string) => {
|
||||
if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null;
|
||||
cancelHoverTimer();
|
||||
},
|
||||
[cancelHoverTimer],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(
|
||||
(itemId: string) => {
|
||||
if (activeRef.current?.itemId !== itemId) return;
|
||||
cancelHoverTimer();
|
||||
cancelCloseTimer();
|
||||
updateActive(null);
|
||||
},
|
||||
[cancelCloseTimer, cancelHoverTimer, updateActive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && activeRef.current) {
|
||||
const advancedSurface = document.querySelector("[data-advanced-focus-surface]");
|
||||
if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface))
|
||||
return;
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (isEditMode) return;
|
||||
if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return;
|
||||
if (activeRef.current?.activation === "manual") return;
|
||||
shiftHeldRef.current = true;
|
||||
startHoverTimer();
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Shift") return;
|
||||
shiftHeldRef.current = false;
|
||||
cancelHoverTimer();
|
||||
if (activeRef.current?.activation === "preview") close(false);
|
||||
};
|
||||
const handleBlur = () => {
|
||||
shiftHeldRef.current = false;
|
||||
cancelHoverTimer();
|
||||
if (activeRef.current?.activation === "preview") close(false);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
cancelHoverTimer();
|
||||
cancelCloseTimer();
|
||||
};
|
||||
}, [cancelCloseTimer, cancelHoverTimer, close, isEditMode, startHoverTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && activeRef.current) close(false);
|
||||
}, [close, isEditMode]);
|
||||
|
||||
useIsomorphicEffect(() => {
|
||||
if (active?.activation !== "manual") return;
|
||||
const background = document.querySelectorAll<HTMLElement>("[data-advanced-focus-background]");
|
||||
background.forEach((element) => {
|
||||
element.inert = true;
|
||||
});
|
||||
return () => {
|
||||
background.forEach((element) => {
|
||||
element.inert = false;
|
||||
});
|
||||
};
|
||||
}, [active?.activation]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ active, open, close, dismiss, hover, leave }),
|
||||
[active, close, dismiss, hover, leave, open],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdvancedFocusContext.Provider value={value}>
|
||||
{children}
|
||||
{active?.activation === "preview" && (
|
||||
<Portal>
|
||||
<Overlay
|
||||
fixed
|
||||
data-advanced-focus-overlay
|
||||
backgroundOpacity={0.38}
|
||||
blur={0}
|
||||
zIndex="var(--homarr-z-index-widget-preview-backdrop)"
|
||||
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
</AdvancedFocusContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdvancedFocus = () => {
|
||||
const context = useContext(AdvancedFocusContext);
|
||||
if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider");
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { isEscapeOwnedByNestedOverlay } from "./escape";
|
||||
|
||||
describe("isEscapeOwnedByNestedOverlay", () => {
|
||||
test("allows Escape from the advanced surface itself", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
const button = document.createElement("button");
|
||||
surface.append(button);
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(button, surface)).toBe(false);
|
||||
});
|
||||
|
||||
test("leaves Escape to a nested Mantine modal", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "region");
|
||||
const modal = document.createElement("section");
|
||||
modal.setAttribute("role", "dialog");
|
||||
const input = document.createElement("input");
|
||||
modal.append(input);
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(input, surface)).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["menu", "listbox"])("leaves Escape to a nested %s overlay", (role) => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "region");
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("role", role);
|
||||
const target = document.createElement("button");
|
||||
overlay.append(target);
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
|
||||
});
|
||||
|
||||
test("honors Mantine's stop-propagation marker for menus and comboboxes", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "region");
|
||||
const target = document.createElement("button");
|
||||
target.setAttribute("data-mantine-stop-propagation", "true");
|
||||
surface.append(target);
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
|
||||
});
|
||||
|
||||
test("leaves Escape to a visible portalled menu when focus stays in the advanced surface", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
const target = document.createElement("button");
|
||||
surface.append(target);
|
||||
document.body.append(surface);
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.getClientRects = () => [{ width: 100, height: 100 }] as unknown as DOMRectList;
|
||||
document.body.append(menu);
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
|
||||
|
||||
menu.remove();
|
||||
surface.remove();
|
||||
});
|
||||
|
||||
test("does not mistake the compact widget source for a nested overlay", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "region");
|
||||
const source = document.createElement("button");
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(source, surface)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
const escapeOwnerSelector = [
|
||||
'[data-mantine-stop-propagation="true"]',
|
||||
'[role="dialog"]',
|
||||
'[role="menu"]',
|
||||
'[role="listbox"]',
|
||||
].join(", ");
|
||||
|
||||
export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => {
|
||||
const escapeOwner = target instanceof Element ? target.closest(escapeOwnerSelector) : null;
|
||||
if (escapeOwner !== null && escapeOwner !== advancedSurface) return true;
|
||||
|
||||
return Array.from(document.querySelectorAll(escapeOwnerSelector)).some(
|
||||
(owner) =>
|
||||
owner !== advancedSurface &&
|
||||
owner.getAttribute("aria-hidden") !== "true" &&
|
||||
owner.closest('[aria-hidden="true"]') === null &&
|
||||
owner.getClientRects().length > 0,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "./geometry";
|
||||
|
||||
describe("getAdvancedFocusRect", () => {
|
||||
it("expands around the widget instead of centering a modal", () => {
|
||||
const rect = getAdvancedFocusRect({ left: 450, top: 300, width: 300, height: 200 }, { width: 1200, height: 800 });
|
||||
|
||||
expect(rect).toEqual({ left: 200, top: 120, width: 800, height: 560 });
|
||||
expect(rect.left + rect.width / 2).toBe(600);
|
||||
expect(rect.top + rect.height / 2).toBe(400);
|
||||
});
|
||||
|
||||
it("uses the available viewport on small screens", () => {
|
||||
expect(getAdvancedFocusRect({ left: 0, top: 0, width: 400, height: 500 }, { width: 390, height: 700 })).toEqual({
|
||||
left: 8,
|
||||
top: 68,
|
||||
width: 374,
|
||||
height: 624,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps controls below the fixed board header on short screens", () => {
|
||||
const rect = getAdvancedFocusRect({ left: 1200, top: 700, width: 500, height: 500 }, { width: 1366, height: 768 });
|
||||
|
||||
expect(rect.left).toBeGreaterThanOrEqual(16);
|
||||
expect(rect.left + rect.width).toBeLessThanOrEqual(1350);
|
||||
expect(rect.top).toBeGreaterThanOrEqual(76);
|
||||
expect(rect.top + rect.height).toBeLessThanOrEqual(752);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAdvancedFocusClosePosition", () => {
|
||||
it("keeps the close control outside the surface when the right side has room", () => {
|
||||
expect(getAdvancedFocusClosePosition({ left: 16, top: 76, width: 800, height: 560 }, 1280)).toEqual({
|
||||
left: 824,
|
||||
top: 76,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the left side when the surface is anchored to the right edge", () => {
|
||||
expect(getAdvancedFocusClosePosition({ left: 464, top: 76, width: 800, height: 560 }, 1280)).toEqual({
|
||||
left: 412,
|
||||
top: 76,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the inside corner on narrow screens", () => {
|
||||
expect(getAdvancedFocusClosePosition({ left: 8, top: 68, width: 374, height: 624 }, 390)).toEqual({
|
||||
left: 330,
|
||||
top: 76,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { appShellHeaderHeight } from "../../layout/constants";
|
||||
|
||||
export interface FocusRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface FocusClosePosition {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
const closeButtonSize = 44;
|
||||
const closeButtonGap = 8;
|
||||
|
||||
export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: number; height: number }): FocusRect => {
|
||||
const margin = viewport.width < 640 ? 8 : 16;
|
||||
const availableWidth = Math.max(0, viewport.width - margin * 2);
|
||||
const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2);
|
||||
const preferredWidth = viewport.width < 640 ? availableWidth : Math.min(800, availableWidth);
|
||||
const preferredHeight = viewport.width < 640 ? availableHeight : Math.min(560, availableHeight);
|
||||
const width = Math.min(availableWidth, Math.max(source.width, preferredWidth));
|
||||
const height = Math.min(availableHeight, Math.max(source.height, preferredHeight));
|
||||
const minimumTop = appShellHeaderHeight + margin;
|
||||
const maximumLeft = Math.max(margin, viewport.width - margin - width);
|
||||
const maximumTop = Math.max(minimumTop, viewport.height - margin - height);
|
||||
|
||||
return {
|
||||
left: Math.min(maximumLeft, Math.max(margin, source.left - (width - source.width) / 2)),
|
||||
top: Math.min(maximumTop, Math.max(minimumTop, source.top - (height - source.height) / 2)),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAdvancedFocusClosePosition = (surface: FocusRect, viewportWidth: number): FocusClosePosition => {
|
||||
const outsideOffset = closeButtonSize + closeButtonGap;
|
||||
const surfaceRight = surface.left + surface.width;
|
||||
|
||||
if (viewportWidth - surfaceRight >= outsideOffset) {
|
||||
return { left: surfaceRight + closeButtonGap, top: surface.top };
|
||||
}
|
||||
|
||||
if (surface.left >= outsideOffset) {
|
||||
return { left: surface.left - outsideOffset, top: surface.top };
|
||||
}
|
||||
|
||||
return {
|
||||
left: Math.max(0, Math.min(viewportWidth - closeButtonSize, surfaceRight - outsideOffset)),
|
||||
top: surface.top + closeButtonGap,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement, Fragment, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { AdvancedFocusManualSurface } from "./manual-surface";
|
||||
|
||||
const sourceRect = { left: 100, top: 120, width: 240, height: 160 };
|
||||
const rect = { left: 40, top: 80, width: 640, height: 480 };
|
||||
const closePosition = { left: 688, top: 80 };
|
||||
|
||||
describe("AdvancedFocusManualSurface", () => {
|
||||
let host: HTMLDivElement | undefined;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root?.unmount());
|
||||
host?.remove();
|
||||
document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("portals dialog semantics, traps focus, closes from the backdrop, and restores focus", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
|
||||
const Harness = () => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement("button", { type: "button", onClick: () => setOpened(true) }, "Open advanced view"),
|
||||
createElement(
|
||||
AdvancedFocusManualSurface,
|
||||
{
|
||||
opened,
|
||||
phase: "visible",
|
||||
id: "advanced-focus-test",
|
||||
label: "Test widget advanced view",
|
||||
closeLabel: "Close advanced view",
|
||||
rect,
|
||||
closePosition,
|
||||
sourceRect,
|
||||
radius: "md",
|
||||
contentRef,
|
||||
onClose: () => setOpened(false),
|
||||
},
|
||||
createElement("button", { type: "button" }, "First widget action"),
|
||||
createElement("button", { type: "button" }, "Last widget action"),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
await act(async () => root?.render(createElement(MantineProvider, null, createElement(Harness))));
|
||||
const trigger = document.querySelector<HTMLButtonElement>("button");
|
||||
expect(trigger).not.toBeNull();
|
||||
trigger?.focus();
|
||||
await act(async () => trigger?.click());
|
||||
await act(async () => await new Promise((resolve) => setTimeout(resolve, 20)));
|
||||
|
||||
const dialog = document.querySelector<HTMLElement>("[role='dialog']");
|
||||
const closeButton = dialog?.querySelector<HTMLButtonElement>("[aria-label='Close advanced view']");
|
||||
const actions = dialog?.querySelectorAll<HTMLButtonElement>("button");
|
||||
expect(dialog?.id).toBe("advanced-focus-test");
|
||||
expect(dialog?.getAttribute("aria-modal")).toBe("true");
|
||||
expect(dialog?.closest("[data-portal]")).not.toBeNull();
|
||||
expect(dialog?.closest("[data-grid-item-id]")).toBeNull();
|
||||
expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe(
|
||||
"Test widget advanced view",
|
||||
);
|
||||
expect(document.activeElement).toBe(closeButton);
|
||||
|
||||
const lastAction = actions?.item((actions?.length ?? 1) - 1);
|
||||
lastAction?.focus();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }));
|
||||
expect(document.activeElement).toBe(closeButton);
|
||||
closeButton?.focus();
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
expect(document.activeElement).toBe(lastAction);
|
||||
|
||||
const backdrop = document.querySelector<HTMLElement>("[data-advanced-focus-overlay]");
|
||||
await act(async () => backdrop?.click());
|
||||
await act(async () => await new Promise((resolve) => setTimeout(resolve, 20)));
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, PropsWithChildren, Ref } from "react";
|
||||
import { Modal, VisuallyHidden } from "@mantine/core";
|
||||
import { useReducedMotion } from "@mantine/hooks";
|
||||
|
||||
import classes from "./advanced-focus.module.css";
|
||||
import type { FocusClosePosition, FocusRect } from "./geometry";
|
||||
|
||||
interface AdvancedFocusManualSurfaceProps extends PropsWithChildren {
|
||||
opened: boolean;
|
||||
phase: "visible" | "closing";
|
||||
id: string;
|
||||
label: string;
|
||||
closeLabel: string;
|
||||
rect: FocusRect;
|
||||
closePosition: FocusClosePosition;
|
||||
sourceRect: FocusRect;
|
||||
radius: string | number;
|
||||
contentRef: Ref<HTMLDivElement>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AdvancedFocusManualSurface = ({
|
||||
opened,
|
||||
phase,
|
||||
id,
|
||||
label,
|
||||
closeLabel,
|
||||
rect,
|
||||
closePosition,
|
||||
sourceRect,
|
||||
radius,
|
||||
contentRef,
|
||||
onClose,
|
||||
children,
|
||||
}: AdvancedFocusManualSurfaceProps) => {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const transitionDuration = reduceMotion ? 0 : 180;
|
||||
const geometryStyle = {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
"--focus-translate-x": `${sourceRect.left - rect.left}px`,
|
||||
"--focus-translate-y": `${sourceRect.top - rect.top}px`,
|
||||
"--focus-scale-x": rect.width > 0 ? sourceRect.width / rect.width : 1,
|
||||
"--focus-scale-y": rect.height > 0 ? sourceRect.height / rect.height : 1,
|
||||
} as CSSProperties;
|
||||
const relativeClosePosition = {
|
||||
left: closePosition.left - rect.left,
|
||||
top: closePosition.top - rect.top,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
radius={radius}
|
||||
padding={0}
|
||||
shadow="none"
|
||||
zIndex="var(--homarr-z-index-widget-focus)"
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside
|
||||
lockScroll
|
||||
trapFocus
|
||||
returnFocus
|
||||
removeScrollProps={{ allowPinchZoom: true }}
|
||||
transitionProps={{ duration: transitionDuration, transition: "fade" }}
|
||||
>
|
||||
<Modal.Overlay
|
||||
data-advanced-focus-overlay
|
||||
backgroundOpacity={0.38}
|
||||
blur={0}
|
||||
className={`${classes.backdrop} ${classes.manualBackdrop} ${
|
||||
phase === "closing" ? classes.backdropClosing : ""
|
||||
}`}
|
||||
/>
|
||||
<Modal.Content
|
||||
ref={contentRef}
|
||||
id={id}
|
||||
data-advanced-focus-surface
|
||||
className={`${classes.manualSurface} ${phase === "closing" ? classes.surfaceClosing : ""}`}
|
||||
style={geometryStyle}
|
||||
>
|
||||
<Modal.Title>
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton
|
||||
className={classes.closeButton}
|
||||
variant="default"
|
||||
size={44}
|
||||
aria-label={closeLabel}
|
||||
aria-controls={id}
|
||||
style={relativeClosePosition}
|
||||
/>
|
||||
{children}
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
const isVerticalScrollContainer = (element: Element) => {
|
||||
if (element.scrollHeight <= element.clientHeight) return false;
|
||||
|
||||
const overflowY = getComputedStyle(element).overflowY;
|
||||
return overflowY === "auto" || overflowY === "scroll";
|
||||
};
|
||||
|
||||
const canScrollVertically = (element: Element, delta: number) => {
|
||||
return delta < 0 ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight;
|
||||
};
|
||||
|
||||
export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null, delta: number) => {
|
||||
if (delta === 0) return false;
|
||||
|
||||
let current = target instanceof Element ? target : null;
|
||||
while (current && root.contains(current)) {
|
||||
const isCurrentScrollContainer = isVerticalScrollContainer(current);
|
||||
if (isCurrentScrollContainer && canScrollVertically(current, delta)) {
|
||||
current.scrollTop += delta;
|
||||
return true;
|
||||
}
|
||||
if (current === root) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetDefaultSizes } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
@@ -19,17 +21,21 @@ export const createItemCallback =
|
||||
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const firstSection = previous.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.filter(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return previous;
|
||||
|
||||
const itemLayouts = createItemLayouts(previous, firstSection, kind);
|
||||
const widget = {
|
||||
id: id ?? createId(),
|
||||
kind,
|
||||
options,
|
||||
layouts: createItemLayouts(previous, firstSection, kind),
|
||||
layouts: itemLayouts,
|
||||
integrationIds,
|
||||
advancedOptions: {
|
||||
title: null,
|
||||
@@ -60,12 +66,14 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
return layouts.map((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
const layoutItemSize = boardLayout
|
||||
? { ...itemSize, width: Math.min(itemSize.width, boardLayout.columnCount) }
|
||||
: itemSize;
|
||||
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
|
||||
const size = {
|
||||
width: Math.min(itemSize.width, columnCount),
|
||||
height: itemSize.height,
|
||||
};
|
||||
|
||||
const emptyPosition = boardLayout
|
||||
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, layoutItemSize)
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
@@ -73,8 +81,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
}
|
||||
|
||||
return {
|
||||
width: layoutItemSize.width,
|
||||
height: layoutItemSize.height,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
...emptyPosition,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "./empty-position";
|
||||
import { getSectionElements } from "./section-elements";
|
||||
|
||||
@@ -15,16 +17,17 @@ export const duplicateItemCallback =
|
||||
if (!itemToDuplicate) return previous;
|
||||
|
||||
const clonedItem = structuredClone(itemToDuplicate);
|
||||
const clonedLayouts = clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
}));
|
||||
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.concat({
|
||||
...clonedItem,
|
||||
id: createId(),
|
||||
layouts: clonedItem.layouts.map((layout) => ({
|
||||
...layout,
|
||||
...getNextPosition(previous, layout),
|
||||
})),
|
||||
layouts: clonedLayouts,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -42,8 +45,10 @@ const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; y
|
||||
}
|
||||
|
||||
const firstSection = board.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) {
|
||||
@@ -67,18 +72,21 @@ const getEmptySectionPosition = (
|
||||
layout: ItemLayout,
|
||||
section: Section,
|
||||
): { xOffset: number; yOffset: number } | undefined => {
|
||||
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
|
||||
const boardLayout = board.layouts.find((candidate) => candidate.id === layout.layoutId);
|
||||
if (!boardLayout) return;
|
||||
|
||||
const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId });
|
||||
if (section.kind !== "dynamic") {
|
||||
return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, {
|
||||
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
|
||||
if (section.kind === "empty") {
|
||||
const columnCount = getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset));
|
||||
|
||||
const size = {
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
});
|
||||
};
|
||||
return getFirstEmptyPosition(sectionElements, columnCount, undefined, size);
|
||||
}
|
||||
|
||||
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
|
||||
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layout.layoutId);
|
||||
if (!sectionLayout) return;
|
||||
|
||||
return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
|
||||
size: { width: number; height: number } = { width: 1, height: 1 },
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
|
||||
for (let xOffset = 0; xOffset <= columnCount - size.width; xOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
|
||||
const isOccupied = elements.some(
|
||||
(element) =>
|
||||
element.yOffset < yOffset + size.height &&
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveAndResizeItemInput {
|
||||
itemId: string;
|
||||
layoutId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
@@ -11,10 +10,8 @@ export interface MoveAndResizeItemInput {
|
||||
}
|
||||
|
||||
export const moveAndResizeItemCallback =
|
||||
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||
({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) =>
|
||||
@@ -23,7 +20,7 @@ export const moveAndResizeItemCallback =
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== currentLayout
|
||||
layout.layoutId !== layoutId
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveItemToSectionInput {
|
||||
itemId: string;
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
@@ -12,10 +11,8 @@ export interface MoveItemToSectionInput {
|
||||
}
|
||||
|
||||
export const moveItemToSectionCallback =
|
||||
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||
({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||
(board: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(board);
|
||||
|
||||
return {
|
||||
...board,
|
||||
items: board.items.map((item) =>
|
||||
@@ -24,7 +21,7 @@ export const moveItemToSectionCallback =
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== currentLayout
|
||||
layout.layoutId !== layoutId
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const getSectionElements = (board: Board, { sectionId, layoutId }: { sectionId: string; layoutId: string }) => {
|
||||
const dynamicSectionsOfFirstSection = board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
const containersOfSection = board.sections
|
||||
.filter((section) => section.kind === "container")
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
.map(({ layouts, ...section }) => ({ ...section, ...layouts.find((layout) => layout.layoutId === layoutId)! }))
|
||||
.filter((section) => section.parentSectionId === sectionId);
|
||||
@@ -14,5 +14,5 @@ export const getSectionElements = (board: Board, { sectionId, layoutId }: { sect
|
||||
}))
|
||||
.filter((item) => item.sectionId === sectionId);
|
||||
|
||||
return [...items, ...dynamicSectionsOfFirstSection];
|
||||
return [...items, ...containersOfSection];
|
||||
};
|
||||
@@ -1,15 +1,33 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import { createItemCallback } from "../create-item";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
|
||||
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions create-item", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("clips oversized widgets to each layout's column count", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 })
|
||||
.addEmptySection({ id: "section" })
|
||||
.build();
|
||||
const baseLayout = board.layouts.find((layout) => layout.role === "base");
|
||||
|
||||
const result = createItemCallback({ kind: "mediaMissing" })(board);
|
||||
|
||||
const createdItem = result.items.at(0);
|
||||
expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3);
|
||||
expect(createdItem?.layouts.find((layout) => layout.layoutId === baseLayout?.id)?.width).toBe(4);
|
||||
});
|
||||
|
||||
test("should add it to first section", () => {
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
@@ -52,9 +70,9 @@ describe("item actions create-item", () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, 9999, { height: 1, width: 1 });
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
|
||||
});
|
||||
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
||||
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||
@@ -63,21 +81,21 @@ describe("item actions create-item", () => {
|
||||
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
|
||||
|
||||
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
|
||||
const containerInFirstSection = new ContainerSectionMockBuilder({ id: "4" })
|
||||
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
|
||||
.build();
|
||||
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
|
||||
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
|
||||
.build();
|
||||
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
|
||||
const otherContainer = new ContainerSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
|
||||
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addEmptySection({ id: "1", yOffset: 2 })
|
||||
.addEmptySection({ id: firstSectionId, yOffset: 0 })
|
||||
.addEmptySection({ id: "3", yOffset: 1 })
|
||||
.addSection(dynamicSectionInFirstSection)
|
||||
.addSection(otherDynamicSection)
|
||||
.addSection(containerInFirstSection)
|
||||
.addSection(otherContainer)
|
||||
.addItem(itemInFirstSection)
|
||||
.addItem(otherItem)
|
||||
.build();
|
||||
@@ -94,8 +112,10 @@ describe("item actions create-item", () => {
|
||||
|
||||
// Assert
|
||||
expect(result.items.length).toBe(3);
|
||||
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
|
||||
expect(item).toEqual(
|
||||
const createdItem = result.items.find(
|
||||
(candidate) => candidate.id !== itemInFirstSection.id && candidate.id !== otherItem.id,
|
||||
);
|
||||
expect(createdItem).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: itemKind,
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||
@@ -104,7 +124,7 @@ describe("item actions create-item", () => {
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||
layout.columnCount,
|
||||
9999,
|
||||
undefined,
|
||||
{ height: 1, width: 1 },
|
||||
);
|
||||
});
|
||||
@@ -123,9 +143,36 @@ describe("item actions create-item", () => {
|
||||
expect(result.items.at(0)?.layouts.at(0)).toEqual(
|
||||
expect.objectContaining({ width: layout.columnCount, height: 4 }),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, 9999, {
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, {
|
||||
width: layout.columnCount,
|
||||
height: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("clamps wide defaults and places below a full automatic canvas", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 1;
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "existing" })
|
||||
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
|
||||
.build(),
|
||||
);
|
||||
|
||||
const result = createItemCallback({ id: "created", kind: "mediaMissing" })(board);
|
||||
const created = result.items.find((item) => item.id === "created");
|
||||
|
||||
expect(created?.layouts).toEqual([
|
||||
expect.objectContaining({
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
width: 1,
|
||||
height: 3,
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { duplicateItemCallback } from "../duplicate-item";
|
||||
import * as emptyPositionModule from "../empty-position";
|
||||
@@ -7,6 +7,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions duplicate-item", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("should copy it in the same section", () => {
|
||||
// Arrange
|
||||
const itemKind = "minecraftServerStatus";
|
||||
@@ -61,4 +65,28 @@ describe("item actions duplicate-item", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the next automatic canvas row when it is the first available space", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 1;
|
||||
const currentItem = new ItemMockBuilder({ id: "source" })
|
||||
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
|
||||
.build();
|
||||
board.items.push(currentItem);
|
||||
|
||||
const result = duplicateItemCallback({ itemId: currentItem.id })(board);
|
||||
const duplicated = result.items.find((item) => item.id !== currentItem.id);
|
||||
|
||||
expect(duplicated?.layouts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -108,7 +108,6 @@ describe("get first empty position", () => {
|
||||
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
|
||||
});
|
||||
});
|
||||
|
||||
const createElementsFromLayout = (layout: string[][]) => {
|
||||
const elements: (Pick<Item["layouts"][number], "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
||||
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getMoveTargets } from "../../item-move-modal";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "./mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
|
||||
describe("item move targets", () => {
|
||||
test("offers the canvas, enabled gutters, and nested sections", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected the board mock to contain a layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 6;
|
||||
layout.leftGutterColumnCount = 1;
|
||||
layout.rightGutterColumnCount = 1;
|
||||
|
||||
board.sections.push(
|
||||
new EmptySectionMockBuilder({ id: "canvas", xOffset: 0 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "right-gutter", xOffset: 1 }).build(),
|
||||
new ContainerSectionMockBuilder({
|
||||
id: "container",
|
||||
options: {
|
||||
title: "",
|
||||
customCssClasses: [],
|
||||
borderColor: "",
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
},
|
||||
})
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
width: 2,
|
||||
height: 2,
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
layout.id,
|
||||
{
|
||||
id: "weather",
|
||||
type: "item",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
canvas: "Dashboard canvas",
|
||||
container: "New section",
|
||||
leftRail: "Left dashboard rail",
|
||||
rightRail: "Right dashboard rail",
|
||||
numbered: (name, index) => `${name}, destination ${index}`,
|
||||
located: (name, location, index) => `${name}, ${location}, destination ${index}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(targets.map((target) => target.label)).toEqual([
|
||||
"Dashboard canvas, destination 1",
|
||||
"Left dashboard rail, destination 2",
|
||||
"Right dashboard rail, destination 3",
|
||||
"New section, destination 4",
|
||||
]);
|
||||
expect(new Set(targets.map((target) => target.label))).toHaveLength(targets.length);
|
||||
});
|
||||
|
||||
test("does not offer canvases that are narrower than a nested section's contents", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected the board mock to contain a layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 5;
|
||||
layout.leftGutterColumnCount = 2;
|
||||
|
||||
const section = new ContainerSectionMockBuilder({ id: "source" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
parentSectionId: "canvas",
|
||||
width: 4,
|
||||
height: 2,
|
||||
})
|
||||
.build();
|
||||
board.sections.push(
|
||||
new EmptySectionMockBuilder({ id: "canvas" }).build(),
|
||||
new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(),
|
||||
section,
|
||||
);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "nested" })
|
||||
.addLayout({
|
||||
layoutId: layout.id,
|
||||
sectionId: section.id,
|
||||
xOffset: 2,
|
||||
width: 2,
|
||||
})
|
||||
.build(),
|
||||
);
|
||||
|
||||
const sourceLayout = section.layouts[0];
|
||||
if (!sourceLayout) throw new Error("Expected the source section to contain a layout");
|
||||
const targets = getMoveTargets(
|
||||
board,
|
||||
layout.id,
|
||||
{ ...sourceLayout, id: section.id, type: "section" },
|
||||
{
|
||||
canvas: "Canvas",
|
||||
container: "Section",
|
||||
leftRail: "Left",
|
||||
rightRail: "Right",
|
||||
numbered: (name, index) => `${name} ${index}`,
|
||||
located: (name, location, index) => `${name} ${location} ${index}`,
|
||||
},
|
||||
);
|
||||
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { Board, DynamicSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { DynamicSectionMockBuilder } from "./dynamic-section-mock";
|
||||
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { ContainerSectionMockBuilder } from "./container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "./empty-section-mock";
|
||||
import { ItemMockBuilder } from "./item-mock";
|
||||
import { LayoutMockBuilder } from "./layout-mock";
|
||||
@@ -45,7 +45,10 @@ export class BoardMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
},
|
||||
],
|
||||
...board,
|
||||
@@ -56,8 +59,8 @@ export class BoardMockBuilder {
|
||||
return this.addSection(new EmptySectionMockBuilder(emptySection).build());
|
||||
}
|
||||
|
||||
public addDynamicSection(dynamicSection?: Partial<DynamicSection>): BoardMockBuilder {
|
||||
return this.addSection(new DynamicSectionMockBuilder(dynamicSection).build());
|
||||
public addContainer(container?: Partial<ContainerSection>): BoardMockBuilder {
|
||||
return this.addSection(new ContainerSectionMockBuilder(container).build());
|
||||
}
|
||||
|
||||
public addSection(section: Section): BoardMockBuilder {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export class CategorySectionMockBuilder {
|
||||
private readonly section: CategorySection;
|
||||
|
||||
constructor(section?: Partial<CategorySection>) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "category",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
name: "Category",
|
||||
collapsed: false,
|
||||
...section,
|
||||
} satisfies CategorySection;
|
||||
}
|
||||
|
||||
public build(): CategorySection {
|
||||
return this.section;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
type ContainerSectionMock = Omit<Partial<ContainerSection>, "options"> & {
|
||||
options?: Partial<ContainerSection["options"]>;
|
||||
};
|
||||
|
||||
export class ContainerSectionMockBuilder {
|
||||
private readonly section: ContainerSection;
|
||||
|
||||
constructor(section?: ContainerSectionMock) {
|
||||
this.section = {
|
||||
id: section?.id ?? createId(),
|
||||
kind: "container",
|
||||
collapsed: section?.collapsed ?? false,
|
||||
layouts: section?.layouts ?? [],
|
||||
options: containerSectionOptionsSchema.parse(section?.options),
|
||||
};
|
||||
}
|
||||
|
||||
public addLayout(layout?: Partial<ContainerSection["layouts"][0]>): ContainerSectionMockBuilder {
|
||||
this.section.layouts.push({
|
||||
layoutId: layout?.layoutId ?? createId(),
|
||||
parentSectionId: layout?.parentSectionId ?? createId(),
|
||||
xOffset: layout?.xOffset ?? 0,
|
||||
yOffset: layout?.yOffset ?? 0,
|
||||
width: layout?.width ?? 1,
|
||||
height: layout?.height ?? 1,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): ContainerSection {
|
||||
return structuredClone(this.section);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export class DynamicSectionMockBuilder {
|
||||
private readonly section: DynamicSection;
|
||||
|
||||
constructor(section?: Partial<DynamicSection>) {
|
||||
this.section = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
customCssClasses: [],
|
||||
},
|
||||
layouts: [],
|
||||
...section,
|
||||
} satisfies DynamicSection;
|
||||
}
|
||||
|
||||
public addLayout(layout?: Partial<DynamicSection["layouts"][0]>): DynamicSectionMockBuilder {
|
||||
this.section.layouts.push({
|
||||
layoutId: "1",
|
||||
height: 1,
|
||||
width: 1,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
parentSectionId: "0",
|
||||
...layout,
|
||||
} satisfies DynamicSection["layouts"][0]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): DynamicSection {
|
||||
return this.section;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@ export class LayoutMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
...layout,
|
||||
} satisfies Board["layouts"][0];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { moveAndResizeItemCallback } from "../move-and-resize-item";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
@@ -25,7 +21,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
@@ -43,8 +39,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
@@ -56,7 +50,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { moveItemToSectionCallback } from "../move-item-to-section";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
@@ -26,7 +22,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
@@ -44,8 +40,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
@@ -58,7 +52,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useCurrentLayout } from "@homarr/boards/context";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
|
||||
@@ -31,13 +32,9 @@ interface UpdateItemIntegrations {
|
||||
|
||||
export const useItemActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
|
||||
const createItem = useCallback(
|
||||
(input: CreateItemInput) => {
|
||||
updateBoard(createItemCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
const createItem = useCallback((input: CreateItemInput) => updateBoard(createItemCallback(input)), [updateBoard]);
|
||||
|
||||
const duplicateItem = useCallback(
|
||||
({ itemId }: DuplicateItemInput) => {
|
||||
@@ -81,17 +78,17 @@ export const useItemActions = () => {
|
||||
);
|
||||
|
||||
const moveAndResizeItem = useCallback(
|
||||
(input: MoveAndResizeItemInput) => {
|
||||
updateBoard(moveAndResizeItemCallback(input));
|
||||
(input: Omit<MoveAndResizeItemInput, "layoutId">) => {
|
||||
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
|
||||
},
|
||||
[updateBoard],
|
||||
[currentLayoutId, updateBoard],
|
||||
);
|
||||
|
||||
const moveItemToSection = useCallback(
|
||||
(input: MoveItemToSectionInput) => {
|
||||
updateBoard(moveItemToSectionCallback(input));
|
||||
(input: Omit<MoveItemToSectionInput, "layoutId">) => {
|
||||
updateBoard(moveItemToSectionCallback({ ...input, layoutId: currentLayoutId }));
|
||||
},
|
||||
[updateBoard],
|
||||
[currentLayoutId, updateBoard],
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
|
||||
@@ -10,3 +10,17 @@
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.editModeInertContent {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.editModeInertContent[data-board-grid-inert-content="true"] {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.editModeInertContent[data-board-grid-inert-content="true"] * {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
@@ -1,71 +1,374 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react";
|
||||
import { Suspense, use, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CardProps } from "@mantine/core";
|
||||
import { Badge, Box, Card, Center, Loader, Portal } from "@mantine/core";
|
||||
import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import {
|
||||
createWidgetRuntimeState,
|
||||
getWidgetQueryKeys,
|
||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||
} from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
|
||||
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
|
||||
import { redirectShiftWheel } from "../advanced-focus/wheel";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
|
||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
}
|
||||
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
if (kind === "iframe" || kind === "assistant") return "hidden";
|
||||
if (kind === "systemResources") return "visible";
|
||||
return undefined;
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
|
||||
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
|
||||
if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" } as const;
|
||||
if (hasCustomCssClasses) return {};
|
||||
return { overflowX: "hidden", overflowY: "auto" } as const;
|
||||
};
|
||||
|
||||
type BoardItemCardProps = CardProps & {
|
||||
item: SectionItem;
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...cardProps}
|
||||
ref={innerRef}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-grid-item-content
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"board-grid-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetDefinitionLoadError = ({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
item,
|
||||
innerRef,
|
||||
}: FallbackProps & Pick<BoardItemCardProps, "item" | "innerRef">) => (
|
||||
<BoardItemCard item={item} innerRef={innerRef}>
|
||||
<Center h="100%">
|
||||
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
);
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<BoardItemCard item={item} innerRef={ref}>
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
}
|
||||
>
|
||||
<LoadedBoardItemContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
contentRef={ref}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
interface LoadedBoardItemContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
contentRef: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const LoadedBoardItemContent = ({
|
||||
item,
|
||||
width,
|
||||
height,
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
contentRef,
|
||||
}: LoadedBoardItemContentProps) => {
|
||||
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||
const sourceRef = useRef<HTMLDivElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const [isEditMode] = useEditMode();
|
||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
|
||||
const widgetName = t(`widget.${item.kind}.name`);
|
||||
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
||||
const advancedViewId = `advanced-focus-${item.id}`;
|
||||
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
|
||||
const isAdvanced = activeFocus !== null;
|
||||
const advancedRect = activeFocus
|
||||
? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight })
|
||||
: null;
|
||||
const closePosition = advancedRect ? getAdvancedFocusClosePosition(advancedRect, viewportWidth) : null;
|
||||
const isPreview = activeFocus?.activation === "preview";
|
||||
const isManual = activeFocus?.activation === "manual";
|
||||
|
||||
useIsomorphicEffect(() => {
|
||||
if (!supportsAdvancedFocus) return;
|
||||
const target = document.createElement("div");
|
||||
target.className = advancedFocusClasses.surfacePortalTarget ?? "";
|
||||
setSurfacePortalTarget(target);
|
||||
return () => target.remove();
|
||||
}, [supportsAdvancedFocus]);
|
||||
|
||||
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.currentTarget !== event.target ||
|
||||
event.key !== "Enter" ||
|
||||
!event.shiftKey ||
|
||||
isEditMode ||
|
||||
!supportsAdvancedFocus
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose: true });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const surface = isManual ? manualSurface : cardRef.current;
|
||||
if (!isAdvanced || !surface) return;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!event.shiftKey) return;
|
||||
const multiplier = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? surface.clientHeight : 1;
|
||||
const delta = (event.deltaY || event.deltaX) * multiplier;
|
||||
if (!redirectShiftWheel(surface, event.target, delta)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
surface.addEventListener("wheel", handleWheel, { capture: true, passive: false });
|
||||
return () => surface.removeEventListener("wheel", handleWheel, { capture: true });
|
||||
}, [isAdvanced, isManual, manualSurface]);
|
||||
|
||||
const mountPortalTarget = (node: HTMLDivElement | null, activation: "compact" | "preview" | "manual") => {
|
||||
if (!node || !surfacePortalTarget) return;
|
||||
const shouldMount =
|
||||
(activation === "compact" && !isAdvanced) ||
|
||||
(activation === "preview" && isPreview) ||
|
||||
(activation === "manual" && isManual);
|
||||
if (shouldMount) node.append(surfacePortalTarget);
|
||||
};
|
||||
|
||||
const previewStyle =
|
||||
isPreview && advancedRect && activeFocus
|
||||
? ({
|
||||
left: advancedRect.left,
|
||||
top: advancedRect.top,
|
||||
width: advancedRect.width,
|
||||
height: advancedRect.height,
|
||||
"--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`,
|
||||
"--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`,
|
||||
"--focus-scale-x": advancedRect.width > 0 ? activeFocus.sourceRect.width / advancedRect.width : 1,
|
||||
"--focus-scale-y": advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1,
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const widgetCard = (
|
||||
<Card
|
||||
ref={cardRef}
|
||||
id={isPreview ? advancedViewId : undefined}
|
||||
role={isPreview ? "region" : undefined}
|
||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||
data-advanced-focus-surface={isPreview || undefined}
|
||||
data-grid-item-content
|
||||
radius={board.itemRadius}
|
||||
p={isAdvanced ? undefined : 0}
|
||||
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"board-grid-item-content",
|
||||
isPreview && advancedFocusClasses.surface,
|
||||
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
|
||||
containerType: "size",
|
||||
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
style={previewStyle}
|
||||
>
|
||||
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
{isEditMode && (
|
||||
<BoardItemMenu item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<InnerContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
displayMode={isAdvanced ? "advanced" : "compact"}
|
||||
definition={definition}
|
||||
Component={Component}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
<WidgetContextMenu
|
||||
item={item}
|
||||
definition={definition}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
sourceRef={sourceRef}
|
||||
>
|
||||
<Box
|
||||
ref={sourceRef}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-advanced-focus-source
|
||||
data-item-id={item.id}
|
||||
tabIndex={!isEditMode && supportsAdvancedFocus ? 0 : undefined}
|
||||
role={!isEditMode && supportsAdvancedFocus ? "group" : undefined}
|
||||
aria-label={
|
||||
!isEditMode && supportsAdvancedFocus ? `${widgetName}: ${t("item.advancedFocus.open")}` : undefined
|
||||
}
|
||||
aria-expanded={supportsAdvancedFocus ? isAdvanced : undefined}
|
||||
aria-controls={isAdvanced ? advancedViewId : undefined}
|
||||
aria-keyshortcuts={supportsAdvancedFocus ? "Shift+Enter" : undefined}
|
||||
onKeyDown={supportsAdvancedFocus ? handleKeyDown : undefined}
|
||||
onPointerEnter={() => {
|
||||
if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current);
|
||||
}}
|
||||
p={0}
|
||||
onPointerLeave={() => {
|
||||
if (supportsAdvancedFocus) leave(item.id);
|
||||
}}
|
||||
className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)}
|
||||
>
|
||||
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
|
||||
</Card>
|
||||
<div ref={(node) => mountPortalTarget(node, "compact")} className={advancedFocusClasses.surfaceHost}>
|
||||
{!supportsAdvancedFocus && widgetCard}
|
||||
</div>
|
||||
{surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)}
|
||||
{isPreview && (
|
||||
<Portal reuseTargetNode={false}>
|
||||
<div
|
||||
ref={(node) => mountPortalTarget(node, "preview")}
|
||||
className={advancedFocusClasses.surfaceHostAdvanced}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
{isManual && activeFocus && advancedRect && closePosition && (
|
||||
<AdvancedFocusManualSurface
|
||||
opened={activeFocus.phase === "visible"}
|
||||
phase={activeFocus.phase}
|
||||
id={advancedViewId}
|
||||
label={advancedViewLabel}
|
||||
closeLabel={t("item.advancedFocus.close")}
|
||||
rect={advancedRect}
|
||||
closePosition={closePosition}
|
||||
sourceRect={activeFocus.sourceRect}
|
||||
radius={board.itemRadius}
|
||||
contentRef={setManualSurface}
|
||||
onClose={() => close()}
|
||||
>
|
||||
<div
|
||||
ref={(node) => mountPortalTarget(node, "manual")}
|
||||
className={advancedFocusClasses.manualSurfaceMount}
|
||||
/>
|
||||
</AdvancedFocusManualSurface>
|
||||
)}
|
||||
</Box>
|
||||
</WidgetContextMenu>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
{!isAdvanced && item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
// It's 4 because of the mantine-react-table that has z-index 3
|
||||
style={{ zIndex: 4 }}
|
||||
style={{ zIndex: "var(--mantine-z-index-app)" }}
|
||||
top={2}
|
||||
left={16}
|
||||
size="xs"
|
||||
@@ -91,35 +394,46 @@ interface InnerContentProps {
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
displayMode: "compact" | "advanced";
|
||||
definition: WidgetDefinition;
|
||||
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
|
||||
const newItem = { ...item, options };
|
||||
const { updateItemOptions } = useItemActions();
|
||||
const { removeItem, updateItemOptions } = useItemActions();
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
const widgetSupportsIntegrations =
|
||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
|
||||
const queryClient = useQueryClient();
|
||||
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, widgetQueryKeys);
|
||||
reset();
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
{isEditMode && (
|
||||
<BoardItemMenu item={newItem} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -131,23 +445,31 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
<BoardItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
{isEditMode && <BoardItemMenu item={newItem} definition={definition} />}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
data-homarr-widget-ready={item.kind}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<Component
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
removeItem={() => removeItem({ itemId: item.id })}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface BoardItemMenuProps {
|
||||
offset: number;
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
resetErrorBoundary?: () => void;
|
||||
}
|
||||
|
||||
export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
if (!session) return null;
|
||||
const [isEditMode] = useEditMode();
|
||||
if (!session || !isEditMode) return null;
|
||||
|
||||
return <BoardItemMenuInner {...props} />;
|
||||
};
|
||||
|
||||
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const { integrations: integrationData = [], section } = useSectionContext();
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
|
||||
// Reset error boundary on next render if item has been edited
|
||||
useEffect(() => {
|
||||
@@ -52,12 +52,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
}
|
||||
}, [item, resetErrorBoundary]);
|
||||
|
||||
if (!isEditMode || isPending) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
@@ -78,19 +77,19 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
return `${t("item.edit.title")} - ${t(`widget.${item.kind}.name`)}`;
|
||||
title(translate) {
|
||||
return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -107,37 +106,48 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={offset}
|
||||
right={offset}
|
||||
top={12}
|
||||
right={8}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
data-board-widget-settings
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
<IconDotsVertical size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={openEditModal}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
openMoveModal({
|
||||
entry: item,
|
||||
sourceSectionId: section.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
{canDuplicate && (
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
|
||||
@@ -1,94 +1,220 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Grid, NumberInput, Stack } from "@mantine/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Grid, NumberInput, Select, Stack } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { GridStack } from "@homarr/gridstack";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getLayoutRowCount } from "../layout";
|
||||
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
|
||||
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
|
||||
|
||||
interface InnerProps {
|
||||
gridStack: GridStack;
|
||||
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||
columnCount: number;
|
||||
export type MovableEntry = Pick<
|
||||
SectionItem | ContainerSectionItem,
|
||||
"id" | "type" | "width" | "height" | "xOffset" | "yOffset"
|
||||
>;
|
||||
|
||||
export interface OpenItemMoveModalInput {
|
||||
entry: MovableEntry;
|
||||
sourceSectionId: string;
|
||||
}
|
||||
|
||||
interface InnerProps extends OpenItemMoveModalInput {
|
||||
board: Board;
|
||||
currentLayoutId: string;
|
||||
commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void;
|
||||
}
|
||||
|
||||
interface MoveTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
}
|
||||
|
||||
interface MoveTargetLabels {
|
||||
canvas: string;
|
||||
container: string;
|
||||
leftRail: string;
|
||||
rightRail: string;
|
||||
numbered: (name: string, index: number) => string;
|
||||
located: (name: string, location: string, index: number) => string;
|
||||
}
|
||||
|
||||
type MoveTargetCandidate = Omit<MoveTarget, "label"> & {
|
||||
name: string;
|
||||
location?: string;
|
||||
};
|
||||
|
||||
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||
const tLandmark = useScopedI18n("board.landmark");
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tMoveResize = useScopedI18n("item.moveResize");
|
||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
||||
const minimumSize = useMemo(
|
||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||
[board, currentLayoutId, entry],
|
||||
);
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
getMoveTargets(board, currentLayoutId, entry, {
|
||||
canvas: tLandmark("canvas"),
|
||||
container: tContainer("action.create"),
|
||||
leftRail: tLandmark("leftRail"),
|
||||
rightRail: tLandmark("rightRail"),
|
||||
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
||||
located: (name, location, index) =>
|
||||
tMoveResize("target.located", {
|
||||
name,
|
||||
location,
|
||||
index: String(index),
|
||||
}),
|
||||
}),
|
||||
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
|
||||
);
|
||||
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
||||
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
||||
|
||||
if (!initialTarget) {
|
||||
throw new Error("No valid dashboard section is available for this item");
|
||||
}
|
||||
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
xOffset: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(innerProps.columnCount - 1),
|
||||
yOffset: z.number().min(0),
|
||||
width: z.number().min(1).max(maxWidthRef.current),
|
||||
height: z.number().min(1),
|
||||
sectionId: z.string().min(1),
|
||||
xOffset: z.number().int().min(0).max(32767),
|
||||
yOffset: z.number().int().min(0).max(32767),
|
||||
width: z.number().int().min(1).max(32767),
|
||||
height: z.number().int().min(1).max(32767),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
xOffset: innerProps.item.xOffset,
|
||||
yOffset: innerProps.item.yOffset,
|
||||
width: innerProps.item.width,
|
||||
height: innerProps.item.height,
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
if (values.xOffset !== previous.xOffset) {
|
||||
maxWidthRef.current = innerProps.columnCount - values.xOffset;
|
||||
}
|
||||
sectionId: initialTarget.id,
|
||||
xOffset: entry.xOffset,
|
||||
yOffset: entry.yOffset,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
},
|
||||
},
|
||||
);
|
||||
const selectedTarget = targetById.get(form.values.sectionId) ?? initialTarget;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: Pick<Item["layouts"][number], "height" | "width" | "xOffset" | "yOffset">) => {
|
||||
const gridItem = innerProps.gridStack
|
||||
.getGridItems()
|
||||
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
|
||||
if (!gridItem) return;
|
||||
innerProps.gridStack.update(gridItem, {
|
||||
h: values.height,
|
||||
w: values.width,
|
||||
x: values.xOffset,
|
||||
(values: { sectionId: string; xOffset: number; yOffset: number; width: number; height: number }) => {
|
||||
const target = targetById.get(values.sectionId);
|
||||
if (!target) return;
|
||||
|
||||
const width = Math.max(minimumSize.width, Math.min(values.width, target.columnCount));
|
||||
const height = Math.max(minimumSize.height, values.height);
|
||||
const nextPlacement: SectionGridPlacement = {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
x: Math.min(values.xOffset, target.columnCount - width),
|
||||
y: values.yOffset,
|
||||
});
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
const targetPlacements = getSectionGridPlacements(board, currentLayoutId, target.id).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
);
|
||||
const resolvedTarget = resolvePinnedGridCollisions(
|
||||
[...targetPlacements, nextPlacement],
|
||||
nextPlacement,
|
||||
target.columnCount,
|
||||
);
|
||||
if (target.maxRowCount !== null && getLayoutRowCount(resolvedTarget) > target.maxRowCount) {
|
||||
form.setFieldError("height", tMoveResize("keyboard.boundary"));
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshots =
|
||||
sourceSectionId === target.id
|
||||
? [{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget }]
|
||||
: [
|
||||
{
|
||||
layoutId: currentLayoutId,
|
||||
sectionId: sourceSectionId,
|
||||
placements: getSectionGridPlacements(board, currentLayoutId, sourceSectionId).filter(
|
||||
(placement) => placement.id !== entry.id,
|
||||
),
|
||||
},
|
||||
{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget },
|
||||
];
|
||||
|
||||
commitSectionGrids(snapshots);
|
||||
actions.closeModal();
|
||||
focusMovedEntry(entry.id, target.id);
|
||||
},
|
||||
[actions, innerProps.gridStack, innerProps.item.id],
|
||||
[
|
||||
actions,
|
||||
board,
|
||||
commitSectionGrids,
|
||||
currentLayoutId,
|
||||
entry,
|
||||
form,
|
||||
minimumSize,
|
||||
sourceSectionId,
|
||||
targetById,
|
||||
tMoveResize,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit, console.error)}>
|
||||
<Stack>
|
||||
<Select
|
||||
data={targets.map((target) => ({ value: target.id, label: target.label }))}
|
||||
label={tMoveResize("field.section.label")}
|
||||
data-autofocus
|
||||
allowDeselect={false}
|
||||
searchable
|
||||
{...form.getInputProps("sectionId")}
|
||||
/>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.xOffset.label")}
|
||||
data-autofocus
|
||||
label={tMoveResize("field.xOffset.label")}
|
||||
min={0}
|
||||
max={innerProps.columnCount - 1}
|
||||
max={selectedTarget.columnCount - 1}
|
||||
{...form.getInputProps("xOffset")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput label={t("item.moveResize.field.yOffset.label")} min={0} {...form.getInputProps("yOffset")} />
|
||||
<NumberInput
|
||||
label={tMoveResize("field.yOffset.label")}
|
||||
min={0}
|
||||
max={selectedTarget.maxRowCount === null ? undefined : selectedTarget.maxRowCount - 1}
|
||||
{...form.getInputProps("yOffset")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.width.label")}
|
||||
min={1}
|
||||
max={innerProps.columnCount - form.values.xOffset}
|
||||
label={tMoveResize("field.width.label")}
|
||||
min={minimumSize.width}
|
||||
max={selectedTarget.columnCount - Math.min(form.values.xOffset, selectedTarget.columnCount - 1)}
|
||||
{...form.getInputProps("width")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
|
||||
<NumberInput
|
||||
label={tMoveResize("field.height.label")}
|
||||
min={minimumSize.height}
|
||||
max={
|
||||
selectedTarget.maxRowCount === null
|
||||
? undefined
|
||||
: selectedTarget.maxRowCount - Math.min(form.values.yOffset, selectedTarget.maxRowCount - 1)
|
||||
}
|
||||
{...form.getInputProps("height")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
@@ -101,3 +227,137 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
|
||||
export const useOpenItemMoveModal = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { commitSectionGrids } = useGridLayoutActions();
|
||||
const { openModal } = useModalAction(ItemMoveModal);
|
||||
|
||||
return useCallback(
|
||||
(input: OpenItemMoveModalInput) => {
|
||||
openModal({
|
||||
...input,
|
||||
board,
|
||||
currentLayoutId,
|
||||
commitSectionGrids,
|
||||
});
|
||||
},
|
||||
[board, commitSectionGrids, currentLayoutId, openModal],
|
||||
);
|
||||
};
|
||||
|
||||
export const getMoveTargets = (
|
||||
board: Board,
|
||||
layoutId: string,
|
||||
entry: MovableEntry,
|
||||
labels: MoveTargetLabels,
|
||||
): MoveTarget[] => {
|
||||
const minimum = getEntryMinimumSize(board, layoutId, entry);
|
||||
|
||||
const candidates = board.sections.flatMap((section): MoveTargetCandidate[] => {
|
||||
if (section.kind === "empty") {
|
||||
const columnCount = getSectionColumnCount(board, layoutId, section.id);
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (columnCount === 0) return [];
|
||||
return minimum.width <= columnCount
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
|
||||
columnCount,
|
||||
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return [];
|
||||
if (
|
||||
(entry.type === "section" && isSameOrDescendant(board, layoutId, section.id, entry.id)) ||
|
||||
minimum.width > layout.width ||
|
||||
minimum.height > layout.height
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: section.id,
|
||||
name: section.options.title || labels.container,
|
||||
columnCount: layout.width,
|
||||
maxRowCount: layout.height,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return candidates.map(({ name, location, ...target }, index) => ({
|
||||
...target,
|
||||
label: location ? labels.located(name, location, index + 1) : labels.numbered(name, index + 1),
|
||||
}));
|
||||
};
|
||||
|
||||
const getSectionColumnCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
if (!layout) throw new Error(`Layout "${layoutId}" was not found`);
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) throw new Error(`Section "${sectionId}" was not found`);
|
||||
|
||||
const columnCount = getSectionGridColumnCount(board, layoutId, sectionId);
|
||||
if (columnCount === null) throw new Error(`Section "${sectionId}" has no current layout`);
|
||||
return columnCount;
|
||||
};
|
||||
|
||||
const getSectionMaxRowCount = (board: Board, layoutId: string, sectionId: string) => {
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return null;
|
||||
if (section.kind === "container") {
|
||||
return section.layouts.find((candidate) => candidate.layoutId === layoutId)?.height ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getEntryMinimumSize = (board: Board, layoutId: string, entry: MovableEntry) => {
|
||||
if (entry.type === "item") return { width: 1, height: 1 };
|
||||
const children = getSectionGridPlacements(board, layoutId, entry.id);
|
||||
return {
|
||||
width: Math.max(1, ...children.map((child) => child.x + child.w)),
|
||||
height: Math.max(1, ...children.map((child) => child.y + child.h)),
|
||||
};
|
||||
};
|
||||
|
||||
const isSameOrDescendant = (board: Board, layoutId: string, candidateId: string, ancestorId: string) => {
|
||||
let current: string | undefined = candidateId;
|
||||
const visited = new Set<string>();
|
||||
while (current && !visited.has(current)) {
|
||||
if (current === ancestorId) return true;
|
||||
visited.add(current);
|
||||
const section = board.sections.find((candidate) => candidate.id === current && candidate.kind === "container");
|
||||
if (!section || section.kind !== "container") return false;
|
||||
current = section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const focusMovedEntry = (entryId: string, sectionId: string) => {
|
||||
let attempts = 0;
|
||||
const tryFocus = () => {
|
||||
const section = Array.from(document.querySelectorAll<HTMLElement>("[data-grid-section-id]")).find(
|
||||
(candidate) => candidate.dataset.gridSectionId === sectionId,
|
||||
);
|
||||
const entry = Array.from(section?.querySelectorAll<HTMLElement>(":scope > [data-grid-item-id]") ?? []).find(
|
||||
(candidate) => candidate.dataset.gridItemId === entryId,
|
||||
);
|
||||
const editorEntry = entry?.querySelector<HTMLElement>("[data-editor-grid-entry]");
|
||||
if (editorEntry) {
|
||||
editorEntry.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
if (attempts < 10) requestAnimationFrame(tryFocus);
|
||||
};
|
||||
|
||||
requestAnimationFrame(tryFocus);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
|
||||
describe("widget selection lock", () => {
|
||||
it("rejects repeated selection synchronously until the active selection finishes", () => {
|
||||
const lock = { current: false };
|
||||
|
||||
expect(tryLockSelection(lock)).toBe(true);
|
||||
expect(tryLockSelection(lock)).toBe(false);
|
||||
|
||||
unlockSelection(lock);
|
||||
expect(tryLockSelection(lock)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatchingIntegrationsAsync", () => {
|
||||
it("waits for the shared integration query before a cold integration-backed selection", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [
|
||||
{ id: "matching", kind: "sonarr" as const },
|
||||
{ id: "other", kind: "radarr" as const },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not wait for integrations when the widget has no integration support", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: false,
|
||||
supportedIntegrations: [],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
export const tryLockSelection = (lock: { current: boolean }) => {
|
||||
if (lock.current) return false;
|
||||
lock.current = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const unlockSelection = (lock: { current: boolean }) => {
|
||||
lock.current = false;
|
||||
};
|
||||
|
||||
export const resolveMatchingIntegrationsAsync = async <TIntegration extends { kind: IntegrationKind }>({
|
||||
hasIntegrationSupport,
|
||||
supportedIntegrations,
|
||||
currentData,
|
||||
ensureDataAsync,
|
||||
}: {
|
||||
hasIntegrationSupport: boolean;
|
||||
supportedIntegrations: readonly IntegrationKind[];
|
||||
currentData: TIntegration[] | undefined;
|
||||
ensureDataAsync: () => Promise<TIntegration[]>;
|
||||
}) => {
|
||||
if (!hasIntegrationSupport) return [];
|
||||
|
||||
const integrations = currentData ?? (await ensureDataAsync());
|
||||
return integrations.filter((integration) => supportedIntegrations.includes(integration.kind));
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
.action {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.card:hover .action,
|
||||
.card:focus-within .action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.action {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,68 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
import classes from "./item-select-modal.module.css";
|
||||
|
||||
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
interface ItemSelectModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
integrationData: RouterOutputs["integration"]["all"] | undefined;
|
||||
customWidgetDefs: RouterOutputs["customWidget"]["available"] | undefined;
|
||||
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
actions,
|
||||
integrationData,
|
||||
customWidgetDefs,
|
||||
ensureIntegrationDataAsync,
|
||||
isAdmin,
|
||||
}: ItemSelectModalContentProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const settings = useSettings();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
objectEntries(widgetImports)
|
||||
.filter(([kind]) => kind !== "customApi")
|
||||
.map(([kind, value]) => ({
|
||||
kind,
|
||||
supportedIntegrations:
|
||||
"supportedIntegrations" in value.definition
|
||||
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
|
||||
: [],
|
||||
icon: value.definition.icon,
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
}))
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
.map((kind) => {
|
||||
return {
|
||||
kind,
|
||||
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
|
||||
(integration) => integration !== "mock",
|
||||
),
|
||||
icon: widgetCatalogIcons[kind],
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
};
|
||||
})
|
||||
.sort((itemA, itemB) => {
|
||||
if (itemA.kind === "app") return -1;
|
||||
if (itemB.kind === "app") return 1;
|
||||
@@ -61,58 +83,112 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
}, [items, search]);
|
||||
|
||||
const filteredCustomWidgets = useMemo(
|
||||
() =>
|
||||
(customWidgetDefs ?? []).filter((def) => def.enabled && def.name.toLowerCase().includes(search.toLowerCase())),
|
||||
() => (customWidgetDefs ?? []).filter((def) => def.name.toLowerCase().includes(search.toLowerCase())),
|
||||
[customWidgetDefs, search],
|
||||
);
|
||||
|
||||
const handleAddCustomWidget = (definitionId: string) => {
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
||||
actions.closeModal();
|
||||
const notifyDefinitionLoadError = (error: unknown) => {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
const definition = widgetImports[kind].definition;
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const matchingIntegrations = hasIntegrationSupport
|
||||
? (integrationData ?? []).filter((integration) =>
|
||||
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
||||
)
|
||||
: [];
|
||||
|
||||
const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
|
||||
const integrationIds = matchingIntegrations.slice(0, maxIntegrations).map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
|
||||
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
const handleAddCustomWidget = async (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(`custom:${customWidgetDefinition.id}`);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const definition = await loadWidgetDefinition("customApi");
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
definitionId: customWidgetDefinition.id,
|
||||
configuration: customWidgetDefinition.defaultOptions,
|
||||
configurationVersion: customWidgetDefinition.updatedAt.getTime(),
|
||||
};
|
||||
actions.closeModal();
|
||||
openEditModal(
|
||||
{
|
||||
kind: "customApi",
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options,
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: configuredOptions });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||
},
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
);
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (kind: WidgetKind) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(kind);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport,
|
||||
supportedIntegrations: definition.supportedIntegrations ?? [],
|
||||
currentData: integrationData,
|
||||
ensureDataAsync: ensureIntegrationDataAsync,
|
||||
});
|
||||
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
},
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -121,9 +197,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleAdd(filteredItems[0]!.kind);
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -131,12 +207,18 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => handleAdd(item.kind)}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onIntent={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredCustomWidgets.length > 0 && (
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
@@ -147,12 +229,12 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
key={def.id}
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
style={{ overflow: "hidden" }}
|
||||
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -166,38 +248,78 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? def.url}
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
|
||||
<Button
|
||||
onClick={() => void handleAddCustomWidget(def)}
|
||||
variant="light"
|
||||
size="xs"
|
||||
fullWidth
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === `custom:${def.id}`}
|
||||
>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
}).withOptions({
|
||||
};
|
||||
const ItemSelectModalFrame = ({
|
||||
actions,
|
||||
innerProps,
|
||||
}: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: { boardId: string };
|
||||
}) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: innerProps.boardId },
|
||||
{ enabled: isAdmin },
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemSelectModalContent
|
||||
actions={actions}
|
||||
integrationData={integrationData}
|
||||
customWidgetDefs={customWidgetDefs}
|
||||
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ItemSelectModal = createModal<{ boardId: string }>((props) => (
|
||||
<ItemSelectModalFrame {...props} />
|
||||
)).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
@@ -205,6 +327,9 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onIntent,
|
||||
disabled,
|
||||
loading,
|
||||
hasMatchingIntegration,
|
||||
}: {
|
||||
item: {
|
||||
@@ -215,23 +340,27 @@ const WidgetItem = ({
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
onIntent: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
hasMatchingIntegration: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
"--_hover-opacity": "0",
|
||||
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
onFocus={onIntent}
|
||||
onPointerEnter={onIntent}
|
||||
aria-busy={loading || undefined}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -248,18 +377,17 @@ const WidgetItem = ({
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
|
||||
let widgetEditModalPromise: Promise<typeof import("@homarr/widgets/modals")> | undefined;
|
||||
|
||||
export const loadWidgetEditModal = () => {
|
||||
if (widgetEditModalPromise) return widgetEditModalPromise;
|
||||
|
||||
const currentPromise = import("@homarr/widgets/modals");
|
||||
widgetEditModalPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (widgetEditModalPromise === currentPromise) widgetEditModalPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
|
||||
|
||||
interface LazyWidgetEditModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: WidgetEditModalProps<WidgetKind>;
|
||||
}
|
||||
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const Component = WidgetEditModal.component;
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>((props) => (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<Center py="xl">
|
||||
<WidgetError
|
||||
error={error}
|
||||
resetErrorBoundary={() => {
|
||||
widgetEditModalPromise = undefined;
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LazyWidgetEditModalContent {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)).withOptions({
|
||||
keepMounted: true,
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -1,112 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconCopy,
|
||||
IconLayoutKanban,
|
||||
IconRefresh,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { MutableRefObject, ReactNode, RefObject } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { usePersistBoard } from "@homarr/boards/updater";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import type { WidgetContextMenuAction } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
|
||||
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
import { useBoardPermissions } from "../permissions/client";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
sourceRef: RefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||
export const WidgetContextMenu = ({
|
||||
item,
|
||||
definition,
|
||||
widgetRuntimeRef,
|
||||
sourceRef,
|
||||
children,
|
||||
}: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const tItem = useScopedI18n("item");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { updateAndPersistBoard } = usePersistBoard(board);
|
||||
const tMenu = useScopedI18n("item.menu.label");
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const isRightClickEnabled = settings.enableRightClickOnWidgets;
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: hasSupportedIntegrations,
|
||||
});
|
||||
const canConfigureWidget =
|
||||
hasChangeAccess && (item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false));
|
||||
const canOpenAdvancedFocus = supportsAdvancedFocus(definition);
|
||||
const queryClient = useQueryClient();
|
||||
const { open: openAdvancedFocus } = useAdvancedFocus();
|
||||
const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
|
||||
const [menuOpened, setMenuOpened] = useState(false);
|
||||
|
||||
const widgetQueryKey = useMemo(
|
||||
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
|
||||
[currentDefinition, item.kind],
|
||||
const persistBoard = useCallback(
|
||||
(updater: (previous: typeof board) => typeof board) => {
|
||||
updateAndPersistBoard(updater, {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("item.menu.notification.saveError.title"),
|
||||
message: t("item.menu.notification.saveError.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[t, updateAndPersistBoard],
|
||||
);
|
||||
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
|
||||
const handleRefetch = useCallback(() => {
|
||||
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
|
||||
}, [queryClient, widgetQueryKey]);
|
||||
|
||||
const options = useMemo(
|
||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
||||
[item.kind, settings, item.options],
|
||||
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
|
||||
[definition, settings, item.options],
|
||||
);
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||
const matchesWidgetQuery = useCallback(
|
||||
(queryKey: QueryKey) =>
|
||||
matchesWidgetItemQuery(
|
||||
queryKey,
|
||||
widgetQueryKeys,
|
||||
{
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
integrationIds: item.integrationIds,
|
||||
options,
|
||||
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
||||
},
|
||||
definition.queryMatcher,
|
||||
),
|
||||
[board.id, definition.queryMatcher, item.id, item.integrationIds, options, widgetQueryKeys, widgetRuntimeRef],
|
||||
);
|
||||
const isWidgetFetching =
|
||||
useIsFetching({
|
||||
type: "active",
|
||||
predicate: (query) => matchesWidgetQuery(query.queryKey),
|
||||
}) > 0;
|
||||
const handleRefetch = useCallback(() => {
|
||||
void queryClient.refetchQueries({ type: "active", predicate: (query) => matchesWidgetQuery(query.queryKey) });
|
||||
}, [matchesWidgetQuery, queryClient]);
|
||||
|
||||
const canInteractWithSelectedIntegrations = useMemo(() => {
|
||||
const allowedIds = new Set(integrationsWithInteractAccess.map(({ id }) => id));
|
||||
return item.integrationIds.length > 0 && item.integrationIds.every((id) => allowedIds.has(id));
|
||||
}, [integrationsWithInteractAccess, item.integrationIds]);
|
||||
|
||||
const setItemOptions = useCallback(
|
||||
(partial: Record<string, unknown>) => {
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((boardItem) =>
|
||||
boardItem.id === item.id ? { ...boardItem, options: { ...boardItem.options, ...partial } } : boardItem,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[item.id, persistBoard],
|
||||
);
|
||||
|
||||
type OptionDef = { type: string; skipContextMenu?: boolean };
|
||||
const toggleOptions = useMemo(() => {
|
||||
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
||||
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
|
||||
}, [currentDefinition, settings]);
|
||||
if (!canConfigureWidget) return [];
|
||||
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
||||
return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu);
|
||||
}, [canConfigureWidget, definition, settings]);
|
||||
|
||||
const widgetContextActions = useMemo(() => {
|
||||
const def = currentDefinition as Record<string, unknown>;
|
||||
if (typeof def.contextActions !== "function") return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actions = (def.contextActions as any)({
|
||||
const widgetContextActions =
|
||||
definition.contextActions?.({
|
||||
options,
|
||||
setOptions: (partial: Record<string, unknown>) => {
|
||||
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
|
||||
},
|
||||
setOptions: setItemOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
context: { isEditMode, boardId: board.id, itemId: item.id },
|
||||
widgetStateRef,
|
||||
});
|
||||
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
|
||||
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
|
||||
|
||||
const persistBoard = useCallback(
|
||||
(updatedItems: typeof board.items) => {
|
||||
saveBoard({ ...board, items: updatedItems });
|
||||
},
|
||||
[board, saveBoard],
|
||||
);
|
||||
context: {
|
||||
isEditMode,
|
||||
boardId: board.id,
|
||||
itemId: item.id,
|
||||
canInteractWithSelectedIntegrations,
|
||||
},
|
||||
widgetRuntimeRef,
|
||||
}) ?? [];
|
||||
|
||||
const openEditModal = useCallback(() => {
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
@@ -116,27 +164,24 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
|
||||
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
|
||||
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
|
||||
if (!isEditMode) {
|
||||
persistBoard(
|
||||
board.items.map((boardItem) =>
|
||||
boardItem.id !== item.id
|
||||
? boardItem
|
||||
: {
|
||||
...boardItem,
|
||||
options: editResult.options,
|
||||
advancedOptions: editResult.advancedOptions,
|
||||
integrationIds: editResult.integrationIds,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((boardItem) =>
|
||||
boardItem.id !== item.id
|
||||
? boardItem
|
||||
: {
|
||||
...boardItem,
|
||||
options: editResult.options,
|
||||
advancedOptions: editResult.advancedOptions,
|
||||
integrationIds: editResult.integrationIds,
|
||||
},
|
||||
),
|
||||
}));
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
definition.supportedIntegrations?.some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||
settings,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
@@ -144,50 +189,61 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
|
||||
);
|
||||
}, [
|
||||
openModal,
|
||||
definition,
|
||||
integrationData,
|
||||
item,
|
||||
updateItemOptions,
|
||||
openModal,
|
||||
persistBoard,
|
||||
settings,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
integrationData,
|
||||
currentDefinition,
|
||||
settings,
|
||||
isEditMode,
|
||||
persistBoard,
|
||||
board,
|
||||
updateItemOptions,
|
||||
]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(key: string) => (checked: boolean) => {
|
||||
const newOptions = { ...options, [key]: checked };
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
persistBoard(
|
||||
board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
|
||||
);
|
||||
},
|
||||
[options, item.id, updateItemOptions, persistBoard, board],
|
||||
(key: string) => (checked: boolean) => setItemOptions({ [key]: checked }),
|
||||
[setItemOptions],
|
||||
);
|
||||
|
||||
if (!session) return <>{children}</>;
|
||||
if (!isRightClickEnabled) return <>{children}</>;
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode) return <>{children}</>;
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={300}
|
||||
closeOnItemClick={false}
|
||||
position="right-start"
|
||||
offset={4}
|
||||
opened={menuOpened}
|
||||
onChange={setMenuOpened}
|
||||
>
|
||||
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
||||
<Menu.Dropdown>
|
||||
{canOpenAdvancedFocus && (
|
||||
<>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconMaximize size={16} />}
|
||||
onClick={() => {
|
||||
if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current);
|
||||
}}
|
||||
>
|
||||
{t("item.advancedFocus.open")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{toggleOptions.length > 0 && (
|
||||
<>
|
||||
<Menu.Label>{tMenu("options")}</Menu.Label>
|
||||
{toggleOptions.map(([key]) => (
|
||||
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
|
||||
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Group wrap="nowrap">
|
||||
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
|
||||
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
|
||||
</Menu.CheckboxItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
@@ -207,82 +263,39 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
disabled={action.disabled}
|
||||
color={action.color}
|
||||
>
|
||||
{String(translateIfNecessary(t, action.label))}
|
||||
{translateIfNecessary(t, action.label)}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconLayoutKanban size={16} />}
|
||||
onClick={() => {
|
||||
if (!gridstack.current) return;
|
||||
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
|
||||
}}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={() => duplicateItem({ itemId: item.id })}
|
||||
>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
{tMenu("refresh")}
|
||||
<WidgetQueryStatus
|
||||
queryClient={queryClient}
|
||||
queryKey={widgetQueryKey}
|
||||
isFetching={isWidgetFetching}
|
||||
t={t}
|
||||
/>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconSettings size={16} />}
|
||||
onClick={openEditModal}
|
||||
disabled={isPending}
|
||||
>
|
||||
{tMenu("settings")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => {
|
||||
openConfirmModal({
|
||||
title: tItem("remove.title"),
|
||||
children: tItem("remove.message"),
|
||||
onConfirm: () => removeItem({ itemId: item.id }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tItem("action.remove")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
{tMenu("refresh")}
|
||||
<WidgetQueryStatus
|
||||
queryClient={queryClient}
|
||||
matchesQuery={matchesWidgetQuery}
|
||||
isFetching={isWidgetFetching}
|
||||
t={t}
|
||||
/>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconSettings size={16} />}
|
||||
onClick={openEditModal}
|
||||
onFocus={preloadWidgetEditModal}
|
||||
onPointerEnter={preloadWidgetEditModal}
|
||||
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
|
||||
>
|
||||
{tMenu("settings")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
@@ -290,16 +303,20 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
|
||||
interface WidgetQueryStatusProps {
|
||||
queryClient: QueryClient;
|
||||
queryKey: QueryKey;
|
||||
matchesQuery: (queryKey: QueryKey) => boolean;
|
||||
isFetching: boolean;
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
|
||||
const queries = queryClient.getQueryCache().findAll({ queryKey });
|
||||
const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQueryStatusProps) => {
|
||||
const queries = queryClient.getQueryCache().findAll({
|
||||
type: "active",
|
||||
predicate: (query) => matchesQuery(query.queryKey),
|
||||
});
|
||||
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
|
||||
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
||||
const ageLabel = useTimeAgo(new Date(latest));
|
||||
const ageLabel = useTimeAgo(new Date(latest || Date.now()));
|
||||
const ageSuffix = latest > 0 ? ` · ${ageLabel}` : "";
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
@@ -320,21 +337,15 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
|
||||
);
|
||||
}
|
||||
|
||||
const hasError = queries.some((q) => q.state.status === "error");
|
||||
|
||||
const hasError = queries.some((query) => query.state.status === "error");
|
||||
if (hasError) {
|
||||
const errorQuery = queries.find((q) => q.state.status === "error");
|
||||
const errorMessage =
|
||||
errorQuery?.state.error instanceof Error
|
||||
? errorQuery.state.error.message
|
||||
: String(errorQuery?.state.error ?? "Unknown error");
|
||||
|
||||
return (
|
||||
<Tooltip label={errorMessage} multiline position="left" w={250}>
|
||||
<Tooltip label={t("item.menu.status.error")} position="left">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" c="red.6">
|
||||
{t("item.menu.status.error")} · {ageLabel}
|
||||
{t("item.menu.status.error")}
|
||||
{ageSuffix}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
@@ -345,7 +356,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.success")} · {ageLabel}
|
||||
{t("item.menu.status.success")}
|
||||
{ageSuffix}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export type Translator = {
|
||||
(key: string): string;
|
||||
};
|
||||
|
||||
export interface HoverMetadataItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const maxMetadataItems = 3;
|
||||
|
||||
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
|
||||
app: true,
|
||||
anchorNote: true,
|
||||
dynamicSelect: true,
|
||||
sortableItemList: true,
|
||||
multiReleasesRepositories: true,
|
||||
umamiEventName: true,
|
||||
umamiEventNames: true,
|
||||
umamiWebsite: true,
|
||||
};
|
||||
|
||||
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
|
||||
|
||||
type OptionDefinitionWithRules = WidgetOptionDefinition & {
|
||||
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
|
||||
options?: readonly { value: unknown; label: unknown }[];
|
||||
};
|
||||
|
||||
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
|
||||
|
||||
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
|
||||
const match = definition.options?.find((option) => option.value === value);
|
||||
const label = match?.label;
|
||||
const fallbackLabel = () => String(value);
|
||||
const labelResolvers: Record<string, () => string> = {
|
||||
function: () => (label as (translator: Translator) => string)(t),
|
||||
string: () => label as string,
|
||||
missing: fallbackLabel,
|
||||
};
|
||||
const labelType = typeof label;
|
||||
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
|
||||
return resolveLabel();
|
||||
};
|
||||
|
||||
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
|
||||
switch: (value, _, t) => {
|
||||
const switchLabels: Record<string, string> = {
|
||||
true: t("common.action.show"),
|
||||
false: t("common.action.hide"),
|
||||
};
|
||||
return switchLabels[String(Boolean(value))] ?? null;
|
||||
},
|
||||
text: (value) => {
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
},
|
||||
number: (value) => String(value),
|
||||
slider: (value) => String(value),
|
||||
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
|
||||
multiSelect: (value) => {
|
||||
const items = value as unknown[];
|
||||
return items.length > 0 ? String(items.length) : null;
|
||||
},
|
||||
multiText: (value) => {
|
||||
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
|
||||
return items.length > 0 ? items.join(", ") : null;
|
||||
},
|
||||
location: (value) => {
|
||||
const location = value as { name?: string };
|
||||
const name = location.name?.trim() ?? "";
|
||||
return name.length > 0 ? name : null;
|
||||
},
|
||||
};
|
||||
|
||||
const formatOptionValue = (
|
||||
type: WidgetOptionType,
|
||||
value: unknown,
|
||||
definition: OptionDefinitionWithRules,
|
||||
t: Translator,
|
||||
) => {
|
||||
const formatter = optionTypeFormatters[type];
|
||||
return formatter?.(value, definition, t) ?? null;
|
||||
};
|
||||
|
||||
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
|
||||
const labelKey = `widget.${kind}.option.${key}.label`;
|
||||
const translated = t(labelKey);
|
||||
return translated === labelKey ? key : translated;
|
||||
};
|
||||
|
||||
export const collectWidgetHoverMetadata = (
|
||||
item: SectionItem,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
integrationKinds: IntegrationKind[],
|
||||
t: Translator,
|
||||
): HoverMetadataItem[] => {
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
|
||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
|
||||
return objectEntries(optionDefinitions)
|
||||
.filter(([key, optionDefinition]) => {
|
||||
const skip = skippedOptionTypes[optionDefinition.type];
|
||||
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
|
||||
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
|
||||
return !skip && !hidden && !unchanged;
|
||||
})
|
||||
.map(([key, optionDefinition]) => {
|
||||
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
|
||||
return {
|
||||
label: getOptionLabel(item.kind, key, t),
|
||||
value: formattedValue ?? "",
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.value.length > 0)
|
||||
.slice(0, maxMetadataItems);
|
||||
};
|
||||
|
||||
interface DisplayNameSource {
|
||||
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
|
||||
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
const displayNameSources: DisplayNameSource[] = [
|
||||
{
|
||||
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
|
||||
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
|
||||
},
|
||||
{
|
||||
matches: (item, options) =>
|
||||
item.kind === "clock" &&
|
||||
Boolean(options.customTitleToggle) &&
|
||||
String(options.customTitle ?? "").trim().length > 0,
|
||||
resolve: (_, options) => String(options.customTitle).trim(),
|
||||
},
|
||||
];
|
||||
|
||||
export const resolveWidgetDisplayName = (
|
||||
item: SectionItem,
|
||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||
t: Translator,
|
||||
) => {
|
||||
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
|
||||
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
|
||||
};
|
||||
@@ -2,21 +2,18 @@
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: calc(100% - 10px);
|
||||
margin-bottom: 1px;
|
||||
top: 10px;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transform: translateY(-6px);
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover)
|
||||
.wrapper,
|
||||
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within)
|
||||
.wrapper {
|
||||
:global([data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
||||
:global([data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
@@ -34,7 +31,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
padding: 4px 36px 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--background-color);
|
||||
@@ -108,6 +105,15 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.panel {
|
||||
border-color: CanvasText;
|
||||
background: Canvas;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.metadataValue {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Avatar, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { loadWidgetDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "./widget-hover-overlay.module.css";
|
||||
@@ -18,8 +19,19 @@ interface WidgetHoverOverlayProps {
|
||||
|
||||
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoadedWidgetHoverOverlay item={item} integrations={integrations} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||
const t = useI18n();
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const definition = use(loadWidgetDefinition(item.kind));
|
||||
const WidgetIcon = definition.icon;
|
||||
|
||||
const displayName = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
@@ -28,12 +40,8 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
|
||||
item.integrationIds.includes(integration.id),
|
||||
);
|
||||
|
||||
if (!isEditMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classes.wrapper} data-board-widget-header>
|
||||
<div className={classes.panel}>
|
||||
<div className={classes.nameSection}>
|
||||
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
|
||||
describe("removePersistedWidgetQueries", () => {
|
||||
test("removes stale widget queries and allows fresh data to be fetched", async () => {
|
||||
describe("removeWidgetDataQueries", () => {
|
||||
test("removes only the failed widget prefix and allows fresh data to be fetched", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||
const sameWidgetQueryKey = [["widget", "mediaServer", "getLibraries"], { input: {} }];
|
||||
const unrelatedWidgetQueryKey = [["widget", "calendar", "getEvents"], { input: {} }];
|
||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(sameWidgetQueryKey, "related");
|
||||
queryClient.setQueryData(unrelatedWidgetQueryKey, "other-widget");
|
||||
queryClient.setQueryData(appQueryKey, "app");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, [[["widget", "mediaServer"]]]);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(sameWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedWidgetQueryKey)).toBe("other-widget");
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBe("app");
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
await expect(
|
||||
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
||||
).resolves.toBe("fresh");
|
||||
});
|
||||
|
||||
test("removes persisted queries outside the widget router", () => {
|
||||
test("uses an explicit supporting query prefix without purging other supporting data", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||
@@ -31,14 +40,14 @@ describe("removePersistedWidgetQueries", () => {
|
||||
queryClient.setQueryData(appQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, [[["docker", "getContainers"]]]);
|
||||
|
||||
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBe("stale");
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
});
|
||||
|
||||
test("removes persisted widget queries when the widget kind and query path differ", () => {
|
||||
test("uses the definition query prefix when the widget kind and query path differ", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
@@ -46,7 +55,7 @@ describe("removePersistedWidgetQueries", () => {
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
removeWidgetDataQueries(queryClient, [[["widget", "anchorNotes"]]]);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
||||
|
||||
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
||||
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
||||
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: readonly QueryKey[]) => {
|
||||
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
|
||||
};
|
||||
@@ -0,0 +1,442 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets";
|
||||
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
|
||||
|
||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
|
||||
type WidgetKind = keyof typeof widgetImports;
|
||||
const allPhotosAlbumId = "all";
|
||||
|
||||
const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQueryMatcherScope => ({
|
||||
itemId: "item-1",
|
||||
boardId: "board-1",
|
||||
integrationIds: ["integration-1"],
|
||||
options: {},
|
||||
runtimeQueries: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => {
|
||||
const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string };
|
||||
const queryKey: QueryKey = [path, { input, type: "query" }];
|
||||
return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher);
|
||||
};
|
||||
|
||||
describe("matchesWidgetItemQuery", () => {
|
||||
test("matches only the current Custom API definition", () => {
|
||||
const scope = createScope({ options: { definitionId: "definition-1" } });
|
||||
const input = { boardId: "board-1", itemId: "item-1", definitionId: "definition-1" };
|
||||
|
||||
expect(matches("customApi", ["widget", "customApi", "getData"], input, scope)).toBe(true);
|
||||
expect(
|
||||
matches("customApi", ["widget", "customApi", "getData"], { ...input, definitionId: "definition-2" }, scope),
|
||||
).toBe(false);
|
||||
expect(matches("customApi", ["widget", "customApi", "getData"], { ...input, itemId: "item-2" }, scope)).toBe(false);
|
||||
expect(matches("customApi", ["widget", "customApi", "getData"], { ...input, boardId: "board-2" }, scope)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("owns only the active compact or advanced Media Missing page size", () => {
|
||||
const path = ["widget", "mediaOrganizer", "getData"];
|
||||
const compactInput = { integrationIds: ["radarr-1"], pageSize: 20 };
|
||||
const advancedInput = { integrationIds: ["radarr-1"], pageSize: 50 };
|
||||
const compactScope = createScope({
|
||||
integrationIds: ["radarr-1"],
|
||||
runtimeQueries: [{ path, input: compactInput }],
|
||||
});
|
||||
const advancedScope = createScope({
|
||||
integrationIds: ["radarr-1"],
|
||||
runtimeQueries: [{ path, input: advancedInput }],
|
||||
});
|
||||
|
||||
expect(matches("mediaMissing", path, compactInput, compactScope)).toBe(true);
|
||||
expect(matches("mediaMissing", path, advancedInput, compactScope)).toBe(false);
|
||||
expect(matches("mediaMissing", path, advancedInput, advancedScope)).toBe(true);
|
||||
expect(matches("mediaMissing", path, compactInput, advancedScope)).toBe(false);
|
||||
});
|
||||
|
||||
test("owns every Releases batch by item id", () => {
|
||||
const scope = createScope({ itemId: "release-item" });
|
||||
const path = ["widget", "releases", "getLatest"];
|
||||
|
||||
expect(matches("releases", path, { itemId: "release-item", repositories: [{ id: "one" }] }, scope)).toBe(true);
|
||||
expect(matches("releases", path, { itemId: "sibling-item", repositories: [{ id: "one" }] }, scope)).toBe(false);
|
||||
});
|
||||
|
||||
test("normalizes Immich's all-photos sentinel and separates Immich widget kinds", () => {
|
||||
const carouselScope = createScope({
|
||||
integrationIds: ["immich-1"],
|
||||
options: { albumId: allPhotosAlbumId },
|
||||
});
|
||||
const serverStatsQuery: NormalizedWidgetQuery = {
|
||||
path: ["widget", "immich", "getServerStats"],
|
||||
input: { integrationId: "immich-1" },
|
||||
};
|
||||
const albumsQuery: NormalizedWidgetQuery = {
|
||||
path: ["widget", "immich", "getAlbums"],
|
||||
input: { integrationId: "immich-1" },
|
||||
};
|
||||
const compactServerScope = createScope({
|
||||
integrationIds: ["immich-1"],
|
||||
runtimeQueries: [serverStatsQuery],
|
||||
});
|
||||
const advancedServerScope = createScope({
|
||||
integrationIds: ["immich-1"],
|
||||
runtimeQueries: [serverStatsQuery, albumsQuery],
|
||||
});
|
||||
|
||||
expect(
|
||||
matches("immich-albumCarousel", ["widget", "immich", "getAlbum"], { integrationId: "immich-1" }, carouselScope),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matches(
|
||||
"immich-albumCarousel",
|
||||
["widget", "immich", "getAlbum"],
|
||||
{ integrationId: "immich-1", albumId: "album-2" },
|
||||
carouselScope,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matches(
|
||||
"immich-albumCarousel",
|
||||
["widget", "immich", "getServerStats"],
|
||||
{ integrationId: "immich-1" },
|
||||
carouselScope,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(matches("immich-serverStats", serverStatsQuery.path, serverStatsQuery.input, compactServerScope)).toBe(true);
|
||||
expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, compactServerScope)).toBe(false);
|
||||
expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, advancedServerScope)).toBe(true);
|
||||
expect(
|
||||
matches("immich-serverStats", ["widget", "immich", "getAlbum"], albumsQuery.input, advancedServerScope),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("uses the resolved Beszel integration, system, and includeDocker input", () => {
|
||||
const runtimeQuery: NormalizedWidgetQuery = {
|
||||
path: ["widget", "beszel", "getSystemStats"],
|
||||
input: {
|
||||
integrationIds: ["beszel-2"],
|
||||
systemId: "system-2",
|
||||
timePeriod: "1h",
|
||||
includeDocker: true,
|
||||
},
|
||||
};
|
||||
const scope = createScope({
|
||||
integrationIds: ["beszel-1", "beszel-2"],
|
||||
options: { systemId: "beszel-2:system-2", timePeriod: "1h", showDockerCpu: true },
|
||||
runtimeQueries: [runtimeQuery],
|
||||
});
|
||||
|
||||
expect(matches("beszelSystemStats", runtimeQuery.path, runtimeQuery.input, scope)).toBe(true);
|
||||
expect(
|
||||
matches(
|
||||
"beszelSystemStats",
|
||||
runtimeQuery.path,
|
||||
{ ...(runtimeQuery.input as Record<string, unknown>), includeDocker: false },
|
||||
scope,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matches(
|
||||
"beszelSystemStats",
|
||||
runtimeQuery.path,
|
||||
{ ...(runtimeQuery.input as Record<string, unknown>), systemId: "system-1" },
|
||||
scope,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("separates Beszel procedure families while retaining shared getSystems ownership", () => {
|
||||
const scope = createScope({
|
||||
integrationIds: ["beszel-1"],
|
||||
options: { showHistory: true, maxHistoryItems: 10 },
|
||||
});
|
||||
const systemsInput = { integrationIds: ["beszel-1"] };
|
||||
const alertsInput = { integrationIds: ["beszel-1"], includeHistory: true, maxHistoryItems: 10 };
|
||||
|
||||
expect(matches("beszelSystemGrid", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true);
|
||||
expect(matches("beszelSystemTable", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true);
|
||||
expect(matches("beszelAlerts", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(false);
|
||||
expect(matches("beszelAlerts", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(true);
|
||||
expect(matches("beszelSystemGrid", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(false);
|
||||
});
|
||||
|
||||
test("owns only the active Beszel table detail query", () => {
|
||||
const activeQuery: NormalizedWidgetQuery = {
|
||||
path: ["widget", "beszel", "getSystemStats"],
|
||||
input: {
|
||||
integrationIds: ["beszel-1"],
|
||||
systemId: "system-1",
|
||||
timePeriod: "1h",
|
||||
includeDocker: true,
|
||||
},
|
||||
};
|
||||
const scope = createScope({ integrationIds: ["beszel-1"], runtimeQueries: [activeQuery] });
|
||||
|
||||
expect(matches("beszelSystemTable", activeQuery.path, activeQuery.input, scope)).toBe(true);
|
||||
expect(
|
||||
matches(
|
||||
"beszelSystemTable",
|
||||
activeQuery.path,
|
||||
{ ...(activeQuery.input as Record<string, unknown>), systemId: "system-2" },
|
||||
scope,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(matches("beszelSystemGrid", activeQuery.path, activeQuery.input, scope)).toBe(false);
|
||||
});
|
||||
|
||||
test("owns only Umami queries registered for the active view and layout", () => {
|
||||
const eventQuery: NormalizedWidgetQuery = {
|
||||
path: ["widget", "umami", "getMultiEventTimeSeries"],
|
||||
input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["signup", "view"] },
|
||||
};
|
||||
const topPagesQuery: NormalizedWidgetQuery = {
|
||||
path: ["widget", "umami", "getTopPages"],
|
||||
input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", limit: 5 },
|
||||
};
|
||||
const eventsScope = createScope({ runtimeQueries: [eventQuery] });
|
||||
const advancedScope = createScope({ runtimeQueries: [eventQuery, topPagesQuery] });
|
||||
|
||||
expect(matches("umami", eventQuery.path, eventQuery.input, eventsScope)).toBe(true);
|
||||
expect(
|
||||
matches(
|
||||
"umami",
|
||||
eventQuery.path,
|
||||
{ integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["view", "signup"] },
|
||||
eventsScope,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(matches("umami", topPagesQuery.path, topPagesQuery.input, eventsScope)).toBe(false);
|
||||
expect(matches("umami", topPagesQuery.path, topPagesQuery.input, advancedScope)).toBe(true);
|
||||
});
|
||||
|
||||
test("maps Media Request status filters and separates list from stats", () => {
|
||||
const listScope = createScope({
|
||||
integrationIds: ["request-1"],
|
||||
options: { statusFilter: [], recentDays: 30, linksTargetNewTab: true },
|
||||
});
|
||||
const allStatuses = ["pending", "approved", "declined", "failed", "completed"];
|
||||
|
||||
expect(
|
||||
matches(
|
||||
"mediaRequests-requestList",
|
||||
["widget", "mediaRequests", "getLatestRequests"],
|
||||
{ integrationIds: ["request-1"], statuses: allStatuses, recentDays: 30 },
|
||||
listScope,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matches(
|
||||
"mediaRequests-requestList",
|
||||
["widget", "mediaRequests", "getLatestRequests"],
|
||||
{ integrationIds: ["request-1"], statuses: ["pending"], recentDays: 30 },
|
||||
listScope,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matches(
|
||||
"mediaRequests-requestList",
|
||||
["widget", "mediaRequests", "getStats"],
|
||||
{ integrationIds: ["request-1"] },
|
||||
listScope,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matches(
|
||||
"mediaRequests-requestStats",
|
||||
["widget", "mediaRequests", "getStats"],
|
||||
{ integrationIds: ["request-1"] },
|
||||
listScope,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("separates Audio Stats stream details from Media Server filters", () => {
|
||||
const statsQuery: NormalizedWidgetQuery = {
|
||||
path: ["widget", "audioStats", "getStats"],
|
||||
input: { integrationId: "audio-1" },
|
||||
};
|
||||
const streamsQuery: NormalizedWidgetQuery = {
|
||||
path: ["widget", "mediaServer", "getCurrentStreams"],
|
||||
input: { integrationIds: ["audio-1"], showOnlyPlaying: false },
|
||||
};
|
||||
const compactAudioScope = createScope({ integrationIds: ["audio-1"], runtimeQueries: [statsQuery] });
|
||||
const advancedAudioScope = createScope({
|
||||
integrationIds: ["audio-1"],
|
||||
runtimeQueries: [statsQuery, streamsQuery],
|
||||
});
|
||||
const mediaScope = createScope({ integrationIds: ["audio-1"], options: { showOnlyPlaying: true } });
|
||||
|
||||
expect(matches("audioStats", statsQuery.path, statsQuery.input, compactAudioScope)).toBe(true);
|
||||
expect(matches("audioStats", streamsQuery.path, streamsQuery.input, compactAudioScope)).toBe(false);
|
||||
expect(matches("audioStats", streamsQuery.path, streamsQuery.input, advancedAudioScope)).toBe(true);
|
||||
expect(
|
||||
matches(
|
||||
"audioStats",
|
||||
streamsQuery.path,
|
||||
{ integrationIds: ["audio-1"], showOnlyPlaying: true },
|
||||
advancedAudioScope,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matches("mediaServer", streamsQuery.path, { integrationIds: ["audio-1"], showOnlyPlaying: true }, mediaScope),
|
||||
).toBe(true);
|
||||
expect(matches("mediaServer", streamsQuery.path, streamsQuery.input, mediaScope)).toBe(false);
|
||||
});
|
||||
|
||||
test("uses each Calendar instance's registered month", () => {
|
||||
const path = ["widget", "calendar", "findAllEvents"];
|
||||
const januaryInput = {
|
||||
integrationIds: ["calendar-1"],
|
||||
month: 0,
|
||||
year: 2026,
|
||||
releaseType: [],
|
||||
showUnmonitored: false,
|
||||
};
|
||||
const februaryInput = { ...januaryInput, month: 1 };
|
||||
const januaryScope = createScope({ runtimeQueries: [{ path, input: januaryInput }] });
|
||||
const februaryScope = createScope({ runtimeQueries: [{ path, input: februaryInput }] });
|
||||
|
||||
expect(matches("calendar", path, januaryInput, januaryScope)).toBe(true);
|
||||
expect(matches("calendar", path, februaryInput, januaryScope)).toBe(false);
|
||||
expect(matches("calendar", path, februaryInput, februaryScope)).toBe(true);
|
||||
});
|
||||
|
||||
test("uses each Media Transcoding instance's registered page and geometry-derived page size", () => {
|
||||
const path = ["widget", "mediaTranscoding", "getDataAsync"];
|
||||
const firstPage = { integrationId: "transcode-1", pageSize: 8, page: 1 };
|
||||
const secondPage = { integrationId: "transcode-1", pageSize: 12, page: 2 };
|
||||
const firstScope = createScope({ runtimeQueries: [{ path, input: firstPage }] });
|
||||
const secondScope = createScope({ runtimeQueries: [{ path, input: secondPage }] });
|
||||
|
||||
expect(matches("mediaTranscoding", path, firstPage, firstScope)).toBe(true);
|
||||
expect(matches("mediaTranscoding", path, secondPage, firstScope)).toBe(false);
|
||||
expect(matches("mediaTranscoding", path, secondPage, secondScope)).toBe(true);
|
||||
});
|
||||
|
||||
test("matches only the Downloads query with the current integrations and limit", () => {
|
||||
const scope = createScope({
|
||||
integrationIds: ["sabnzbd-1", "qbittorrent-1"],
|
||||
options: { limitPerIntegration: 25 },
|
||||
});
|
||||
const path = ["widget", "downloads", "getJobsAndStatuses"];
|
||||
|
||||
expect(
|
||||
matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 25 }, scope),
|
||||
).toBe(true);
|
||||
expect(matches("downloads", path, { integrationIds: ["sabnzbd-1"], limitPerIntegration: 25 }, scope)).toBe(false);
|
||||
expect(
|
||||
matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 50 }, scope),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matches("downloads", ["widget", "downloads", "pause"], { integrationIds: ["sabnzbd-1", "qbittorrent-1"] }, scope),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("matches only the active Stock Price symbol and time frame", () => {
|
||||
const scope = createScope({ options: { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" } });
|
||||
const path = ["widget", "stockPrice", "getPriceHistory"];
|
||||
|
||||
expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(true);
|
||||
expect(matches("stockPrice", path, { stock: "MSFT", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(false);
|
||||
expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "5d", timeInterval: "1d" }, scope)).toBe(false);
|
||||
});
|
||||
|
||||
test("matches Weather by the coordinates sent to the location procedure", () => {
|
||||
const scope = createScope({
|
||||
options: { location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 } },
|
||||
});
|
||||
const path = ["widget", "weather", "atLocation"];
|
||||
|
||||
expect(matches("weather", path, { latitude: 48.85341, longitude: 2.3488 }, scope)).toBe(true);
|
||||
expect(matches("weather", path, { latitude: 51.5072, longitude: -0.1276 }, scope)).toBe(false);
|
||||
expect(
|
||||
matches("weather", ["widget", "weather", "forecast"], { latitude: 48.85341, longitude: 2.3488 }, scope),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("matches Clock weather only when enabled and at its configured coordinates", () => {
|
||||
const path = ["widget", "weather", "atLocation"];
|
||||
const location = { name: "Brisbane", latitude: -27.4698, longitude: 153.0251 };
|
||||
const enabledScope = createScope({ options: { showWeather: true, weatherLocation: location } });
|
||||
const disabledScope = createScope({ options: { showWeather: false, weatherLocation: location } });
|
||||
|
||||
expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, enabledScope)).toBe(true);
|
||||
expect(matches("clock", path, { latitude: 48.85341, longitude: 2.3488 }, enabledScope)).toBe(false);
|
||||
expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, disabledScope)).toBe(false);
|
||||
});
|
||||
|
||||
test("matches Minecraft Server Status by the full input passed to useQuery", () => {
|
||||
const scope = createScope({
|
||||
options: { title: "Survival", domain: "play.example.com", isBedrockServer: false },
|
||||
});
|
||||
const path = ["widget", "minecraft", "getServerStatus"];
|
||||
|
||||
expect(
|
||||
matches(
|
||||
"minecraftServerStatus",
|
||||
path,
|
||||
{ title: "Survival", domain: "play.example.com", isBedrockServer: false },
|
||||
scope,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matches(
|
||||
"minecraftServerStatus",
|
||||
path,
|
||||
{ title: "Creative", domain: "play.example.com", isBedrockServer: false },
|
||||
scope,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matches(
|
||||
"minecraftServerStatus",
|
||||
path,
|
||||
{ title: "Survival", domain: "bedrock.example.com", isBedrockServer: true },
|
||||
scope,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("matches Notifications by integrations and the full input passed to useQuery", () => {
|
||||
const scope = createScope({ integrationIds: ["ntfy-1"], options: { hideLogos: true } });
|
||||
const path = ["widget", "notifications", "getNotifications"];
|
||||
|
||||
expect(matches("notifications", path, { hideLogos: true, integrationIds: ["ntfy-1"] }, scope)).toBe(true);
|
||||
expect(matches("notifications", path, { hideLogos: false, integrationIds: ["ntfy-1"] }, scope)).toBe(false);
|
||||
expect(matches("notifications", path, { hideLogos: true, integrationIds: ["gotify-1"] }, scope)).toBe(false);
|
||||
expect(
|
||||
matches("notifications", ["widget", "notifications", "dismiss"], { integrationIds: ["ntfy-1"] }, scope),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps renamed RSS input fields item-specific", () => {
|
||||
const scope = createScope({
|
||||
integrationIds: [],
|
||||
options: { feedUrls: ["https://feed.example/rss"], maximumAmountPosts: 100 },
|
||||
});
|
||||
const path = ["widget", "rssFeed", "getFeeds"];
|
||||
|
||||
expect(matches("rssFeed", path, { urls: ["https://feed.example/rss"], maximumAmountPosts: 100 }, scope)).toBe(true);
|
||||
expect(matches("rssFeed", path, { urls: ["https://other.example/rss"], maximumAmountPosts: 100 }, scope)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("generic fallback ignores transformed non-identity inputs", () => {
|
||||
const scope = createScope({ integrationIds: ["integration-1"], options: { pageSize: 10 } });
|
||||
const definitionKeys = [[["widget", "example"]]] as const;
|
||||
|
||||
expect(
|
||||
matchesWidgetItemQuery(
|
||||
[["widget", "example", "getData"], { input: { integrationIds: ["integration-1"], pageSize: 50 } }],
|
||||
definitionKeys,
|
||||
scope,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import type { WidgetQueryMatcher, WidgetQueryMatcherScope } from "@homarr/widgets";
|
||||
import { normalizeWidgetQuery, widgetQueryValueEquals } from "@homarr/widgets";
|
||||
|
||||
export const matchesWidgetItemQuery = (
|
||||
queryKey: QueryKey,
|
||||
widgetQueryKeys: readonly QueryKey[],
|
||||
scope: WidgetQueryMatcherScope,
|
||||
queryMatcher?: WidgetQueryMatcher,
|
||||
) => {
|
||||
const query = normalizeWidgetQuery(queryKey);
|
||||
if (!query || !widgetQueryKeys.some((candidate) => matchesDefinitionPath(query.path, candidate))) return false;
|
||||
if (queryMatcher) return queryMatcher(query, scope);
|
||||
|
||||
return matchesQueryInput(query.input, scope);
|
||||
};
|
||||
|
||||
const matchesDefinitionPath = (path: readonly string[], candidate: QueryKey) => {
|
||||
const candidatePath = candidate[0];
|
||||
return (
|
||||
Array.isArray(candidatePath) &&
|
||||
candidatePath.every((part, index) => typeof part === "string" && path[index] === part)
|
||||
);
|
||||
};
|
||||
|
||||
const matchesQueryInput = (input: unknown, scope: WidgetQueryMatcherScope) => {
|
||||
if (input == null) return true;
|
||||
if (Array.isArray(input)) {
|
||||
return widgetQueryValueEquals(input, scope.integrationIds) || containsValue(scope.options, input);
|
||||
}
|
||||
|
||||
if (!isRecord(input)) {
|
||||
return (
|
||||
input === scope.itemId ||
|
||||
input === scope.boardId ||
|
||||
scope.integrationIds.includes(String(input)) ||
|
||||
containsValue(scope.options, input)
|
||||
);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (key === "itemId") {
|
||||
if (value !== scope.itemId) return false;
|
||||
continue;
|
||||
}
|
||||
if (key === "boardId") {
|
||||
if (value !== scope.boardId) return false;
|
||||
continue;
|
||||
}
|
||||
if (key === "integrationId") {
|
||||
if (!scope.integrationIds.includes(String(value))) return false;
|
||||
continue;
|
||||
}
|
||||
if (key === "integrationIds") {
|
||||
if (!widgetQueryValueEquals(value, scope.integrationIds)) return false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isIdentityKey(key) && !matchesAnyScopedValue(value, scope)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const matchesAnyScopedValue = (value: unknown, scope: WidgetQueryMatcherScope) =>
|
||||
value === scope.itemId ||
|
||||
value === scope.boardId ||
|
||||
scope.integrationIds.includes(String(value)) ||
|
||||
containsValue(scope.options, value);
|
||||
|
||||
const isIdentityKey = (key: string) => key === "id" || key.endsWith("Id") || key.endsWith("Ids");
|
||||
|
||||
const containsValue = (container: unknown, expected: unknown, seen = new WeakSet<object>()): boolean => {
|
||||
if (widgetQueryValueEquals(container, expected)) return true;
|
||||
if (container === null || typeof container !== "object" || seen.has(container)) return false;
|
||||
seen.add(container);
|
||||
|
||||
return Object.values(container).some((value) => containsValue(value, expected, seen));
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -0,0 +1,27 @@
|
||||
# Fixed dashboard geometry
|
||||
|
||||
The board uses fixed logical units:
|
||||
|
||||
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
||||
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
|
||||
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
|
||||
- the canvas is transformed once with one uniform scale derived from its available width.
|
||||
|
||||
Items keep the same logical dimensions at every viewport. The viewport reserves
|
||||
the painted size while the inner canvas remains at its logical dimensions, so a
|
||||
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
|
||||
stable.
|
||||
|
||||
The read-only renderer should:
|
||||
|
||||
1. convert persisted `xOffset/yOffset/width/height` values to `x/y/w/h`;
|
||||
2. apply collapsed container heights and call `getCollapsedDisplayLayout`;
|
||||
3. render DOM children in `sortGridPlacementsForReadingOrder` order;
|
||||
4. position children with `getLogicalItemStyle`;
|
||||
5. wrap them with `getScaledCanvasStyles` and the read-only semantic helpers.
|
||||
|
||||
Edit mode can use any interaction library, but its final coordinates should be
|
||||
normalized through this core. `placeGridItem` is the deterministic
|
||||
keyboard/numeric fallback: the active item is pinned while collisions move out
|
||||
of the way and all other items compact vertically. Pointer coordinates from a
|
||||
scaled editor can be converted with `toLogicalCanvasPoint`.
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { BoardLane } from "@homarr/definitions";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
|
||||
import { getCollapsedDisplayLayout } from "./reflow";
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
export { getBoardLaneColumnCount };
|
||||
|
||||
export const getRootSectionForLane = (board: Board, lane: BoardLane): EmptySection | undefined => {
|
||||
const roots = board.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === lane,
|
||||
);
|
||||
if (roots.length > 1) {
|
||||
throw new Error(`Board "${board.id}" has multiple ${lane} canvas roots`);
|
||||
}
|
||||
|
||||
return roots[0];
|
||||
};
|
||||
|
||||
export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) => {
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
if (!layout) return 0;
|
||||
|
||||
const rowCounts = (["left", "main", "right"] as const).flatMap((lane) => {
|
||||
const columnCount = getBoardLaneColumnCount(layout, lane);
|
||||
const root = getRootSectionForLane(board, lane);
|
||||
if (columnCount === 0 || !root) return [];
|
||||
|
||||
const placements: GridPlacement[] = [
|
||||
...board.items.flatMap((item) => {
|
||||
const itemLayout = item.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.sectionId === root.id,
|
||||
);
|
||||
return itemLayout
|
||||
? [
|
||||
normalizeGridPlacement(
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
x: itemLayout.xOffset,
|
||||
y: itemLayout.yOffset,
|
||||
w: itemLayout.width,
|
||||
h: itemLayout.height,
|
||||
},
|
||||
columnCount,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "container") return [];
|
||||
const sectionLayout = section.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.parentSectionId === root.id,
|
||||
);
|
||||
return sectionLayout
|
||||
? [
|
||||
normalizeGridPlacement(
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
x: sectionLayout.xOffset,
|
||||
y: sectionLayout.yOffset,
|
||||
w: sectionLayout.width,
|
||||
h: sectionLayout.height,
|
||||
},
|
||||
columnCount,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
const collapsedSectionIds = new Set(
|
||||
board.sections
|
||||
.filter(
|
||||
(section) =>
|
||||
section.kind === "container" &&
|
||||
section.collapsed &&
|
||||
section.options.collapsible &&
|
||||
placements.some((placement) => placement.id === section.id),
|
||||
)
|
||||
.map((section) => section.id),
|
||||
);
|
||||
const displayPlacements =
|
||||
collapsedSectionIds.size > 0
|
||||
? getCollapsedDisplayLayout(placements, { columnCount, collapsedItemIds: collapsedSectionIds })
|
||||
: placements;
|
||||
|
||||
return [getLayoutRowCount(displayPlacements)];
|
||||
});
|
||||
|
||||
return getLogicalTrackSize(Math.max(0, ...rowCounts));
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* A grid unit is a fixed logical size. The canvas is scaled as one surface;
|
||||
* individual cards never change their internal layout at different viewports.
|
||||
*/
|
||||
export const LOGICAL_GRID_CELL_SIZE = 200;
|
||||
export const LOGICAL_GRID_GAP = 24;
|
||||
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||
export const MIN_ACCESSIBLE_CANVAS_SCALE = 0.75;
|
||||
|
||||
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "./constants";
|
||||
import type { GridCoordinates, GridPlacement } from "./types";
|
||||
|
||||
export const getLogicalTrackSize = (trackCount: number) => {
|
||||
const normalizedTrackCount = Math.max(0, toFiniteNumber(trackCount, "trackCount"));
|
||||
if (normalizedTrackCount === 0) return 0;
|
||||
|
||||
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
|
||||
};
|
||||
|
||||
export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
|
||||
position: "absolute",
|
||||
left: x * LOGICAL_GRID_PITCH,
|
||||
top: y * LOGICAL_GRID_PITCH,
|
||||
width: getLogicalTrackSize(w),
|
||||
height: getLogicalTrackSize(h),
|
||||
});
|
||||
|
||||
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
|
||||
placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0);
|
||||
|
||||
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
columnCount: number,
|
||||
): TPlacement => {
|
||||
const normalizedColumnCount = toPositiveInteger(columnCount, "columnCount");
|
||||
const width = Math.min(toPositiveInteger(placement.w, "placement.w"), normalizedColumnCount);
|
||||
const x = Math.min(Math.max(0, toNonNegativeInteger(placement.x)), normalizedColumnCount - width);
|
||||
|
||||
return {
|
||||
...placement,
|
||||
x,
|
||||
y: toNonNegativeInteger(placement.y),
|
||||
w: width,
|
||||
h: toPositiveInteger(placement.h, "placement.h"),
|
||||
};
|
||||
};
|
||||
|
||||
const toNonNegativeInteger = (value: number) => Math.max(0, Math.trunc(toFiniteNumber(value, "value")));
|
||||
|
||||
const toPositiveInteger = (value: number, name: string) => {
|
||||
const integer = Math.trunc(toFiniteNumber(value, name));
|
||||
if (integer < 1) throw new RangeError(`${name} must be at least 1`);
|
||||
|
||||
return integer;
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value: number, name: string) => {
|
||||
if (!Number.isFinite(value)) throw new TypeError(`${name} must be finite`);
|
||||
|
||||
return value;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./constants";
|
||||
export * from "./board-lanes";
|
||||
export * from "./geometry";
|
||||
export * from "./reflow";
|
||||
export * from "./semantics";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,207 @@
|
||||
import { COLLAPSED_SECTION_ROW_COUNT } from "./constants";
|
||||
import { normalizeGridPlacement } from "./geometry";
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
interface ReflowOptions {
|
||||
columnCount: number;
|
||||
/**
|
||||
* A pinned item keeps its requested x/y while every other item compacts
|
||||
* around it. Use this for the item currently being moved or resized.
|
||||
*/
|
||||
pinnedItemId?: string;
|
||||
}
|
||||
|
||||
interface CollapsedLayoutOptions {
|
||||
columnCount: number;
|
||||
collapsedItemIds: ReadonlySet<string>;
|
||||
collapsedRowCount?: number;
|
||||
}
|
||||
|
||||
export const doGridPlacementsOverlap = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x < second.x + second.w &&
|
||||
first.x + first.w > second.x &&
|
||||
first.y < second.y + second.h &&
|
||||
first.y + first.h > second.y;
|
||||
|
||||
/**
|
||||
* Resolves overlaps and vertically compacts items. Ordering is based on the
|
||||
* persisted position and stable id, so the result does not depend on React or
|
||||
* database array order.
|
||||
*/
|
||||
export const reflowVerticalLayout = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
{ columnCount, pinnedItemId }: ReflowOptions,
|
||||
): TPlacement[] => {
|
||||
assertUniqueIds(placements);
|
||||
|
||||
const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount));
|
||||
const pinned = pinnedItemId ? normalized.find((placement) => placement.id === pinnedItemId) : undefined;
|
||||
|
||||
if (pinnedItemId && !pinned) {
|
||||
throw new Error(`Pinned grid item "${pinnedItemId}" does not exist`);
|
||||
}
|
||||
|
||||
const placed: TPlacement[] = pinned ? [pinned] : [];
|
||||
const candidates = normalized.filter((placement) => placement.id !== pinnedItemId).toSorted(comparePlacements);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
placed.push({
|
||||
...candidate,
|
||||
y: findFirstAvailableRow(candidate, placed),
|
||||
});
|
||||
}
|
||||
|
||||
const byId = new Map(placed.map((placement) => [placement.id, placement]));
|
||||
return normalized.map((placement) => getRequiredPlacement(byId, placement.id));
|
||||
};
|
||||
|
||||
/**
|
||||
* Produces the view-only layout after inline containers are collapsed. Only
|
||||
* display geometry changes; callers retain the persisted expanded height.
|
||||
*/
|
||||
export const getCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
{ columnCount, collapsedItemIds, collapsedRowCount = COLLAPSED_SECTION_ROW_COUNT }: CollapsedLayoutOptions,
|
||||
): TPlacement[] => {
|
||||
if (!Number.isFinite(collapsedRowCount) || collapsedRowCount <= 0) {
|
||||
throw new RangeError("collapsedRowCount must be positive and finite");
|
||||
}
|
||||
|
||||
const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount));
|
||||
const displayPlacements = normalized.map((placement) =>
|
||||
collapsedItemIds.has(placement.id)
|
||||
? {
|
||||
...placement,
|
||||
h: collapsedRowCount,
|
||||
}
|
||||
: placement,
|
||||
);
|
||||
|
||||
return reflowCollapsedDisplayLayout(normalized, displayPlacements, collapsedItemIds, collapsedRowCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the requested placement and pins it while neighbors compact into
|
||||
* free space or move down. This is the deterministic fallback used by
|
||||
* keyboard/numeric editing and can also validate a drag library result.
|
||||
*/
|
||||
export const placeGridItem = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
nextPlacement: TPlacement,
|
||||
columnCount: number,
|
||||
) => {
|
||||
if (!placements.some((placement) => placement.id === nextPlacement.id)) {
|
||||
throw new Error(`Grid item "${nextPlacement.id}" does not exist`);
|
||||
}
|
||||
|
||||
return reflowVerticalLayout(
|
||||
placements.map((placement) => (placement.id === nextPlacement.id ? nextPlacement : placement)),
|
||||
{
|
||||
columnCount,
|
||||
pinnedItemId: nextPlacement.id,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const sortGridPlacementsForReadingOrder = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
) => placements.toSorted(comparePlacements);
|
||||
|
||||
const findFirstAvailableRow = (candidate: GridPlacement, placed: readonly GridPlacement[]) => {
|
||||
let y = 0;
|
||||
for (;;) {
|
||||
const atRow = { ...candidate, y };
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(atRow, placement));
|
||||
if (collisions.length === 0) return y;
|
||||
|
||||
y = Math.max(...collisions.map((placement) => placement.y + placement.h));
|
||||
}
|
||||
};
|
||||
|
||||
const reflowCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
|
||||
expandedPlacements: readonly TPlacement[],
|
||||
displayPlacements: readonly TPlacement[],
|
||||
collapsedItemIds: ReadonlySet<string>,
|
||||
collapsedRowCount: number,
|
||||
): TPlacement[] => {
|
||||
assertUniqueIds(expandedPlacements);
|
||||
const displayById = new Map(displayPlacements.map((placement) => [placement.id, placement]));
|
||||
const collapsedPlacements = expandedPlacements.filter((placement) => collapsedItemIds.has(placement.id));
|
||||
const placed: TPlacement[] = [];
|
||||
|
||||
for (const expandedPlacement of expandedPlacements.toSorted(comparePlacements)) {
|
||||
const candidate = getRequiredPlacement(displayById, expandedPlacement.id);
|
||||
const verticalShift = getCollapsedVerticalShift(expandedPlacement, collapsedPlacements, collapsedRowCount);
|
||||
placed.push({
|
||||
...candidate,
|
||||
y: findFirstAvailableRowAtOrAfter(candidate, placed, Math.max(0, candidate.y - verticalShift)),
|
||||
});
|
||||
}
|
||||
|
||||
const byId = new Map(placed.map((placement) => [placement.id, placement]));
|
||||
return displayPlacements.map((placement) => getRequiredPlacement(byId, placement.id));
|
||||
};
|
||||
|
||||
const getCollapsedVerticalShift = (
|
||||
candidate: GridPlacement,
|
||||
collapsedPlacements: readonly GridPlacement[],
|
||||
collapsedRowCount: number,
|
||||
) => {
|
||||
const removedIntervals = collapsedPlacements
|
||||
.filter(
|
||||
(collapsed) =>
|
||||
collapsed.id !== candidate.id &&
|
||||
collapsed.y + collapsed.h <= candidate.y &&
|
||||
doGridPlacementsOverlapHorizontally(candidate, collapsed),
|
||||
)
|
||||
.map((collapsed) => ({ start: collapsed.y + collapsedRowCount, end: collapsed.y + collapsed.h }))
|
||||
.toSorted((first, second) => first.start - second.start || first.end - second.end);
|
||||
|
||||
let shift = 0;
|
||||
let intervalEnd = Number.NEGATIVE_INFINITY;
|
||||
for (const interval of removedIntervals) {
|
||||
const start = Math.max(interval.start, intervalEnd);
|
||||
if (interval.end > start) shift += interval.end - start;
|
||||
intervalEnd = Math.max(intervalEnd, interval.end);
|
||||
}
|
||||
return shift;
|
||||
};
|
||||
|
||||
const doGridPlacementsOverlapHorizontally = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x < second.x + second.w && first.x + first.w > second.x;
|
||||
|
||||
const findFirstAvailableRowAtOrAfter = (
|
||||
candidate: GridPlacement,
|
||||
placed: readonly GridPlacement[],
|
||||
minimumRow: number,
|
||||
) => {
|
||||
let y = minimumRow;
|
||||
for (;;) {
|
||||
const atRow = { ...candidate, y };
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(atRow, placement));
|
||||
if (collisions.length === 0) return y;
|
||||
|
||||
y = Math.max(...collisions.map((placement) => placement.y + placement.h));
|
||||
}
|
||||
};
|
||||
|
||||
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.y - second.y || first.x - second.x || (first.id === second.id ? 0 : first.id < second.id ? -1 : 1);
|
||||
|
||||
const assertUniqueIds = (placements: readonly GridPlacement[]) => {
|
||||
const ids = new Set<string>();
|
||||
for (const placement of placements) {
|
||||
if (ids.has(placement.id)) throw new Error(`Duplicate grid item id "${placement.id}"`);
|
||||
ids.add(placement.id);
|
||||
}
|
||||
};
|
||||
|
||||
const getRequiredPlacement = <TPlacement extends GridPlacement>(
|
||||
placements: ReadonlyMap<string, TPlacement>,
|
||||
id: string,
|
||||
) => {
|
||||
const placement = placements.get(id);
|
||||
if (!placement) throw new Error(`Grid item "${id}" was lost during layout`);
|
||||
|
||||
return placement;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
.viewport {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.sizer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
transform-origin: top left;
|
||||
will-change: transform;
|
||||
|
||||
/*
|
||||
* Keep dashboard UI at its normal readable density while the fixed canvas
|
||||
* is fitted below 100%. Above 100%, the canvas still behaves like browser
|
||||
* zoom and enlarges everything together.
|
||||
*/
|
||||
--mantine-scale: var(--board-canvas-ui-scale, 1);
|
||||
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.canvas {
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants";
|
||||
import classes from "./scaled-board-canvas.module.css";
|
||||
|
||||
export { MIN_ACCESSIBLE_CANVAS_SCALE };
|
||||
|
||||
const BoardCanvasScaleContext = createContext(1);
|
||||
|
||||
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
|
||||
|
||||
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
|
||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
|
||||
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
|
||||
|
||||
return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, availableWidth / logicalWidth);
|
||||
};
|
||||
|
||||
export const calculateBoardUiScale = (canvasScale: number) => {
|
||||
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
|
||||
|
||||
return canvasScale < 1 ? 1 / canvasScale : 1;
|
||||
};
|
||||
|
||||
interface ScaledBoardCanvasProps {
|
||||
logicalWidth: number;
|
||||
initialLogicalHeight: number;
|
||||
initialAvailableWidth: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales the complete board as one surface while keeping widget layout in
|
||||
* fixed logical pixels. The scale floor deliberately allows horizontal
|
||||
* overflow so browser zoom still magnifies content instead of being cancelled
|
||||
* by responsive downscaling.
|
||||
*/
|
||||
export const ScaledBoardCanvas = ({
|
||||
logicalWidth,
|
||||
initialLogicalHeight,
|
||||
initialAvailableWidth,
|
||||
label,
|
||||
children,
|
||||
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
|
||||
const { ref: viewportRef, width: availableWidth } = useElementSize<HTMLDivElement>();
|
||||
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
|
||||
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
|
||||
const scale = useMemo(
|
||||
() => calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth),
|
||||
[logicalWidth, resolvedAvailableWidth],
|
||||
);
|
||||
const uiScale = calculateBoardUiScale(scale);
|
||||
const visualWidth = logicalWidth * scale;
|
||||
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="section"
|
||||
ref={viewportRef}
|
||||
className={classes.viewport}
|
||||
data-testid="board-canvas"
|
||||
data-board-hydrated={isHydrated ? "true" : "false"}
|
||||
data-canvas-scale={scale}
|
||||
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
|
||||
data-canvas-initial-height={initialLogicalHeight}
|
||||
data-canvas-initial-width={initialAvailableWidth}
|
||||
aria-label={label}
|
||||
>
|
||||
<Box className={classes.sizer} w={visualWidth} h={resolvedLogicalHeight * scale}>
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
className={classes.canvas}
|
||||
w={logicalWidth}
|
||||
style={{
|
||||
"--board-canvas-ui-scale": uiScale,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
interface ReadonlyCanvasAttributesInput {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EditableCanvasAttributesInput extends ReadonlyCanvasAttributesInput {
|
||||
columnCount: number;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
interface EditableGridCellAttributesInput {
|
||||
label: string;
|
||||
placement: GridPlacement;
|
||||
}
|
||||
|
||||
/**
|
||||
* View mode is a labelled region, not an ARIA grid. It keeps the native
|
||||
* semantics and focus behavior of links, buttons and widget controls inside.
|
||||
*/
|
||||
export const getReadonlyCanvasAttributes = ({ label }: ReadonlyCanvasAttributesInput) =>
|
||||
({
|
||||
role: "region",
|
||||
"aria-label": label,
|
||||
"data-grid-readonly": "true",
|
||||
}) as const;
|
||||
|
||||
export const getReadonlyGridItemAttributes = (placement: GridPlacement) =>
|
||||
({
|
||||
"data-grid-id": placement.id,
|
||||
"data-grid-x": placement.x,
|
||||
"data-grid-y": placement.y,
|
||||
"data-grid-w": placement.w,
|
||||
"data-grid-h": placement.h,
|
||||
}) as const;
|
||||
|
||||
/**
|
||||
* Edit mode remains a labelled region. Dashboard placement is not an ARIA
|
||||
* data grid: arrow keys edit geometry rather than navigate rows and columns.
|
||||
* Each tile is one focusable direct-manipulation surface, while controls inside
|
||||
* it keep their native semantics and tab order.
|
||||
*/
|
||||
export const getEditableCanvasAttributes = ({ label }: EditableCanvasAttributesInput) =>
|
||||
({
|
||||
role: "region",
|
||||
"aria-label": label,
|
||||
"data-grid-editable": "true",
|
||||
}) as const;
|
||||
|
||||
export const getEditableGridCellAttributes = ({ label, placement }: EditableGridCellAttributesInput) =>
|
||||
({
|
||||
role: "group",
|
||||
"aria-label": label,
|
||||
tabIndex: 0,
|
||||
...getReadonlyGridItemAttributes(placement),
|
||||
}) as const;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
|
||||
|
||||
describe("board lanes", () => {
|
||||
test("gutters consume columns from the main canvas", () => {
|
||||
const layout = {
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 2,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(2);
|
||||
expect(getBoardLaneColumnCount(layout, "main")).toBe(7);
|
||||
expect(getBoardLaneColumnCount(layout, "right")).toBe(3);
|
||||
});
|
||||
|
||||
test("clamps invalid gutters while preserving one main column", () => {
|
||||
const layout = {
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 4,
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(3);
|
||||
expect(getBoardLaneColumnCount(layout, "main")).toBe(1);
|
||||
expect(getBoardLaneColumnCount(layout, "right")).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects ambiguous duplicate roots", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: "main-a" }).build())
|
||||
.addSection(new EmptySectionMockBuilder({ id: "main-b" }).build())
|
||||
.build();
|
||||
|
||||
expect(() => getRootSectionForLane(board, "main")).toThrow("multiple main canvas roots");
|
||||
});
|
||||
|
||||
test("calculates a stable server height from the tallest visible lane", () => {
|
||||
const layoutId = "layout";
|
||||
const mainSectionId = "main";
|
||||
const leftSectionId = "left";
|
||||
const containerSectionId = "container";
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: mainSectionId }).build())
|
||||
.addSection(new EmptySectionMockBuilder({ id: leftSectionId, xOffset: -1 }).build())
|
||||
.addSection(
|
||||
new ContainerSectionMockBuilder({
|
||||
id: containerSectionId,
|
||||
collapsed: true,
|
||||
options: { collapsible: true },
|
||||
})
|
||||
.addLayout({
|
||||
layoutId,
|
||||
parentSectionId: mainSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 3,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder()
|
||||
.addLayout({
|
||||
layoutId,
|
||||
sectionId: mainSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 3,
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder()
|
||||
.addLayout({
|
||||
layoutId,
|
||||
sectionId: leftSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 4,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
board.layouts = [
|
||||
{
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 8,
|
||||
leftGutterColumnCount: 1,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
},
|
||||
];
|
||||
|
||||
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, normalizeGridPlacement } from "../index";
|
||||
|
||||
describe("fixed dashboard geometry", () => {
|
||||
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
|
||||
expect(getLogicalTrackSize(1)).toBe(200);
|
||||
expect(getLogicalTrackSize(0.5)).toBe(88);
|
||||
expect(getLogicalTrackSize(2)).toBe(424);
|
||||
expect(getLogicalTrackSize(3)).toBe(648);
|
||||
|
||||
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
||||
position: "absolute",
|
||||
left: 224,
|
||||
top: 448,
|
||||
width: 424,
|
||||
height: 648,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes invalid bounds and derives the required row count", () => {
|
||||
const placement = normalizeGridPlacement({ id: "wide", x: 7, y: -2, w: 8, h: 2 }, 4);
|
||||
|
||||
expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 });
|
||||
expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
doGridPlacementsOverlap,
|
||||
getCollapsedDisplayLayout,
|
||||
placeGridItem,
|
||||
reflowVerticalLayout,
|
||||
sortGridPlacementsForReadingOrder,
|
||||
} from "../index";
|
||||
import type { GridPlacement } from "../index";
|
||||
|
||||
describe("dashboard layout reflow", () => {
|
||||
test("compacts each horizontal lane and removes collisions", () => {
|
||||
const result = reflowVerticalLayout(
|
||||
[
|
||||
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
|
||||
{ id: "b", x: 0, y: 3, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 3, w: 1, h: 1 },
|
||||
],
|
||||
{ columnCount: 2 },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
|
||||
{ id: "b", x: 0, y: 1, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 0, w: 1, h: 1 },
|
||||
]);
|
||||
expect(hasOverlap(result)).toBe(false);
|
||||
});
|
||||
|
||||
test("shrinks a collapsed inline container without compacting unrelated columns", () => {
|
||||
const result = getCollapsedDisplayLayout(
|
||||
[
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
{ id: "below", x: 0, y: 3, w: 2, h: 1 },
|
||||
{ id: "other-lane", x: 2, y: 4, w: 1, h: 1 },
|
||||
],
|
||||
{
|
||||
columnCount: 3,
|
||||
collapsedItemIds: new Set(["section"]),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 0.5 },
|
||||
{ id: "below", x: 0, y: 0.5, w: 2, h: 1 },
|
||||
{ id: "other-lane", x: 2, y: 4, w: 1, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves intentional gaps below a collapsed container", () => {
|
||||
const result = getCollapsedDisplayLayout(
|
||||
[
|
||||
{ id: "section", x: 0, y: 1, w: 2, h: 3 },
|
||||
{ id: "below", x: 0, y: 6, w: 2, h: 1 },
|
||||
{ id: "above", x: 0, y: 0, w: 2, h: 1 },
|
||||
],
|
||||
{ columnCount: 2, collapsedItemIds: new Set(["section"]) },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "section", x: 0, y: 1, w: 2, h: 0.5 },
|
||||
{ id: "below", x: 0, y: 3.5, w: 2, h: 1 },
|
||||
{ id: "above", x: 0, y: 0, w: 2, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not double-count side-by-side collapsed space", () => {
|
||||
const result = getCollapsedDisplayLayout(
|
||||
[
|
||||
{ id: "left", x: 0, y: 0, w: 1, h: 3 },
|
||||
{ id: "right", x: 1, y: 0, w: 1, h: 3 },
|
||||
{ id: "below", x: 0, y: 3, w: 2, h: 1 },
|
||||
],
|
||||
{ columnCount: 2, collapsedItemIds: new Set(["left", "right"]) },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "left", x: 0, y: 0, w: 1, h: 0.5 },
|
||||
{ id: "right", x: 1, y: 0, w: 1, h: 0.5 },
|
||||
{ id: "below", x: 0, y: 0.5, w: 2, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("pins a moved or expanded item and pushes collisions out of the way", () => {
|
||||
const result = placeGridItem(
|
||||
[
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 1 },
|
||||
{ id: "neighbor", x: 0, y: 1, w: 2, h: 1 },
|
||||
{ id: "side", x: 2, y: 2, w: 1, h: 1 },
|
||||
],
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
3,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
{ id: "neighbor", x: 0, y: 3, w: 2, h: 1 },
|
||||
{ id: "side", x: 2, y: 0, w: 1, h: 1 },
|
||||
]);
|
||||
expect(hasOverlap(result)).toBe(false);
|
||||
});
|
||||
|
||||
test("is deterministic regardless of input array order", () => {
|
||||
const first = [
|
||||
{ id: "b", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "a", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 8, w: 1, h: 1 },
|
||||
];
|
||||
const second = first.toReversed();
|
||||
|
||||
expect(byId(reflowVerticalLayout(first, { columnCount: 2 }))).toEqual(
|
||||
byId(reflowVerticalLayout(second, { columnCount: 2 })),
|
||||
);
|
||||
});
|
||||
|
||||
test("provides row-major DOM order for the read-only renderer", () => {
|
||||
const items = [
|
||||
{ id: "last", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "right", x: 1, y: 0, w: 1, h: 1 },
|
||||
{ id: "left", x: 0, y: 0, w: 1, h: 1 },
|
||||
];
|
||||
|
||||
expect(sortGridPlacementsForReadingOrder(items).map((item) => item.id)).toEqual(["left", "right", "last"]);
|
||||
});
|
||||
});
|
||||
|
||||
const hasOverlap = (placements: readonly GridPlacement[]) =>
|
||||
placements.some((placement, index) =>
|
||||
placements.slice(index + 1).some((otherPlacement) => doGridPlacementsOverlap(placement, otherPlacement)),
|
||||
);
|
||||
|
||||
const byId = (placements: readonly GridPlacement[]) =>
|
||||
Object.fromEntries(placements.map((placement) => [placement.id, placement]));
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas";
|
||||
|
||||
describe("board canvas scale policy", () => {
|
||||
test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => {
|
||||
const scale = calculateBoardCanvasScale(840, 1000);
|
||||
|
||||
expect(scale).toBe(0.84);
|
||||
expect(1000 * scale).toBe(840);
|
||||
});
|
||||
|
||||
test("scales up uniformly on a larger screen", () => {
|
||||
expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8);
|
||||
});
|
||||
|
||||
test("stops counter-scaling at 0.75 and lets the canvas overflow", () => {
|
||||
const availableWidth = 600;
|
||||
const logicalWidth = 1000;
|
||||
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
|
||||
|
||||
expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(logicalWidth * scale).toBeGreaterThan(availableWidth);
|
||||
});
|
||||
|
||||
test("keeps content materially magnified at 200% effective zoom", () => {
|
||||
const logicalWidth = 1696;
|
||||
const normalAvailableWidth = 1872;
|
||||
const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth);
|
||||
const zoomedScale = calculateBoardCanvasScale(normalAvailableWidth / 2, logicalWidth);
|
||||
const normalPaintedCellSize = 200 * normalScale;
|
||||
const zoomedPaintedCellSize = 200 * zoomedScale * 2;
|
||||
|
||||
expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[0, 1000],
|
||||
[1000, 0],
|
||||
[Number.NaN, 1000],
|
||||
[1000, Number.POSITIVE_INFINITY],
|
||||
])("uses a stable initial scale for unavailable geometry (%s, %s)", (availableWidth, logicalWidth) => {
|
||||
expect(calculateBoardCanvasScale(availableWidth, logicalWidth)).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps UI typography and density at 100% while fitting the canvas down", () => {
|
||||
expect(calculateBoardUiScale(0.75)).toBeCloseTo(4 / 3);
|
||||
expect(calculateBoardUiScale(0.84)).toBeCloseTo(1 / 0.84);
|
||||
expect(calculateBoardUiScale(1)).toBe(1);
|
||||
expect(calculateBoardUiScale(1.5)).toBe(1);
|
||||
});
|
||||
|
||||
test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
"uses stable UI density for an invalid canvas scale (%s)",
|
||||
(scale) => {
|
||||
expect(calculateBoardUiScale(scale)).toBe(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getEditableCanvasAttributes,
|
||||
getEditableGridCellAttributes,
|
||||
getReadonlyCanvasAttributes,
|
||||
getReadonlyGridItemAttributes,
|
||||
} from "../index";
|
||||
|
||||
describe("dashboard layout semantics", () => {
|
||||
const placement = { id: "weather", x: 1, y: 2, w: 3, h: 2 };
|
||||
|
||||
test("keeps read-only mode a native labelled region", () => {
|
||||
expect(getReadonlyCanvasAttributes({ label: "Home dashboard" })).toEqual({
|
||||
role: "region",
|
||||
"aria-label": "Home dashboard",
|
||||
"data-grid-readonly": "true",
|
||||
});
|
||||
expect(getReadonlyGridItemAttributes(placement)).toEqual({
|
||||
"data-grid-id": "weather",
|
||||
"data-grid-x": 1,
|
||||
"data-grid-y": 2,
|
||||
"data-grid-w": 3,
|
||||
"data-grid-h": 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps edit mode a labelled region with spatial item metadata", () => {
|
||||
expect(
|
||||
getEditableCanvasAttributes({
|
||||
label: "Edit home dashboard",
|
||||
columnCount: 12,
|
||||
rowCount: 8,
|
||||
}),
|
||||
).toEqual({
|
||||
role: "region",
|
||||
"aria-label": "Edit home dashboard",
|
||||
"data-grid-editable": "true",
|
||||
});
|
||||
|
||||
expect(
|
||||
getEditableGridCellAttributes({
|
||||
label: "Weather widget",
|
||||
placement,
|
||||
}),
|
||||
).toEqual({
|
||||
role: "group",
|
||||
"aria-label": "Weather widget",
|
||||
tabIndex: 0,
|
||||
"data-grid-id": "weather",
|
||||
"data-grid-x": 1,
|
||||
"data-grid-y": 2,
|
||||
"data-grid-w": 3,
|
||||
"data-grid-h": 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface GridCoordinates {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface GridPlacement extends GridCoordinates {
|
||||
id: string;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { CategoryMenu } from "./category/category-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
|
||||
|
||||
interface Props {
|
||||
section: CategorySection;
|
||||
}
|
||||
|
||||
export const BoardCategorySection = ({ section }: Props) => {
|
||||
const { status } = useSession();
|
||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const initialCollapsed =
|
||||
typeof window !== "undefined"
|
||||
? (localStorage.getItem(localStorageKey(section.id)) ?? String(section.collapsed)) === "true"
|
||||
: section.collapsed;
|
||||
|
||||
const [opened, { toggle }] = useDisclosure(initialCollapsed, {
|
||||
onOpen() {
|
||||
if (status === "authenticated") {
|
||||
mutate({ sectionId: section.id, collapsed: true });
|
||||
} else if (status === "unauthenticated") {
|
||||
localStorage.setItem(localStorageKey(section.id), "true");
|
||||
}
|
||||
},
|
||||
onClose() {
|
||||
if (status === "authenticated") {
|
||||
mutate({ sectionId: section.id, collapsed: false });
|
||||
} else if (status === "unauthenticated") {
|
||||
localStorage.setItem(localStorageKey(section.id), "false");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
|
||||
<Stack>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||
<Group wrap="nowrap">
|
||||
{opened ? <IconChevronUp size={20} /> : <IconChevronDown size={20} />}
|
||||
<Title order={3}>{section.name}</Title>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<CategoryMenu category={section} />
|
||||
</Group>
|
||||
<Collapse expanded={opened} p="sm" pt={0}>
|
||||
<GridStack section={section} />
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveCategoryInput {
|
||||
id: string;
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
export const moveCategoryCallback =
|
||||
(input: MoveCategoryInput) =>
|
||||
(previous: RouterOutputs["board"]["getHomeBoard"]): RouterOutputs["board"]["getHomeBoard"] => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
|
||||
);
|
||||
if (!currentCategory) {
|
||||
return previous;
|
||||
}
|
||||
if (currentCategory.yOffset === 1 && input.direction === "up") {
|
||||
return previous;
|
||||
}
|
||||
if (currentCategory.yOffset === previous.sections.length - 2 && input.direction === "down") {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
if (section.kind !== "category" && section.kind !== "empty") {
|
||||
return section;
|
||||
}
|
||||
const offset = input.direction === "up" ? -2 : 2;
|
||||
// Move category and empty section
|
||||
if (section.yOffset === currentCategory.yOffset || section.yOffset === currentCategory.yOffset + 1) {
|
||||
return {
|
||||
...section,
|
||||
yOffset: section.yOffset + offset,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
section.yOffset === currentCategory.yOffset + offset ||
|
||||
section.yOffset === currentCategory.yOffset + offset + 1
|
||||
) {
|
||||
return {
|
||||
...section,
|
||||
yOffset: section.yOffset - offset,
|
||||
};
|
||||
}
|
||||
|
||||
return section;
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,150 +0,0 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
|
||||
import type { Board, CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface RemoveCategoryInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeCategoryCallback =
|
||||
(input: RemoveCategoryInput) =>
|
||||
(previous: Board): Board => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
|
||||
);
|
||||
if (!currentCategory) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const emptySectionsAbove = previous.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && section.yOffset < currentCategory.yOffset,
|
||||
);
|
||||
const aboveSection = emptySectionsAbove.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset).at(0);
|
||||
|
||||
const emptySectionsBelow = previous.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && section.yOffset > currentCategory.yOffset,
|
||||
);
|
||||
const removedSection = emptySectionsBelow.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset).at(0);
|
||||
|
||||
if (!aboveSection || !removedSection) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const aboveYOffsets = getBoardLayouts(previous).map((layoutId) => {
|
||||
return {
|
||||
layoutId,
|
||||
yOffset: Math.max(
|
||||
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: aboveSection.id, layoutId }),
|
||||
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
|
||||
sectionId: aboveSection.id,
|
||||
layoutId,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const categoryYOffsets = getBoardLayouts(previous).map((layoutId) => {
|
||||
return {
|
||||
layoutId,
|
||||
yOffset: Math.max(
|
||||
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: currentCategory.id, layoutId }),
|
||||
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
|
||||
sectionId: currentCategory.id,
|
||||
layoutId,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections
|
||||
.filter((section) => section.id !== currentCategory.id && section.id !== removedSection.id)
|
||||
.map((section) =>
|
||||
section.kind === "dynamic"
|
||||
? {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
const categoryYOffset =
|
||||
categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
|
||||
if (layout.parentSectionId === currentCategory.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (layout.parentSectionId === removedSection.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
|
||||
parentSectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
return layout;
|
||||
}),
|
||||
}
|
||||
: section,
|
||||
),
|
||||
|
||||
items: previous.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
const categoryYOffset = categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||
|
||||
if (layout.sectionId === currentCategory.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset,
|
||||
sectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (layout.sectionId === removedSection.id) {
|
||||
return {
|
||||
...layout,
|
||||
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
|
||||
sectionId: aboveSection.id,
|
||||
};
|
||||
}
|
||||
|
||||
return layout;
|
||||
}),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffsetForDynamicSectionLayouts = (
|
||||
sections: Section[],
|
||||
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
|
||||
) => {
|
||||
return sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.map((section) => section.layouts.find((layout) => layout.layoutId === layoutId))
|
||||
.filter((layout) => layout !== undefined)
|
||||
.filter((layout) => layout.parentSectionId === sectionId)
|
||||
.reduce((acc, layout) => {
|
||||
const yHeightWithOffset = layout.yOffset + layout.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffsetForItemLayouts = (
|
||||
board: Board,
|
||||
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
|
||||
) =>
|
||||
board.items
|
||||
.map((item) => item.layouts.find((layout) => layout.layoutId === layoutId))
|
||||
.filter((layout) => layout !== undefined)
|
||||
.filter((layout) => layout.sectionId === sectionId)
|
||||
.reduce((acc, layout) => {
|
||||
const yHeightWithOffset = layout.yOffset + layout.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { moveCategoryCallback } from "../move-category";
|
||||
|
||||
describe("Move Category", () => {
|
||||
test.each([
|
||||
[3, [0, 3, 4, 1, 2, 5, 6]],
|
||||
[5, [0, 1, 2, 5, 6, 3, 4]],
|
||||
])("should move category up", (moveId, expectedOrder) => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: moveId.toString(),
|
||||
direction: "up" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
|
||||
});
|
||||
test.each([
|
||||
[1, [0, 3, 4, 1, 2, 5, 6]],
|
||||
[3, [0, 1, 2, 5, 6, 3, 4]],
|
||||
])("should move category down", (moveId, expectedOrder) => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: moveId.toString(),
|
||||
direction: "down" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
|
||||
});
|
||||
test("should not move category up if it is at the top", () => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: "1",
|
||||
direction: "up" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
test("should not move category down if it is at the bottom", () => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: "5",
|
||||
direction: "down" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
const createSections = (categoryCount: number) => {
|
||||
return Array.from({ length: categoryCount * 2 + 1 }, (_, index) => ({
|
||||
id: index.toString(),
|
||||
kind: index % 2 === 1 ? ("category" as const) : ("empty" as const),
|
||||
name: `Category ${index}`,
|
||||
yOffset: index,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
items: [],
|
||||
})) satisfies Section[];
|
||||
};
|
||||
|
||||
const sortSections = (sections: Section[]) => {
|
||||
return sections
|
||||
.filter((section) => section.kind !== "dynamic")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
};
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import type { DynamicSection, Section } from "~/app/[locale]/boards/_types";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { CategorySectionMockBuilder } from "~/components/board/items/actions/test/mocks/category-section-mock";
|
||||
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { removeCategoryCallback } from "../remove-category";
|
||||
|
||||
describe("Remove Category", () => {
|
||||
test.each([
|
||||
[3, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 5, 6], [3, 4], 2],
|
||||
[5, [0, 1, 2, 3, 4, 5, 6], [0, 1, 2, 3, 4], [5, 6], 4],
|
||||
[1, [0, 1, 2, 3, 4, 5, 6], [0, 3, 4, 5, 6], [1, 2], 0],
|
||||
[3, [0, 3, 6, 7, 8], [0, 7, 8], [3, 6], 0],
|
||||
])(
|
||||
"should remove category",
|
||||
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
|
||||
// Arrange
|
||||
const layoutId = "1";
|
||||
const input = removeId.toString();
|
||||
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: layoutId })
|
||||
.addSections(createSections(initialYOffsets))
|
||||
.addItems(createSectionItems(initialYOffsets, layoutId))
|
||||
.build();
|
||||
|
||||
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
|
||||
|
||||
// Act
|
||||
const result = removeCategoryCallback({ id: input })(board);
|
||||
|
||||
// Assert
|
||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
|
||||
expectedRemovals.forEach((expectedRemoval) => {
|
||||
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
|
||||
});
|
||||
const aboveSectionItems = result.items.filter(
|
||||
(item) => item.layouts[0]?.sectionId === expectedLocationOfItems.toString(),
|
||||
);
|
||||
expect(aboveSectionItems.map((item) => parseInt(item.id, 10))).toEqual(expect.arrayContaining(expectedRemovals));
|
||||
},
|
||||
);
|
||||
|
||||
test("should correctly move items to above empty section", () => {
|
||||
// Arrange
|
||||
const layoutId = "1";
|
||||
const sectionIds = {
|
||||
above: "2",
|
||||
category: "3",
|
||||
below: "4",
|
||||
dynamic: "7",
|
||||
};
|
||||
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout({ id: layoutId })
|
||||
.addSections(createSections(initialYOffsets))
|
||||
.addItems(createSectionItems([0, 1, 5, 6], layoutId)) // Only add items to other sections
|
||||
.addDynamicSection(
|
||||
new DynamicSectionMockBuilder({ id: sectionIds.dynamic })
|
||||
.addLayout({ layoutId, parentSectionId: sectionIds.category, yOffset: 7, height: 3 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(new ItemMockBuilder({ id: "above-1" }).addLayout({ layoutId, sectionId: sectionIds.above }).build())
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "above-2" })
|
||||
.addLayout({ layoutId, sectionId: sectionIds.above, yOffset: 3, xOffset: 2, height: 2 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "category-1" }).addLayout({ layoutId, sectionId: sectionIds.category }).build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "category-2" })
|
||||
.addLayout({ layoutId, sectionId: sectionIds.category, yOffset: 2, xOffset: 4, width: 4 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "below-1" }).addLayout({ layoutId, sectionId: sectionIds.below, xOffset: 5 }).build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder({ id: "below-2" })
|
||||
.addLayout({ layoutId, sectionId: sectionIds.below, yOffset: 1, xOffset: 1, height: 2 })
|
||||
.build(),
|
||||
)
|
||||
.addItem(new ItemMockBuilder({ id: "dynamic-1" }).addLayout({ layoutId, sectionId: sectionIds.dynamic }).build())
|
||||
.build();
|
||||
|
||||
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
|
||||
|
||||
// Act
|
||||
const result = removeCategoryCallback({ id: sectionIds.category })(board);
|
||||
|
||||
// Assert
|
||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
|
||||
const aboveSectionItems = result.items.filter((item) => item.layouts[0]?.sectionId === sectionIds.above);
|
||||
expect(aboveSectionItems.length).toBe(6);
|
||||
|
||||
expect(
|
||||
aboveSectionItems
|
||||
.map((item) => ({
|
||||
...item,
|
||||
...item.layouts[0]!,
|
||||
}))
|
||||
.sort((itemA, itemB) => itemA.yOffset - itemB.yOffset),
|
||||
).toEqual([
|
||||
expect.objectContaining({ id: "above-1", yOffset: 0, xOffset: 0 }),
|
||||
expect.objectContaining({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||
expect.objectContaining({ id: "category-1", yOffset: 5, xOffset: 0 }),
|
||||
expect.objectContaining({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
|
||||
expect.objectContaining({ id: "below-1", yOffset: 15, xOffset: 5 }),
|
||||
expect.objectContaining({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
|
||||
]);
|
||||
|
||||
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
|
||||
expect(dynamicSection.layouts.at(0)?.yOffset).toBe(12);
|
||||
expect(dynamicSection.layouts[0]?.parentSectionId).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
const createSections = (initialYOffsets: number[]) => {
|
||||
return initialYOffsets.map((yOffset, index) =>
|
||||
index % 2 === 0
|
||||
? new EmptySectionMockBuilder({
|
||||
id: yOffset.toString(),
|
||||
yOffset,
|
||||
}).build()
|
||||
: new CategorySectionMockBuilder({
|
||||
id: yOffset.toString(),
|
||||
yOffset,
|
||||
}).build(),
|
||||
) satisfies Section[];
|
||||
};
|
||||
|
||||
const createSectionItems = (initialYOffsets: number[], layoutId: string) => {
|
||||
return initialYOffsets.map((yOffset) =>
|
||||
new ItemMockBuilder({ id: yOffset.toString() }).addLayout({ layoutId, sectionId: yOffset.toString() }).build(),
|
||||
);
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
import type { MoveCategoryInput } from "./actions/move-category";
|
||||
import { moveCategoryCallback } from "./actions/move-category";
|
||||
import type { RemoveCategoryInput } from "./actions/remove-category";
|
||||
import { removeCategoryCallback } from "./actions/remove-category";
|
||||
|
||||
interface AddCategory {
|
||||
name: string;
|
||||
yOffset: number;
|
||||
}
|
||||
|
||||
interface RenameCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const useCategoryActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addCategory = useCallback(
|
||||
({ name, yOffset }: AddCategory) => {
|
||||
if (yOffset <= -1) {
|
||||
return;
|
||||
}
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: [
|
||||
// Place sections before the new category
|
||||
...previous.sections.filter(
|
||||
(section) => (section.kind === "category" || section.kind === "empty") && section.yOffset < yOffset,
|
||||
),
|
||||
{
|
||||
id: createId(),
|
||||
name,
|
||||
kind: "category",
|
||||
yOffset,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
yOffset: yOffset + 1,
|
||||
xOffset: 0,
|
||||
},
|
||||
// Place sections after the new category
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
(section.kind === "category" || section.kind === "empty") && section.yOffset >= yOffset,
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
yOffset: section.yOffset + 2,
|
||||
})),
|
||||
...previous.sections.filter((section) => section.kind !== "category" && section.kind !== "empty"),
|
||||
] satisfies Section[],
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const addCategoryToEnd = useCallback(
|
||||
({ name }: { name: string }) => {
|
||||
updateBoard((previous) => {
|
||||
const lastSection = previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
section.kind === "empty" || section.kind === "category",
|
||||
)
|
||||
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!lastSection) return previous;
|
||||
const lastYOffset = lastSection.yOffset;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: [
|
||||
...previous.sections,
|
||||
{
|
||||
id: createId(),
|
||||
name,
|
||||
kind: "category",
|
||||
yOffset: lastYOffset + 1,
|
||||
xOffset: 0,
|
||||
collapsed: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
yOffset: lastYOffset + 2,
|
||||
xOffset: 0,
|
||||
},
|
||||
] satisfies Section[],
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const renameCategory = useCallback(
|
||||
({ id: categoryId, name }: RenameCategory) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
if (section.kind !== "category") return section;
|
||||
if (section.id !== categoryId) return section;
|
||||
return {
|
||||
...section,
|
||||
name,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveCategory = useCallback(
|
||||
(input: MoveCategoryInput) => {
|
||||
updateBoard(moveCategoryCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeCategory = useCallback(
|
||||
(input: RemoveCategoryInput) => {
|
||||
updateBoard(removeCategoryCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
addCategory,
|
||||
addCategoryToEnd,
|
||||
renameCategory,
|
||||
moveCategory,
|
||||
removeCategory,
|
||||
};
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
submitLabel: string;
|
||||
category: Category;
|
||||
onSuccess: (category: Category) => void;
|
||||
}
|
||||
|
||||
export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(z.object({ name: z.string().min(1) }), {
|
||||
initialValues: {
|
||||
name: innerProps.category.name,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
void innerProps.onSuccess({
|
||||
...innerProps.category,
|
||||
name: values.name,
|
||||
});
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} />
|
||||
<ModalFormFooter onCancel={actions.closeModal} submitLabel={innerProps.submitLabel} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { useCategoryActions } from "./category-actions";
|
||||
import { CategoryEditModal } from "./category-edit-modal";
|
||||
import { filterByItemKind } from "./filter";
|
||||
|
||||
export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
const { openModal } = useModalAction(CategoryEditModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
|
||||
const t = useI18n();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const createCategoryAtYOffset = useCallback(
|
||||
(position: number) => {
|
||||
openModal(
|
||||
{
|
||||
category: {
|
||||
id: createId(),
|
||||
name: t("section.category.create.title"),
|
||||
},
|
||||
onSuccess: (category) => {
|
||||
addCategory({
|
||||
name: category.name,
|
||||
yOffset: position,
|
||||
});
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
);
|
||||
},
|
||||
[addCategory, t, openModal],
|
||||
);
|
||||
|
||||
// creates a new category above the current
|
||||
const addCategoryAbove = useCallback(() => {
|
||||
const aboveYOffset = category.yOffset;
|
||||
createCategoryAtYOffset(aboveYOffset);
|
||||
}, [category.yOffset, createCategoryAtYOffset]);
|
||||
|
||||
// creates a new category below the current
|
||||
const addCategoryBelow = useCallback(() => {
|
||||
const belowYOffset = category.yOffset + 2;
|
||||
createCategoryAtYOffset(belowYOffset);
|
||||
}, [category.yOffset, createCategoryAtYOffset]);
|
||||
|
||||
const moveCategoryUp = useCallback(() => {
|
||||
moveCategory({
|
||||
id: category.id,
|
||||
direction: "up",
|
||||
});
|
||||
}, [category.id, moveCategory]);
|
||||
|
||||
const moveCategoryDown = useCallback(() => {
|
||||
moveCategory({
|
||||
id: category.id,
|
||||
direction: "down",
|
||||
});
|
||||
}, [category.id, moveCategory]);
|
||||
|
||||
// Removes the current category
|
||||
const remove = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("section.category.remove.title"),
|
||||
children: t("section.category.remove.message", {
|
||||
name: category.name,
|
||||
}),
|
||||
onConfirm: () => {
|
||||
removeCategory({
|
||||
id: category.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [category.id, category.name, removeCategory, t, openConfirmModal]);
|
||||
|
||||
const edit = useCallback(() => {
|
||||
openModal(
|
||||
{
|
||||
category,
|
||||
submitLabel: t("section.category.edit.submit"),
|
||||
onSuccess: (category) => {
|
||||
renameCategory({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.edit.title"),
|
||||
},
|
||||
);
|
||||
}, [category, openModal, renameCategory, t]);
|
||||
|
||||
const settings = useSettings();
|
||||
const openAllInNewTabs = useCallback(async () => {
|
||||
const currentLayoutId = getCurrentLayout(board);
|
||||
const appIds = filterByItemKind(
|
||||
board.items.filter(
|
||||
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
||||
),
|
||||
settings,
|
||||
"app",
|
||||
).map((item) => {
|
||||
return item.options.appId;
|
||||
});
|
||||
|
||||
const apps = await fetchApi.app.byIds.query(appIds);
|
||||
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
|
||||
|
||||
for (const app of appsWithUrls) {
|
||||
const openedWindow = window.open(app.href ?? undefined);
|
||||
if (openedWindow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
openConfirmModal({
|
||||
title: t("section.category.openAllInNewTabs.title"),
|
||||
children: t("section.category.openAllInNewTabs.text"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [category, board, t, openConfirmModal, settings]);
|
||||
|
||||
return {
|
||||
addCategoryAbove,
|
||||
addCategoryBelow,
|
||||
moveCategoryUp,
|
||||
moveCategoryDown,
|
||||
remove,
|
||||
edit,
|
||||
openAllInNewTabs,
|
||||
};
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconTransitionBottom,
|
||||
IconTransitionTop,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { useCategoryMenuActions } from "./category-menu-actions";
|
||||
|
||||
interface Props {
|
||||
category: CategorySection;
|
||||
}
|
||||
|
||||
export const CategoryMenu = ({ category }: Props) => {
|
||||
const actions = useActions(category);
|
||||
const t = useScopedI18n("section.category");
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon mr="sm" variant="transparent" aria-label={t("menu.label.settings")}>
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{actions.map((action) => {
|
||||
return (
|
||||
<React.Fragment key={action.label}>
|
||||
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
|
||||
<Menu.Item
|
||||
leftSection={<action.icon size="1rem" />}
|
||||
onClick={action.onClick}
|
||||
color={"color" in action ? action.color : undefined}
|
||||
>
|
||||
{t(action.label)}
|
||||
</Menu.Item>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const useActions = (category: CategorySection) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const editModeActions = useEditModeActions(category);
|
||||
const nonEditModeActions = useNonEditModeActions(category);
|
||||
|
||||
return useMemo(
|
||||
() => (isEditMode ? editModeActions : nonEditModeActions),
|
||||
[isEditMode, editModeActions, nonEditModeActions],
|
||||
);
|
||||
};
|
||||
|
||||
const useEditModeActions = (category: CategorySection) => {
|
||||
const { addCategoryAbove, addCategoryBelow, moveCategoryUp, moveCategoryDown, edit, remove } =
|
||||
useCategoryMenuActions(category);
|
||||
|
||||
return [
|
||||
{
|
||||
icon: IconEdit,
|
||||
label: "action.edit",
|
||||
onClick: edit,
|
||||
},
|
||||
{
|
||||
icon: IconTrash,
|
||||
color: "red",
|
||||
label: "action.remove",
|
||||
onClick: remove,
|
||||
},
|
||||
{
|
||||
group: "menu.label.changePosition",
|
||||
icon: IconTransitionTop,
|
||||
label: "action.moveUp",
|
||||
onClick: moveCategoryUp,
|
||||
},
|
||||
{
|
||||
icon: IconTransitionBottom,
|
||||
label: "action.moveDown",
|
||||
onClick: moveCategoryDown,
|
||||
},
|
||||
{
|
||||
group: "menu.label.create",
|
||||
icon: IconRowInsertTop,
|
||||
label: "action.createAbove",
|
||||
onClick: addCategoryAbove,
|
||||
},
|
||||
{
|
||||
icon: IconRowInsertBottom,
|
||||
label: "action.createBelow",
|
||||
onClick: addCategoryBelow,
|
||||
},
|
||||
] as const satisfies ActionDefinition[];
|
||||
};
|
||||
|
||||
const useNonEditModeActions = (category: CategorySection) => {
|
||||
const { openAllInNewTabs } = useCategoryMenuActions(category);
|
||||
return [
|
||||
{
|
||||
icon: IconExternalLink,
|
||||
label: "action.openAllInNewTabs",
|
||||
onClick: openAllInNewTabs,
|
||||
},
|
||||
] as const satisfies ActionDefinition[];
|
||||
};
|
||||
|
||||
interface ActionDefinition {
|
||||
icon: TablerIcon;
|
||||
label: string;
|
||||
onClick: () => MaybePromise<void>;
|
||||
color?: string;
|
||||
group?: string;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const filterByItemKind = <TKind extends WidgetKind>(
|
||||
items: Item[],
|
||||
settings: SettingsContextProps,
|
||||
kind: TKind,
|
||||
) => {
|
||||
return items
|
||||
.filter((item) => item.kind === kind)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefaultValues(
|
||||
kind,
|
||||
settings,
|
||||
item.options,
|
||||
) as WidgetComponentProps<TKind>["options"],
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { ActionIcon, Badge, Box, Button, Card } from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { SectionGrid } from "./grid/section-grid";
|
||||
import { useSectionCollapse } from "./section-collapse";
|
||||
import { useOpenSectionApps } from "./use-open-section-apps";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
const BoardContainerMenu = dynamic(
|
||||
() => import("./container/container-menu").then((module) => module.BoardContainerMenu),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface Props {
|
||||
section: ContainerSectionItem;
|
||||
}
|
||||
|
||||
export const BoardContainerSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useScopedI18n("section.container");
|
||||
const tAll = useI18n();
|
||||
const options = section.options;
|
||||
const { open: openAllInNewTabs, isLoading: areAppsLoading } = useOpenSectionApps(
|
||||
section.id,
|
||||
options.showOpenAll && !isEditMode,
|
||||
);
|
||||
const { isVisuallyCollapsed, toggle } = useSectionCollapse({
|
||||
sectionId: section.id,
|
||||
collapsible: options.collapsible,
|
||||
});
|
||||
const label = options.title.trim() || t("untitled");
|
||||
const contentId = `board-container-${section.id}-content`;
|
||||
const labelLeft = isEditMode ? 40 : 8;
|
||||
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
|
||||
|
||||
return (
|
||||
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
|
||||
<Card
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
options.customCssClasses.join(" "),
|
||||
isVisuallyCollapsed && classes.collapsedContainerCard,
|
||||
)}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-board-container-collapsed={isVisuallyCollapsed ? "true" : "false"}
|
||||
styles={{
|
||||
root: {
|
||||
overflow: "visible",
|
||||
"--opacity": board.opacity / 100,
|
||||
"--border-color": options.borderColor || undefined,
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{options.collapsible && !isEditMode && (
|
||||
<Button
|
||||
className={classes.containerToggle}
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? 0 : -24}
|
||||
left={0}
|
||||
w="100%"
|
||||
h={isVisuallyCollapsed ? "100%" : 24}
|
||||
px={12}
|
||||
pe={options.showOpenAll ? 40 : 12}
|
||||
radius="sm"
|
||||
variant="default"
|
||||
justify={options.showLabel ? "flex-start" : "center"}
|
||||
leftSection={
|
||||
options.showLabel ? (
|
||||
isVisuallyCollapsed ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronUp size={16} />
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
onClick={toggle}
|
||||
aria-expanded={!isVisuallyCollapsed}
|
||||
aria-controls={contentId}
|
||||
aria-label={`${t(isVisuallyCollapsed ? "action.expand" : "action.collapse")}: ${label}`}
|
||||
data-board-container-collapsed-control={isVisuallyCollapsed ? "true" : undefined}
|
||||
data-board-container-label
|
||||
title={options.showLabel ? label : undefined}
|
||||
>
|
||||
{options.showLabel ? (
|
||||
label
|
||||
) : isVisuallyCollapsed ? (
|
||||
<IconChevronDown size={16} />
|
||||
) : (
|
||||
<IconChevronUp size={16} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
data-grid-container-drag-handle
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconGripVertical size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
left={labelLeft}
|
||||
maw={`calc(100% - ${labelLeft + labelRight}px)`}
|
||||
size="md"
|
||||
radius="sm"
|
||||
variant="default"
|
||||
c="var(--mantine-color-text)"
|
||||
style={{
|
||||
zIndex: 9,
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
backgroundColor: "var(--background-color)",
|
||||
borderColor: "var(--border-color)",
|
||||
}}
|
||||
title={options.title}
|
||||
data-board-container-label
|
||||
>
|
||||
{options.title}
|
||||
</Badge>
|
||||
)}
|
||||
{options.showOpenAll && !isEditMode && (
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
top={isVisuallyCollapsed ? "50%" : -24}
|
||||
right={8}
|
||||
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
|
||||
variant={options.collapsible ? "subtle" : "default"}
|
||||
size={24}
|
||||
radius="sm"
|
||||
loading={areAppsLoading}
|
||||
onClick={openAllInNewTabs}
|
||||
aria-label={tAll("section.action.openAllInNewTabsFor", { name: label })}
|
||||
>
|
||||
<IconExternalLink size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Box
|
||||
id={contentId}
|
||||
className={classes.containerBody}
|
||||
h="100%"
|
||||
data-collapsed={isVisuallyCollapsed ? "true" : "false"}
|
||||
aria-hidden={isVisuallyCollapsed}
|
||||
inert={isVisuallyCollapsed}
|
||||
>
|
||||
<SectionGrid section={section} columnCount={section.width} requestedRowCount={section.height} label={label} />
|
||||
</Box>
|
||||
</Card>
|
||||
{isEditMode && <BoardContainerMenu section={section} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import type { Board, ContainerSection, ContainerSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { getBoardLaneColumnCount } from "~/components/board/layout";
|
||||
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
|
||||
import { getSectionElements } from "~/components/board/items/actions/section-elements";
|
||||
|
||||
export const addContainerCallback = () => (board: Board) => {
|
||||
const firstSection = board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return board;
|
||||
|
||||
const sectionLayouts = createContainerLayouts(board, firstSection);
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: containerSectionOptionsSchema.parse(undefined),
|
||||
layouts: sectionLayouts,
|
||||
} satisfies ContainerSection;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections.concat(newSection),
|
||||
};
|
||||
};
|
||||
|
||||
const createContainerLayouts = (board: Board, currentSection: EmptySection): ContainerSectionLayout[] => {
|
||||
const layouts = getBoardLayouts(board);
|
||||
|
||||
return layouts.map((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : 1;
|
||||
const size = {
|
||||
width: Math.min(4, columnCount),
|
||||
height: 3,
|
||||
};
|
||||
|
||||
const emptyPosition = boardLayout
|
||||
? getFirstEmptyPosition(elements, columnCount, undefined, size)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) throw new Error("Your board is full");
|
||||
|
||||
return {
|
||||
...size,
|
||||
...emptyPosition,
|
||||
parentSectionId: currentSection.id,
|
||||
layoutId,
|
||||
};
|
||||
});
|
||||
};
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import type { ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
import * as emptyPositionModule from "~/components/board/items/actions/empty-position";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { addContainerCallback } from "./add-container";
|
||||
import { removeContainerCallback } from "./remove-container";
|
||||
|
||||
describe("container actions", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("creates a container in the first main root for every responsive layout", () => {
|
||||
const board = new BoardMockBuilder().addEmptySection({ id: "root", yOffset: 0 }).build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 2;
|
||||
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layout.id]);
|
||||
vi.spyOn(emptyPositionModule, "getFirstEmptyPosition").mockReturnValue({ xOffset: 0, yOffset: 4 });
|
||||
|
||||
const result = addContainerCallback()(board);
|
||||
const created = result.sections.find((section) => section.kind === "container");
|
||||
|
||||
expect(created).toMatchObject({
|
||||
kind: "container",
|
||||
collapsed: false,
|
||||
options: {
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
},
|
||||
layouts: [
|
||||
{
|
||||
layoutId: "layout",
|
||||
parentSectionId: "root",
|
||||
xOffset: 0,
|
||||
yOffset: 4,
|
||||
width: 2,
|
||||
height: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("removes a container and flattens its direct children into the parent", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 3, yOffset: 4, width: 6, height: 5 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2, width: 2, height: 2 })
|
||||
.build();
|
||||
const nestedItem = new ItemMockBuilder({ id: "nested-item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1, width: 1, height: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addEmptySection({ id: "root" })
|
||||
.addSection(removed)
|
||||
.addSection(child)
|
||||
.addItem(nestedItem)
|
||||
.build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
const flattenedChild = result.sections.find(
|
||||
(section): section is ContainerSection => section.kind === "container" && section.id === child.id,
|
||||
);
|
||||
|
||||
expect(result.sections.some((section) => section.id === removed.id)).toBe(false);
|
||||
expect(flattenedChild?.layouts[0]).toMatchObject({
|
||||
parentSectionId: "root",
|
||||
xOffset: 4,
|
||||
yOffset: 6,
|
||||
});
|
||||
expect(result.items.find((item) => item.id === nestedItem.id)?.layouts[0]).toMatchObject({
|
||||
sectionId: "root",
|
||||
xOffset: 5,
|
||||
yOffset: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes, pins the top-left child, and resolves collisions while flattening malformed geometry", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 2 })
|
||||
.build();
|
||||
const existing = new ItemMockBuilder({ id: "existing" })
|
||||
.addLayout({ layoutId: "layout", sectionId: "root", xOffset: 0, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const childA = new ItemMockBuilder({ id: "a" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 1, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const childB = new ItemMockBuilder({ id: "b" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 0, yOffset: 0, width: 2, height: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addEmptySection({ id: "root" })
|
||||
.addSection(removed)
|
||||
.addItems([existing, childA, childB])
|
||||
.build();
|
||||
const layout = board.layouts[0];
|
||||
if (!layout) throw new Error("Expected a board layout");
|
||||
layout.id = "layout";
|
||||
layout.columnCount = 4;
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
const layouts = Object.fromEntries(
|
||||
result.items.map((item) => {
|
||||
const itemLayout = item.layouts[0];
|
||||
if (!itemLayout) throw new Error("Expected an item layout");
|
||||
return [item.id, itemLayout];
|
||||
}),
|
||||
);
|
||||
|
||||
expect(layouts.existing).toMatchObject({ xOffset: 0, yOffset: 0, width: 2, height: 1 });
|
||||
expect(layouts.a).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 1, width: 2, height: 1 });
|
||||
expect(layouts.b).toMatchObject({ sectionId: "root", xOffset: 2, yOffset: 0, width: 2, height: 1 });
|
||||
});
|
||||
|
||||
test("keeps the container when a child container has layout geometry that cannot be flattened", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when a child item has layout geometry that cannot be flattened", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "other-layout", parentSectionId: "root", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const item = new ItemMockBuilder({ id: "item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when flattening would move a child back into the removed container", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "removed", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const item = new ItemMockBuilder({ id: "item" })
|
||||
.addLayout({ layoutId: "layout", sectionId: removed.id, xOffset: 2, yOffset: 1 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addItem(item).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
|
||||
test("keeps the container when its destination is a child container", () => {
|
||||
const removed = new ContainerSectionMockBuilder({ id: "removed" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: "child", xOffset: 3, yOffset: 4 })
|
||||
.build();
|
||||
const child = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId: "layout", parentSectionId: removed.id, xOffset: 1, yOffset: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSection(removed).addSection(child).build();
|
||||
|
||||
const result = removeContainerCallback({ id: removed.id })(board);
|
||||
|
||||
expect(result).toBe(board);
|
||||
expect(result.sections).toContainEqual(removed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Board, ContainerSection } from "~/app/[locale]/boards/_types";
|
||||
import { resolvePinnedGridCollisions } from "~/components/board/sections/grid/dnd";
|
||||
import {
|
||||
getSectionGridColumnCount,
|
||||
getSectionGridPlacements,
|
||||
} from "~/components/board/sections/grid/section-grid-placements";
|
||||
import type { SectionGridPlacement } from "~/components/board/sections/grid/use-grid-layout-actions";
|
||||
|
||||
export interface RemoveContainerInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeContainerCallback =
|
||||
({ id }: RemoveContainerInput) =>
|
||||
(board: Board): Board => {
|
||||
const sectionToRemove = board.sections.find(
|
||||
(section): section is ContainerSection => section.id === id && section.kind === "container",
|
||||
);
|
||||
if (!sectionToRemove) return board;
|
||||
|
||||
const removedLayoutsById = new Map(sectionToRemove.layouts.map((layout) => [layout.layoutId, layout]));
|
||||
const childLayoutIds = [
|
||||
...board.sections.flatMap((section) =>
|
||||
section.kind === "container"
|
||||
? section.layouts.flatMap((layout) =>
|
||||
layout.parentSectionId === sectionToRemove.id ? [layout.layoutId] : [],
|
||||
)
|
||||
: [],
|
||||
),
|
||||
...board.items.flatMap((item) =>
|
||||
item.layouts.flatMap((layout) => (layout.sectionId === sectionToRemove.id ? [layout.layoutId] : [])),
|
||||
),
|
||||
];
|
||||
const hasUnresolvableChildLayout = childLayoutIds.some((layoutId) => {
|
||||
const removedLayout = removedLayoutsById.get(layoutId);
|
||||
return (
|
||||
!removedLayout || !isValidFlattenDestination(board, sectionToRemove.id, layoutId, removedLayout.parentSectionId)
|
||||
);
|
||||
});
|
||||
if (hasUnresolvableChildLayout) return board;
|
||||
|
||||
const flattened = {
|
||||
...board,
|
||||
sections: board.sections
|
||||
.filter((section) => section.id !== id)
|
||||
.map((section) => {
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.parentSectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) return layout;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
parentSectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
items: board.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
if (layout.sectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) return layout;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
sectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
return resolveFlattenedLayouts(flattened, sectionToRemove, board);
|
||||
};
|
||||
|
||||
const isValidFlattenDestination = (
|
||||
board: Board,
|
||||
removedSectionId: string,
|
||||
layoutId: string,
|
||||
destinationSectionId: string,
|
||||
) => {
|
||||
const visited = new Set<string>();
|
||||
let sectionId = destinationSectionId;
|
||||
|
||||
while (true) {
|
||||
if (sectionId === removedSectionId || visited.has(sectionId)) return false;
|
||||
visited.add(sectionId);
|
||||
|
||||
const section = board.sections.find((candidate) => candidate.id === sectionId);
|
||||
if (!section) return false;
|
||||
if (section.kind !== "container") return true;
|
||||
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
if (!layout) return false;
|
||||
sectionId = layout.parentSectionId;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveFlattenedLayouts = (board: Board, removed: ContainerSection, original: Board): Board => {
|
||||
const resolvedByLayoutAndId = new Map<string, SectionGridPlacement>();
|
||||
|
||||
for (const removedLayout of removed.layouts) {
|
||||
const movedIds = [
|
||||
...original.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
|
||||
return layout?.sectionId === removed.id ? [item.id] : [];
|
||||
}),
|
||||
...original.sections.flatMap((section) => {
|
||||
if (section.kind !== "container") return [];
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === removedLayout.layoutId);
|
||||
return layout?.parentSectionId === removed.id ? [section.id] : [];
|
||||
}),
|
||||
];
|
||||
const columnCount = getSectionGridColumnCount(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
if (!columnCount) continue;
|
||||
const placements = getSectionGridPlacements(board, removedLayout.layoutId, removedLayout.parentSectionId);
|
||||
const movedIdSet = new Set(movedIds);
|
||||
const pinned = placements
|
||||
.filter((placement) => movedIdSet.has(placement.id))
|
||||
.toSorted((first, second) => first.y - second.y || first.x - second.x || first.id.localeCompare(second.id))[0];
|
||||
if (!pinned) continue;
|
||||
|
||||
for (const placement of resolvePinnedGridCollisions(placements, pinned, columnCount)) {
|
||||
resolvedByLayoutAndId.set(`${removedLayout.layoutId}:${placement.id}`, placement);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedByLayoutAndId.size === 0) return board;
|
||||
return {
|
||||
...board,
|
||||
items: board.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${item.id}`);
|
||||
return placement?.type === "item"
|
||||
? {
|
||||
...layout,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
}
|
||||
: layout;
|
||||
}),
|
||||
})),
|
||||
sections: board.sections.map((section) =>
|
||||
section.kind !== "container"
|
||||
? section
|
||||
: {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
const placement = resolvedByLayoutAndId.get(`${layout.layoutId}:${section.id}`);
|
||||
return placement?.type === "section"
|
||||
? {
|
||||
...layout,
|
||||
xOffset: placement.x,
|
||||
yOffset: placement.y,
|
||||
width: placement.w,
|
||||
height: placement.h,
|
||||
}
|
||||
: layout;
|
||||
}),
|
||||
},
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { ContainerSectionOptions } from "@homarr/validation/shared";
|
||||
|
||||
import { addContainerCallback } from "./actions/add-container";
|
||||
import type { RemoveContainerInput } from "./actions/remove-container";
|
||||
import { removeContainerCallback } from "./actions/remove-container";
|
||||
|
||||
interface UpdateContainerOptions {
|
||||
containerId: string;
|
||||
newOptions: ContainerSectionOptions;
|
||||
}
|
||||
|
||||
export const useContainerActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addContainer = useCallback(() => {
|
||||
updateBoard(addContainerCallback());
|
||||
}, [updateBoard]);
|
||||
|
||||
const updateContainer = useCallback(
|
||||
({ containerId, newOptions }: UpdateContainerOptions) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((item) =>
|
||||
item.id !== containerId || item.kind !== "container" ? item : { ...item, options: newOptions },
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeContainer = useCallback(
|
||||
(input: RemoveContainerInput) => {
|
||||
updateBoard(removeContainerCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return { addContainer, updateContainer, removeContainer };
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { CloseButton, ColorInput, Stack, Switch, TextInput, useMantineTheme } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { TextMultiSelect } from "@homarr/ui";
|
||||
import { containerSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
interface ModalProps {
|
||||
value: z.infer<typeof containerSectionOptionsSchema>;
|
||||
onSuccessfulEdit: (value: z.infer<typeof containerSectionOptionsSchema>) => void;
|
||||
}
|
||||
|
||||
export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
const form = useZodForm(containerSectionOptionsSchema.unwrap(), {
|
||||
mode: "controlled",
|
||||
initialValues: { ...innerProps.value },
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
innerProps.onSuccessfulEdit(values);
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("section.container.option.title.label")} data-autofocus {...form.getInputProps("title")} />
|
||||
<Switch
|
||||
label={t("section.option.showLabel.label")}
|
||||
description={t("section.option.showLabel.description")}
|
||||
{...form.getInputProps("showLabel", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("section.option.collapsible.label")}
|
||||
description={t("section.option.collapsible.description")}
|
||||
{...form.getInputProps("collapsible", { type: "checkbox" })}
|
||||
/>
|
||||
<Switch
|
||||
label={t("section.option.showOpenAll.label")}
|
||||
description={t("section.option.showOpenAll.description")}
|
||||
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
|
||||
/>
|
||||
<TextMultiSelect
|
||||
label={t("section.container.option.customCssClasses.label")}
|
||||
{...form.getInputProps("customCssClasses")}
|
||||
/>
|
||||
<ColorInput
|
||||
label={t("section.container.option.borderColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
rightSection={
|
||||
<CloseButton
|
||||
onClick={() => form.setFieldValue("borderColor", "")}
|
||||
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
{...form.getInputProps("borderColor")}
|
||||
/>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useOpenItemMoveModal } from "../../items/item-move-modal";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { useContainerActions } from "./container-actions";
|
||||
import { ContainerEditModal } from "./container-edit-modal";
|
||||
|
||||
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
|
||||
const t = useI18n();
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tItem = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(ContainerEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const { updateContainer, removeContainer } = useContainerActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { section: parentSection } = useSectionContext();
|
||||
const label = section.options.title || tContainer("action.create");
|
||||
const menuRightOffset = parentSection.kind === "container" ? 44 : 4;
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal({
|
||||
value: section.options,
|
||||
onSuccessfulEdit: (options) => updateContainer({ containerId: section.id, newOptions: options }),
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tContainer("remove.title"),
|
||||
children: tContainer("remove.message"),
|
||||
onConfirm: () => removeContainer({ id: section.id }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={-24}
|
||||
right={menuRightOffset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
<IconLayoutKanban size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowsMove size={16} />}
|
||||
onClick={() => openMoveModal({ entry: section, sourceSectionId: section.parentSectionId })}
|
||||
>
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tContainer("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,19 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
|
||||
import { BoardDynamicSection } from "./dynamic-section";
|
||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||
import { BoardContainerSection } from "./container-section";
|
||||
import { FixedGridItem } from "./grid/fixed-grid-item";
|
||||
import { useSectionContext } from "./section-context";
|
||||
import { useSectionItems } from "./use-section-items";
|
||||
|
||||
export const SectionContent = () => {
|
||||
const { innerSections, items, refs } = useSectionContext();
|
||||
const { data: integrations } = clientApi.integration.all.useQuery();
|
||||
const { innerSections, integrations, items } = useSectionContext();
|
||||
|
||||
/**
|
||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||
* THE ITEMS RENDERED WITH GRIDSTACK, OTHERWISE THE ITEMS WILL BE MIXED UP
|
||||
* @see https://github.com/homarr-labs/homarr/pull/1770
|
||||
*/
|
||||
/** Keep portal content order deterministic across static and edit renderers. */
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...items, ...innerSections].toSorted((itemA, itemB) => {
|
||||
if (itemA.yOffset === itemB.yOffset) {
|
||||
@@ -35,57 +27,39 @@ export const SectionContent = () => {
|
||||
return (
|
||||
<>
|
||||
{sortedItems.map((item) => (
|
||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
|
||||
<SectionContentItem key={item.id} item={item} integrations={integrations} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps {
|
||||
item: DynamicSectionItem | SectionItem;
|
||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||
item: ContainerSectionItem | SectionItem;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
}
|
||||
|
||||
const Item = ({ item, innerRef, integrations }: ItemProps) => {
|
||||
export const SectionContentItem = ({ item, integrations }: ItemProps) => {
|
||||
const minWidth = useMinSize(item, "x");
|
||||
const minHeight = useMinSize(item, "y");
|
||||
return (
|
||||
<GridStackItem
|
||||
key={item.id}
|
||||
innerRef={innerRef}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
xOffset={item.xOffset}
|
||||
yOffset={item.yOffset}
|
||||
kind={item.kind}
|
||||
id={item.id}
|
||||
type={item.type}
|
||||
minWidth={minWidth}
|
||||
minHeight={minHeight}
|
||||
>
|
||||
<FixedGridItem key={item.id} item={item} minWidth={minWidth} minHeight={minHeight}>
|
||||
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
||||
</GridStackItem>
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardContainerSection section={item} />}
|
||||
</FixedGridItem>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the min width / height of a section by taking the maximum of
|
||||
* the sum of the offset and size of all items and dynamic sections inside.
|
||||
* @param direction either "x" or "y"
|
||||
* @param items items of the section
|
||||
* @param sections sections of the board to look for dynamic sections
|
||||
* @param parentSectionId the id of the section we want to calculate the min size for
|
||||
* @returns the min size
|
||||
* Calculates a container's minimum width or height from its direct content.
|
||||
*/
|
||||
const useMinSize = (item: DynamicSectionItem | SectionItem, direction: "x" | "y") => {
|
||||
export const useMinSize = (item: ContainerSectionItem | SectionItem, direction: "x" | "y") => {
|
||||
const { items, innerSections } = useSectionItems(item.id);
|
||||
if (item.type === "item") return undefined;
|
||||
|
||||
const size = direction === "x" ? "width" : "height";
|
||||
return Math.max(
|
||||
...items.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
...innerSections.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
1,
|
||||
...items.map((childItem) => childItem[`${direction}Offset`] + childItem[size]),
|
||||
...innerSections.map((childSection) => childSection[`${direction}Offset`] + childSection[size]),
|
||||
);
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Badge, Box, Card } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
interface Props {
|
||||
section: DynamicSectionItem;
|
||||
}
|
||||
|
||||
export const BoardDynamicSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const options = section.options;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="grid-stack-item-content"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
|
||||
w="100%"
|
||||
h="100%"
|
||||
styles={{
|
||||
root: {
|
||||
overflow: "visible",
|
||||
"--opacity": board.opacity / 100,
|
||||
"--border-color": options.borderColor || undefined,
|
||||
},
|
||||
}}
|
||||
radius={board.itemRadius}
|
||||
p={0}
|
||||
>
|
||||
{options.title && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
top={-15}
|
||||
left={10}
|
||||
size="md"
|
||||
radius={board.itemRadius}
|
||||
color="var(--background-color)"
|
||||
c="var(--mantine-color-text)"
|
||||
bd="1px solid var(--border-color)"
|
||||
>
|
||||
{options.title}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Use unique key by layout to reinitialize gridstack */}
|
||||
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
|
||||
</Card>
|
||||
<BoardDynamicSectionMenu section={section} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import { getBoardLayouts } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import type { Board, DynamicSection, DynamicSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
|
||||
import { getSectionElements } from "~/components/board/items/actions/section-elements";
|
||||
|
||||
export const addDynamicSectionCallback = () => (board: Board) => {
|
||||
const firstSection = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!firstSection) return board;
|
||||
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
options: {
|
||||
title: "",
|
||||
borderColor: "",
|
||||
customCssClasses: [],
|
||||
},
|
||||
layouts: createDynamicSectionLayouts(board, firstSection),
|
||||
} satisfies DynamicSection;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections.concat(newSection as unknown as DynamicSection),
|
||||
};
|
||||
};
|
||||
|
||||
const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection): DynamicSectionLayout[] => {
|
||||
const layouts = getBoardLayouts(board);
|
||||
|
||||
return layouts.map((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
|
||||
const emptyPosition = boardLayout
|
||||
? getFirstEmptyPosition(elements, boardLayout.columnCount)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
throw new Error("Your board is full");
|
||||
}
|
||||
|
||||
return {
|
||||
width: 1,
|
||||
height: 1,
|
||||
...emptyPosition,
|
||||
parentSectionId: currentSection.id,
|
||||
layoutId,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Board, DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface RemoveDynamicSectionInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const removeDynamicSectionCallback =
|
||||
({ id }: RemoveDynamicSectionInput) =>
|
||||
(board: Board): Board => {
|
||||
const sectionToRemove = board.sections.find(
|
||||
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
|
||||
);
|
||||
if (!sectionToRemove) return board;
|
||||
|
||||
return {
|
||||
...board,
|
||||
sections: board.sections
|
||||
.filter((section) => section.id !== id)
|
||||
.map((section) => {
|
||||
if (section.kind !== "dynamic") return section;
|
||||
|
||||
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.parentSectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
parentSectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
// Move all items in dynamic section to parent of the removed section
|
||||
items: board.items.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => {
|
||||
if (layout.sectionId !== sectionToRemove.id) return layout;
|
||||
|
||||
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||
);
|
||||
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||
|
||||
return {
|
||||
...layout,
|
||||
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||
sectionId: removedSectionLayout.parentSectionId,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
|
||||
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
|
||||
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
|
||||
|
||||
interface UpdateDynamicOptions {
|
||||
itemId: string;
|
||||
newOptions: z.infer<typeof dynamicSectionOptionsSchema>;
|
||||
}
|
||||
|
||||
export const useDynamicSectionActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addDynamicSection = useCallback(() => {
|
||||
updateBoard(addDynamicSectionCallback());
|
||||
}, [updateBoard]);
|
||||
|
||||
const updateDynamicSection = useCallback(
|
||||
({ itemId, newOptions }: UpdateDynamicOptions) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeDynamicSection = useCallback(
|
||||
(input: RemoveDynamicSectionInput) => {
|
||||
updateBoard(removeDynamicSectionCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
addDynamicSection,
|
||||
updateDynamicSection,
|
||||
removeDynamicSection,
|
||||
};
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CloseButton, ColorInput, Stack, TextInput, useMantineTheme } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { TextMultiSelect } from "@homarr/ui";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
|
||||
interface ModalProps {
|
||||
value: z.infer<typeof dynamicSectionOptionsSchema>;
|
||||
onSuccessfulEdit: (value: z.infer<typeof dynamicSectionOptionsSchema>) => void;
|
||||
}
|
||||
|
||||
export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const form = useZodForm(dynamicSectionOptionsSchema, {
|
||||
mode: "controlled",
|
||||
initialValues: { ...innerProps.value },
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
innerProps.onSuccessfulEdit(values);
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label={t("section.dynamic.option.title.label")} data-autofocus {...form.getInputProps("title")} />
|
||||
<TextMultiSelect
|
||||
label={t("section.dynamic.option.customCssClasses.label")}
|
||||
{...form.getInputProps("customCssClasses")}
|
||||
/>
|
||||
<ColorInput
|
||||
label={t("section.dynamic.option.borderColor.label")}
|
||||
format="hex"
|
||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||
rightSection={
|
||||
<CloseButton
|
||||
onClick={() => form.setFieldValue("borderColor", "")}
|
||||
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
{...form.getInputProps("borderColor")}
|
||||
/>
|
||||
<ModalFormFooter onCancel={actions.closeModal} />
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconDotsVertical, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||
import { DynamicSectionEditModal } from "./dynamic-edit-modal";
|
||||
|
||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
|
||||
const t = useI18n();
|
||||
const tDynamic = useScopedI18n("section.dynamic");
|
||||
const tItem = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(DynamicSectionEditModal);
|
||||
const { updateDynamicSection, removeDynamicSection } = useDynamicSectionActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal({
|
||||
value: section.options,
|
||||
onSuccessfulEdit: (options) => {
|
||||
updateDynamicSection({
|
||||
itemId: section.id,
|
||||
newOptions: options,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tDynamic("remove.title"),
|
||||
children: tDynamic("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeDynamicSection({ id: section.id });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tDynamic("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,32 @@
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import { SectionGrid } from "./grid/section-grid";
|
||||
import { useSectionItems } from "./use-section-items";
|
||||
|
||||
interface Props {
|
||||
section: EmptySection;
|
||||
columnCount: number;
|
||||
requestedRowCount: number;
|
||||
railPlacement?: "main" | "left" | "right";
|
||||
}
|
||||
|
||||
export const BoardEmptySection = ({ section }: Props) => {
|
||||
export const BoardEmptySection = ({ section, columnCount, requestedRowCount, railPlacement = "main" }: Props) => {
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const totalLength = items.length + innerSections.length;
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
|
||||
if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null;
|
||||
|
||||
return (
|
||||
<GridStack
|
||||
<SectionGrid
|
||||
section={section}
|
||||
style={{ transitionDuration: "0s" }}
|
||||
className={combineClasses("min-row", totalLength > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
|
||||
columnCount={columnCount}
|
||||
requestedRowCount={requestedRowCount}
|
||||
label={t("items")}
|
||||
railPlacement={railPlacement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, PropsWithChildren } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
|
||||
|
||||
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
|
||||
|
||||
/**
|
||||
* Keeps the dnd-kit runtime outside the read-only bundle while guaranteeing
|
||||
* that every root and nested grid shares one drag-and-drop provider.
|
||||
*/
|
||||
export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const [Provider, setProvider] = useState<BoardGridEditorProviderComponent | null>(null);
|
||||
const [hasLoadError, setHasLoadError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || Provider) return;
|
||||
|
||||
let cancelled = false;
|
||||
setHasLoadError(false);
|
||||
void loadGridEditorAsync()
|
||||
.then((module) => {
|
||||
if (!cancelled) setProvider(() => module.BoardGridEditorProvider);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHasLoadError(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [Provider, isEditMode]);
|
||||
|
||||
if (!isEditMode) {
|
||||
return <GridEditorRuntimeProvider status="idle">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
if (hasLoadError) {
|
||||
return <GridEditorRuntimeProvider status="error">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
if (!Provider) {
|
||||
return <GridEditorRuntimeProvider status="loading">{children}</GridEditorRuntimeProvider>;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridEditorRuntimeProvider status="ready">
|
||||
<Provider>{children}</Provider>
|
||||
</GridEditorRuntimeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import {
|
||||
getContinuousGridDelta,
|
||||
getContinuousResizePlacement,
|
||||
getPointerProjectedShape,
|
||||
getSnappedGridCoordinates,
|
||||
getSnappedGridDelta,
|
||||
} from "./coordinates";
|
||||
|
||||
describe("dnd grid coordinates", () => {
|
||||
test.each([0.75, 1, 1.25, 2])("maps transformed drag geometry at %sx scale", (scale) => {
|
||||
const target = { left: 137, top: -42, width: getLogicalTrackSize(8) * scale };
|
||||
const dragShape = {
|
||||
left: target.left + 3 * LOGICAL_GRID_PITCH * scale,
|
||||
top: target.top + 7 * LOGICAL_GRID_PITCH * scale,
|
||||
};
|
||||
|
||||
expect(getSnappedGridCoordinates({ dragShape, target, columnCount: 8 })).toEqual({ x: 3, y: 7 });
|
||||
});
|
||||
|
||||
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
|
||||
const scale = 0.75;
|
||||
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
|
||||
const initialShape = { left: target.left, top: target.top, width: 424 * scale, height: 200 * scale };
|
||||
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
|
||||
|
||||
expect(
|
||||
getSnappedGridCoordinates({
|
||||
dragShape: {
|
||||
left: initialShape.left + pointerDelta.x,
|
||||
top: initialShape.top + pointerDelta.y,
|
||||
},
|
||||
target,
|
||||
columnCount: 5,
|
||||
}),
|
||||
).toEqual({ x: 2, y: 3 });
|
||||
});
|
||||
|
||||
test.each([0.75, 1, 1.25, 2])("projects a pointer drag in visual coordinates at %sx scale", (scale) => {
|
||||
const target = { left: 496, top: -938, width: getLogicalTrackSize(8) * scale };
|
||||
const initialShape = {
|
||||
left: target.left + 4 * LOGICAL_GRID_PITCH * scale,
|
||||
top: target.top + 8 * LOGICAL_GRID_PITCH * scale,
|
||||
width: getLogicalTrackSize(2) * scale,
|
||||
height: getLogicalTrackSize(2) * scale,
|
||||
};
|
||||
const initialPointer = { x: initialShape.left + 134 * scale, y: initialShape.top + 89 * scale };
|
||||
const dragShape = getPointerProjectedShape(
|
||||
{ initialPointer, initialShape },
|
||||
{ x: initialPointer.x, y: initialPointer.y - LOGICAL_GRID_PITCH * scale },
|
||||
);
|
||||
|
||||
expect(dragShape.left).toBe(initialShape.left);
|
||||
expect(dragShape).toMatchObject({ width: initialShape.width, height: initialShape.height });
|
||||
expect(getSnappedGridCoordinates({ dragShape, target, columnCount: 8 })).toEqual({ x: 4, y: 7 });
|
||||
});
|
||||
|
||||
test.each([0.75, 1, 1.25, 2])("snaps resize pointer deltas at %sx scale", (visualScale) => {
|
||||
expect(
|
||||
getSnappedGridDelta({
|
||||
initial: { x: 19, y: 31 },
|
||||
current: {
|
||||
x: 19 - 2 * LOGICAL_GRID_PITCH * visualScale,
|
||||
y: 31 + 4 * LOGICAL_GRID_PITCH * visualScale,
|
||||
},
|
||||
visualScale,
|
||||
}),
|
||||
).toEqual({ x: -2, y: 4 });
|
||||
});
|
||||
|
||||
test("keeps the visual resize continuous between snapped grid breakpoints", () => {
|
||||
const visualScale = 0.75;
|
||||
const delta = getContinuousGridDelta({
|
||||
initial: { x: 10, y: 20 },
|
||||
current: {
|
||||
x: 10 + LOGICAL_GRID_PITCH * visualScale * 0.42,
|
||||
y: 20 + LOGICAL_GRID_PITCH * visualScale * 0.68,
|
||||
},
|
||||
visualScale,
|
||||
});
|
||||
|
||||
expect(delta?.x).toBeCloseTo(0.42);
|
||||
expect(delta?.y).toBeCloseTo(0.68);
|
||||
expect(
|
||||
getSnappedGridDelta({
|
||||
initial: { x: 10, y: 20 },
|
||||
current: {
|
||||
x: 10 + LOGICAL_GRID_PITCH * visualScale * 0.42,
|
||||
y: 20 + LOGICAL_GRID_PITCH * visualScale * 0.68,
|
||||
},
|
||||
visualScale,
|
||||
}),
|
||||
).toEqual({ x: 0, y: 1 });
|
||||
});
|
||||
|
||||
test("projects a continuous resize while anchoring the opposite edges and respecting bounds", () => {
|
||||
const placement = { id: "widget", x: 2, y: 3, w: 3, h: 2 };
|
||||
|
||||
expect(
|
||||
getContinuousResizePlacement({
|
||||
placement,
|
||||
direction: "nw",
|
||||
deltaColumns: 0.35,
|
||||
deltaRows: -0.6,
|
||||
columnCount: 8,
|
||||
maxRowCount: 10,
|
||||
}),
|
||||
).toEqual({ id: "widget", x: 2.35, y: 2.4, w: 2.65, h: 2.6 });
|
||||
expect(
|
||||
getContinuousResizePlacement({
|
||||
placement,
|
||||
direction: "se",
|
||||
deltaColumns: 99,
|
||||
deltaRows: 99,
|
||||
columnCount: 8,
|
||||
maxRowCount: 7,
|
||||
}),
|
||||
).toEqual({ id: "widget", x: 2, y: 3, w: 6, h: 4 });
|
||||
});
|
||||
|
||||
test("uses independently measured visual axes for transformed resize geometry", () => {
|
||||
expect(
|
||||
getSnappedGridDelta({
|
||||
initial: { x: 10, y: 20 },
|
||||
current: { x: 10 + LOGICAL_GRID_PITCH * 1.25, y: 20 - LOGICAL_GRID_PITCH * 1.5 },
|
||||
visualScale: { x: 1.25, y: 1.5 },
|
||||
}),
|
||||
).toEqual({ x: 1, y: -1 });
|
||||
});
|
||||
|
||||
test("rejects non-positive or non-finite scales", () => {
|
||||
expect(
|
||||
getSnappedGridCoordinates({
|
||||
dragShape: { left: 0, top: 0 },
|
||||
target: { left: 0, top: 0, width: 0 },
|
||||
columnCount: 4,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getSnappedGridDelta({ initial: { x: 0, y: 0 }, current: { x: 1, y: 1 }, visualScale: Number.NaN }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import type { GridResizeDirection } from "./types";
|
||||
|
||||
interface RectangleLike {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ProjectedRectangle extends RectangleLike {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DragProjectionOrigin {
|
||||
initialPointer: { x: number; y: number };
|
||||
initialShape: ProjectedRectangle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the dragged shape from the grab point instead of dnd-kit's
|
||||
* feedback element. The latter can live in a CSS-zoomed overlay whose
|
||||
* bounding rectangle is in a different coordinate space from pointer events.
|
||||
*/
|
||||
export const getPointerProjectedShape = (origin: DragProjectionOrigin, currentPointer: { x: number; y: number }) => ({
|
||||
left: origin.initialShape.left + currentPointer.x - origin.initialPointer.x,
|
||||
top: origin.initialShape.top + currentPointer.y - origin.initialPointer.y,
|
||||
width: origin.initialShape.width,
|
||||
height: origin.initialShape.height,
|
||||
});
|
||||
|
||||
export const getSnappedGridCoordinates = ({
|
||||
dragShape,
|
||||
target,
|
||||
columnCount,
|
||||
}: {
|
||||
dragShape: Pick<RectangleLike, "left" | "top">;
|
||||
target: RectangleLike;
|
||||
columnCount: number;
|
||||
}) => {
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0;
|
||||
if (!Number.isFinite(visualScale) || visualScale <= 0) return null;
|
||||
|
||||
return {
|
||||
x: Math.round((dragShape.left - target.left) / (LOGICAL_GRID_PITCH * visualScale)),
|
||||
y: Math.round((dragShape.top - target.top) / (LOGICAL_GRID_PITCH * visualScale)),
|
||||
};
|
||||
};
|
||||
|
||||
export const getSnappedGridDelta = ({
|
||||
current,
|
||||
initial,
|
||||
visualScale,
|
||||
}: {
|
||||
current: { x: number; y: number };
|
||||
initial: { x: number; y: number };
|
||||
visualScale: number | { x: number; y: number };
|
||||
}) => {
|
||||
const delta = getContinuousGridDelta({ current, initial, visualScale });
|
||||
if (!delta) return null;
|
||||
return {
|
||||
x: Math.round(delta.x),
|
||||
y: Math.round(delta.y),
|
||||
};
|
||||
};
|
||||
|
||||
export const getContinuousGridDelta = ({
|
||||
current,
|
||||
initial,
|
||||
visualScale,
|
||||
}: {
|
||||
current: { x: number; y: number };
|
||||
initial: { x: number; y: number };
|
||||
visualScale: number | { x: number; y: number };
|
||||
}) => {
|
||||
const scale = typeof visualScale === "number" ? { x: visualScale, y: visualScale } : visualScale;
|
||||
if (!Number.isFinite(scale.x) || scale.x <= 0 || !Number.isFinite(scale.y) || scale.y <= 0) return null;
|
||||
return {
|
||||
x: (current.x - initial.x) / (LOGICAL_GRID_PITCH * scale.x),
|
||||
y: (current.y - initial.y) / (LOGICAL_GRID_PITCH * scale.y),
|
||||
};
|
||||
};
|
||||
|
||||
/** Projects a resize continuously while the committed layout remains snapped. */
|
||||
export const getContinuousResizePlacement = <TPlacement extends GridPlacement>({
|
||||
placement,
|
||||
direction,
|
||||
deltaColumns,
|
||||
deltaRows,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
minWidth = 1,
|
||||
minHeight = 1,
|
||||
}: {
|
||||
placement: TPlacement;
|
||||
direction: GridResizeDirection;
|
||||
deltaColumns: number;
|
||||
deltaRows: number;
|
||||
columnCount: number;
|
||||
maxRowCount: number | null;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
}): TPlacement => {
|
||||
const fixedRight = placement.x + placement.w;
|
||||
const fixedBottom = placement.y + placement.h;
|
||||
let x = placement.x;
|
||||
let y = placement.y;
|
||||
let w = placement.w;
|
||||
let h = placement.h;
|
||||
|
||||
if (direction.includes("w")) {
|
||||
w = clamp(placement.w - deltaColumns, minWidth, fixedRight);
|
||||
x = fixedRight - w;
|
||||
} else if (direction.includes("e")) {
|
||||
w = clamp(placement.w + deltaColumns, minWidth, columnCount - placement.x);
|
||||
}
|
||||
|
||||
if (direction.includes("n")) {
|
||||
h = clamp(placement.h - deltaRows, minHeight, fixedBottom);
|
||||
y = fixedBottom - h;
|
||||
} else if (direction.includes("s")) {
|
||||
h = clamp(
|
||||
placement.h + deltaRows,
|
||||
minHeight,
|
||||
maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - placement.y,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...placement, x, y, w, h };
|
||||
};
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.min(Math.max(value, minimum), maximum);
|
||||
@@ -0,0 +1,757 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { doGridPlacementsOverlap } from "~/components/board/layout";
|
||||
import {
|
||||
beginGridTransaction,
|
||||
cancelGridTransaction,
|
||||
commitGridTransaction,
|
||||
previewGridMove,
|
||||
previewGridResize,
|
||||
} from "./engine";
|
||||
import type {
|
||||
GridPreviewResult,
|
||||
GridResizeDirection,
|
||||
GridTransaction,
|
||||
TransactionalGrid,
|
||||
TransactionalGridState,
|
||||
} from "./types";
|
||||
|
||||
interface TestPlacement {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
kind: "container" | "item";
|
||||
marker?: string;
|
||||
}
|
||||
|
||||
const placement = (
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w = 1,
|
||||
h = 1,
|
||||
kind: TestPlacement["kind"] = "item",
|
||||
): TestPlacement => ({ id, x, y, w, h, kind, marker: `metadata:${id}` });
|
||||
|
||||
const grid = (
|
||||
id: string,
|
||||
columnCount: number,
|
||||
placements: readonly TestPlacement[],
|
||||
options: Pick<TransactionalGrid<TestPlacement>, "maxRowCount" | "ownerPlacementId" | "parentGridId"> = {},
|
||||
): TransactionalGrid<TestPlacement> => ({ id, columnCount, placements, ...options });
|
||||
|
||||
const state = (...grids: readonly TransactionalGrid<TestPlacement>[]): TransactionalGridState<TestPlacement> => ({
|
||||
grids,
|
||||
});
|
||||
|
||||
const accept = (result: GridPreviewResult<TestPlacement>): GridTransaction<TestPlacement> => {
|
||||
if (!result.accepted) throw new Error(`Expected preview to be accepted, received ${result.rejection.code}`);
|
||||
return result.transaction;
|
||||
};
|
||||
|
||||
const getGrid = (layout: TransactionalGridState<TestPlacement>, id: string) => {
|
||||
const found = layout.grids.find((candidate) => candidate.id === id);
|
||||
if (!found) throw new Error(`Missing test grid ${id}`);
|
||||
return found;
|
||||
};
|
||||
|
||||
const getPlacement = (layout: TransactionalGridState<TestPlacement>, id: string) => {
|
||||
for (const candidateGrid of layout.grids) {
|
||||
const found = candidateGrid.placements.find((candidate) => candidate.id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
throw new Error(`Missing test placement ${id}`);
|
||||
};
|
||||
|
||||
const coordinatesById = (layout: TransactionalGridState<TestPlacement>, gridId: string) =>
|
||||
Object.fromEntries(getGrid(layout, gridId).placements.map(({ id, x, y, w, h }) => [id, { x, y, w, h }]));
|
||||
|
||||
const expectValidState = (layout: TransactionalGridState<TestPlacement>) => {
|
||||
const allIds = new Set<string>();
|
||||
|
||||
for (const candidateGrid of layout.grids) {
|
||||
for (const [index, candidate] of candidateGrid.placements.entries()) {
|
||||
expect(allIds.has(candidate.id), `duplicate placement ${candidate.id}`).toBe(false);
|
||||
allIds.add(candidate.id);
|
||||
expect([candidate.x, candidate.y, candidate.w, candidate.h].every(Number.isInteger)).toBe(true);
|
||||
expect(candidate.x).toBeGreaterThanOrEqual(0);
|
||||
expect(candidate.y).toBeGreaterThanOrEqual(0);
|
||||
expect(candidate.w).toBeGreaterThanOrEqual(1);
|
||||
expect(candidate.h).toBeGreaterThanOrEqual(1);
|
||||
expect(candidate.x + candidate.w).toBeLessThanOrEqual(candidateGrid.columnCount);
|
||||
if (candidateGrid.maxRowCount !== undefined && candidateGrid.maxRowCount !== null) {
|
||||
expect(candidate.y + candidate.h).toBeLessThanOrEqual(candidateGrid.maxRowCount);
|
||||
}
|
||||
|
||||
for (const other of candidateGrid.placements.slice(index + 1)) {
|
||||
expect(
|
||||
doGridPlacementsOverlap(candidate, other),
|
||||
`${candidate.id} overlaps ${other.id} in ${candidateGrid.id}`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidateGrid of layout.grids) {
|
||||
if (!candidateGrid.ownerPlacementId) continue;
|
||||
const parent = getGrid(layout, candidateGrid.parentGridId ?? "");
|
||||
expect(parent.placements.some(({ id }) => id === candidateGrid.ownerPlacementId)).toBe(true);
|
||||
}
|
||||
};
|
||||
|
||||
describe("grid transaction setup", () => {
|
||||
test("captures detached snapshot and preview copies without mutating caller state", () => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 0), placement("other", 2, 3)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active", sourceGridId: "root" });
|
||||
|
||||
expect(transaction.snapshot).toEqual(original);
|
||||
expect(transaction.preview).toEqual(original);
|
||||
expect(transaction.snapshot).not.toBe(original);
|
||||
expect(transaction.snapshot.grids[0]).not.toBe(original.grids[0]);
|
||||
expect(transaction.snapshot.grids[0]?.placements[0]).not.toBe(original.grids[0]?.placements[0]);
|
||||
expect(transaction.preview).not.toBe(transaction.snapshot);
|
||||
});
|
||||
|
||||
test("rejects a missing active item or stale source-grid hint", () => {
|
||||
const original = state(grid("root", 2, [placement("active", 0, 0)]));
|
||||
|
||||
expect(() => beginGridTransaction(original, { activeId: "missing" })).toThrow('Grid item "missing" does not exist');
|
||||
expect(() => beginGridTransaction(original, { activeId: "active", sourceGridId: "stale" })).toThrow(
|
||||
'belongs to grid "root", not "stale"',
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "duplicate grid ids",
|
||||
layout: state(grid("root", 2, [placement("a", 0, 0)]), grid("root", 2, [placement("b", 0, 0)])),
|
||||
message: "Duplicate grid id",
|
||||
},
|
||||
{
|
||||
name: "duplicate placement ids across grids",
|
||||
layout: state(grid("first", 2, [placement("a", 0, 0)]), grid("second", 2, [placement("a", 0, 0)])),
|
||||
message: "Duplicate grid item id",
|
||||
},
|
||||
{
|
||||
name: "overlaps",
|
||||
layout: state(grid("root", 3, [placement("a", 0, 0, 2), placement("b", 1, 0, 2)])),
|
||||
message: "overlap",
|
||||
},
|
||||
{
|
||||
name: "column overflow",
|
||||
layout: state(grid("root", 2, [placement("a", 1, 0, 2)])),
|
||||
message: "exceeds grid",
|
||||
},
|
||||
{
|
||||
name: "row overflow",
|
||||
layout: state(grid("root", 2, [placement("a", 0, 1, 1, 2)], { maxRowCount: 2 })),
|
||||
message: "exceeds grid",
|
||||
},
|
||||
{
|
||||
name: "missing hierarchy owner",
|
||||
layout: state(
|
||||
grid("root", 2, [placement("a", 0, 0)]),
|
||||
grid("child", 2, [], { parentGridId: "root", ownerPlacementId: "missing" }),
|
||||
),
|
||||
message: "Owner item",
|
||||
},
|
||||
{
|
||||
name: "cyclic hierarchy",
|
||||
layout: state(
|
||||
grid("first", 2, [placement("owns-second", 0, 0)], {
|
||||
parentGridId: "second",
|
||||
ownerPlacementId: "owns-first",
|
||||
}),
|
||||
grid("second", 2, [placement("owns-first", 0, 0)], {
|
||||
parentGridId: "first",
|
||||
ownerPlacementId: "owns-second",
|
||||
}),
|
||||
),
|
||||
message: "cycle",
|
||||
},
|
||||
])("rejects invalid initial state: $name", ({ layout, message }) => {
|
||||
expect(() => beginGridTransaction(layout, { activeId: layout.grids[0]?.placements[0]?.id ?? "missing" })).toThrow(
|
||||
message,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("same-grid movement", () => {
|
||||
test("keeps the exact snapshot for a no-op instead of compacting intentional gaps", () => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 3), placement("other", 2, 5)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const moved = accept(previewGridMove(transaction, { targetGridId: "root", x: 0, y: 3 }));
|
||||
|
||||
expect(moved.preview).toEqual(original);
|
||||
expect(moved.preview).not.toBe(original);
|
||||
});
|
||||
|
||||
test("swaps an equal-size item occupying the requested exact slot", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("active", 0, 0), placement("target", 1, 0), placement("untouched", 2, 0)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
active: { x: 1, y: 0, w: 1, h: 1 },
|
||||
target: { x: 0, y: 0, w: 1, h: 1 },
|
||||
untouched: { x: 2, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("swaps different-size items when both resulting placements remain valid", () => {
|
||||
const original = state(grid("root", 4, [placement("wide", 0, 0, 2), placement("compact", 2, 0)]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "wide" }), {
|
||||
targetGridId: "root",
|
||||
x: 2,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
wide: { x: 2, y: 0, w: 2, h: 1 },
|
||||
compact: { x: 0, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("uses pinned reflow when an exact-slot swap would exceed the grid bounds", () => {
|
||||
const original = state(grid("root", 4, [placement("compact", 3, 0), placement("wide", 0, 0, 2)]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "compact" }), {
|
||||
targetGridId: "root",
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
compact: { x: 0, y: 0, w: 1, h: 1 },
|
||||
wide: { x: 0, y: 1, w: 2, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("pushes a different-size collision without compacting unrelated gaps", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("active", 0, 0), placement("wide", 1, 0, 2), placement("lower-left", 0, 1)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "root")).toEqual({
|
||||
active: { x: 1, y: 0, w: 1, h: 1 },
|
||||
wide: { x: 1, y: 1, w: 2, h: 1 },
|
||||
"lower-left": { x: 0, y: 1, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("swaps laterally as soon as a move pushes into one compatible item", () => {
|
||||
const original = state(grid("root", 5, [placement("active", 0, 0, 2), placement("target", 2, 0, 2)]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 2, y: 0, w: 2, h: 1 });
|
||||
expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 0, y: 0, w: 2, h: 1 });
|
||||
});
|
||||
|
||||
test("swaps the pushed item into the vacated slot when moving from right to left", () => {
|
||||
const original = state(grid("root", 5, [placement("target", 0, 0, 2), placement("active", 2, 0, 2)]));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 0, y: 0, w: 2, h: 1 });
|
||||
expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 2, y: 0, w: 2, h: 1 });
|
||||
});
|
||||
|
||||
test("preserves intentional empty rows outside the collision chain", () => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("active", 0, 0), placement("collision", 1, 0, 2), placement("far", 3, 8)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
x: 1,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "collision")).toMatchObject({ x: 1, y: 1 });
|
||||
expect(getPlacement(moved.preview, "far")).toMatchObject({ x: 3, y: 8 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ requested: { x: -20, y: -10 }, expected: { x: 0, y: 0 } },
|
||||
{ requested: { x: 99, y: 99 }, expected: { x: 2, y: 3 } },
|
||||
{ requested: { x: 2.9, y: 1.9 }, expected: { x: 2, y: 1 } },
|
||||
])("clamps and quantizes requested coordinates: $requested", ({ requested, expected }) => {
|
||||
const original = state(grid("root", 4, [placement("active", 1, 1, 2, 2)], { maxRowCount: 5 }));
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "root",
|
||||
...requested,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(moved.preview, "active")).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("produces the same result regardless of backing-array order", () => {
|
||||
const placements = [
|
||||
placement("active", 3, 0),
|
||||
placement("left", 0, 2),
|
||||
placement("middle", 1, 2),
|
||||
placement("right", 2, 2),
|
||||
];
|
||||
const forward = state(grid("root", 4, placements));
|
||||
const reversed = state(grid("root", 4, placements.toReversed()));
|
||||
const input = { targetGridId: "root", x: 0, y: 0 };
|
||||
|
||||
const first = accept(previewGridMove(beginGridTransaction(forward, { activeId: "active" }), input));
|
||||
const second = accept(previewGridMove(beginGridTransaction(reversed, { activeId: "active" }), input));
|
||||
|
||||
expect(coordinatesById(first.preview, "root")).toEqual(coordinatesById(second.preview, "root"));
|
||||
});
|
||||
|
||||
test("recomputes every chained preview from the interaction snapshot", () => {
|
||||
const original = state(
|
||||
grid("root", 3, [placement("active", 0, 0), placement("middle", 1, 0), placement("last", 2, 0)]),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const first = accept(previewGridMove(transaction, { targetGridId: "root", x: 1, y: 0 }));
|
||||
const second = accept(previewGridMove(first, { targetGridId: "root", x: 2, y: 0 }));
|
||||
|
||||
expect(coordinatesById(first.preview, "root")).toEqual({
|
||||
active: { x: 1, y: 0, w: 1, h: 1 },
|
||||
middle: { x: 0, y: 0, w: 1, h: 1 },
|
||||
last: { x: 2, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
expect(coordinatesById(second.preview, "root")).toEqual({
|
||||
active: { x: 2, y: 0, w: 1, h: 1 },
|
||||
middle: { x: 1, y: 0, w: 1, h: 1 },
|
||||
last: { x: 0, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-grid movement", () => {
|
||||
test("updates source and target atomically without compacting the source gap", () => {
|
||||
const original = state(
|
||||
grid("source", 2, [placement("active", 0, 0), placement("source-neighbor", 0, 1)]),
|
||||
grid("target", 2, [placement("target-neighbor", 0, 0)]),
|
||||
);
|
||||
const moved = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "target",
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(moved.preview, "source")).toEqual({
|
||||
"source-neighbor": { x: 0, y: 1, w: 1, h: 1 },
|
||||
});
|
||||
expect(coordinatesById(moved.preview, "target")).toEqual({
|
||||
"target-neighbor": { x: 0, y: 1, w: 1, h: 1 },
|
||||
active: { x: 0, y: 0, w: 1, h: 1 },
|
||||
});
|
||||
expect(getPlacement(moved.preview, "active").marker).toBe("metadata:active");
|
||||
expect(original).toEqual(
|
||||
state(
|
||||
grid("source", 2, [placement("active", 0, 0), placement("source-neighbor", 0, 1)]),
|
||||
grid("target", 2, [placement("target-neighbor", 0, 0)]),
|
||||
),
|
||||
);
|
||||
expectValidState(moved.preview);
|
||||
});
|
||||
|
||||
test("chained target changes do not leak an earlier cross-grid preview", () => {
|
||||
const original = state(
|
||||
grid("source", 2, [placement("active", 0, 0)]),
|
||||
grid("first-target", 2, [placement("first-neighbor", 0, 0)]),
|
||||
grid("second-target", 2, [placement("second-neighbor", 0, 0)]),
|
||||
);
|
||||
const first = accept(
|
||||
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId: "first-target",
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
);
|
||||
const second = accept(previewGridMove(first, { targetGridId: "second-target", x: 0, y: 0 }));
|
||||
|
||||
expect(getGrid(second.preview, "first-target").placements).toEqual(getGrid(original, "first-target").placements);
|
||||
expect(getGrid(second.preview, "source").placements).toHaveLength(0);
|
||||
expect(getPlacement(second.preview, "active")).toMatchObject({ x: 0, y: 0 });
|
||||
expect(getPlacement(second.preview, "second-neighbor")).toMatchObject({ x: 0, y: 1 });
|
||||
});
|
||||
|
||||
test("moves a container's owned grid with it and supports commit/cancel", () => {
|
||||
const original = state(
|
||||
grid("source", 3, [placement("container", 0, 0, 2, 2, "container")]),
|
||||
grid("target", 3, []),
|
||||
grid("container-content", 2, [placement("child", 0, 0)], {
|
||||
parentGridId: "source",
|
||||
ownerPlacementId: "container",
|
||||
}),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "container" });
|
||||
const moved = accept(previewGridMove(transaction, { targetGridId: "target", x: 1, y: 2 }));
|
||||
|
||||
expect(getGrid(moved.preview, "container-content").parentGridId).toBe("target");
|
||||
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 2, kind: "container" });
|
||||
expect(cancelGridTransaction(moved)).toEqual(original);
|
||||
expect(commitGridTransaction(moved)).toEqual(moved.preview);
|
||||
expect(commitGridTransaction(moved)).not.toBe(moved.preview);
|
||||
});
|
||||
|
||||
test.each(["child", "grandchild"])("rejects a container move into its %s grid", (targetGridId) => {
|
||||
const original = state(
|
||||
grid("root", 4, [placement("container", 0, 0, 2, 2, "container")]),
|
||||
grid("child", 3, [placement("nested-container", 0, 0, 2, 2, "container")], {
|
||||
parentGridId: "root",
|
||||
ownerPlacementId: "container",
|
||||
}),
|
||||
grid("grandchild", 2, [], {
|
||||
parentGridId: "child",
|
||||
ownerPlacementId: "nested-container",
|
||||
}),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "container" });
|
||||
const result = previewGridMove(transaction, { targetGridId, x: 0, y: 0 });
|
||||
|
||||
expect(result.accepted).toBe(false);
|
||||
if (result.accepted) throw new Error("Expected descendant move rejection");
|
||||
expect(result.rejection.code).toBe("descendant-target");
|
||||
expect(result.transaction).toBe(transaction);
|
||||
});
|
||||
|
||||
test("runs caller guards with hierarchy context and preserves custom rejection details", () => {
|
||||
const original = state(grid("source", 2, [placement("active", 0, 0)]), grid("target", 2, []));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
let observedTarget = "";
|
||||
const guarded = previewGridMove(
|
||||
transaction,
|
||||
{ targetGridId: "target", x: 0, y: 0 },
|
||||
{
|
||||
guards: [
|
||||
(context) => {
|
||||
observedTarget = context.targetGrid.id;
|
||||
expect(context.active.id).toBe("active");
|
||||
expect(context.sourceGrid.id).toBe("source");
|
||||
expect(context.targetIsDescendant).toBe(false);
|
||||
expect(context.snapshot).toBe(transaction.snapshot);
|
||||
return true;
|
||||
},
|
||||
() => ({ code: "locked-container", message: "Unlock the container first" }),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(observedTarget).toBe("target");
|
||||
expect(guarded.accepted).toBe(false);
|
||||
if (guarded.accepted) throw new Error("Expected custom guard rejection");
|
||||
expect(guarded.rejection).toEqual({ code: "locked-container", message: "Unlock the container first" });
|
||||
|
||||
const booleanRejection = previewGridMove(
|
||||
transaction,
|
||||
{ targetGridId: "target", x: 0, y: 0 },
|
||||
{
|
||||
guards: [() => false],
|
||||
},
|
||||
);
|
||||
expect(booleanRejection.accepted).toBe(false);
|
||||
if (booleanRejection.accepted) throw new Error("Expected boolean guard rejection");
|
||||
expect(booleanRejection.rejection.code).toBe("guard-rejected");
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "unknown grid",
|
||||
original: state(grid("source", 2, [placement("active", 0, 0)])),
|
||||
input: { targetGridId: "missing", x: 0, y: 0 },
|
||||
code: "unknown-target-grid",
|
||||
},
|
||||
{
|
||||
name: "destination narrower than the item",
|
||||
original: state(grid("source", 3, [placement("active", 0, 0, 2)]), grid("target", 1, [])),
|
||||
input: { targetGridId: "target", x: 0, y: 0 },
|
||||
code: "item-too-large",
|
||||
},
|
||||
{
|
||||
name: "reflow beyond destination row limit",
|
||||
original: state(
|
||||
grid("source", 2, [placement("active", 0, 0, 2)]),
|
||||
grid("target", 2, [placement("target", 0, 0, 2)], { maxRowCount: 1 }),
|
||||
),
|
||||
input: { targetGridId: "target", x: 0, y: 0 },
|
||||
code: "row-limit",
|
||||
},
|
||||
{
|
||||
name: "non-finite coordinate",
|
||||
original: state(grid("source", 2, [placement("active", 0, 0)])),
|
||||
input: { targetGridId: "source", x: Number.NaN, y: 0 },
|
||||
code: "invalid-coordinate",
|
||||
},
|
||||
])("rejects $name without changing the transaction", ({ original, input, code }) => {
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const result = previewGridMove(transaction, input);
|
||||
|
||||
expect(result.accepted).toBe(false);
|
||||
if (result.accepted) throw new Error("Expected move rejection");
|
||||
expect(result.rejection.code).toBe(code);
|
||||
expect(result.transaction).toBe(transaction);
|
||||
expect(result.transaction.preview).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eight-direction resize", () => {
|
||||
test.each<{
|
||||
direction: GridResizeDirection;
|
||||
deltaColumns: number;
|
||||
deltaRows: number;
|
||||
expected: Pick<TestPlacement, "x" | "y" | "w" | "h">;
|
||||
}>([
|
||||
{ direction: "n", deltaColumns: 0, deltaRows: -1, expected: { x: 3, y: 2, w: 4, h: 5 } },
|
||||
{ direction: "ne", deltaColumns: 1, deltaRows: -1, expected: { x: 3, y: 2, w: 5, h: 5 } },
|
||||
{ direction: "e", deltaColumns: 1, deltaRows: 0, expected: { x: 3, y: 3, w: 5, h: 4 } },
|
||||
{ direction: "se", deltaColumns: 1, deltaRows: 1, expected: { x: 3, y: 3, w: 5, h: 5 } },
|
||||
{ direction: "s", deltaColumns: 0, deltaRows: 1, expected: { x: 3, y: 3, w: 4, h: 5 } },
|
||||
{ direction: "sw", deltaColumns: -1, deltaRows: 1, expected: { x: 2, y: 3, w: 5, h: 5 } },
|
||||
{ direction: "w", deltaColumns: -1, deltaRows: 0, expected: { x: 2, y: 3, w: 5, h: 4 } },
|
||||
{ direction: "nw", deltaColumns: -1, deltaRows: -1, expected: { x: 2, y: 2, w: 5, h: 5 } },
|
||||
])("resizes from $direction while anchoring the opposite edge", ({ expected, ...input }) => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(previewGridResize(beginGridTransaction(original, { activeId: "active" }), input));
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("clamps a north-west shrink to minimum size while keeping right and bottom anchored", () => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(
|
||||
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction: "nw",
|
||||
deltaColumns: 10,
|
||||
deltaRows: 10,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject({ x: 5, y: 5, w: 2, h: 2 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ direction: "nw" as const, deltaColumns: -99, deltaRows: -99, expected: { x: 0, y: 0, w: 7, h: 7 } },
|
||||
{ direction: "se" as const, deltaColumns: 99, deltaRows: 99, expected: { x: 3, y: 3, w: 9, h: 9 } },
|
||||
])("clamps $direction growth to grid bounds", ({ expected, ...input }) => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(previewGridResize(beginGridTransaction(original, { activeId: "active" }), input));
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("honors explicit maximum dimensions", () => {
|
||||
const original = state(grid("root", 12, [placement("active", 3, 3, 4, 4)], { maxRowCount: 12 }));
|
||||
const resized = accept(
|
||||
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction: "se",
|
||||
deltaColumns: 99,
|
||||
deltaRows: 99,
|
||||
maxWidth: 6,
|
||||
maxHeight: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getPlacement(resized.preview, "active")).toMatchObject({ x: 3, y: 3, w: 6, h: 5 });
|
||||
});
|
||||
|
||||
test("pins the resized item and deterministically pushes collisions", () => {
|
||||
const original = state(
|
||||
grid("root", 3, [placement("active", 0, 0, 2), placement("neighbor", 0, 1, 2), placement("side", 2, 2)]),
|
||||
);
|
||||
const resized = accept(
|
||||
previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction: "s",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(coordinatesById(resized.preview, "root")).toEqual({
|
||||
active: { x: 0, y: 0, w: 2, h: 3 },
|
||||
neighbor: { x: 0, y: 3, w: 2, h: 1 },
|
||||
side: { x: 2, y: 2, w: 1, h: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects resize when collision reflow would exceed the row limit", () => {
|
||||
const original = state(
|
||||
grid("root", 2, [placement("active", 0, 0, 2), placement("neighbor", 0, 1, 2, 2)], {
|
||||
maxRowCount: 3,
|
||||
}),
|
||||
);
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const result = previewGridResize(transaction, {
|
||||
direction: "s",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 1,
|
||||
});
|
||||
|
||||
expect(result.accepted).toBe(false);
|
||||
if (result.accepted) throw new Error("Expected row-limit rejection");
|
||||
expect(result.rejection.code).toBe("row-limit");
|
||||
expect(result.transaction).toBe(transaction);
|
||||
});
|
||||
|
||||
test("rejects non-finite deltas and geometrically impossible constraints", () => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 0, 2, 2)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
const invalidDelta = previewGridResize(transaction, {
|
||||
direction: "e",
|
||||
deltaColumns: Number.POSITIVE_INFINITY,
|
||||
deltaRows: 0,
|
||||
});
|
||||
const impossible = previewGridResize(transaction, {
|
||||
direction: "w",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 0,
|
||||
minWidth: 5,
|
||||
maxWidth: 5,
|
||||
});
|
||||
|
||||
expect(invalidDelta.accepted).toBe(false);
|
||||
if (invalidDelta.accepted) throw new Error("Expected invalid-coordinate rejection");
|
||||
expect(invalidDelta.rejection.code).toBe("invalid-coordinate");
|
||||
expect(impossible.accepted).toBe(false);
|
||||
if (impossible.accepted) throw new Error("Expected resize-constraints rejection");
|
||||
expect(impossible.rejection.code).toBe("resize-constraints");
|
||||
});
|
||||
|
||||
test.each([{ minWidth: 0 }, { minHeight: 1.5 }, { minWidth: 3, maxWidth: 2 }, { minHeight: 3, maxHeight: 2 }])(
|
||||
"throws on invalid programmer-supplied constraints: $minWidth $minHeight",
|
||||
(constraints) => {
|
||||
const original = state(grid("root", 4, [placement("active", 0, 0, 2, 2)]));
|
||||
const transaction = beginGridTransaction(original, { activeId: "active" });
|
||||
|
||||
expect(() =>
|
||||
previewGridResize(transaction, {
|
||||
direction: "se",
|
||||
deltaColumns: 0,
|
||||
deltaRows: 0,
|
||||
...constraints,
|
||||
}),
|
||||
).toThrow(RangeError);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("transaction completion", () => {
|
||||
test("a rejection keeps the latest accepted preview, cancel restores start, and commit detaches output", () => {
|
||||
const original = state(grid("root", 3, [placement("active", 0, 0), placement("other", 1, 0)]));
|
||||
const started = beginGridTransaction(original, { activeId: "active" });
|
||||
const moved = accept(previewGridMove(started, { targetGridId: "root", x: 1, y: 0 }));
|
||||
const rejected = previewGridMove(moved, { targetGridId: "missing", x: 0, y: 0 });
|
||||
|
||||
expect(rejected.accepted).toBe(false);
|
||||
if (rejected.accepted) throw new Error("Expected rejection");
|
||||
expect(rejected.transaction).toBe(moved);
|
||||
expect(rejected.transaction.preview).toEqual(moved.preview);
|
||||
|
||||
const committed = commitGridTransaction(rejected.transaction);
|
||||
const cancelled = cancelGridTransaction(rejected.transaction);
|
||||
expect(committed).toEqual(moved.preview);
|
||||
expect(committed).not.toBe(moved.preview);
|
||||
expect(cancelled).toEqual(original);
|
||||
expect(cancelled).not.toBe(rejected.transaction.snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interaction invariant matrix", () => {
|
||||
test("preserves invariants across every sampled same-grid and cross-grid coordinate", () => {
|
||||
const original = state(
|
||||
grid(
|
||||
"source",
|
||||
6,
|
||||
[
|
||||
placement("active", 0, 0, 2, 2),
|
||||
placement("source-a", 2, 0, 2),
|
||||
placement("source-b", 4, 0, 2, 2),
|
||||
placement("source-c", 2, 1, 2),
|
||||
],
|
||||
{ maxRowCount: 10 },
|
||||
),
|
||||
grid("target", 5, [placement("target-a", 0, 0), placement("target-b", 1, 0, 2, 2)], {
|
||||
maxRowCount: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const targetGridId of ["source", "target"]) {
|
||||
for (let x = -3; x <= 8; x += 1) {
|
||||
for (let y = -2; y <= 11; y += 1) {
|
||||
const result = previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
|
||||
targetGridId,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
if (!result.accepted) {
|
||||
expect(result.rejection.code).toBe("row-limit");
|
||||
continue;
|
||||
}
|
||||
|
||||
expectValidState(result.transaction.preview);
|
||||
expect(getPlacement(result.transaction.preview, "active").marker).toBe("metadata:active");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves invariants across every direction and representative signed deltas", () => {
|
||||
const original = state(grid("root", 8, [placement("active", 2, 2, 3, 3)], { maxRowCount: 9 }));
|
||||
const directions: readonly GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
||||
const deltas = [-20, -3, -1, 0, 1, 3, 20];
|
||||
|
||||
for (const direction of directions) {
|
||||
for (const deltaColumns of deltas) {
|
||||
for (const deltaRows of deltas) {
|
||||
const result = previewGridResize(beginGridTransaction(original, { activeId: "active" }), {
|
||||
direction,
|
||||
deltaColumns,
|
||||
deltaRows,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 6,
|
||||
maxHeight: 7,
|
||||
});
|
||||
expect(result.accepted).toBe(true);
|
||||
if (!result.accepted) throw new Error(`Unexpected ${result.rejection.code} for ${direction}`);
|
||||
expectValidState(result.transaction.preview);
|
||||
expect(getPlacement(result.transaction.preview, "active").marker).toBe("metadata:active");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
import { doGridPlacementsOverlap, getLayoutRowCount, normalizeGridPlacement } from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import type {
|
||||
BeginGridTransactionInput,
|
||||
GridMoveGuard,
|
||||
GridMoveInput,
|
||||
GridMoveOptions,
|
||||
GridOperationRejection,
|
||||
GridPreviewResult,
|
||||
GridResizeDirection,
|
||||
GridResizeInput,
|
||||
GridTransaction,
|
||||
TransactionalGrid,
|
||||
TransactionalGridState,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Starts an immutable interaction transaction. The returned snapshot is the
|
||||
* rollback source for dnd-kit's `onDragCancel` and the baseline for every
|
||||
* `onDragMove` / `onDragOver` preview.
|
||||
*/
|
||||
export const beginGridTransaction = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
input: BeginGridTransactionInput,
|
||||
): GridTransaction<TPlacement> => {
|
||||
validateGridState(state);
|
||||
|
||||
const located = findPlacement(state, input.activeId);
|
||||
if (!located) throw new Error(`Grid item "${input.activeId}" does not exist`);
|
||||
if (input.sourceGridId !== undefined && located.grid.id !== input.sourceGridId) {
|
||||
throw new Error(`Grid item "${input.activeId}" belongs to grid "${located.grid.id}", not "${input.sourceGridId}"`);
|
||||
}
|
||||
|
||||
const snapshot = cloneGridState(state);
|
||||
return {
|
||||
activeId: input.activeId,
|
||||
sourceGridId: located.grid.id,
|
||||
snapshot,
|
||||
preview: cloneGridState(snapshot),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes a move preview from the transaction snapshot. Same-grid moves that
|
||||
* collide with one item swap into that item's slot when both resulting
|
||||
* placements remain valid; every other move pins the active item and pushes
|
||||
* only actual collisions. Intentional empty rows remain untouched.
|
||||
*/
|
||||
export const previewGridMove = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
input: GridMoveInput,
|
||||
options: GridMoveOptions<TPlacement> = {},
|
||||
): GridPreviewResult<TPlacement> => {
|
||||
const sourceGrid = getGrid(transaction.snapshot, transaction.sourceGridId);
|
||||
const targetGrid = findGrid(transaction.snapshot, input.targetGridId);
|
||||
if (!targetGrid) {
|
||||
return rejected(transaction, {
|
||||
code: "unknown-target-grid",
|
||||
message: `Grid "${input.targetGridId}" does not exist`,
|
||||
});
|
||||
}
|
||||
|
||||
const active = getPlacement(sourceGrid, transaction.activeId);
|
||||
const targetIsDescendant = isTargetWithinActiveSubtree(transaction.snapshot, targetGrid.id, transaction.activeId);
|
||||
if (targetIsDescendant) {
|
||||
return rejected(transaction, {
|
||||
code: "descendant-target",
|
||||
message: `Grid "${targetGrid.id}" is inside item "${active.id}"`,
|
||||
});
|
||||
}
|
||||
|
||||
const guardRejection = evaluateMoveGuards(options.guards ?? [], {
|
||||
active,
|
||||
sourceGrid,
|
||||
targetGrid,
|
||||
snapshot: transaction.snapshot,
|
||||
targetIsDescendant,
|
||||
});
|
||||
if (guardRejection) return rejected(transaction, guardRejection);
|
||||
|
||||
if (!Number.isFinite(input.x) || !Number.isFinite(input.y)) {
|
||||
return rejected(transaction, { code: "invalid-coordinate" });
|
||||
}
|
||||
if (active.w > targetGrid.columnCount || active.h > getMaximumRows(targetGrid)) {
|
||||
return rejected(transaction, { code: "item-too-large" });
|
||||
}
|
||||
|
||||
const nextActive = clampMovePlacement(active, input, targetGrid);
|
||||
const preview =
|
||||
sourceGrid.id === targetGrid.id
|
||||
? previewSameGridMove(transaction.snapshot, sourceGrid, active, nextActive)
|
||||
: previewCrossGridMove(transaction.snapshot, sourceGrid, targetGrid, active, nextActive);
|
||||
|
||||
if (!fitsRowLimits(preview)) return rejected(transaction, { code: "row-limit" });
|
||||
validateGridState(preview);
|
||||
return accepted(transaction, preview);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes an eight-direction resize preview. Deltas describe movement of the
|
||||
* selected handle, while the opposite edge stays anchored. Bounds and caller
|
||||
* constraints are applied before pinned collision reflow.
|
||||
*/
|
||||
export const previewGridResize = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
input: GridResizeInput,
|
||||
): GridPreviewResult<TPlacement> => {
|
||||
const grid = getGrid(transaction.snapshot, transaction.sourceGridId);
|
||||
const active = getPlacement(grid, transaction.activeId);
|
||||
|
||||
if (!Number.isFinite(input.deltaColumns) || !Number.isFinite(input.deltaRows)) {
|
||||
return rejected(transaction, { code: "invalid-coordinate" });
|
||||
}
|
||||
|
||||
const constraints = getResizeConstraints(input, grid);
|
||||
const resized = resizePlacement(active, input.direction, input, constraints, grid);
|
||||
if (!resized) return rejected(transaction, { code: "resize-constraints" });
|
||||
|
||||
const preview = cloneGridState(transaction.snapshot);
|
||||
const previewGrid = getGrid(preview, grid.id);
|
||||
previewGrid.placements = areCoordinatesEqual(active, resized)
|
||||
? previewGrid.placements
|
||||
: resolvePinnedGridCollisions(previewGrid.placements, resized, previewGrid.columnCount);
|
||||
|
||||
if (!fitsRowLimits(preview)) return rejected(transaction, { code: "row-limit" });
|
||||
validateGridState(preview);
|
||||
return accepted(transaction, preview);
|
||||
};
|
||||
|
||||
/** Returns the latest accepted preview as one atomic state update. */
|
||||
export const commitGridTransaction = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
): TransactionalGridState<TPlacement> => cloneGridState(transaction.preview);
|
||||
|
||||
/** Restores the exact interaction-start snapshot. */
|
||||
export const cancelGridTransaction = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
): TransactionalGridState<TPlacement> => cloneGridState(transaction.snapshot);
|
||||
|
||||
const previewSameGridMove = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
active: TPlacement,
|
||||
nextActive: TPlacement,
|
||||
) => {
|
||||
const preview = cloneGridState(state);
|
||||
const previewGrid = getGrid(preview, grid.id);
|
||||
if (areCoordinatesEqual(active, nextActive)) return preview;
|
||||
|
||||
const collisions = grid.placements.filter(
|
||||
(placement) => placement.id !== active.id && doGridPlacementsOverlap(placement, nextActive),
|
||||
);
|
||||
const displaced = collisions.length === 1 ? collisions[0] : undefined;
|
||||
|
||||
const swapped = displaced
|
||||
? grid.placements.map((placement) => {
|
||||
if (placement.id === active.id) return { ...placement, x: displaced.x, y: displaced.y };
|
||||
if (placement.id === displaced.id) return { ...placement, x: active.x, y: active.y };
|
||||
return { ...placement };
|
||||
})
|
||||
: null;
|
||||
|
||||
previewGrid.placements =
|
||||
swapped && placementsFitGrid(swapped, grid)
|
||||
? swapped
|
||||
: resolvePinnedGridCollisions(grid.placements, nextActive, grid.columnCount);
|
||||
return preview;
|
||||
};
|
||||
|
||||
const placementsFitGrid = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
) =>
|
||||
placements.every(
|
||||
(placement) =>
|
||||
placement.x >= 0 &&
|
||||
placement.y >= 0 &&
|
||||
placement.x + placement.w <= grid.columnCount &&
|
||||
(grid.maxRowCount === undefined || grid.maxRowCount === null || placement.y + placement.h <= grid.maxRowCount),
|
||||
) &&
|
||||
placements.every((placement, index) =>
|
||||
placements.slice(index + 1).every((other) => !doGridPlacementsOverlap(placement, other)),
|
||||
);
|
||||
|
||||
const previewCrossGridMove = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
sourceGrid: TransactionalGrid<TPlacement>,
|
||||
targetGrid: TransactionalGrid<TPlacement>,
|
||||
active: TPlacement,
|
||||
nextActive: TPlacement,
|
||||
) => {
|
||||
const preview = cloneGridState(state);
|
||||
const previewSource = getGrid(preview, sourceGrid.id);
|
||||
const previewTarget = getGrid(preview, targetGrid.id);
|
||||
|
||||
previewSource.placements = sourceGrid.placements
|
||||
.filter((placement) => placement.id !== active.id)
|
||||
.map((placement) => ({ ...placement }));
|
||||
previewTarget.placements = resolvePinnedGridCollisions(
|
||||
[...targetGrid.placements, nextActive],
|
||||
nextActive,
|
||||
targetGrid.columnCount,
|
||||
);
|
||||
|
||||
preview.grids = preview.grids.map((grid) =>
|
||||
grid.ownerPlacementId === active.id ? { ...grid, parentGridId: targetGrid.id } : grid,
|
||||
);
|
||||
return preview;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pins the active placement, keeps every non-colliding item at its persisted
|
||||
* coordinates, and pushes only the deterministic collision chain downward.
|
||||
*/
|
||||
export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
pinnedPlacement: TPlacement,
|
||||
columnCount: number,
|
||||
): TPlacement[] => {
|
||||
const normalized = placements.map((placement) =>
|
||||
normalizeGridPlacement(placement.id === pinnedPlacement.id ? pinnedPlacement : placement, columnCount),
|
||||
);
|
||||
const pinned = normalized.find((placement) => placement.id === pinnedPlacement.id);
|
||||
if (!pinned) throw new Error(`Pinned grid item "${pinnedPlacement.id}" does not exist`);
|
||||
|
||||
const placed: TPlacement[] = [pinned];
|
||||
const candidates = normalized.filter((placement) => placement.id !== pinned.id).toSorted(comparePlacements);
|
||||
for (const candidate of candidates) {
|
||||
let resolved = candidate;
|
||||
for (;;) {
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(resolved, placement));
|
||||
if (collisions.length === 0) break;
|
||||
resolved = {
|
||||
...resolved,
|
||||
y: Math.max(...collisions.map((placement) => placement.y + placement.h)),
|
||||
};
|
||||
}
|
||||
placed.push(resolved);
|
||||
}
|
||||
|
||||
const byId = new Map(placed.map((placement) => [placement.id, placement]));
|
||||
return normalized.map((placement) => byId.get(placement.id) ?? placement);
|
||||
};
|
||||
|
||||
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.y - second.y || first.x - second.x || first.id.localeCompare(second.id);
|
||||
|
||||
const clampMovePlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
input: GridMoveInput,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
) => {
|
||||
const maximumX = grid.columnCount - placement.w;
|
||||
const maximumY = getMaximumRows(grid) - placement.h;
|
||||
return {
|
||||
...placement,
|
||||
x: clamp(Math.trunc(input.x), 0, maximumX),
|
||||
y: clamp(Math.trunc(input.y), 0, maximumY),
|
||||
};
|
||||
};
|
||||
|
||||
interface ResolvedResizeConstraints {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
const getResizeConstraints = <TPlacement extends GridPlacement>(
|
||||
input: GridResizeInput,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
): ResolvedResizeConstraints => {
|
||||
const minWidth = toPositiveInteger(input.minWidth ?? 1, "minWidth");
|
||||
const minHeight = toPositiveInteger(input.minHeight ?? 1, "minHeight");
|
||||
const maxWidth = toPositiveInteger(input.maxWidth ?? grid.columnCount, "maxWidth");
|
||||
const maxHeight = toPositiveInteger(input.maxHeight ?? getMaximumRows(grid), "maxHeight");
|
||||
if (maxWidth < minWidth) throw new RangeError("maxWidth must be greater than or equal to minWidth");
|
||||
if (maxHeight < minHeight) throw new RangeError("maxHeight must be greater than or equal to minHeight");
|
||||
|
||||
return { minWidth, minHeight, maxWidth, maxHeight };
|
||||
};
|
||||
|
||||
const resizePlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
direction: GridResizeDirection,
|
||||
input: GridResizeInput,
|
||||
constraints: ResolvedResizeConstraints,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
): TPlacement | null => {
|
||||
const deltaX = Math.trunc(input.deltaColumns);
|
||||
const deltaY = Math.trunc(input.deltaRows);
|
||||
const resizeWest = direction.includes("w");
|
||||
const resizeEast = direction.includes("e");
|
||||
const resizeNorth = direction.includes("n");
|
||||
const resizeSouth = direction.includes("s");
|
||||
|
||||
let x = placement.x;
|
||||
let y = placement.y;
|
||||
let width = placement.w;
|
||||
let height = placement.h;
|
||||
|
||||
if (resizeWest) {
|
||||
const fixedRight = placement.x + placement.w;
|
||||
const maximumWidth = Math.min(constraints.maxWidth, fixedRight);
|
||||
if (constraints.minWidth > maximumWidth) return null;
|
||||
width = clamp(placement.w - deltaX, constraints.minWidth, maximumWidth);
|
||||
x = fixedRight - width;
|
||||
} else if (resizeEast) {
|
||||
const maximumWidth = Math.min(constraints.maxWidth, grid.columnCount - placement.x);
|
||||
if (constraints.minWidth > maximumWidth) return null;
|
||||
width = clamp(placement.w + deltaX, constraints.minWidth, maximumWidth);
|
||||
}
|
||||
|
||||
if (resizeNorth) {
|
||||
const fixedBottom = placement.y + placement.h;
|
||||
const maximumHeight = Math.min(constraints.maxHeight, fixedBottom);
|
||||
if (constraints.minHeight > maximumHeight) return null;
|
||||
height = clamp(placement.h - deltaY, constraints.minHeight, maximumHeight);
|
||||
y = fixedBottom - height;
|
||||
} else if (resizeSouth) {
|
||||
const maximumHeight = Math.min(constraints.maxHeight, getMaximumRows(grid) - placement.y);
|
||||
if (constraints.minHeight > maximumHeight) return null;
|
||||
height = clamp(placement.h + deltaY, constraints.minHeight, maximumHeight);
|
||||
}
|
||||
|
||||
return { ...placement, x, y, w: width, h: height };
|
||||
};
|
||||
|
||||
const evaluateMoveGuards = <TPlacement extends GridPlacement>(
|
||||
guards: readonly GridMoveGuard<TPlacement>[],
|
||||
context: Parameters<GridMoveGuard<TPlacement>>[0],
|
||||
): GridOperationRejection | null => {
|
||||
for (const guard of guards) {
|
||||
const result = guard(context);
|
||||
if (result === true) continue;
|
||||
if (result === false) return { code: "guard-rejected" };
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isTargetWithinActiveSubtree = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
targetGridId: string,
|
||||
activeId: string,
|
||||
) => {
|
||||
let current: TransactionalGrid<TPlacement> | undefined = findGrid(state, targetGridId);
|
||||
const visited = new Set<string>();
|
||||
while (current) {
|
||||
if (current.ownerPlacementId === activeId) return true;
|
||||
if (!current.parentGridId || visited.has(current.id)) return false;
|
||||
visited.add(current.id);
|
||||
current = findGrid(state, current.parentGridId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const accepted = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
preview: TransactionalGridState<TPlacement>,
|
||||
): GridPreviewResult<TPlacement> => ({
|
||||
accepted: true,
|
||||
transaction: { ...transaction, preview },
|
||||
});
|
||||
|
||||
const rejected = <TPlacement extends GridPlacement>(
|
||||
transaction: GridTransaction<TPlacement>,
|
||||
rejection: GridOperationRejection,
|
||||
): GridPreviewResult<TPlacement> => ({ accepted: false, transaction, rejection });
|
||||
|
||||
const fitsRowLimits = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>) =>
|
||||
state.grids.every((grid) => {
|
||||
if (grid.maxRowCount === undefined || grid.maxRowCount === null) return true;
|
||||
return getLayoutRowCount(grid.placements) <= grid.maxRowCount;
|
||||
});
|
||||
|
||||
const getMaximumRows = <TPlacement extends GridPlacement>(grid: TransactionalGrid<TPlacement>) =>
|
||||
grid.maxRowCount ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const cloneGridState = <TPlacement extends GridPlacement>(
|
||||
state: TransactionalGridState<TPlacement>,
|
||||
): TransactionalGridState<TPlacement> => ({
|
||||
grids: state.grids.map((grid) => ({
|
||||
...grid,
|
||||
placements: grid.placements.map((placement) => ({ ...placement })),
|
||||
})),
|
||||
});
|
||||
|
||||
const findGrid = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, gridId: string) =>
|
||||
state.grids.find((grid) => grid.id === gridId);
|
||||
|
||||
const getGrid = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, gridId: string) => {
|
||||
const grid = findGrid(state, gridId);
|
||||
if (!grid) throw new Error(`Grid "${gridId}" does not exist`);
|
||||
return grid;
|
||||
};
|
||||
|
||||
const getPlacement = <TPlacement extends GridPlacement>(grid: TransactionalGrid<TPlacement>, itemId: string) => {
|
||||
const placement = grid.placements.find((candidate) => candidate.id === itemId);
|
||||
if (!placement) throw new Error(`Grid item "${itemId}" does not exist in grid "${grid.id}"`);
|
||||
return placement;
|
||||
};
|
||||
|
||||
const findPlacement = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>, itemId: string) => {
|
||||
for (const grid of state.grids) {
|
||||
const placement = grid.placements.find((candidate) => candidate.id === itemId);
|
||||
if (placement) return { grid, placement };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const areCoordinatesEqual = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.min(Math.max(value, minimum), maximum);
|
||||
|
||||
const toPositiveInteger = (value: number, name: string) => {
|
||||
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
|
||||
throw new RangeError(`${name} must be a positive integer`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const validateGridState = <TPlacement extends GridPlacement>(state: TransactionalGridState<TPlacement>) => {
|
||||
const gridIds = new Set<string>();
|
||||
const placementIds = new Set<string>();
|
||||
const ownerIds = new Set<string>();
|
||||
|
||||
for (const grid of state.grids) {
|
||||
if (gridIds.has(grid.id)) throw new Error(`Duplicate grid id "${grid.id}"`);
|
||||
gridIds.add(grid.id);
|
||||
toPositiveInteger(grid.columnCount, `columnCount for grid "${grid.id}"`);
|
||||
if (grid.maxRowCount !== undefined && grid.maxRowCount !== null) {
|
||||
toPositiveInteger(grid.maxRowCount, `maxRowCount for grid "${grid.id}"`);
|
||||
}
|
||||
|
||||
for (const placement of grid.placements) {
|
||||
if (placementIds.has(placement.id)) throw new Error(`Duplicate grid item id "${placement.id}"`);
|
||||
placementIds.add(placement.id);
|
||||
validatePlacementBounds(placement, grid);
|
||||
}
|
||||
for (let index = 0; index < grid.placements.length; index += 1) {
|
||||
const placement = grid.placements[index];
|
||||
if (!placement) continue;
|
||||
for (const other of grid.placements.slice(index + 1)) {
|
||||
if (doGridPlacementsOverlap(placement, other)) {
|
||||
throw new Error(`Grid items "${placement.id}" and "${other.id}" overlap in grid "${grid.id}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const grid of state.grids) {
|
||||
if (grid.parentGridId && !gridIds.has(grid.parentGridId)) {
|
||||
throw new Error(`Parent grid "${grid.parentGridId}" for grid "${grid.id}" does not exist`);
|
||||
}
|
||||
if (grid.ownerPlacementId) {
|
||||
if (!grid.parentGridId) throw new Error(`Owned grid "${grid.id}" must have a parent grid`);
|
||||
if (ownerIds.has(grid.ownerPlacementId)) {
|
||||
throw new Error(`Grid item "${grid.ownerPlacementId}" owns more than one grid`);
|
||||
}
|
||||
ownerIds.add(grid.ownerPlacementId);
|
||||
const parent = getGrid(state, grid.parentGridId);
|
||||
if (!parent.placements.some((placement) => placement.id === grid.ownerPlacementId)) {
|
||||
throw new Error(`Owner item "${grid.ownerPlacementId}" does not exist in parent grid "${grid.parentGridId}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const grid of state.grids) {
|
||||
const visited = new Set<string>();
|
||||
let current: TransactionalGrid<TPlacement> | undefined = grid;
|
||||
while (current?.parentGridId) {
|
||||
if (visited.has(current.id)) throw new Error(`Grid hierarchy contains a cycle at "${current.id}"`);
|
||||
visited.add(current.id);
|
||||
current = getGrid(state, current.parentGridId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validatePlacementBounds = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
grid: TransactionalGrid<TPlacement>,
|
||||
) => {
|
||||
const values = [placement.x, placement.y, placement.w, placement.h];
|
||||
if (values.some((value) => !Number.isFinite(value) || !Number.isInteger(value))) {
|
||||
throw new Error(`Grid item "${placement.id}" must use finite integer coordinates`);
|
||||
}
|
||||
if (placement.x < 0 || placement.y < 0 || placement.w < 1 || placement.h < 1) {
|
||||
throw new Error(`Grid item "${placement.id}" has invalid bounds`);
|
||||
}
|
||||
if (placement.x + placement.w > grid.columnCount) {
|
||||
throw new Error(`Grid item "${placement.id}" exceeds grid "${grid.id}" columns`);
|
||||
}
|
||||
if (placement.y + placement.h > getMaximumRows(grid)) {
|
||||
throw new Error(`Grid item "${placement.id}" exceeds grid "${grid.id}" rows`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
export {
|
||||
beginGridTransaction,
|
||||
cancelGridTransaction,
|
||||
commitGridTransaction,
|
||||
previewGridMove,
|
||||
previewGridResize,
|
||||
resolvePinnedGridCollisions,
|
||||
} from "./engine";
|
||||
export {
|
||||
getContinuousGridDelta,
|
||||
getContinuousResizePlacement,
|
||||
getPointerProjectedShape,
|
||||
getSnappedGridCoordinates,
|
||||
getSnappedGridDelta,
|
||||
} from "./coordinates";
|
||||
export type { DragProjectionOrigin } from "./coordinates";
|
||||
export { getPreferredNestedExitTargetId } from "./targeting";
|
||||
export type { GridTargetGeometry } from "./targeting";
|
||||
export type {
|
||||
BeginGridTransactionInput,
|
||||
GridMoveGuard,
|
||||
GridMoveGuardContext,
|
||||
GridMoveGuardResult,
|
||||
GridMoveInput,
|
||||
GridMoveOptions,
|
||||
GridOperationRejection,
|
||||
GridPreviewResult,
|
||||
GridResizeDirection,
|
||||
GridResizeInput,
|
||||
GridTransaction,
|
||||
TransactionalGrid,
|
||||
TransactionalGridState,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GridTargetGeometry } from "./targeting";
|
||||
import { getPreferredNestedExitTargetId } from "./targeting";
|
||||
|
||||
const grid = (
|
||||
id: string,
|
||||
rectangle: GridTargetGeometry["rectangle"],
|
||||
parentGridId: string | null = null,
|
||||
): GridTargetGeometry => ({ id, parentGridId, rectangle });
|
||||
|
||||
const alwaysEligible = () => true;
|
||||
|
||||
describe("nested grid exit targeting", () => {
|
||||
const grids = [
|
||||
grid("canvas", { left: 0, top: 0, width: 1200, height: 800 }),
|
||||
grid("container", { left: 200, top: 100, width: 600, height: 500 }, "canvas"),
|
||||
];
|
||||
|
||||
test("keeps the nested grid while most of a large item remains inside", () => {
|
||||
expect(
|
||||
getPreferredNestedExitTargetId({
|
||||
grids,
|
||||
pointerTargetId: "container",
|
||||
sourceGridId: "container",
|
||||
dragShape: { left: 100, top: 150, width: 400, height: 200 },
|
||||
isEligible: alwaysEligible,
|
||||
}),
|
||||
).toBe("container");
|
||||
});
|
||||
|
||||
test("selects the parent before the pointer leaves when most of the item is outside", () => {
|
||||
expect(
|
||||
getPreferredNestedExitTargetId({
|
||||
grids,
|
||||
pointerTargetId: "container",
|
||||
sourceGridId: "container",
|
||||
dragShape: { left: 0, top: 150, width: 400, height: 200 },
|
||||
isEligible: alwaysEligible,
|
||||
}),
|
||||
).toBe("canvas");
|
||||
});
|
||||
|
||||
test("preserves a sibling container selected by the pointer", () => {
|
||||
const withSibling = [...grids, grid("sibling", { left: 820, top: 100, width: 300, height: 500 }, "canvas")];
|
||||
expect(
|
||||
getPreferredNestedExitTargetId({
|
||||
grids: withSibling,
|
||||
pointerTargetId: "sibling",
|
||||
sourceGridId: "container",
|
||||
dragShape: { left: 820, top: 150, width: 300, height: 200 },
|
||||
isEligible: alwaysEligible,
|
||||
}),
|
||||
).toBe("sibling");
|
||||
});
|
||||
|
||||
test("does not escape to an ineligible parent", () => {
|
||||
expect(
|
||||
getPreferredNestedExitTargetId({
|
||||
grids,
|
||||
pointerTargetId: "container",
|
||||
sourceGridId: "container",
|
||||
dragShape: { left: 0, top: 150, width: 400, height: 200 },
|
||||
isEligible: (gridId) => gridId !== "canvas",
|
||||
}),
|
||||
).toBe("container");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
interface RectangleLike {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface GridTargetGeometry {
|
||||
id: string;
|
||||
parentGridId?: string | null;
|
||||
rectangle: RectangleLike;
|
||||
}
|
||||
|
||||
interface PreferredNestedExitTargetInput {
|
||||
grids: readonly GridTargetGeometry[];
|
||||
pointerTargetId: string;
|
||||
sourceGridId: string;
|
||||
dragShape: RectangleLike;
|
||||
isEligible: (gridId: string) => boolean;
|
||||
}
|
||||
|
||||
const NESTED_EXIT_RETAINED_AREA = 0.7;
|
||||
|
||||
/**
|
||||
* Keeps pointer precision for entering containers, but lets a large item leave
|
||||
* once a clear portion of its projected shape is visibly outside.
|
||||
*/
|
||||
export const getPreferredNestedExitTargetId = ({
|
||||
grids,
|
||||
pointerTargetId,
|
||||
sourceGridId,
|
||||
dragShape,
|
||||
isEligible,
|
||||
}: PreferredNestedExitTargetInput) => {
|
||||
const byId = new Map(grids.map((grid) => [grid.id, grid]));
|
||||
const source = byId.get(sourceGridId);
|
||||
let current = byId.get(pointerTargetId);
|
||||
if (!source || !current || !isSameGridOrAncestor(current.id, source, byId)) return pointerTargetId;
|
||||
|
||||
while (current.parentGridId) {
|
||||
if (getCoveredAreaRatio(dragShape, current.rectangle) > NESTED_EXIT_RETAINED_AREA) break;
|
||||
|
||||
const parent = byId.get(current.parentGridId);
|
||||
if (!parent || !isEligible(parent.id) || !containsCenter(parent.rectangle, dragShape)) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return current.id;
|
||||
};
|
||||
|
||||
const isSameGridOrAncestor = (
|
||||
candidateId: string,
|
||||
source: GridTargetGeometry,
|
||||
byId: ReadonlyMap<string, GridTargetGeometry>,
|
||||
) => {
|
||||
let current: GridTargetGeometry | undefined = source;
|
||||
const visited = new Set<string>();
|
||||
while (current) {
|
||||
if (current.id === candidateId) return true;
|
||||
if (!current.parentGridId || visited.has(current.id)) return false;
|
||||
visited.add(current.id);
|
||||
current = byId.get(current.parentGridId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getCoveredAreaRatio = (shape: RectangleLike, target: RectangleLike) => {
|
||||
const shapeArea = shape.width * shape.height;
|
||||
if (!Number.isFinite(shapeArea) || shapeArea <= 0) return 1;
|
||||
|
||||
const width = Math.max(
|
||||
0,
|
||||
Math.min(shape.left + shape.width, target.left + target.width) - Math.max(shape.left, target.left),
|
||||
);
|
||||
const height = Math.max(
|
||||
0,
|
||||
Math.min(shape.top + shape.height, target.top + target.height) - Math.max(shape.top, target.top),
|
||||
);
|
||||
return (width * height) / shapeArea;
|
||||
};
|
||||
|
||||
const containsCenter = (target: RectangleLike, shape: RectangleLike) => {
|
||||
const center = { x: shape.left + shape.width / 2, y: shape.top + shape.height / 2 };
|
||||
return (
|
||||
center.x >= target.left &&
|
||||
center.x <= target.left + target.width &&
|
||||
center.y >= target.top &&
|
||||
center.y <= target.top + target.height
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
|
||||
/** A positioned grid with optional ownership metadata for nested containers. */
|
||||
export interface TransactionalGrid<TPlacement extends GridPlacement = GridPlacement> {
|
||||
id: string;
|
||||
columnCount: number;
|
||||
maxRowCount?: number | null;
|
||||
/** The grid containing the placement that owns this nested grid. */
|
||||
parentGridId?: string | null;
|
||||
/** The container placement whose content is rendered by this grid. */
|
||||
ownerPlacementId?: string | null;
|
||||
placements: readonly TPlacement[];
|
||||
}
|
||||
|
||||
/** The complete layout that must change atomically for cross-grid operations. */
|
||||
export interface TransactionalGridState<TPlacement extends GridPlacement = GridPlacement> {
|
||||
grids: readonly TransactionalGrid<TPlacement>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag or resize transaction. Every preview is recomputed from `snapshot`,
|
||||
* never from the previous preview, so noisy drag-over events cannot accumulate
|
||||
* coordinate drift.
|
||||
*/
|
||||
export interface GridTransaction<TPlacement extends GridPlacement = GridPlacement> {
|
||||
activeId: string;
|
||||
sourceGridId: string;
|
||||
snapshot: TransactionalGridState<TPlacement>;
|
||||
preview: TransactionalGridState<TPlacement>;
|
||||
}
|
||||
|
||||
export interface BeginGridTransactionInput {
|
||||
activeId: string;
|
||||
/** Optional disambiguation and stale-event protection. */
|
||||
sourceGridId?: string;
|
||||
}
|
||||
|
||||
export interface GridMoveInput {
|
||||
targetGridId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type GridResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
||||
|
||||
export interface GridResizeInput {
|
||||
direction: GridResizeDirection;
|
||||
/** Signed displacement of the resize handle in grid columns. */
|
||||
deltaColumns: number;
|
||||
/** Signed displacement of the resize handle in grid rows. */
|
||||
deltaRows: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export interface GridOperationRejection {
|
||||
code:
|
||||
| "descendant-target"
|
||||
| "guard-rejected"
|
||||
| "invalid-coordinate"
|
||||
| "item-too-large"
|
||||
| "resize-constraints"
|
||||
| "row-limit"
|
||||
| "unknown-target-grid"
|
||||
| (string & {});
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface GridMoveGuardContext<TPlacement extends GridPlacement = GridPlacement> {
|
||||
active: TPlacement;
|
||||
sourceGrid: TransactionalGrid<TPlacement>;
|
||||
targetGrid: TransactionalGrid<TPlacement>;
|
||||
snapshot: TransactionalGridState<TPlacement>;
|
||||
/** True when the target is owned by the active container or one of its descendants. */
|
||||
targetIsDescendant: boolean;
|
||||
}
|
||||
|
||||
export type GridMoveGuardResult = boolean | GridOperationRejection;
|
||||
export type GridMoveGuard<TPlacement extends GridPlacement = GridPlacement> = (
|
||||
context: GridMoveGuardContext<TPlacement>,
|
||||
) => GridMoveGuardResult;
|
||||
|
||||
export interface GridMoveOptions<TPlacement extends GridPlacement = GridPlacement> {
|
||||
guards?: readonly GridMoveGuard<TPlacement>[];
|
||||
}
|
||||
|
||||
export type GridPreviewResult<TPlacement extends GridPlacement = GridPlacement> =
|
||||
| {
|
||||
accepted: true;
|
||||
transaction: GridTransaction<TPlacement>;
|
||||
}
|
||||
| {
|
||||
accepted: false;
|
||||
transaction: GridTransaction<TPlacement>;
|
||||
rejection: GridOperationRejection;
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useCurrentLayout } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import {
|
||||
getEditableGridCellAttributes,
|
||||
getLogicalItemStyle,
|
||||
getReadonlyGridItemAttributes,
|
||||
} from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
|
||||
import type { GridTransaction } from "./dnd";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
import classes from "./section-grid.module.css";
|
||||
|
||||
interface FixedGridItemProps {
|
||||
item: ContainerSectionItem | SectionItem;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static SSR geometry in view mode and the stable content/keyboard handle in
|
||||
* edit mode. The focused tile owns grid-based keyboard movement while dnd-kit
|
||||
* handles pointer and touch input on the outer editor shell.
|
||||
*/
|
||||
export const FixedGridItem = ({
|
||||
item,
|
||||
minWidth = 1,
|
||||
minHeight = 1,
|
||||
children,
|
||||
}: PropsWithChildren<FixedGridItemProps>) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const runtimeStatus = useGridEditorRuntimeStatus();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const t = useI18n();
|
||||
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
|
||||
const { commitSectionGrid } = useGridLayoutActions();
|
||||
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
|
||||
const instructionsId = useId();
|
||||
const isEditorActive = isEditMode && runtimeStatus === "ready";
|
||||
const placement = toPlacement(item);
|
||||
const renderedPlacements = [...items, ...innerSections].map(toPlacement);
|
||||
const keyboardGridRef = useRef<{
|
||||
source: readonly SectionGridPlacement[];
|
||||
current: readonly SectionGridPlacement[];
|
||||
}>({
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
});
|
||||
const displayName =
|
||||
item.type === "item"
|
||||
? item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`)
|
||||
: item.options.title.trim() || t("section.container.untitled");
|
||||
const accessibleLabel = t("item.moveResize.entryLabel", {
|
||||
name: displayName,
|
||||
column: String(placement.x + 1),
|
||||
row: String(placement.y + 1),
|
||||
});
|
||||
const semantics = isEditorActive
|
||||
? getEditableGridCellAttributes({ label: accessibleLabel, placement })
|
||||
: getReadonlyGridItemAttributes(placement);
|
||||
|
||||
const setKeyboardEditing = (next: boolean) => {
|
||||
setIsKeyboardEditing(next);
|
||||
announce(
|
||||
`${accessibleLabel}: ${t(next ? "item.moveResize.keyboard.started" : "item.moveResize.keyboard.stopped")}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget || !isEditorActive || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setKeyboardEditing(!isKeyboardEditing);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && isKeyboardEditing) {
|
||||
event.preventDefault();
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
}
|
||||
if (!isKeyboardEditing || !isArrowKey(event.key)) return;
|
||||
|
||||
event.preventDefault();
|
||||
if (document.body.hasAttribute("data-board-grid-interacting")) {
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
}
|
||||
if (!arePlacementListsEqual(keyboardGridRef.current.source, renderedPlacements)) {
|
||||
keyboardGridRef.current = {
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
};
|
||||
}
|
||||
const placements = keyboardGridRef.current.current;
|
||||
const current = placements.find((candidate) => candidate.id === item.id);
|
||||
if (!current) return;
|
||||
|
||||
let transaction: GridTransaction<SectionGridPlacement>;
|
||||
try {
|
||||
transaction = beginGridTransaction(
|
||||
{
|
||||
grids: [
|
||||
{
|
||||
id: section.id,
|
||||
columnCount,
|
||||
maxRowCount,
|
||||
placements,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ activeId: current.id, sourceGridId: section.id },
|
||||
);
|
||||
} catch {
|
||||
keyboardGridRef.current = {
|
||||
source: renderedPlacements,
|
||||
current: renderedPlacements,
|
||||
};
|
||||
setKeyboardEditing(false);
|
||||
return;
|
||||
}
|
||||
const delta = getKeyboardDelta(event.key);
|
||||
const result = event.shiftKey
|
||||
? previewGridResize(transaction, {
|
||||
direction: "se",
|
||||
deltaColumns: delta.x,
|
||||
deltaRows: delta.y,
|
||||
minWidth,
|
||||
minHeight,
|
||||
})
|
||||
: previewGridMove(transaction, {
|
||||
targetGridId: section.id,
|
||||
x: current.x + delta.x,
|
||||
y: current.y + delta.y,
|
||||
});
|
||||
const next = result.transaction.preview.grids[0]?.placements.find((candidate) => candidate.id === item.id);
|
||||
if (!result.accepted || !next || arePlacementsEqual(current, next)) {
|
||||
announce(`${accessibleLabel}: ${t("item.moveResize.keyboard.boundary")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const committed = commitGridTransaction(result.transaction).grids[0];
|
||||
if (!committed) return;
|
||||
keyboardGridRef.current = {
|
||||
...keyboardGridRef.current,
|
||||
current: committed.placements,
|
||||
};
|
||||
commitSectionGrid({ layoutId: currentLayoutId, sectionId: section.id, placements: committed.placements });
|
||||
announce(
|
||||
`${displayName}: ${t("item.moveResize.field.xOffset.label")} ${next.x + 1}, ${t(
|
||||
"item.moveResize.field.yOffset.label",
|
||||
)} ${next.y + 1}, ${t("item.moveResize.field.width.label")} ${next.w}, ${t(
|
||||
"item.moveResize.field.height.label",
|
||||
)} ${next.h}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (isKeyboardEditing && event.target === event.currentTarget) {
|
||||
setKeyboardEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...semantics}
|
||||
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
|
||||
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
|
||||
data-grid-item-id={isEditorActive ? undefined : item.id}
|
||||
data-grid-item-type={isEditorActive ? undefined : item.type}
|
||||
data-editor-grid-entry={isEditorActive ? "true" : undefined}
|
||||
data-type={item.type}
|
||||
data-kind={item.kind}
|
||||
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
|
||||
aria-describedby={isEditorActive ? instructionsId : undefined}
|
||||
aria-keyshortcuts={
|
||||
isEditorActive
|
||||
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
|
||||
: undefined
|
||||
}
|
||||
onBlur={isEditorActive ? handleBlur : undefined}
|
||||
onKeyDown={isEditorActive ? handleKeyboard : undefined}
|
||||
>
|
||||
{children}
|
||||
{isEditorActive && (
|
||||
<span id={instructionsId} className={classes.liveRegion}>
|
||||
{t("item.moveResize.keyboard.instructions")}
|
||||
</span>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const toPlacement = (item: ContainerSectionItem | SectionItem): SectionGridPlacement => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
x: item.xOffset,
|
||||
y: item.yOffset,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
});
|
||||
|
||||
const isArrowKey = (key: string): key is "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" =>
|
||||
key === "ArrowLeft" || key === "ArrowRight" || key === "ArrowUp" || key === "ArrowDown";
|
||||
|
||||
const getKeyboardDelta = (key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown") => ({
|
||||
x: key === "ArrowLeft" ? -1 : key === "ArrowRight" ? 1 : 0,
|
||||
y: key === "ArrowUp" ? -1 : key === "ArrowDown" ? 1 : 0,
|
||||
});
|
||||
|
||||
const arePlacementsEqual = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
|
||||
|
||||
const arePlacementListsEqual = (first: readonly GridPlacement[], second: readonly GridPlacement[]) =>
|
||||
first.length === second.length &&
|
||||
first.every((placement, index) => {
|
||||
const candidate = second[index];
|
||||
return candidate?.id === placement.id && arePlacementsEqual(placement, candidate);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export const getGridDepth = (gridId: string, parentGridById: ReadonlyMap<string, string | null>) => {
|
||||
let depth = 0;
|
||||
let currentGridId = gridId;
|
||||
const visited = new Set([gridId]);
|
||||
|
||||
while (true) {
|
||||
const parentGridId = parentGridById.get(currentGridId);
|
||||
if (!parentGridId || visited.has(parentGridId)) return depth;
|
||||
|
||||
depth += 1;
|
||||
visited.add(parentGridId);
|
||||
currentGridId = parentGridId;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import type * as GridEditorModule from "./grid-editor";
|
||||
|
||||
let editorModulePromise: Promise<typeof GridEditorModule> | undefined;
|
||||
|
||||
export const loadGridEditorAsync = () => {
|
||||
editorModulePromise ??= import("./grid-editor").catch((error: unknown) => {
|
||||
editorModulePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return editorModulePromise;
|
||||
};
|
||||
|
||||
export const scheduleGridEditorWarmup = (loadAsync: () => Promise<unknown>) => {
|
||||
let animationFrameId: number | undefined;
|
||||
let idleCallbackId: number | undefined;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
let hydrationObserver: MutationObserver | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const load = () => {
|
||||
if (!cancelled) void loadAsync().catch(() => undefined);
|
||||
};
|
||||
const scheduleAfterPaint = () => {
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
if ("requestIdleCallback" in window) {
|
||||
idleCallbackId = window.requestIdleCallback(load, { timeout: 2_000 });
|
||||
} else {
|
||||
timeoutId = setTimeout(load, 1_000);
|
||||
}
|
||||
});
|
||||
};
|
||||
const scheduleWhenInteractive = () => {
|
||||
if (document.querySelector('[data-board-hydrated="true"]')) {
|
||||
scheduleAfterPaint();
|
||||
return;
|
||||
}
|
||||
|
||||
hydrationObserver = new MutationObserver(() => {
|
||||
if (!document.querySelector('[data-board-hydrated="true"]')) return;
|
||||
hydrationObserver?.disconnect();
|
||||
hydrationObserver = undefined;
|
||||
scheduleAfterPaint();
|
||||
});
|
||||
hydrationObserver.observe(document.documentElement, {
|
||||
attributeFilter: ["data-board-hydrated"],
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
scheduleWhenInteractive();
|
||||
};
|
||||
|
||||
if (document.visibilityState === "visible") {
|
||||
scheduleWhenInteractive();
|
||||
} else {
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
hydrationObserver?.disconnect();
|
||||
if (animationFrameId !== undefined) window.cancelAnimationFrame(animationFrameId);
|
||||
if (idleCallbackId !== undefined && "cancelIdleCallback" in window) {
|
||||
window.cancelIdleCallback(idleCallbackId);
|
||||
}
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export type GridEditorRuntimeStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
const GridEditorRuntimeContext = createContext<GridEditorRuntimeStatus>("idle");
|
||||
|
||||
export const GridEditorRuntimeProvider = ({
|
||||
children,
|
||||
status,
|
||||
}: PropsWithChildren<{ status: GridEditorRuntimeStatus }>) => (
|
||||
<GridEditorRuntimeContext.Provider value={status}>{children}</GridEditorRuntimeContext.Provider>
|
||||
);
|
||||
|
||||
export const useGridEditorRuntimeStatus = () => useContext(GridEditorRuntimeContext);
|
||||
@@ -0,0 +1,271 @@
|
||||
.board-grid-editor {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
overflow: visible;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.board-grid-entry {
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
transition:
|
||||
left 100ms ease,
|
||||
top 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-dnd-active="true"],
|
||||
.board-grid-entry[data-dnd-drag-source="true"] {
|
||||
z-index: 1000;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-dnd-drag-source="true"] {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay[data-dnd-dragging] {
|
||||
position: fixed !important;
|
||||
z-index: 10000;
|
||||
top: var(--dnd-top, 0) !important;
|
||||
left: var(--dnd-left, 0) !important;
|
||||
width: var(--dnd-width, auto);
|
||||
max-width: var(--dnd-width, auto);
|
||||
height: var(--dnd-height, auto);
|
||||
max-height: var(--dnd-height, auto);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
color: inherit;
|
||||
pointer-events: none !important;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
transform: var(--dnd-transform, none) !important;
|
||||
transition: var(--dnd-transition) !important;
|
||||
will-change: translate;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay[data-dnd-dragging]:not([data-dnd-dropping]) {
|
||||
translate: var(--dnd-translate) !important;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay[data-dnd-dragging][style*="--dnd-scale"] {
|
||||
scale: var(--dnd-scale) !important;
|
||||
transform-origin: var(--dnd-transform-origin) !important;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay-card {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--mantine-spacing-md);
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-text);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
|
||||
border: 2px solid var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
zoom: var(--board-grid-overlay-content-zoom, 1);
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting="drag"] .board-grid-entry,
|
||||
body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.board-grid-content-mount,
|
||||
.board-grid-portal-content,
|
||||
.board-grid-portal-content > [data-editor-grid-entry] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.board-grid-placeholder {
|
||||
z-index: 0;
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
||||
border: 2px dashed var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
left 100ms ease,
|
||||
top 100ms ease,
|
||||
width 100ms ease,
|
||||
height 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-placeholder[data-grid-placeholder-mode="resize"] {
|
||||
z-index: 1001;
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent);
|
||||
}
|
||||
|
||||
.board-grid-resize-outline {
|
||||
z-index: 1002;
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
|
||||
border: 2px solid var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.board-grid-resize-outline[data-grid-resize-valid="false"] {
|
||||
background: color-mix(in srgb, var(--mantine-color-red-filled) 8%, transparent);
|
||||
border-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
.board-grid-editor[data-dnd-drop-target="true"] {
|
||||
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
|
||||
outline-color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: block;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-disabled="true"] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"]
|
||||
> .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle::after {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
width: 12px;
|
||||
height: 3px;
|
||||
content: "";
|
||||
background: color-mix(in srgb, var(--mantine-color-text) 70%, transparent);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry:hover > .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after,
|
||||
.board-grid-resize-handle:not([data-grid-resize-disabled="true"]):focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -8px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
right: 44px;
|
||||
left: 44px;
|
||||
height: 32px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
top: 44px;
|
||||
bottom: 44px;
|
||||
width: 32px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
z-index: 9;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
top: 0;
|
||||
left: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"]::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"]::after {
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.board-grid-entry,
|
||||
.board-grid-placeholder,
|
||||
.board-grid-resize-outline,
|
||||
.board-grid-resize-handle::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 1326 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user