Merge custom option test fix into dnd kit
This commit is contained in:
21 files changed
+246
-179
No files matched your search
@@ -38,7 +38,8 @@ Underscores and hyphen-minus are allowed.
|
||||
|
||||
The column count defines how many fixed-width cells are available in each row.
|
||||
One cell contains a `200 × 200` logical-pixel tile.
|
||||
Homarr scales the whole board uniformly instead of changing widget proportions, with horizontal scrolling when further shrinking would make controls too small.
|
||||
Homarr scales the whole board uniformly instead of changing widget proportions, and always fits it to the available width without horizontal scrolling.
|
||||
The main canvas and enabled rails stay at least one viewport tall, and the canvas grows automatically while you drag or resize items near its lower edge.
|
||||
|
||||
### Public
|
||||
|
||||
@@ -100,7 +101,7 @@ A `2 × 1` tile therefore contains two fixed cell widths plus the gap between th
|
||||
Widgets see their logical size, while the board's visual scale makes the complete layout larger or smaller.
|
||||
When fitting below 100%, Homarr keeps widget typography and control density at their normal readable size.
|
||||
On larger canvases, the complete board grows together like browser zoom.
|
||||
The scale never drops below 75%; on narrower screens the canvas scrolls horizontally instead of shrinking controls below an accessible size.
|
||||
The complete canvas always fits the available width and never adds horizontal board scrolling.
|
||||
|
||||
The position and size of every item are independent in each responsive layout.
|
||||
Moving or resizing an item in one layout does not change any other layout.
|
||||
@@ -108,8 +109,8 @@ When adding a new layout, its initial positions and sizes are generated from the
|
||||
New items use their widget's default grid size, while a new Container starts at a useful `4 × 3` size when the layout has enough columns.
|
||||
Both are placed in the first available position.
|
||||
|
||||
The current board edge is a hard drag boundary, so moving a tile outside the canvas cancels that placement instead of creating empty rows.
|
||||
Adding content can still create the rows required for the new tile.
|
||||
The canvas is at least one window tall, even when it is empty.
|
||||
Its left and right edges are hard drag boundaries, while its bottom edge extends automatically as a tile is dragged or resized downward.
|
||||
Nested Containers keep their own resizable bounds.
|
||||
|
||||
#### Name
|
||||
@@ -133,8 +134,8 @@ For example, a 12-column layout with a two-column left rail leaves ten columns f
|
||||
Enabling or widening a rail reflows canvas items into the remaining columns.
|
||||
Disabling a rail moves its contents back into the next available canvas positions.
|
||||
|
||||
Rails remain visible while the page scrolls.
|
||||
Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode.
|
||||
Rails fill the available window height and remain fixed while the page scrolls.
|
||||
Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary.
|
||||
|
||||
#### Breakpoint
|
||||
|
||||
|
||||
@@ -9,14 +9,48 @@
|
||||
}
|
||||
|
||||
.gutter {
|
||||
position: relative;
|
||||
top: 0;
|
||||
--board-gutter-sticky-top: calc((var(--app-shell-header-offset, 0px) + 1rem) * var(--board-canvas-inverse-scale, 1));
|
||||
--board-gutter-viewport-height: calc(
|
||||
(100dvh - var(--app-shell-header-offset, 0px) - 1rem) * var(--board-canvas-inverse-scale, 1)
|
||||
);
|
||||
|
||||
position: sticky;
|
||||
top: var(--board-gutter-sticky-top);
|
||||
align-self: start;
|
||||
box-sizing: border-box;
|
||||
height: var(--board-gutter-viewport-height);
|
||||
padding-top: 24px;
|
||||
margin-top: -24px;
|
||||
max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md));
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
transform: translateY(var(--board-gutter-sticky-offset, 0px));
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"] {
|
||||
outline: 1px dashed var(--mantine-color-default-border);
|
||||
outline-offset: -1px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.gutter[data-board-editing="true"]::before {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
height: 24px;
|
||||
padding-inline: var(--mantine-spacing-xs);
|
||||
margin-top: -24px;
|
||||
overflow: hidden;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
line-height: 24px;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
content: attr(aria-label);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import {
|
||||
@@ -23,66 +23,12 @@ import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
|
||||
const useFixedBoardGutters = () => {
|
||||
const [columns, setColumns] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!columns) return;
|
||||
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const columnsRect = columns.getBoundingClientRect();
|
||||
const headerBottom = document.querySelector("[data-app-shell-header]")?.getBoundingClientRect().bottom ?? 0;
|
||||
const stickyTop = Math.max(0, headerBottom + 16);
|
||||
|
||||
for (const gutter of columns.querySelectorAll<HTMLElement>("[data-board-gutter]")) {
|
||||
const rect = gutter.getBoundingClientRect();
|
||||
if (gutter.offsetWidth <= 0) continue;
|
||||
|
||||
const effectiveScale = rect.width / gutter.offsetWidth;
|
||||
if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue;
|
||||
|
||||
const previousOffset = Number(gutter.dataset.stickyOffset ?? 0);
|
||||
const uncorrectedTop = rect.top - previousOffset * effectiveScale;
|
||||
const maximumTop = columnsRect.bottom - rect.height;
|
||||
const targetTop = Math.min(stickyTop, maximumTop);
|
||||
const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale;
|
||||
|
||||
gutter.dataset.stickyOffset = String(nextOffset);
|
||||
gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`);
|
||||
}
|
||||
};
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== 0) return;
|
||||
frame = window.requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleUpdate);
|
||||
resizeObserver.observe(columns);
|
||||
document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true });
|
||||
window.addEventListener("scroll", scheduleUpdate, { passive: true });
|
||||
window.addEventListener("resize", scheduleUpdate, { passive: true });
|
||||
scheduleUpdate();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
resizeObserver.disconnect();
|
||||
document.removeEventListener("scroll", scheduleUpdate, { capture: true });
|
||||
window.removeEventListener("scroll", scheduleUpdate);
|
||||
window.removeEventListener("resize", scheduleUpdate);
|
||||
};
|
||||
}, [columns]);
|
||||
|
||||
return setColumns;
|
||||
};
|
||||
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
const [isEditMode] = useEditMode();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const initialViewportWidth = useInitialViewportWidth();
|
||||
const columnsRef = useFixedBoardGutters();
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0);
|
||||
if (!currentLayout) throw new Error("Expected the board to contain a layout");
|
||||
|
||||
@@ -122,16 +68,13 @@ export const ClientBoard = () => {
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div
|
||||
ref={columnsRef}
|
||||
className={classes.columns}
|
||||
style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}
|
||||
>
|
||||
<div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
@@ -157,6 +100,7 @@ export const ClientBoard = () => {
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
|
||||
@@ -5,12 +5,14 @@ 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.
|
||||
- the canvas is zoomed 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.
|
||||
stable. The visual canvas always fits the available width, every root lane
|
||||
keeps at least one viewport of height, and edit-mode previews extend it downward
|
||||
as needed.
|
||||
|
||||
The read-only renderer should:
|
||||
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
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;
|
||||
@@ -21,6 +21,13 @@ export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSPropert
|
||||
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
|
||||
placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0);
|
||||
|
||||
export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale: number) => {
|
||||
if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
|
||||
if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
|
||||
|
||||
return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH));
|
||||
};
|
||||
|
||||
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
columnCount: number,
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] {
|
||||
@@ -18,8 +16,6 @@
|
||||
.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
|
||||
@@ -44,9 +40,3 @@
|
||||
--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;
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,8 @@ 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);
|
||||
@@ -18,7 +15,7 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
|
||||
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);
|
||||
return availableWidth / logicalWidth;
|
||||
};
|
||||
|
||||
export const calculateBoardUiScale = (canvasScale: number) => {
|
||||
@@ -36,9 +33,8 @@ interface ScaledBoardCanvasProps {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* fixed logical pixels. The complete canvas always fits its viewport so board
|
||||
* content can never widen the document or require horizontal scrolling.
|
||||
*/
|
||||
export const ScaledBoardCanvas = ({
|
||||
logicalWidth,
|
||||
@@ -62,6 +58,7 @@ export const ScaledBoardCanvas = ({
|
||||
[logicalWidth, resolvedAvailableWidth],
|
||||
);
|
||||
const uiScale = calculateBoardUiScale(scale);
|
||||
const inverseScale = scale > 0 ? 1 / scale : 1;
|
||||
const visualWidth = logicalWidth * scale;
|
||||
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
|
||||
|
||||
@@ -84,8 +81,9 @@ export const ScaledBoardCanvas = ({
|
||||
className={classes.canvas}
|
||||
w={logicalWidth}
|
||||
style={{
|
||||
"--board-canvas-inverse-scale": inverseScale,
|
||||
"--board-canvas-ui-scale": uiScale,
|
||||
transform: `scale(${scale})`,
|
||||
zoom: scale,
|
||||
}}
|
||||
>
|
||||
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, normalizeGridPlacement } from "../index";
|
||||
import {
|
||||
getGridRowCountForVisualHeight,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
normalizeGridPlacement,
|
||||
} from "../index";
|
||||
|
||||
describe("fixed dashboard geometry", () => {
|
||||
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
|
||||
@@ -24,4 +30,12 @@ describe("fixed dashboard geometry", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
test("derives enough logical rows to fill the visual viewport at any canvas scale", () => {
|
||||
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
|
||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
|
||||
|
||||
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
|
||||
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas";
|
||||
import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas";
|
||||
|
||||
describe("board canvas scale policy", () => {
|
||||
test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => {
|
||||
test("fits the logical canvas exactly", () => {
|
||||
const scale = calculateBoardCanvasScale(840, 1000);
|
||||
|
||||
expect(scale).toBe(0.84);
|
||||
@@ -14,16 +14,16 @@ describe("board canvas scale policy", () => {
|
||||
expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8);
|
||||
});
|
||||
|
||||
test("stops counter-scaling at 0.75 and lets the canvas overflow", () => {
|
||||
test("keeps fitting narrow viewports without horizontal overflow", () => {
|
||||
const availableWidth = 600;
|
||||
const logicalWidth = 1000;
|
||||
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
|
||||
|
||||
expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(logicalWidth * scale).toBeGreaterThan(availableWidth);
|
||||
expect(scale).toBe(0.6);
|
||||
expect(logicalWidth * scale).toBe(availableWidth);
|
||||
});
|
||||
|
||||
test("keeps content materially magnified at 200% effective zoom", () => {
|
||||
test("keeps the board fitted at 200% effective zoom", () => {
|
||||
const logicalWidth = 1696;
|
||||
const normalAvailableWidth = 1872;
|
||||
const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth);
|
||||
@@ -31,8 +31,8 @@ describe("board canvas scale policy", () => {
|
||||
const normalPaintedCellSize = 200 * normalScale;
|
||||
const zoomedPaintedCellSize = 200 * zoomedScale * 2;
|
||||
|
||||
expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25);
|
||||
expect(zoomedScale).toBeCloseTo(normalScale / 2);
|
||||
expect(zoomedPaintedCellSize).toBeCloseTo(normalPaintedCellSize);
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
||||
@@ -18,7 +18,7 @@ export const BoardEmptySection = ({ section, columnCount, requestedRowCount, rai
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
|
||||
if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null;
|
||||
if (totalLength === 0 && !isEditMode && requestedRowCount === 0 && railPlacement !== "main") return null;
|
||||
|
||||
return (
|
||||
<SectionGrid
|
||||
|
||||
@@ -86,6 +86,10 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="true"] > .board-grid-content-mount {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.board-grid-placeholder {
|
||||
z-index: 0;
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
|
||||
@@ -178,7 +182,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
right: 44px;
|
||||
left: 44px;
|
||||
height: 32px;
|
||||
height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
@@ -194,7 +198,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
top: 44px;
|
||||
bottom: 44px;
|
||||
width: 32px;
|
||||
width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
@@ -217,8 +221,8 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
|
||||
.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;
|
||||
width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CollisionPriority } from "@dnd-kit/abstract";
|
||||
import { pointerIntersection } from "@dnd-kit/collision";
|
||||
import type { DragDropManager, Draggable as DomDraggable } from "@dnd-kit/dom";
|
||||
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
|
||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers";
|
||||
import { getEventCoordinates, getFrameTransform, isPointerEvent } from "@dnd-kit/dom/utilities";
|
||||
import type {
|
||||
BeforeDragStartEvent,
|
||||
@@ -720,6 +721,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
|
||||
if (!previewGrid || !interaction) return placements;
|
||||
|
||||
if (interaction.sourceGridId === sectionId) {
|
||||
if (interaction.mode === "resize") return previewGrid.placements;
|
||||
const original = controlledById.get(interaction.activeId);
|
||||
if (!original) return previewGrid.placements;
|
||||
return previewGrid.placements.some((placement) => placement.id === interaction.activeId)
|
||||
@@ -734,9 +736,16 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
|
||||
);
|
||||
const targetPlacement =
|
||||
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
|
||||
const growsDuringDrag =
|
||||
interaction?.mode === "drag" &&
|
||||
(interaction.targetGridId === sectionId ||
|
||||
(interaction.targetGridId === null && interaction.sourceGridId === sectionId));
|
||||
const participatesInInteraction =
|
||||
interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId);
|
||||
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements);
|
||||
const renderedRowCount =
|
||||
section.kind !== "container" && interaction?.mode === "resize"
|
||||
? Math.max(rowCount, getLayoutRowCount(renderPlacements))
|
||||
section.kind !== "container" && participatesInInteraction
|
||||
? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0)
|
||||
: rowCount;
|
||||
|
||||
const { ref: droppableRef, isDropTarget } = useDroppable<GridTargetData>({
|
||||
@@ -876,6 +885,14 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
|
||||
],
|
||||
[],
|
||||
);
|
||||
const modifiers = useMemo(
|
||||
() => [
|
||||
RestrictToElement.configure({
|
||||
element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null,
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
const {
|
||||
ref: draggableRef,
|
||||
handleRef,
|
||||
@@ -886,6 +903,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
|
||||
type: GRID_ENTRY_TYPE,
|
||||
data: sourceData,
|
||||
sensors,
|
||||
modifiers,
|
||||
});
|
||||
const setShellRef = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, LoadingOverlay } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
@@ -13,12 +13,14 @@ import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types
|
||||
import {
|
||||
getCollapsedDisplayLayout,
|
||||
getEditableCanvasAttributes,
|
||||
getGridRowCountForVisualHeight,
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
getReadonlyCanvasAttributes,
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||
import { loadGridEditorAsync } from "./grid-editor-loader";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
@@ -81,6 +83,7 @@ export const SectionGrid = ({
|
||||
className,
|
||||
}: SectionGridProps) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const canvasScale = useBoardCanvasScale();
|
||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
@@ -144,8 +147,9 @@ export const SectionGrid = ({
|
||||
);
|
||||
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
|
||||
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
|
||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||
const rowCount = Math.max(contentRowCount, requestedRowCount);
|
||||
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
||||
const logicalWidth = getLogicalTrackSize(columnCount);
|
||||
const logicalHeight = getLogicalTrackSize(rowCount);
|
||||
const canvasAttributes = isEditMode
|
||||
@@ -199,6 +203,24 @@ export const SectionGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
||||
const [visualHeight, setVisualHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight);
|
||||
update();
|
||||
window.addEventListener("resize", update, { passive: true });
|
||||
window.visualViewport?.addEventListener("resize", update, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
window.visualViewport?.removeEventListener("resize", update);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
|
||||
};
|
||||
|
||||
const getContainerMinimumSize = (
|
||||
board: ReturnType<typeof useRequiredBoard>,
|
||||
layoutId: string,
|
||||
|
||||
@@ -27,6 +27,7 @@ describe("board grid import boundary", () => {
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/abstract" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/collision" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/modifiers" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/utilities" },
|
||||
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/react" },
|
||||
]);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
||||
import { Button, Modal } from "@mantine/core";
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
|
||||
import { modalSizeSelect } from "@homarr/modals";
|
||||
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
|
||||
@@ -14,7 +16,7 @@ export function WorkshopCssImportButton({ onImport }: { onImport(css: string): v
|
||||
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={() => setOpened(true)}>
|
||||
Import from Workshop
|
||||
</Button>
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} title="Import Custom CSS" size="xl">
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} title="Import Custom CSS" size={modalSizeSelect}>
|
||||
<WorkshopBrowser
|
||||
type="customCss"
|
||||
onUseCss={(css) => {
|
||||
|
||||
+72
-52
@@ -6,11 +6,7 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { stringify as stringifySuperJSON } from "superjson";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import {
|
||||
LOGICAL_GRID_CELL_SIZE,
|
||||
LOGICAL_GRID_GAP,
|
||||
MIN_ACCESSIBLE_CANVAS_SCALE,
|
||||
} from "../apps/nextjs/src/components/board/layout/constants";
|
||||
import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP } from "../apps/nextjs/src/components/board/layout/constants";
|
||||
import * as sqliteSchema from "../packages/db/schema/sqlite";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
@@ -26,7 +22,6 @@ const gridRuntimeMarker = "homarr-dnd-kit-v1";
|
||||
const dndKitPayloadMarker = "dnd-kit-description";
|
||||
const logicalCellSize = LOGICAL_GRID_CELL_SIZE;
|
||||
const logicalCellPitch = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||
const minimumCanvasScale = MIN_ACCESSIBLE_CANVAS_SCALE;
|
||||
|
||||
describe("Board grid", () => {
|
||||
test("supports fixed, nested, rail, pointer, keyboard and reconciliation workflows", async () => {
|
||||
@@ -100,6 +95,11 @@ describe("Board grid", () => {
|
||||
await expect(rail).toBeVisible();
|
||||
await expect(rail).toHaveAttribute("data-board-gutter", "left");
|
||||
await expect(railRootSection).toHaveAttribute("data-rail-placement", "left");
|
||||
await expect(rail).toHaveCSS("overflow-x", "hidden");
|
||||
const initialRailBox = await expectBoundingBoxAsync(rail);
|
||||
const initialRailRootBox = await expectBoundingBoxAsync(railRootSection);
|
||||
expect(initialRailRootBox.width).toBeCloseTo(initialRailBox.width, 1);
|
||||
expect(initialRailRootBox.height).toBeGreaterThanOrEqual(initialRailBox.height - 1);
|
||||
await expect(railItem).toHaveAttribute("data-grid-x", "0");
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
|
||||
await expect(containerSection).toHaveAttribute("data-grid-h", "2");
|
||||
@@ -174,21 +174,15 @@ describe("Board grid", () => {
|
||||
expect(await readLayoutShiftScoreAsync(page)).toBeLessThan(0.01);
|
||||
|
||||
// CSS zoom exercises Chromium's browser-zoom layout path without relying
|
||||
// on desktop browser chrome. At 200% the scale floor must preserve
|
||||
// magnification and expose horizontal scrolling instead of shrinking the
|
||||
// board by the inverse zoom factor.
|
||||
// on desktop browser chrome. The board must keep fitting without adding
|
||||
// horizontal scrolling at any effective zoom.
|
||||
await setDocumentZoomAsync(page, 2);
|
||||
await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale));
|
||||
await expect(canvas).toHaveAttribute("data-canvas-overflow", "true");
|
||||
const zoomedCanvasScale = await readCanvasScaleAsync(canvas);
|
||||
expect(zoomedCanvasScale).toBeLessThan(normalCanvasScale);
|
||||
await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
|
||||
await expectFixedLogicalTileAsync(logicalTile);
|
||||
await expectUniformVisualScaleAsync(logicalTile);
|
||||
const fittedClockFontSize = await firstItem.locator(".clock-time-text").evaluate((element) => {
|
||||
return Number.parseFloat(getComputedStyle(element).fontSize);
|
||||
});
|
||||
expect(fittedClockFontSize * minimumCanvasScale).toBeGreaterThanOrEqual(25);
|
||||
const zoomedTileBox = await expectBoundingBoxAsync(logicalTile);
|
||||
expect(zoomedTileBox.width).toBeGreaterThan(normalZoomTileBox.width * 1.25);
|
||||
await expectHorizontalOverflowAsync(canvas);
|
||||
await expect(canvas).toHaveCSS("overflow-x", "clip");
|
||||
await expectDocumentNotHorizontallyScrollableAsync(page);
|
||||
const overflowRailY = (await expectBoundingBoxAsync(rail)).y;
|
||||
await page.evaluate(() => window.scrollTo({ top: 300, behavior: "auto" }));
|
||||
@@ -251,6 +245,14 @@ describe("Board grid", () => {
|
||||
await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3);
|
||||
await expect(page.getByTestId("board-grid-editor-loading")).toHaveCount(0);
|
||||
await expect(page.getByTestId("board-canvas-row-count-button")).toHaveCount(0);
|
||||
await expect(rail).toHaveAttribute("data-board-editing", "true");
|
||||
await expect(rail).toHaveCSS("position", "sticky");
|
||||
await expect(rail).toHaveCSS("transform", "none");
|
||||
const railBox = await expectBoundingBoxAsync(rail);
|
||||
expect(railBox.height + railBox.y).toBeCloseTo(page.viewportSize()?.height ?? 0, 0);
|
||||
expect(await rail.evaluate((element) => getComputedStyle(element, "::before").content.replaceAll('"', ""))).toBe(
|
||||
"Left dashboard rail",
|
||||
);
|
||||
await expect(logicalTile).toHaveCSS("overflow-x", "hidden");
|
||||
await expect(logicalTile).toHaveCSS("overflow-y", "auto");
|
||||
await logicalTile.evaluate((element) => {
|
||||
@@ -337,8 +339,11 @@ describe("Board grid", () => {
|
||||
await expect(itemSelectDialog).toHaveCount(0);
|
||||
|
||||
const mainGrid = page.locator(`[data-grid-section-id="${fixture.sectionId}"]`);
|
||||
const editorViewport = page.viewportSize();
|
||||
if (!editorViewport) throw new Error("Expected a browser viewport");
|
||||
expect((await expectBoundingBoxAsync(mainGrid)).height).toBeGreaterThanOrEqual(editorViewport.height - 1);
|
||||
const interactionSnapshot = await readEditorGridSnapshotAsync(mainGrid);
|
||||
await expectGridBoundaryRejectsOverflowAsync(page, mainGrid, firstItem);
|
||||
await expectGridGrowsAtBoundaryAsync(page, canvas, mainGrid, firstItem);
|
||||
await firstItem.locator("[data-grid-item-content]").evaluate((element) => {
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.dataset.e2eDragIframe = "true";
|
||||
@@ -367,8 +372,8 @@ describe("Board grid", () => {
|
||||
.evaluateAll((elements) => elements.forEach((element) => element.remove()));
|
||||
|
||||
await page.setViewportSize({ width: 600, height: 1200 });
|
||||
await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale));
|
||||
await expectHorizontalOverflowAsync(canvas);
|
||||
await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
|
||||
await expect(canvas).toHaveCSS("overflow-x", "clip");
|
||||
await expectDocumentNotHorizontallyScrollableAsync(page);
|
||||
for (const direction of ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const) {
|
||||
await expectTargetSizeAsync(firstItem.locator(`:scope > [data-grid-resize-handle="${direction}"]`));
|
||||
@@ -718,13 +723,12 @@ describe("Board grid", () => {
|
||||
expect(continuousResizeWidth).toBeGreaterThan(logicalCellSize * canvasScale);
|
||||
|
||||
await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 6 });
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "1");
|
||||
await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "1");
|
||||
expect((await expectBoundingBoxAsync(resizeOutline)).width).toBeCloseTo(logicalCellSize * canvasScale, 1);
|
||||
const liveNestedGridBox = await expectBoundingBoxAsync(nestedEditor);
|
||||
const liveContainerBox = await expectBoundingBoxAsync(containerSection);
|
||||
expect(liveNestedGridBox.width).toBeCloseTo(originalResizeWidth, 1);
|
||||
expect(liveNestedGridBox.width).toBeLessThanOrEqual(liveContainerBox.width + 1);
|
||||
expect(liveContainerBox.width).toBeCloseTo(logicalCellSize * canvasScale, 1);
|
||||
await expect(containerSection.locator(":scope > .board-grid-content-mount")).toHaveCSS("overflow", "clip");
|
||||
await page.mouse.up();
|
||||
await expect(containerSection).toHaveAttribute("data-grid-w", "1");
|
||||
await expect(resizeOutline).toHaveCount(0);
|
||||
@@ -759,15 +763,26 @@ describe("Board grid", () => {
|
||||
await expect(containerSection).toHaveAttribute("data-grid-h", "2");
|
||||
|
||||
const southResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="s"]');
|
||||
await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale);
|
||||
const gridHeightBeforeSouthResize = (await expectBoundingBoxAsync(mainGrid)).height;
|
||||
const southResizeBox = await expectBoundingBoxAsync(southResizeHandle);
|
||||
const southResizeStart = {
|
||||
x: southResizeBox.x + southResizeBox.width / 2,
|
||||
y: southResizeBox.y + southResizeBox.height / 2,
|
||||
};
|
||||
await page.mouse.move(southResizeStart.x, southResizeStart.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(southResizeStart.x, southResizeStart.y + logicalCellPitch * canvasScale, { steps: 8 });
|
||||
await expect(containerSection).toHaveAttribute("data-grid-h", "3");
|
||||
await expect(belowItem).toHaveAttribute("data-grid-y", "3");
|
||||
await expect
|
||||
.poll(async () => (await expectBoundingBoxAsync(mainGrid)).height)
|
||||
.toBeGreaterThanOrEqual(gridHeightBeforeSouthResize);
|
||||
await page.mouse.up();
|
||||
expect(
|
||||
gridLayoutsOverlap(await readGridLayoutAsync(containerSection), await readGridLayoutAsync(belowItem)),
|
||||
).toBe(false);
|
||||
|
||||
// Auto-scroll needs real document overflow. Grow the canvas through an
|
||||
// explicit resize instead of relying on edit mode to add empty rows.
|
||||
// Grow the canvas further to exercise sustained document auto-scroll.
|
||||
await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale * 6);
|
||||
await expect(containerSection).toHaveAttribute("data-grid-h", "9");
|
||||
await expect(belowItem).toHaveAttribute("data-grid-y", "9");
|
||||
@@ -843,14 +858,10 @@ describe("Board grid", () => {
|
||||
false,
|
||||
);
|
||||
|
||||
const stickyRailY = (await expectBoundingBoxAsync(rail)).y;
|
||||
await page.evaluate(() => window.scrollTo({ top: 500, behavior: "auto" }));
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const y = (await expectBoundingBoxAsync(rail)).y;
|
||||
return y >= 0 && y <= stickyRailY + 0.5;
|
||||
})
|
||||
.toBe(true);
|
||||
const stickyRailY = (await expectBoundingBoxAsync(rail)).y;
|
||||
await page.evaluate(() => window.scrollTo({ top: 600, behavior: "auto" }));
|
||||
await expect.poll(async () => (await expectBoundingBoxAsync(rail)).y).toBeCloseTo(stickyRailY, 0);
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" }));
|
||||
|
||||
await setDocumentZoomAsync(page, 1.25);
|
||||
@@ -979,6 +990,15 @@ describe("Board grid", () => {
|
||||
const emptyRightGutter = page.getByRole("complementary", { name: "Right dashboard rail" });
|
||||
await expect(emptyRightGutter).toHaveCount(1);
|
||||
expect((await expectBoundingBoxAsync(emptyRightGutter)).width).toBeGreaterThan(0);
|
||||
await page.getByTestId("board-edit-mode-toggle").click();
|
||||
await expect(emptyRightGutter).toHaveAttribute("data-board-editing", "true");
|
||||
expect(
|
||||
await emptyRightGutter.evaluate((element) => ({
|
||||
content: getComputedStyle(element, "::before").content.replaceAll('"', ""),
|
||||
insetInlineStart: getComputedStyle(element, "::before").insetInlineStart,
|
||||
top: getComputedStyle(element, "::before").top,
|
||||
})),
|
||||
).toEqual({ content: "Right dashboard rail", insetInlineStart: "0px", top: "0px" });
|
||||
expect(pageErrors).toEqual([]);
|
||||
} finally {
|
||||
runtimeResources?.stop();
|
||||
@@ -1713,9 +1733,10 @@ const expectImmediateOutsideReleaseRestoresSnapshotAsync = async (page: Page, gr
|
||||
await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot);
|
||||
};
|
||||
|
||||
const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator, locator: Locator) => {
|
||||
const expectGridGrowsAtBoundaryAsync = async (page: Page, canvas: Locator, grid: Locator, locator: Locator) => {
|
||||
const snapshot = await readEditorGridSnapshotAsync(grid);
|
||||
const gridBox = await expectBoundingBoxAsync(grid);
|
||||
const canvasBox = await expectBoundingBoxAsync(canvas);
|
||||
const itemBox = await expectBoundingBoxAsync(locator);
|
||||
const start = { x: itemBox.x + itemBox.width * 0.63, y: itemBox.y + itemBox.height * 0.42 };
|
||||
|
||||
@@ -1723,14 +1744,25 @@ const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator,
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(start.x + 8, start.y);
|
||||
await expect(locator).toHaveAttribute("data-dnd-drag-source", "true");
|
||||
await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch, { steps: 12 });
|
||||
await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(0);
|
||||
const activeGridBox = await expectBoundingBoxAsync(grid);
|
||||
expect(activeGridBox.height).toBeCloseTo(gridBox.height, 1);
|
||||
await expect.poll(async () => (await expectBoundingBoxAsync(grid)).height).toBeGreaterThan(gridBox.height);
|
||||
|
||||
await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch / 2, {
|
||||
steps: 12,
|
||||
});
|
||||
await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(1);
|
||||
const activeGridBox = await expectBoundingBoxAsync(grid);
|
||||
expect(activeGridBox.height).toBeGreaterThan(gridBox.height);
|
||||
|
||||
await page.mouse.move(canvasBox.x + canvasBox.width + 1000, start.y, { steps: 12 });
|
||||
const overlayBox = await expectBoundingBoxAsync(page.locator(".board-grid-drag-overlay[data-dnd-dragging]"));
|
||||
expect(overlayBox.x + overlayBox.width).toBeLessThanOrEqual(canvasBox.x + canvasBox.width + 1);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.mouse.up();
|
||||
await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting");
|
||||
await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot);
|
||||
await locator.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" }));
|
||||
await expectGridEntryGeometrySettledAsync(locator);
|
||||
};
|
||||
|
||||
const expectInvalidNestedDropRestoresSnapshotAsync = async (
|
||||
@@ -2036,18 +2068,6 @@ const expectUniformVisualScaleAsync = async (locator: Locator) => {
|
||||
expect(scale.horizontal).toBeCloseTo(scale.vertical, 3);
|
||||
};
|
||||
|
||||
const expectHorizontalOverflowAsync = async (locator: Locator) => {
|
||||
const dimensions = await locator.evaluate((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
return {
|
||||
clientWidth: htmlElement.clientWidth,
|
||||
scrollWidth: htmlElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(dimensions.scrollWidth).toBeGreaterThan(dimensions.clientWidth);
|
||||
};
|
||||
|
||||
const expectDocumentNotHorizontallyScrollableAsync = async (page: Page) => {
|
||||
await expect
|
||||
.poll(
|
||||
|
||||
@@ -71,17 +71,16 @@ describe("lean Custom Widget options", () => {
|
||||
});
|
||||
|
||||
it("replaces stale values with current defaults", () => {
|
||||
const options = customWidgetDefinitionSchema.parse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Updated options",
|
||||
sources: {},
|
||||
requests: {},
|
||||
options: {
|
||||
limit: { label: "Limit", control: "slider", default: 5, min: 1, max: 10 },
|
||||
label: { label: "Label", control: "text", default: "Default" },
|
||||
},
|
||||
template: "<Text>{options.limit}</Text>",
|
||||
}).options;
|
||||
const options = {
|
||||
limit: customWidgetOptionSchema.parse({
|
||||
label: "Limit",
|
||||
control: "slider",
|
||||
default: 5,
|
||||
min: 1,
|
||||
max: 10,
|
||||
}),
|
||||
label: customWidgetOptionSchema.parse({ label: "Label", control: "text", default: "Default" }),
|
||||
};
|
||||
|
||||
const normalized = normalizeCustomWidgetOptions(options, {
|
||||
limit: "5",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { integrationDefs } from "../integration";
|
||||
|
||||
describe("Icon url's of integrations should be valid and return 200", () => {
|
||||
objectEntries(integrationDefs).forEach(([integration, { iconUrl }]) => {
|
||||
it.concurrent(`should return 200 for ${integration}`, async () => {
|
||||
it(`should return 200 for ${integration}`, { concurrent: true, retry: 2, timeout: 15_000 }, async () => {
|
||||
const res = await fetch(iconUrl);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -44,8 +44,16 @@ describe("section behavior validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["category", "dynamic"])("rejects the legacy %s kind", (kind) => {
|
||||
expect(() => sectionSchema.parse({ id: "legacy", kind, layouts: [] })).toThrow();
|
||||
it("normalizes the legacy dynamic kind to a container", () => {
|
||||
expect(sectionSchema.parse({ id: "legacy", kind: "dynamic", layouts: [] })).toMatchObject({
|
||||
id: "legacy",
|
||||
kind: "container",
|
||||
layouts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects the legacy category kind", () => {
|
||||
expect(() => sectionSchema.parse({ id: "legacy", kind: "category", layouts: [] })).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -93,4 +93,8 @@ const containerSectionSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema]);
|
||||
const legacyDynamicSectionSchema = containerSectionSchema
|
||||
.extend({ kind: z.literal("dynamic") })
|
||||
.transform((section) => ({ ...section, kind: "container" as const }));
|
||||
|
||||
export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema, legacyDynamicSectionSchema]);
|
||||
Reference in new issue
Block a user