Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37289a2247 | ||
|
|
7b672e180e | ||
|
|
854b59ede4 | ||
|
|
eb918b805a | ||
|
|
4a4b04d471 | ||
|
|
3943741838 | ||
|
|
e425e060d9 | ||
|
|
6754e53f56 | ||
|
|
357adffb30 | ||
|
|
d24eca12ef | ||
|
|
dfcd1dbb64 | ||
|
|
252ce998ae | ||
|
|
a940a8223d | ||
|
|
bbd1abd4f0 | ||
|
|
7adc18b1ce | ||
|
|
6a5162d615 | ||
|
|
02fe99d5aa | ||
|
|
7bbca3bd40 | ||
|
|
923a528038 | ||
|
|
bb599e7fae | ||
|
|
bb5df3a2e6 | ||
|
|
c805d5c631 | ||
|
|
747234c040 | ||
|
|
b828f59568 | ||
|
|
60ea03e72e | ||
|
|
e9853f1fb1 | ||
|
|
f8b364dce2 | ||
|
|
cd8773681f | ||
|
|
2f99f1f34b | ||
|
|
6fc851f054 | ||
|
|
7bedf4a13d | ||
|
|
a9c8b65c14 | ||
|
|
e336059079 | ||
|
|
708105ab3f | ||
|
|
e6bd1adfc2 | ||
|
|
b217d8e026 | ||
|
|
35b7c4fc2d | ||
|
|
251a4c8ae8 | ||
|
|
b4404113c4 | ||
|
|
c1efaabd36 | ||
|
|
b75483982f | ||
|
|
c1da19c9b2 | ||
|
|
46898cd0ea | ||
|
|
6f4cd7522f | ||
|
|
eaaeaaccdb | ||
|
|
d63653142d |
No files matched your search
@@ -82,6 +82,8 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
|
||||
- Set desktop and mobile home boards for the current user
|
||||
- Configure global board defaults such as public home boards and status behavior
|
||||
|
||||
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
|
||||
|
||||
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
|
||||
|
||||
## Partial integration failures
|
||||
|
||||
@@ -90,11 +90,12 @@ Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
### Layout
|
||||
|
||||
In this section you can configure the different responsive layouts.
|
||||
Homarr selects a layout from the screen width, then scales its complete fixed-size canvas uniformly.
|
||||
In this section you can configure the different responsive layouts. Homarr selects the layout with the highest breakpoint that is not wider than the current screen, then scales its complete fixed-size canvas uniformly.
|
||||
|
||||

|
||||
|
||||
Every board has two protected layouts:
|
||||
|
||||
Every `1 × 1` tile has a `200 × 200` logical-pixel content area.
|
||||
The `24` logical-pixel gap between tiles is separate from that size.
|
||||
A `2 × 1` tile therefore contains two fixed cell widths plus the gap between them.
|
||||
@@ -103,28 +104,31 @@ When fitting below 100%, Homarr keeps widget typography and control density at t
|
||||
On larger canvases, the complete board grows together like browser zoom.
|
||||
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.
|
||||
When adding a new layout, its initial positions and sizes are generated from the column count.
|
||||
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.
|
||||
- **Mobile** starts at `0px` and uses three columns on new boards.
|
||||
- **Base** is the widest layout and starts at `768px` on new boards.
|
||||
|
||||
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between their breakpoints.
|
||||
|
||||
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in Base or any custom layout. New custom layouts are generated from Base and scaled to their selected column count.
|
||||
|
||||
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
|
||||
|
||||
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.
|
||||
New items use their widget's default size and new Containers use a useful `4 × 3` size when space allows.
|
||||
|
||||
#### Name
|
||||
|
||||
The name of the layout can be chosen freely. It is only used for management and will not be visible on the board.
|
||||
The name of the layout can be chosen freely. It is only used for management and is not displayed on the board.
|
||||
|
||||
#### Column count
|
||||
|
||||
Total number of columns for this layout.
|
||||
When changed, Homarr moves items into the next available positions without overlap.
|
||||
The total number of columns in the layout. Changing it projects the saved positions and sizes into the new grid width without overlap.
|
||||
|
||||
#### Fixed rails
|
||||
|
||||
Each responsive layout can enable a fixed left rail, a fixed right rail, or both.
|
||||
Base and custom responsive layouts can enable a fixed left rail, a fixed right rail, or both. Mobile has no fixed rails.
|
||||
Set a rail width to zero to disable it for that layout.
|
||||
Choose a width from one to three columns with the three-step slider.
|
||||
The preview shows how much space remains for the main canvas.
|
||||
@@ -139,8 +143,7 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
|
||||
|
||||
#### Breakpoint
|
||||
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minimum of the next layout is reached, the layout will change.
|
||||
The minimum screen width where the layout is used. Mobile is fixed at `0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint.
|
||||
|
||||
#### View and edit modes
|
||||
|
||||
@@ -165,6 +168,10 @@ The **Move / resize item** action in the edit menu remains available for precise
|
||||
|
||||
#### Preview
|
||||
|
||||
Layout Settings shows a lightweight structural preview for every layout, including saved spans, Containers, widget labels, and app icons. The canvas width represents the screen range where that layout is active.
|
||||
|
||||
Choose **Edit layout** to open the real board editor at that representative width on desktop. Save returns to Layout Settings. Cancel discards the local changes. On phones and tablets, the normal board editor continues to use the layout selected by the device width.
|
||||
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Box } from "@mantine/core";
|
||||
import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getRepresentativeLayoutWidth } from "../_layout-utils";
|
||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
@@ -27,9 +28,11 @@ const APP_SHELL_INLINE_PADDING = 32;
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.landmark");
|
||||
const tPreview = useScopedI18n("board.setting.section.layout.preview");
|
||||
const [isEditMode] = useEditMode();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const initialViewportWidth = useInitialViewportWidth();
|
||||
const layoutOverrideId = useLayoutOverride();
|
||||
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");
|
||||
|
||||
@@ -47,7 +50,8 @@ export const ClientBoard = () => {
|
||||
const logicalWidth =
|
||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId) + LOGICAL_GRID_GAP;
|
||||
const initialAvailableWidth = Math.max(1, initialViewportWidth - APP_SHELL_INLINE_PADDING);
|
||||
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
||||
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
||||
const gridTemplateColumns = [
|
||||
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalTrackSize(mainColumnCount)}px`,
|
||||
@@ -56,7 +60,7 @@ export const ClientBoard = () => {
|
||||
.filter((value) => value !== null)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
@@ -121,4 +125,24 @@ export const ClientBoard = () => {
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md" mih="100%">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })}
|
||||
</Text>
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
w={`min(${representativeWidth}px, calc(100vw - 2rem))`}
|
||||
mih="calc(100dvh - 8rem)"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{content}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -2,10 +2,18 @@
|
||||
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { IconLayoutBoard, IconPencil, IconPencilOff, IconReplace, IconSettings } from "@tabler/icons-react";
|
||||
import {
|
||||
IconDeviceFloppy,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconReplace,
|
||||
IconSettings,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -76,11 +84,19 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const commonT = useI18n();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
const returnToLayoutSettings =
|
||||
isEditMode &&
|
||||
searchParams.get("edit") === "true" &&
|
||||
searchParams.get("layout") !== null &&
|
||||
searchParams.get("returnTo") === "settings";
|
||||
const latestBoardRef = useRef(board);
|
||||
|
||||
latestBoardRef.current = board;
|
||||
|
||||
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
@@ -91,6 +107,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
void utils.widget.customApi.getData.invalidate();
|
||||
void revalidatePathActionAsync(`/boards/${board.name}`);
|
||||
close();
|
||||
if (returnToLayoutSettings) router.push(`/boards/${board.name}/settings?tab=layout`);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
@@ -155,9 +172,45 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
}
|
||||
}, [demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
|
||||
|
||||
const cancelLayoutEdit = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: commonT("board.setting.section.layout.edit.cancel.title"),
|
||||
children: commonT("board.setting.section.layout.edit.cancel.message"),
|
||||
confirmProps: { children: commonT("common.action.discard"), color: "red" },
|
||||
onConfirm() {
|
||||
void (async () => {
|
||||
await utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
close();
|
||||
router.push(`/boards/${board.name}/settings?tab=layout`);
|
||||
})();
|
||||
},
|
||||
});
|
||||
}, [board.name, close, commonT, openConfirmModal, router, utils.board.getBoardByName]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
if (returnToLayoutSettings) {
|
||||
return (
|
||||
<>
|
||||
<HeaderButton
|
||||
onClick={() => saveBoard(board)}
|
||||
loading={isPending}
|
||||
aria-label={commonT("board.setting.section.layout.edit.save")}
|
||||
>
|
||||
<IconDeviceFloppy stroke={1.5} />
|
||||
</HeaderButton>
|
||||
<HeaderButton
|
||||
onClick={cancelLayoutEdit}
|
||||
disabled={isPending}
|
||||
aria-label={commonT("board.setting.section.layout.edit.cancel.action")}
|
||||
>
|
||||
<IconX stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TourTarget id="board-edit-mode">
|
||||
<HeaderButton
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
.canvas {
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 82%, var(--mantine-primary-color-light));
|
||||
padding: var(--mantine-spacing-xs);
|
||||
min-height: 7rem;
|
||||
max-height: min(18rem, 38dvh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.section {
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: color-mix(in srgb, var(--mantine-color-default) 78%, transparent);
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
grid-auto-rows: clamp(1.35rem, 2.2vw, 1.75rem);
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.tile {
|
||||
container-type: inline-size;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--mantine-primary-color-filled) 24%, var(--mantine-color-default-border));
|
||||
border-radius: calc(var(--mantine-radius-sm) * 0.75);
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-light) 70%, var(--mantine-color-body));
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.tileContent {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tileLabel {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
max-height: 2.1em;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
@container (min-width: 9rem) {
|
||||
.tileContent {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.tileLabel {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.containerTile {
|
||||
border-style: dashed;
|
||||
background: color-mix(in srgb, var(--mantine-color-default) 88%, var(--mantine-primary-color-light));
|
||||
}
|
||||
|
||||
.appIcon {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { IconLayoutGrid } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import type { BoardLayout } from "../../_layout-utils";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "../../_layout-utils";
|
||||
import classes from "./_layout-preview.module.css";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
layout: BoardLayout;
|
||||
layouts: BoardLayout[];
|
||||
sourceLayout: BoardLayout;
|
||||
apps: RouterOutputs["app"]["byIds"];
|
||||
}
|
||||
|
||||
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
|
||||
const t = useI18n();
|
||||
const elements = projectBoardLayout(board, sourceLayout, layout);
|
||||
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
|
||||
const largestRepresentativeWidth = Math.max(
|
||||
...layouts.map((candidate) => getRepresentativeLayoutWidth(candidate, layouts)),
|
||||
);
|
||||
const previewWidth = `${(representativeWidth / largestRepresentativeWidth) * 100}%`;
|
||||
const appsById = new Map(apps.map((app) => [app.id, app]));
|
||||
const rootSections = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((sectionA, sectionB) => sectionA.xOffset - sectionB.xOffset);
|
||||
|
||||
return (
|
||||
<Stack gap={6} align="center" w="100%">
|
||||
<Group gap="xs" justify="center">
|
||||
<Badge variant="light" color={layout.role === "mobile" ? "teal" : layout.role === "base" ? "blue" : "gray"}>
|
||||
{representativeWidth}px
|
||||
</Badge>
|
||||
<Text size="xs">
|
||||
{layout.columnCount} {t("board.setting.section.layout.preview.columns")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
|
||||
{elements.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" ta="center" py="xl">
|
||||
{t("board.setting.section.layout.preview.empty")}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{rootSections.map((section) => {
|
||||
const sectionElements = elements.filter((element) => element.sectionId === section.id);
|
||||
const columnCount = getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset));
|
||||
if (sectionElements.length === 0 || columnCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack key={section.id} gap={4} className={classes.section}>
|
||||
<Box
|
||||
className={classes.grid}
|
||||
style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{sectionElements.map((element) => {
|
||||
const item =
|
||||
element.type === "item" ? board.items.find((candidate) => candidate.id === element.id) : null;
|
||||
const appId =
|
||||
item?.kind === "app" && typeof item.options.appId === "string" ? item.options.appId : null;
|
||||
const app = appId ? appsById.get(appId) : undefined;
|
||||
const container =
|
||||
element.type === "section"
|
||||
? board.sections.find((candidate) => candidate.id === element.id)
|
||||
: undefined;
|
||||
const containerLabel =
|
||||
container?.kind === "container" && container.options.title
|
||||
? container.options.title
|
||||
: t("section.container.untitled");
|
||||
const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : containerLabel);
|
||||
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
|
||||
|
||||
return (
|
||||
<Tooltip key={`${element.type}-${element.id}`} label={label} openDelay={350}>
|
||||
<Box
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={`${classes.tile} ${element.type === "section" ? classes.containerTile : ""}`}
|
||||
style={{
|
||||
gridColumn: `${element.xOffset + 1} / span ${element.width}`,
|
||||
gridRow: `${element.yOffset + 1} / span ${element.height}`,
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap" className={classes.tileContent}>
|
||||
<ThemeIcon size="sm" variant="light" radius="sm" style={{ flexShrink: 0 }}>
|
||||
{app ? (
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={false}
|
||||
alt=""
|
||||
className={classes.appIcon}
|
||||
/>
|
||||
) : (
|
||||
<WidgetIcon size={13} stroke={1.7} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<Text component="span" size="xs" fw={500} className={classes.tileLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 112px;
|
||||
min-height: 88px;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Fieldset,
|
||||
@@ -15,118 +18,295 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconEdit, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SectionCard } from "~/components/manage/section-card";
|
||||
import type { Board } from "../../_types";
|
||||
import { LayoutPreview } from "./_layout-preview";
|
||||
import type { FormValues } from "./_settings-form";
|
||||
import classes from "./_layout.module.css";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
form: UseFormReturnType<FormValues>;
|
||||
isSaving: boolean;
|
||||
saveSettingsAsync: () => Promise<FormValues | null>;
|
||||
}
|
||||
|
||||
export const LayoutSettingsContent = ({ form }: Props) => {
|
||||
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [editingLayoutId, setEditingLayoutId] = useState<string | null>(null);
|
||||
const [resettingLayoutId, setResettingLayoutId] = useState<string | null>(null);
|
||||
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const appIds = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
board.items.flatMap((item) =>
|
||||
item.kind === "app" && typeof item.options.appId === "string" ? [item.options.appId] : [],
|
||||
),
|
||||
),
|
||||
),
|
||||
[board.items],
|
||||
);
|
||||
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
|
||||
|
||||
const openLayoutEditorAsync = async (layout: FormValues["layouts"][number]) => {
|
||||
setEditingLayoutId(layout.id);
|
||||
try {
|
||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
const savedValues = form.isDirty() || !persistedLayout ? await saveSettingsAsync() : form.values;
|
||||
const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
|
||||
if (!canonicalLayout) return;
|
||||
|
||||
router.push(`/boards/${board.name}?layout=${encodeURIComponent(canonicalLayout.id)}&edit=true&returnTo=settings`);
|
||||
} finally {
|
||||
setEditingLayoutId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReset = (layout: FormValues["layouts"][number]) => {
|
||||
openConfirmModal({
|
||||
title: t("board.setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
|
||||
children: t("board.setting.section.layout.reset.confirm.message"),
|
||||
confirmProps: { children: t("board.setting.section.layout.reset.action"), color: "red" },
|
||||
onConfirm() {
|
||||
setResettingLayoutId(layout.id);
|
||||
void (async () => {
|
||||
try {
|
||||
const savedValues = form.isDirty() ? await saveSettingsAsync() : form.values;
|
||||
const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
|
||||
if (!canonicalLayout) return;
|
||||
|
||||
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
|
||||
await utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
router.refresh();
|
||||
} catch {
|
||||
// The mutation callback displays the error notification.
|
||||
} finally {
|
||||
setResettingLayoutId(null);
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
||||
const sortedLayouts = form.values.layouts
|
||||
.map((layout, index) => ({ layout, index }))
|
||||
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("board.setting.section.layout.title")}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2} maw="52rem">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Text size="sm">{t("board.setting.section.layout.responsive.description")}</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
variant="light"
|
||||
disabled={nextBreakpoint === null || !baseLayout}
|
||||
onClick={() => {
|
||||
form.setValues((previous) => ({
|
||||
...previous,
|
||||
layouts: [
|
||||
...(previous.layouts ?? []),
|
||||
{
|
||||
id: createId(),
|
||||
name: "",
|
||||
columnCount: 10,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
}));
|
||||
if (nextBreakpoint === null || !baseLayout) return;
|
||||
form.setFieldValue("layouts", [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: t("board.setting.section.layout.custom.defaultName"),
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
breakpoint: nextBreakpoint,
|
||||
role: "custom",
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => (
|
||||
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
</Grid.Col>
|
||||
{sortedLayouts.map(({ layout, index }) => {
|
||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||
const customBreakpoints = form.values.layouts
|
||||
.filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id)
|
||||
.map((candidate) => candidate.breakpoint);
|
||||
const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768;
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
value={layout.columnCount}
|
||||
thumbLabel={t("layout.field.columnCount.label")}
|
||||
onChange={(columnCount) => {
|
||||
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
|
||||
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
|
||||
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
|
||||
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
|
||||
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
|
||||
}}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
return (
|
||||
<Fieldset
|
||||
key={layout.id}
|
||||
legend={
|
||||
<Group gap="xs">
|
||||
<Text>{layout.name || t(`board.setting.section.layout.role.${layout.role}`)}</Text>
|
||||
<Badge size="sm" variant="default">
|
||||
{t(`board.setting.section.layout.role.${layout.role}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
bg="transparent"
|
||||
>
|
||||
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
|
||||
<Stack gap="md">
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider
|
||||
thumbLabel={`${t("layout.field.columnCount.label")} — ${layout.name}`}
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
marks={[1, 6, 12, 18, 24].map((value) => ({ value, label: String(value) }))}
|
||||
styles={{
|
||||
markLabel: { color: "light-dark(var(--mantine-color-black), var(--mantine-color-white))" },
|
||||
}}
|
||||
value={layout.columnCount}
|
||||
onChange={(columnCount) => {
|
||||
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
|
||||
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
|
||||
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
|
||||
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
|
||||
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
|
||||
}}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={
|
||||
layout.role === "mobile"
|
||||
? t("board.setting.section.layout.mobile.breakpointDescription")
|
||||
: t("layout.field.breakpoint.description")
|
||||
}
|
||||
disabled={layout.role === "mobile"}
|
||||
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
||||
min={
|
||||
layout.role === "base"
|
||||
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
|
||||
: 1
|
||||
}
|
||||
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
|
||||
{sourceLayout && (
|
||||
<LayoutPreview
|
||||
board={board}
|
||||
layout={layout}
|
||||
layouts={form.values.layouts}
|
||||
sourceLayout={sourceLayout}
|
||||
apps={apps}
|
||||
/>
|
||||
)}
|
||||
</Grid.Col>
|
||||
{layout.role !== "mobile" && (
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<GutterSettings
|
||||
left={layout.leftGutterColumnCount}
|
||||
right={layout.rightGutterColumnCount}
|
||||
columnCount={layout.columnCount}
|
||||
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
|
||||
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<GutterSettings
|
||||
left={layout.leftGutterColumnCount}
|
||||
right={layout.rightGutterColumnCount}
|
||||
columnCount={layout.columnCount}
|
||||
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
|
||||
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
{layout.role !== "base" && persistedLayout && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
|
||||
loading={resettingLayoutId === layout.id}
|
||||
disabled={isSaving}
|
||||
onClick={() => confirmReset(layout)}
|
||||
>
|
||||
{t("board.setting.section.layout.reset.action")}
|
||||
</Button>
|
||||
)}
|
||||
{layout.role === "custom" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={isSaving}
|
||||
onClick={() => {
|
||||
form.setFieldValue(
|
||||
"layouts",
|
||||
form.values.layouts.filter((candidate) => candidate.id !== layout.id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) => ({
|
||||
...previous,
|
||||
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}));
|
||||
}}
|
||||
variant="default"
|
||||
leftSection={<IconEdit size={16} color="var(--mantine-color-red-5)" />}
|
||||
loading={editingLayoutId === layout.id}
|
||||
disabled={isSaving || !form.isValid()}
|
||||
onClick={() => void openLayoutEditorAsync(layout)}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
{form.isDirty() || !persistedLayout
|
||||
? t("board.setting.section.layout.edit.saveAndEdit")
|
||||
: t("board.setting.section.layout.edit.action")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Fieldset>
|
||||
))}
|
||||
</Fieldset>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
const findCanonicalLayout = (layouts: FormValues["layouts"], layout: FormValues["layouts"][number]) =>
|
||||
layouts.find((candidate) => candidate.id === layout.id) ??
|
||||
layouts.find((candidate) => candidate.breakpoint === layout.breakpoint && candidate.role === layout.role);
|
||||
|
||||
const getNextCustomBreakpoint = (layouts: Array<{ breakpoint: number }>) => {
|
||||
const sortedBreakpoints = layouts
|
||||
.map((layout) => layout.breakpoint)
|
||||
.toSorted((breakpointA, breakpointB) => breakpointA - breakpointB);
|
||||
let largestGap: { start: number; end: number } | null = null;
|
||||
|
||||
for (let index = 0; index < sortedBreakpoints.length - 1; index++) {
|
||||
const start = sortedBreakpoints[index];
|
||||
const end = sortedBreakpoints[index + 1];
|
||||
if (start === undefined || end === undefined || end - start <= 1) continue;
|
||||
if (!largestGap || end - start > largestGap.end - largestGap.start) largestGap = { start, end };
|
||||
}
|
||||
|
||||
return largestGap ? Math.floor((largestGap.start + largestGap.end) / 2) : null;
|
||||
};
|
||||
|
||||
interface GutterSettingsProps {
|
||||
left: number;
|
||||
right: number;
|
||||
@@ -158,7 +338,7 @@ const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange
|
||||
<GutterPreview left={left} right={right} columnCount={columnCount} />
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<GutterControl
|
||||
side="left"
|
||||
value={left}
|
||||
@@ -166,7 +346,7 @@ const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange
|
||||
onChange={onLeftChange}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<GutterControl
|
||||
side="right"
|
||||
value={right}
|
||||
@@ -201,7 +381,6 @@ const GutterControl = ({
|
||||
disabled={max < 1}
|
||||
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
|
||||
label={t(`layout.field.gutters.${side}.label`)}
|
||||
description={t(`layout.field.gutters.${side}.description`)}
|
||||
/>
|
||||
{enabled && (
|
||||
<Input.Wrapper label={t("layout.field.gutters.width.label")}>
|
||||
|
||||
@@ -36,6 +36,11 @@ const boardSettingsFormSchema = boardSavePartialSettingsSchema
|
||||
|
||||
export type FormValues = z.infer<typeof boardSettingsFormSchema>;
|
||||
|
||||
const normalizeMobileLayoutGutters = (layouts: readonly FormValues["layouts"][number][]) =>
|
||||
layouts.map((layout) =>
|
||||
layout.role === "mobile" ? { ...layout, leftGutterColumnCount: 0, rightGutterColumnCount: 0 } : layout,
|
||||
);
|
||||
|
||||
const PARTIAL_FORM_KEYS = [
|
||||
"pageTitle",
|
||||
"metaTitle",
|
||||
@@ -70,7 +75,7 @@ const buildInitialValues = (board: Board): FormValues => ({
|
||||
itemRadius: board.itemRadius,
|
||||
customCss: board.customCss ?? "",
|
||||
disableStatus: board.disableStatus,
|
||||
layouts: board.layouts,
|
||||
layouts: normalizeMobileLayoutGutters(board.layouts),
|
||||
});
|
||||
|
||||
interface BoardSettingsFormProps {
|
||||
@@ -122,46 +127,45 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
|
||||
const handleSubmitAsync = async (values: FormValues) => {
|
||||
const saveSettingsAsync = async (values: FormValues): Promise<FormValues | null> => {
|
||||
const defaults = initialValuesRef.current;
|
||||
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
||||
fields.some((field) => values[field] !== defaults[field]);
|
||||
|
||||
const { layouts, ...partialSettings } = values;
|
||||
|
||||
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
|
||||
{
|
||||
when: changed(...PARTIAL_FORM_KEYS),
|
||||
action: () =>
|
||||
savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
}),
|
||||
},
|
||||
{
|
||||
when: changed("layouts"),
|
||||
action: () => saveLayouts.mutateAsync({ id: board.id, layouts }),
|
||||
},
|
||||
];
|
||||
|
||||
const promises = saveActions.filter((saveAction) => saveAction.when).map((saveAction) => saveAction.action());
|
||||
if (promises.length === 0) return;
|
||||
const { layouts: submittedLayouts, ...partialSettings } = values;
|
||||
const layouts = normalizeMobileLayoutGutters(submittedLayouts);
|
||||
const partialSettingsChanged = changed(...PARTIAL_FORM_KEYS);
|
||||
const layoutsChanged = changed("layouts");
|
||||
if (!partialSettingsChanged && !layoutsChanged) return values;
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
lastSavedRef.current = { pageTitle: values.pageTitle, logoImageUrl: values.logoImageUrl };
|
||||
initialValuesRef.current = values;
|
||||
form.setInitialValues(values);
|
||||
form.resetDirty();
|
||||
const [, canonicalLayouts] = await Promise.all([
|
||||
partialSettingsChanged
|
||||
? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
|
||||
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
|
||||
})
|
||||
: Promise.resolve(),
|
||||
layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null),
|
||||
]);
|
||||
const canonicalValues = canonicalLayouts ? { ...values, layouts: canonicalLayouts } : values;
|
||||
|
||||
lastSavedRef.current = { pageTitle: canonicalValues.pageTitle, logoImageUrl: canonicalValues.logoImageUrl };
|
||||
initialValuesRef.current = canonicalValues;
|
||||
form.setValues(canonicalValues);
|
||||
form.setInitialValues(canonicalValues);
|
||||
form.resetDirty(canonicalValues);
|
||||
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("common.notification.update.success"),
|
||||
});
|
||||
return canonicalValues;
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message: t("common.notification.update.error"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,10 +183,15 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<form onSubmit={form.onSubmit((values) => void saveSettingsAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<GeneralSettingsContent board={board} form={form} />
|
||||
<LayoutSettingsContent form={form} />
|
||||
<LayoutSettingsContent
|
||||
board={board}
|
||||
form={form}
|
||||
isSaving={isPending}
|
||||
saveSettingsAsync={() => saveSettingsAsync(form.values)}
|
||||
/>
|
||||
<BackgroundSettingsContent form={form} />
|
||||
<ColorSettingsContent form={form} />
|
||||
<CustomCssSettingsContent form={form} />
|
||||
|
||||
@@ -53,7 +53,7 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Container size="xl" px={{ base: "md", md: "xl" }}>
|
||||
<Stack>
|
||||
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
|
||||
<BoardSettingsForm
|
||||
|
||||
@@ -6,8 +6,7 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "@homarr/boards/layout-selection";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
@@ -19,6 +18,7 @@ import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
|
||||
import { env } from "~/env";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import type { Board } from "./_types";
|
||||
import { BoardProviders } from "./_providers";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
import { CustomCss } from "./(content)/_custom-css";
|
||||
import { BoardReadyProvider } from "./(content)/_ready-context";
|
||||
@@ -88,34 +88,34 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
throw error;
|
||||
}
|
||||
const initialBoard = initialBoardResult.board;
|
||||
const { hasChangeAccess } = constructBoardPermissions(initialBoard, session);
|
||||
const initialLayoutId = getLayoutIdForViewportWidth(initialBoard.layouts, initialViewportWidth);
|
||||
|
||||
return (
|
||||
<BoardProvider
|
||||
<BoardProviders
|
||||
initialBoard={initialBoard}
|
||||
initialLayoutId={initialLayoutId}
|
||||
initialViewportWidth={initialViewportWidth}
|
||||
canModify={hasChangeAccess}
|
||||
>
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<ModalProvider>
|
||||
<CustomCss />
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourGate>
|
||||
</ModalProvider>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<ModalProvider>
|
||||
<CustomCss />
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourGate>
|
||||
</ModalProvider>
|
||||
</BoardMantineProvider>
|
||||
</BoardReadyProvider>
|
||||
</BoardProvider>
|
||||
</BoardProviders>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
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 { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils";
|
||||
|
||||
const mobile = {
|
||||
id: "mobile",
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
};
|
||||
const custom = {
|
||||
id: "tablet",
|
||||
name: "Tablet",
|
||||
columnCount: 6,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 480,
|
||||
role: "custom" as const,
|
||||
};
|
||||
const base = {
|
||||
id: "base",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
};
|
||||
|
||||
describe("layout representative widths", () => {
|
||||
test("uses a phone width, activation midpoint, and desktop minimum", () => {
|
||||
const layouts = [mobile, custom, base];
|
||||
expect(getRepresentativeLayoutWidth(mobile, layouts)).toBe(390);
|
||||
expect(getRepresentativeLayoutWidth(custom, layouts)).toBe(624);
|
||||
expect(getRepresentativeLayoutWidth(base, layouts)).toBe(1280);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectBoardLayout", () => {
|
||||
test("returns exact saved positions without recursively re-projecting the active layout", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container" })
|
||||
.addLayout({ layoutId: mobile.id, parentSectionId: rootSection.id, width: 3, height: 2 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, container]).build();
|
||||
|
||||
expect(projectBoardLayout(board, mobile, mobile)).toEqual([
|
||||
expect.objectContaining({ id: container.id, sectionId: rootSection.id, width: 3, height: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses production ordering and sizing for items and containers", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container" })
|
||||
.addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 })
|
||||
.build();
|
||||
const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" })
|
||||
.addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 })
|
||||
.build();
|
||||
const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" })
|
||||
.addLayout({ layoutId: base.id, sectionId: container.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(mobile)
|
||||
.addSections([rootSection, container])
|
||||
.addItems([rootItem, nestedItem])
|
||||
.build();
|
||||
|
||||
const projected = projectBoardLayout(board, base, mobile);
|
||||
|
||||
expect(projected.find((element) => element.id === rootItem.id)).toMatchObject({
|
||||
type: "item",
|
||||
width: 3,
|
||||
height: 2,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
sectionId: rootSection.id,
|
||||
});
|
||||
expect(projected.find((element) => element.id === container.id)).toMatchObject({
|
||||
type: "section",
|
||||
width: 3,
|
||||
xOffset: 0,
|
||||
yOffset: 2,
|
||||
sectionId: rootSection.id,
|
||||
});
|
||||
expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({
|
||||
type: "item",
|
||||
width: 3,
|
||||
sectionId: container.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores missing saved positions and supports empty boards", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const itemWithoutBasePosition = new ItemMockBuilder({ id: "missing", kind: "clock" }).build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(mobile)
|
||||
.addSection(rootSection)
|
||||
.addItem(itemWithoutBasePosition)
|
||||
.build();
|
||||
|
||||
expect(projectBoardLayout(board, base, mobile)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { generateResponsiveGridFor } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
import type { BoardLane } from "@homarr/definitions";
|
||||
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board } from "./_types";
|
||||
|
||||
export type BoardLayout = Board["layouts"][number];
|
||||
type LayoutGeometry = Pick<BoardLayout, "id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount">;
|
||||
|
||||
export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => {
|
||||
if (layout.role === "mobile") return 390;
|
||||
if (layout.role === "base") return Math.max(1280, layout.breakpoint);
|
||||
|
||||
const nextLayout = allLayouts
|
||||
.filter((candidate) => candidate.breakpoint > layout.breakpoint)
|
||||
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint)
|
||||
.at(0);
|
||||
|
||||
return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint);
|
||||
};
|
||||
|
||||
export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, targetLayout: LayoutGeometry) => {
|
||||
const elements = getElementsForLayout(board, sourceLayout.id);
|
||||
|
||||
if (
|
||||
sourceLayout.id === targetLayout.id &&
|
||||
sourceLayout.columnCount === targetLayout.columnCount &&
|
||||
sourceLayout.leftGutterColumnCount === targetLayout.leftGutterColumnCount &&
|
||||
sourceLayout.rightGutterColumnCount === targetLayout.rightGutterColumnCount
|
||||
) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const emptyRoots = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => first.xOffset - second.xOffset || first.id.localeCompare(second.id));
|
||||
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
|
||||
for (const root of emptyRoots) {
|
||||
const lane = getRootSectionLane(root.xOffset);
|
||||
if (!rootByLane.has(lane)) rootByLane.set(lane, root);
|
||||
}
|
||||
|
||||
const mainRoot = rootByLane.get("main");
|
||||
if (!mainRoot) return [];
|
||||
|
||||
const sourceRootById = new Map(
|
||||
emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const),
|
||||
);
|
||||
const targetLaneBySourceLane = new Map<BoardLane, BoardLane>(
|
||||
boardLanes.map((lane) => [
|
||||
lane,
|
||||
lane !== "main" && getBoardLaneColumnCount(targetLayout, lane) === 0 ? "main" : lane,
|
||||
]),
|
||||
);
|
||||
const remappedElements = elements.map((element) => {
|
||||
const sourceLane = sourceRootById.get(element.sectionId);
|
||||
if (!sourceLane) return element;
|
||||
const targetLane = targetLaneBySourceLane.get(sourceLane) ?? "main";
|
||||
return { ...element, sectionId: (rootByLane.get(targetLane) ?? mainRoot).id };
|
||||
});
|
||||
|
||||
return boardLanes.flatMap((lane) => {
|
||||
const root = rootByLane.get(lane);
|
||||
const width = getBoardLaneColumnCount(targetLayout, lane);
|
||||
if (!root || width === 0) return [];
|
||||
|
||||
const sourceLanes = boardLanes.filter(
|
||||
(sourceLane) =>
|
||||
rootByLane.has(sourceLane) &&
|
||||
getBoardLaneColumnCount(sourceLayout, sourceLane) > 0 &&
|
||||
targetLaneBySourceLane.get(sourceLane) === lane,
|
||||
);
|
||||
const previousWidth =
|
||||
sourceLanes.length === 1
|
||||
? getBoardLaneColumnCount(sourceLayout, sourceLanes[0] ?? "main")
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return generateResponsiveGridFor({
|
||||
items: remappedElements,
|
||||
previousWidth,
|
||||
width,
|
||||
sectionId: root.id,
|
||||
}).items;
|
||||
});
|
||||
};
|
||||
|
||||
const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [
|
||||
...board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
height: layout.height,
|
||||
width: layout.width,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
sectionId: layout.sectionId,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "container") return [];
|
||||
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
|
||||
return layout?.parentSectionId
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
height: layout.height,
|
||||
width: layout.width,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
sectionId: layout.parentSectionId,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createElement, act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { BoardProviders } from "./_providers";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
searchParams: new URLSearchParams(),
|
||||
layoutOverrideId: undefined as string | null | undefined,
|
||||
initialOpen: undefined as boolean | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => mocks.searchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
BoardProvider: ({ children, layoutOverrideId }: { children: React.ReactNode; layoutOverrideId: string | null }) => {
|
||||
mocks.layoutOverrideId = layoutOverrideId;
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/boards/edit-mode", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
EditModeProvider: ({ children, initialOpen }: { children: React.ReactNode; initialOpen: boolean }) => {
|
||||
mocks.initialOpen = initialOpen;
|
||||
return React.createElement(React.Fragment, null, children);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("board providers", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
mocks.layoutOverrideId = undefined;
|
||||
mocks.initialOpen = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["base", "base"],
|
||||
["custom", "custom"],
|
||||
])("uses the requested %s layout for an explicit edit request", (_name, requestedLayoutId) => {
|
||||
mocks.searchParams = new URLSearchParams(`layout=${requestedLayoutId}&edit=true&returnTo=settings`);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
BoardProviders,
|
||||
{
|
||||
initialBoard: { layouts: [{ id: "mobile" }, { id: "custom" }, { id: "base" }] } as never,
|
||||
initialLayoutId: "mobile",
|
||||
initialViewportWidth: 390,
|
||||
canModify: true,
|
||||
},
|
||||
createElement("span", null, "board"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.layoutOverrideId).toBe(requestedLayoutId);
|
||||
expect(mocks.initialOpen).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["without edit mode", "layout=base", true],
|
||||
["for an unknown layout", "layout=unknown&edit=true", true],
|
||||
["without modify access", "layout=base&edit=true", false],
|
||||
])("keeps viewport layout selection %s", (_name, query, canModify) => {
|
||||
mocks.searchParams = new URLSearchParams(query);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(
|
||||
BoardProviders,
|
||||
{
|
||||
initialBoard: { layouts: [{ id: "mobile" }, { id: "base" }] } as never,
|
||||
initialLayoutId: "mobile",
|
||||
initialViewportWidth: 390,
|
||||
canModify,
|
||||
},
|
||||
createElement("span", null, "board"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.layoutOverrideId).toBeNull();
|
||||
expect(mocks.initialOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
|
||||
import type { Board } from "./_types";
|
||||
|
||||
interface Props {
|
||||
initialBoard: Board;
|
||||
initialLayoutId: string;
|
||||
initialViewportWidth: number;
|
||||
canModify: boolean;
|
||||
}
|
||||
|
||||
export const BoardProviders = ({
|
||||
children,
|
||||
initialBoard,
|
||||
initialLayoutId,
|
||||
initialViewportWidth,
|
||||
canModify,
|
||||
}: PropsWithChildren<Props>) => {
|
||||
const searchParams = useSearchParams();
|
||||
const requestedLayoutId = searchParams.get("layout");
|
||||
const isRequestedLayoutValid = initialBoard.layouts.some((layout) => layout.id === requestedLayoutId);
|
||||
const isLayoutEditRequest = canModify && searchParams.get("edit") === "true" && isRequestedLayoutValid;
|
||||
const layoutOverrideId = isLayoutEditRequest ? requestedLayoutId : null;
|
||||
|
||||
return (
|
||||
<BoardProvider
|
||||
initialBoard={initialBoard}
|
||||
initialLayoutId={initialLayoutId}
|
||||
initialViewportWidth={initialViewportWidth}
|
||||
layoutOverrideId={layoutOverrideId}
|
||||
>
|
||||
<EditModeProvider initialOpen={isLayoutEditRequest}>{children}</EditModeProvider>
|
||||
</BoardProvider>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,20 @@ describe("item actions create-item", () => {
|
||||
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";
|
||||
|
||||
@@ -47,7 +47,8 @@ export class BoardMockBuilder {
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
},
|
||||
],
|
||||
...board,
|
||||
|
||||
@@ -12,7 +12,8 @@ export class LayoutMockBuilder {
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 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,6 +32,7 @@ interface UpdateItemIntegrations {
|
||||
|
||||
export const useItemActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
|
||||
const createItem = useCallback((input: CreateItemInput) => updateBoard(createItemCallback(input)), [updateBoard]);
|
||||
|
||||
@@ -76,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(
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("board lanes", () => {
|
||||
leftGutterColumnCount: 2,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(2);
|
||||
@@ -31,6 +32,7 @@ describe("board lanes", () => {
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(3);
|
||||
@@ -38,6 +40,22 @@ describe("board lanes", () => {
|
||||
expect(getBoardLaneColumnCount(layout, "right")).toBe(0);
|
||||
});
|
||||
|
||||
test("disables gutters for mobile layouts", () => {
|
||||
const layout = {
|
||||
id: "mobile",
|
||||
name: "Mobile",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 2,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(0);
|
||||
expect(getBoardLaneColumnCount(layout, "main")).toBe(12);
|
||||
expect(getBoardLaneColumnCount(layout, "right")).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects ambiguous duplicate roots", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: "main-a" }).build())
|
||||
@@ -104,6 +122,7 @@ describe("board lanes", () => {
|
||||
leftGutterColumnCount: 1,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
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 { wouldCreateSectionCycle } from "./section-actions";
|
||||
|
||||
describe("wouldCreateSectionCycle", () => {
|
||||
test("rejects moving a container into itself or one of its descendants", () => {
|
||||
const layoutId = "layout";
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const parentSection = new ContainerSectionMockBuilder({ id: "parent" })
|
||||
.addLayout({ layoutId, parentSectionId: rootSection.id })
|
||||
.build();
|
||||
const childSection = new ContainerSectionMockBuilder({ id: "child" })
|
||||
.addLayout({ layoutId, parentSectionId: parentSection.id })
|
||||
.build();
|
||||
const board = new BoardMockBuilder().addSections([rootSection, parentSection, childSection]).build();
|
||||
|
||||
expect(
|
||||
wouldCreateSectionCycle(board, {
|
||||
innerSectionId: parentSection.id,
|
||||
sectionId: parentSection.id,
|
||||
layoutId,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wouldCreateSectionCycle(board, {
|
||||
innerSectionId: parentSection.id,
|
||||
sectionId: childSection.id,
|
||||
layoutId,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wouldCreateSectionCycle(board, {
|
||||
innerSectionId: childSection.id,
|
||||
sectionId: rootSection.id,
|
||||
layoutId,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
import { useCurrentLayout } from "@homarr/boards/context";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface MoveAndResizeInnerSection {
|
||||
innerSectionId: string;
|
||||
xOffset: number;
|
||||
@@ -21,6 +23,7 @@ interface MoveInnerSectionToSection {
|
||||
|
||||
export const useSectionActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
|
||||
const moveAndResizeInnerSection = useCallback(
|
||||
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
|
||||
@@ -31,12 +34,10 @@ export const useSectionActions = () => {
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.layoutId !== currentLayout) return layout;
|
||||
if (layout.layoutId !== currentLayoutId) return layout;
|
||||
return {
|
||||
...layout,
|
||||
...positionProps,
|
||||
@@ -46,12 +47,16 @@ export const useSectionActions = () => {
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
[currentLayoutId, updateBoard],
|
||||
);
|
||||
|
||||
const moveInnerSectionToSection = useCallback(
|
||||
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
|
||||
updateBoard((previous) => {
|
||||
if (wouldCreateSectionCycle(previous, { innerSectionId, sectionId, layoutId: currentLayoutId })) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
@@ -59,12 +64,10 @@ export const useSectionActions = () => {
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.layoutId !== currentLayout) return layout;
|
||||
if (layout.layoutId !== currentLayoutId) return layout;
|
||||
return {
|
||||
...layout,
|
||||
...positionProps,
|
||||
@@ -76,7 +79,7 @@ export const useSectionActions = () => {
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
[currentLayoutId, updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -84,3 +87,24 @@ export const useSectionActions = () => {
|
||||
moveInnerSectionToSection,
|
||||
};
|
||||
};
|
||||
|
||||
export const wouldCreateSectionCycle = (
|
||||
board: Board,
|
||||
{ innerSectionId, sectionId, layoutId }: { innerSectionId: string; sectionId: string; layoutId: string },
|
||||
) => {
|
||||
const visitedSectionIds = new Set<string>();
|
||||
let currentSectionId: string | null = sectionId;
|
||||
|
||||
while (currentSectionId) {
|
||||
if (currentSectionId === innerSectionId) return true;
|
||||
if (visitedSectionIds.has(currentSectionId)) return true;
|
||||
visitedSectionIds.add(currentSectionId);
|
||||
|
||||
const currentSection = board.sections.find((section) => section.id === currentSectionId);
|
||||
if (currentSection?.kind !== "container") return false;
|
||||
|
||||
currentSectionId = currentSection.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
+579
-191
@@ -2,13 +2,15 @@ import { TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createId, generateResponsiveGridFor } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
||||
import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
|
||||
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
|
||||
import { seedProtectedBoardLayoutsAsync } from "@homarr/db/migrations/seed";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
getRootSectionLane,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
normalizeBoardLayoutRoles,
|
||||
rootSectionOffsets,
|
||||
widgetDefaultSizes,
|
||||
widgetKinds,
|
||||
@@ -52,6 +55,7 @@ import {
|
||||
boardCreateSchema,
|
||||
boardDuplicateSchema,
|
||||
boardRenameSchema,
|
||||
boardResetLayoutSchema,
|
||||
boardSaveLayoutsSchema,
|
||||
boardSavePartialSettingsSchema,
|
||||
boardSavePermissionsSchema,
|
||||
@@ -69,7 +73,6 @@ import {
|
||||
throwIfCustomWidgetBoardDuplicationForbidden,
|
||||
throwIfCustomWidgetPlacementChangeForbidden,
|
||||
} from "./board/custom-widget-placement-access";
|
||||
import { generateResponsiveGridFor } from "./board/grid-algorithm";
|
||||
import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
@@ -322,15 +325,28 @@ export const boardRouter = createTRPCRouter({
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
createBoardCollection.layouts.push({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: input.columnCount,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
});
|
||||
createBoardCollection.layouts.push(
|
||||
{
|
||||
id: createId(),
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
boardId,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: input.columnCount,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
},
|
||||
);
|
||||
|
||||
await createBoardCollection.insertAllAsync(ctx.db);
|
||||
|
||||
@@ -387,8 +403,65 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
const newBoardId = createId();
|
||||
|
||||
const layoutsMap = new Map<string, string>(boardLayouts.map((layout) => [layout.id, createId()]));
|
||||
const layoutsToInsert = boardLayouts.map((layout) => ({
|
||||
const generatedMobileLayoutId = createId();
|
||||
const generatedMobilePositions =
|
||||
boardLayouts.length === 1 && boardLayouts[0]
|
||||
? getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
layoutId: boardLayouts[0].id,
|
||||
columnCount: boardLayouts[0].columnCount,
|
||||
leftGutterColumnCount: boardLayouts[0].leftGutterColumnCount,
|
||||
rightGutterColumnCount: boardLayouts[0].rightGutterColumnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId: generatedMobileLayoutId,
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const normalizedBoardLayouts =
|
||||
boardLayouts.length === 0
|
||||
? [
|
||||
{
|
||||
id: generatedMobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
boardId: board.id,
|
||||
},
|
||||
]
|
||||
: boardLayouts.length === 1 && boardLayouts[0]
|
||||
? [
|
||||
{
|
||||
id: generatedMobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
boardId: board.id,
|
||||
},
|
||||
{ ...boardLayouts[0], breakpoint: 768, role: "base" as const },
|
||||
]
|
||||
: normalizeBoardLayoutRoles(boardLayouts);
|
||||
const layoutsMap = new Map<string, string>(normalizedBoardLayouts.map((layout) => [layout.id, createId()]));
|
||||
const layoutsToInsert = normalizedBoardLayouts.map((layout) => ({
|
||||
...layout,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: layoutsMap.get(layout.id)!,
|
||||
@@ -405,9 +478,24 @@ export const boardRouter = createTRPCRouter({
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections.flatMap((section) =>
|
||||
section.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof sectionLayouts> => ({
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections
|
||||
.flatMap((section) =>
|
||||
section.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof sectionLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
parentSectionId: layoutSection.parentSectionId
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionMap.get(layoutSection.parentSectionId)!
|
||||
: layoutSection.parentSectionId,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
(generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
@@ -416,10 +504,9 @@ export const boardRouter = createTRPCRouter({
|
||||
parentSectionId: layoutSection.parentSectionId
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionMap.get(layoutSection.parentSectionId)!
|
||||
: layoutSection.parentSectionId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
const sectionCollapseStatesToInsert: InferInsertModel<typeof sectionCollapseStates>[] = boardSections.flatMap(
|
||||
(section) =>
|
||||
section.collapseStates.map(
|
||||
@@ -441,9 +528,22 @@ export const boardRouter = createTRPCRouter({
|
||||
}),
|
||||
);
|
||||
|
||||
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems.flatMap((item) =>
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems
|
||||
.flatMap((item) =>
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(layoutSection.itemId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
(generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
@@ -451,9 +551,8 @@ export const boardRouter = createTRPCRouter({
|
||||
itemId: itemMap.get(layoutSection.itemId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
}),
|
||||
),
|
||||
);
|
||||
})),
|
||||
);
|
||||
|
||||
// Creates a list with all integration ids the user has access to
|
||||
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
|
||||
@@ -694,153 +793,338 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||
}),
|
||||
saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||
saveLayouts: protectedProcedure
|
||||
.input(boardSaveLayoutsSchema)
|
||||
.output(boardSaveLayoutsSchema.shape.layouts)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||
|
||||
const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
|
||||
input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
|
||||
);
|
||||
await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
|
||||
const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
|
||||
input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
|
||||
);
|
||||
await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
|
||||
|
||||
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
const existingLayoutsById = new Map(board.layouts.map((layout) => [layout.id, layout]));
|
||||
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
|
||||
const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
|
||||
|
||||
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
|
||||
if (addedLayouts.some((layout) => layout.role !== "custom")) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "New layouts must use the custom role" });
|
||||
}
|
||||
if (removedLayouts.some((layout) => layout.role !== "custom")) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Mobile and Base layouts cannot be removed" });
|
||||
}
|
||||
if (
|
||||
input.layouts.some((layout) => {
|
||||
const existingLayout = existingLayoutsById.get(layout.id);
|
||||
return existingLayout && existingLayout.role !== layout.role;
|
||||
})
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Layout roles cannot be changed" });
|
||||
}
|
||||
|
||||
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
|
||||
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
const baseLayout = board.layouts.find((layout) => layout.role === "base");
|
||||
if (!baseLayout) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" });
|
||||
}
|
||||
|
||||
for (const addedLayout of addedLayouts) {
|
||||
const layoutId = createId();
|
||||
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
|
||||
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
const savedLayoutIds = new Map<string, string>();
|
||||
const requestedBaseLayout = input.layouts.find((layout) => layout.id === baseLayout.id);
|
||||
const resizedBaseLayout =
|
||||
requestedBaseLayout &&
|
||||
(requestedBaseLayout.columnCount !== baseLayout.columnCount ||
|
||||
requestedBaseLayout.leftGutterColumnCount !== baseLayout.leftGutterColumnCount ||
|
||||
requestedBaseLayout.rightGutterColumnCount !== baseLayout.rightGutterColumnCount)
|
||||
? getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
layoutId: baseLayout.id,
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId: baseLayout.id,
|
||||
columnCount: requestedBaseLayout.columnCount,
|
||||
leftGutterColumnCount: requestedBaseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: requestedBaseLayout.rightGutterColumnCount,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const baseSourceElements = resizedBaseLayout ? getElementsForProjectedLayout(resizedBaseLayout) : undefined;
|
||||
const baseSourceGeometry = {
|
||||
layoutId: baseLayout.id,
|
||||
columnCount: requestedBaseLayout?.columnCount ?? baseLayout.columnCount,
|
||||
leftGutterColumnCount: requestedBaseLayout?.leftGutterColumnCount ?? baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: requestedBaseLayout?.rightGutterColumnCount ?? baseLayout.rightGutterColumnCount,
|
||||
};
|
||||
|
||||
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
|
||||
// Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
|
||||
const layoutToClone =
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1)!;
|
||||
|
||||
const updatedBoardLayout = getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
layoutId: layoutToClone.id,
|
||||
columnCount: layoutToClone.columnCount,
|
||||
leftGutterColumnCount: layoutToClone.leftGutterColumnCount,
|
||||
rightGutterColumnCount: layoutToClone.rightGutterColumnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId,
|
||||
for (const addedLayout of addedLayouts) {
|
||||
const layoutId = createId();
|
||||
savedLayoutIds.set(addedLayout.id, layoutId);
|
||||
layoutsToInsert.push({
|
||||
id: layoutId,
|
||||
name: addedLayout.name,
|
||||
columnCount: addedLayout.columnCount,
|
||||
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
|
||||
breakpoint: addedLayout.breakpoint,
|
||||
role: "custom",
|
||||
boardId: board.id,
|
||||
});
|
||||
|
||||
const projectedLayout = getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
...baseSourceGeometry,
|
||||
elements: baseSourceElements,
|
||||
},
|
||||
current: {
|
||||
layoutId,
|
||||
columnCount: addedLayout.columnCount,
|
||||
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
|
||||
},
|
||||
});
|
||||
itemSectionLayoutsToInsert.push(...projectedLayout.itemSectionLayouts);
|
||||
sectionLayoutsToInsert.push(...projectedLayout.sectionLayouts);
|
||||
}
|
||||
|
||||
const itemSectionLayoutsToUpdate: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToUpdate: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
const layoutsToUpdate = filterUpdatedItems(input.layouts, board.layouts);
|
||||
|
||||
for (const updatedLayout of layoutsToUpdate) {
|
||||
const dbLayout = existingLayoutsById.get(updatedLayout.id);
|
||||
if (
|
||||
!dbLayout ||
|
||||
(dbLayout.columnCount === updatedLayout.columnCount &&
|
||||
dbLayout.leftGutterColumnCount === updatedLayout.leftGutterColumnCount &&
|
||||
dbLayout.rightGutterColumnCount === updatedLayout.rightGutterColumnCount)
|
||||
)
|
||||
continue;
|
||||
|
||||
const projectedLayout =
|
||||
updatedLayout.role === "base" && resizedBaseLayout
|
||||
? resizedBaseLayout
|
||||
: getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: dbLayout.columnCount,
|
||||
leftGutterColumnCount: dbLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: dbLayout.rightGutterColumnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: updatedLayout.columnCount,
|
||||
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
|
||||
},
|
||||
});
|
||||
itemSectionLayoutsToUpdate.push(...projectedLayout.itemSectionLayouts);
|
||||
sectionLayoutsToUpdate.push(...projectedLayout.sectionLayouts);
|
||||
}
|
||||
|
||||
const removedLayoutIds = removedLayouts.map((layout) => layout.id);
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
if (layoutsToInsert.length > 0) await transaction.insert(schema.layouts).values(layoutsToInsert);
|
||||
if (itemSectionLayoutsToInsert.length > 0) {
|
||||
await transaction.insert(schema.itemLayouts).values(itemSectionLayoutsToInsert);
|
||||
}
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
|
||||
}
|
||||
|
||||
for (const itemLayout of itemSectionLayoutsToUpdate) {
|
||||
await transaction
|
||||
.update(schema.itemLayouts)
|
||||
.set({
|
||||
height: itemLayout.height,
|
||||
width: itemLayout.width,
|
||||
xOffset: itemLayout.xOffset,
|
||||
yOffset: itemLayout.yOffset,
|
||||
sectionId: itemLayout.sectionId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.itemLayouts.itemId, itemLayout.itemId),
|
||||
eq(schema.itemLayouts.layoutId, itemLayout.layoutId),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const sectionLayout of sectionLayoutsToUpdate) {
|
||||
await transaction
|
||||
.update(schema.sectionLayouts)
|
||||
.set({
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.sectionLayouts.sectionId, sectionLayout.sectionId),
|
||||
eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const layout of layoutsToUpdate) {
|
||||
await transaction
|
||||
.update(schema.layouts)
|
||||
.set({
|
||||
name: layout.name,
|
||||
columnCount: layout.columnCount,
|
||||
leftGutterColumnCount: layout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: layout.rightGutterColumnCount,
|
||||
breakpoint: layout.breakpoint,
|
||||
})
|
||||
.where(eq(schema.layouts.id, layout.id));
|
||||
}
|
||||
if (removedLayoutIds.length > 0) {
|
||||
await transaction.delete(schema.layouts).where(inArray(schema.layouts.id, removedLayoutIds));
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
if (layoutsToInsert.length > 0) transaction.insert(layouts).values(layoutsToInsert).run();
|
||||
if (itemSectionLayoutsToInsert.length > 0) {
|
||||
transaction.insert(itemLayouts).values(itemSectionLayoutsToInsert).run();
|
||||
}
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
|
||||
}
|
||||
|
||||
for (const itemLayout of itemSectionLayoutsToUpdate) {
|
||||
transaction
|
||||
.update(itemLayouts)
|
||||
.set({
|
||||
height: itemLayout.height,
|
||||
width: itemLayout.width,
|
||||
xOffset: itemLayout.xOffset,
|
||||
yOffset: itemLayout.yOffset,
|
||||
sectionId: itemLayout.sectionId,
|
||||
})
|
||||
.where(and(eq(itemLayouts.itemId, itemLayout.itemId), eq(itemLayouts.layoutId, itemLayout.layoutId)))
|
||||
.run();
|
||||
}
|
||||
for (const sectionLayout of sectionLayoutsToUpdate) {
|
||||
transaction
|
||||
.update(sectionLayouts)
|
||||
.set({
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(sectionLayouts.sectionId, sectionLayout.sectionId),
|
||||
eq(sectionLayouts.layoutId, sectionLayout.layoutId),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
for (const layout of layoutsToUpdate) {
|
||||
transaction
|
||||
.update(layouts)
|
||||
.set({
|
||||
name: layout.name,
|
||||
columnCount: layout.columnCount,
|
||||
leftGutterColumnCount: layout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: layout.rightGutterColumnCount,
|
||||
breakpoint: layout.breakpoint,
|
||||
})
|
||||
.where(eq(layouts.id, layout.id))
|
||||
.run();
|
||||
}
|
||||
if (removedLayoutIds.length > 0)
|
||||
transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
layoutsToInsert.push({
|
||||
id: layoutId,
|
||||
name: addedLayout.name,
|
||||
columnCount: addedLayout.columnCount,
|
||||
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
|
||||
breakpoint: addedLayout.breakpoint,
|
||||
boardId: board.id,
|
||||
});
|
||||
return input.layouts
|
||||
.map((layout) => ({
|
||||
...layout,
|
||||
id: savedLayoutIds.get(layout.id) ?? layout.id,
|
||||
role: existingLayoutsById.get(layout.id)?.role ?? "custom",
|
||||
}))
|
||||
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint);
|
||||
}),
|
||||
resetLayout: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Reset a board's Mobile or custom layout from its Base layout while preserving the target layout settings. Requires modify permission. REQUIRED: boardId (board ID), layoutId (non-Base layout ID)",
|
||||
},
|
||||
})
|
||||
.input(boardResetLayoutSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
|
||||
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
|
||||
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
|
||||
}
|
||||
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.boardId), ctx.session.user.id);
|
||||
const targetLayout = board.layouts.find((layout) => layout.id === input.layoutId);
|
||||
const baseLayout = board.layouts.find((layout) => layout.role === "base");
|
||||
|
||||
if (layoutsToInsert.length > 0) {
|
||||
await ctx.db.insert(layouts).values(layoutsToInsert);
|
||||
}
|
||||
|
||||
if (itemSectionLayoutsToInsert.length > 0) {
|
||||
await ctx.db.insert(itemLayouts).values(itemSectionLayoutsToInsert);
|
||||
}
|
||||
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
await ctx.db.insert(sectionLayouts).values(sectionLayoutsToInsert);
|
||||
}
|
||||
|
||||
const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts);
|
||||
for (const updatedLayout of updatedLayouts) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id)!;
|
||||
let updatedBoardLayout: ReturnType<typeof getUpdatedBoardLayout> | undefined;
|
||||
|
||||
if (
|
||||
dbLayout.columnCount !== updatedLayout.columnCount ||
|
||||
dbLayout.leftGutterColumnCount !== updatedLayout.leftGutterColumnCount ||
|
||||
dbLayout.rightGutterColumnCount !== updatedLayout.rightGutterColumnCount
|
||||
) {
|
||||
updatedBoardLayout = getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: dbLayout.columnCount,
|
||||
leftGutterColumnCount: dbLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: dbLayout.rightGutterColumnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: updatedLayout.columnCount,
|
||||
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
|
||||
},
|
||||
});
|
||||
|
||||
for (const itemSectionLayout of updatedBoardLayout.itemSectionLayouts) {
|
||||
await ctx.db
|
||||
.update(itemLayouts)
|
||||
.set({
|
||||
height: itemSectionLayout.height,
|
||||
width: itemSectionLayout.width,
|
||||
xOffset: itemSectionLayout.xOffset,
|
||||
yOffset: itemSectionLayout.yOffset,
|
||||
sectionId: itemSectionLayout.sectionId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(itemLayouts.itemId, itemSectionLayout.itemId),
|
||||
eq(itemLayouts.layoutId, itemSectionLayout.layoutId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const sectionLayout of updatedBoardLayout.sectionLayouts) {
|
||||
await ctx.db
|
||||
.update(sectionLayouts)
|
||||
.set({
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(sectionLayouts.sectionId, sectionLayout.sectionId),
|
||||
eq(sectionLayouts.layoutId, sectionLayout.layoutId),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!targetLayout) throw new TRPCError({ code: "NOT_FOUND", message: "Layout not found" });
|
||||
if (!baseLayout) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" });
|
||||
}
|
||||
if (targetLayout.role === "base") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "The Base layout cannot be reset" });
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(layouts)
|
||||
.set({
|
||||
name: updatedLayout.name,
|
||||
columnCount: updatedLayout.columnCount,
|
||||
leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
|
||||
breakpoint: updatedLayout.breakpoint,
|
||||
})
|
||||
.where(eq(layouts.id, updatedLayout.id));
|
||||
}
|
||||
const projectedLayout = getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
layoutId: baseLayout.id,
|
||||
columnCount: baseLayout.columnCount,
|
||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId: targetLayout.id,
|
||||
columnCount: targetLayout.columnCount,
|
||||
leftGutterColumnCount: targetLayout.leftGutterColumnCount,
|
||||
rightGutterColumnCount: targetLayout.rightGutterColumnCount,
|
||||
},
|
||||
});
|
||||
|
||||
const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
|
||||
const removedLayoutIds = removedLayouts.map((layout) => layout.id);
|
||||
if (removedLayoutIds.length > 0) {
|
||||
await ctx.db.delete(layouts).where(inArray(layouts.id, removedLayoutIds));
|
||||
}
|
||||
}),
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.delete(schema.itemLayouts).where(eq(schema.itemLayouts.layoutId, targetLayout.id));
|
||||
await transaction.delete(schema.sectionLayouts).where(eq(schema.sectionLayouts.layoutId, targetLayout.id));
|
||||
if (projectedLayout.itemSectionLayouts.length > 0) {
|
||||
await transaction.insert(schema.itemLayouts).values(projectedLayout.itemSectionLayouts);
|
||||
}
|
||||
if (projectedLayout.sectionLayouts.length > 0) {
|
||||
await transaction.insert(schema.sectionLayouts).values(projectedLayout.sectionLayouts);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.delete(itemLayouts).where(eq(itemLayouts.layoutId, targetLayout.id)).run();
|
||||
transaction.delete(sectionLayouts).where(eq(sectionLayouts.layoutId, targetLayout.id)).run();
|
||||
if (projectedLayout.itemSectionLayouts.length > 0) {
|
||||
transaction.insert(itemLayouts).values(projectedLayout.itemSectionLayouts).run();
|
||||
}
|
||||
if (projectedLayout.sectionLayouts.length > 0) {
|
||||
transaction.insert(sectionLayouts).values(projectedLayout.sectionLayouts).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}),
|
||||
savePartialBoardSettings: protectedProcedure
|
||||
.meta({
|
||||
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
|
||||
@@ -1556,13 +1840,14 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
|
||||
const sizeForLayout = { ...defaultSize, width: Math.min(layout.columnCount, defaultSize.width) };
|
||||
|
||||
const fitsAt = (x: number, y: number) => {
|
||||
if (x + defaultSize.width > layout.columnCount) return false;
|
||||
for (let dy = 0; dy < defaultSize.height; dy++) {
|
||||
if (x + sizeForLayout.width > layout.columnCount) return false;
|
||||
for (let dy = 0; dy < sizeForLayout.height; dy++) {
|
||||
const row = occupied[y + dy];
|
||||
if (!row) continue;
|
||||
for (let dx = 0; dx < defaultSize.width; dx++) {
|
||||
for (let dx = 0; dx < sizeForLayout.width; dx++) {
|
||||
if (row[x + dx]) return false;
|
||||
}
|
||||
}
|
||||
@@ -1580,8 +1865,8 @@ export const boardRouter = createTRPCRouter({
|
||||
layoutId: layout.id,
|
||||
xOffset: x,
|
||||
yOffset: y,
|
||||
width: defaultSize.width,
|
||||
height: defaultSize.height,
|
||||
width: sizeForLayout.width,
|
||||
height: sizeForLayout.height,
|
||||
});
|
||||
placed = true;
|
||||
}
|
||||
@@ -1691,14 +1976,43 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
|
||||
}
|
||||
};
|
||||
|
||||
interface BoardForLayoutProjection {
|
||||
id: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
layouts: Array<{
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
}>;
|
||||
}>;
|
||||
sections: Array<{
|
||||
id: string;
|
||||
kind: string;
|
||||
xOffset?: number | null;
|
||||
yOffset?: number | null;
|
||||
layouts?: Array<{
|
||||
layoutId: string;
|
||||
parentSectionId: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
const getUpdatedBoardLayout = (
|
||||
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
|
||||
board: BoardForLayoutProjection,
|
||||
options: {
|
||||
previous: BoardLayoutGeometry;
|
||||
previous: BoardLayoutGeometry & { elements?: GridAlgorithmItem[] };
|
||||
current: BoardLayoutGeometry;
|
||||
},
|
||||
) => {
|
||||
const elements = getElementsForLayout(board, options.previous.layoutId);
|
||||
const elements = options.previous.elements ?? getElementsForLayout(board, options.previous.layoutId);
|
||||
const emptyRoots = board.sections
|
||||
.filter((section) => section.kind === "empty")
|
||||
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0) || first.id.localeCompare(second.id));
|
||||
@@ -1734,7 +2048,12 @@ const getUpdatedBoardLayout = (
|
||||
const root = rootByLane.get(lane);
|
||||
const width = getBoardLaneColumnCount(options.current, lane);
|
||||
if (!root || width === 0) return [];
|
||||
const sourceLanes = boardLanes.filter((sourceLane) => targetLaneBySourceLane.get(sourceLane) === lane);
|
||||
const sourceLanes = boardLanes.filter(
|
||||
(sourceLane) =>
|
||||
rootByLane.has(sourceLane) &&
|
||||
getBoardLaneColumnCount(options.previous, sourceLane) > 0 &&
|
||||
targetLaneBySourceLane.get(sourceLane) === lane,
|
||||
);
|
||||
const previousWidth =
|
||||
sourceLanes.length === 1
|
||||
? getBoardLaneColumnCount(options.previous, sourceLanes[0] ?? "main")
|
||||
@@ -1793,6 +2112,35 @@ const getUpdatedBoardLayout = (
|
||||
};
|
||||
};
|
||||
|
||||
const getElementsForProjectedLayout = (
|
||||
projectedLayout: ReturnType<typeof getUpdatedBoardLayout>,
|
||||
): GridAlgorithmItem[] => [
|
||||
...projectedLayout.itemSectionLayouts.map((layout) => ({
|
||||
id: layout.itemId,
|
||||
type: "item" as const,
|
||||
height: layout.height,
|
||||
width: layout.width,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
sectionId: layout.sectionId,
|
||||
})),
|
||||
...projectedLayout.sectionLayouts.flatMap((layout) =>
|
||||
layout.parentSectionId
|
||||
? [
|
||||
{
|
||||
id: layout.sectionId,
|
||||
type: "section" as const,
|
||||
height: layout.height,
|
||||
width: layout.width,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
sectionId: layout.parentSectionId,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
];
|
||||
|
||||
interface BoardLayoutGeometry {
|
||||
layoutId: string;
|
||||
columnCount: number;
|
||||
@@ -1873,43 +2221,54 @@ const ensureGutterRootSectionsAsync = async (db: Database, boardId: string, lane
|
||||
});
|
||||
};
|
||||
|
||||
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
|
||||
const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string) => {
|
||||
const sectionElements = board.sections
|
||||
.filter((section) => section.kind === "container")
|
||||
.map((section) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!;
|
||||
.flatMap((section) => {
|
||||
const clonedLayout = section.layouts?.find((sectionLayout) => sectionLayout.layoutId === layoutId);
|
||||
if (!clonedLayout?.parentSectionId) return [];
|
||||
|
||||
return {
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
return [
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
height: clonedLayout.height,
|
||||
width: clonedLayout.width,
|
||||
xOffset: clonedLayout.xOffset,
|
||||
yOffset: clonedLayout.yOffset,
|
||||
sectionId: clonedLayout.parentSectionId,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const itemElements = board.items.flatMap((item) => {
|
||||
const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId);
|
||||
if (!clonedLayout) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
height: clonedLayout.height,
|
||||
width: clonedLayout.width,
|
||||
xOffset: clonedLayout.xOffset,
|
||||
yOffset: clonedLayout.yOffset,
|
||||
sectionId: clonedLayout.parentSectionId,
|
||||
};
|
||||
});
|
||||
|
||||
const itemElements = board.items.map((item) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId)!;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
height: clonedLayout.height,
|
||||
width: clonedLayout.width,
|
||||
xOffset: clonedLayout.xOffset,
|
||||
yOffset: clonedLayout.yOffset,
|
||||
sectionId: clonedLayout.sectionId,
|
||||
};
|
||||
sectionId: clonedLayout.sectionId,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return [...itemElements, ...sectionElements];
|
||||
};
|
||||
|
||||
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
|
||||
const protectedLayoutRepairPromises = new Map<string, Promise<void>>();
|
||||
|
||||
const getFullBoardWithWhereAsync = async (
|
||||
db: Database,
|
||||
where: SQL<unknown>,
|
||||
userId: string | null,
|
||||
repairProtectedLayouts = true,
|
||||
) => {
|
||||
const groupPermissionWhere = userId
|
||||
? inArray(
|
||||
boardGroupPermissions.groupId,
|
||||
@@ -1965,6 +2324,18 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
});
|
||||
}
|
||||
|
||||
if (repairProtectedLayouts && boardLayoutsNeedRepair(board.layouts)) {
|
||||
let repairPromise = protectedLayoutRepairPromises.get(board.id);
|
||||
if (!repairPromise) {
|
||||
repairPromise = seedProtectedBoardLayoutsAsync(db, board.id).finally(() => {
|
||||
protectedLayoutRepairPromises.delete(board.id);
|
||||
});
|
||||
protectedLayoutRepairPromises.set(board.id, repairPromise);
|
||||
}
|
||||
await repairPromise;
|
||||
return getFullBoardWithWhereAsync(db, where, userId, false);
|
||||
}
|
||||
|
||||
const { sections, items, layouts, ...otherBoardProperties } = board;
|
||||
|
||||
return {
|
||||
@@ -2010,6 +2381,23 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
||||
};
|
||||
};
|
||||
|
||||
export const boardLayoutsNeedRepair = (
|
||||
boardLayouts: Array<{ id: string; breakpoint: number; role: "mobile" | "base" | "custom" }>,
|
||||
) => {
|
||||
const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
|
||||
const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
|
||||
const baseLayout = baseLayouts.at(0);
|
||||
|
||||
return (
|
||||
mobileLayouts.length !== 1 ||
|
||||
baseLayouts.length !== 1 ||
|
||||
mobileLayouts.at(0)?.breakpoint !== 0 ||
|
||||
!baseLayout ||
|
||||
boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) ||
|
||||
new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
|
||||
);
|
||||
};
|
||||
|
||||
const forKind = <T extends WidgetKind>(kind: T) =>
|
||||
z.object({
|
||||
kind: z.literal(kind),
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
export interface GridAlgorithmItem {
|
||||
id: string;
|
||||
type: "item" | "section";
|
||||
width: number;
|
||||
height: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
interface GridAlgorithmInput {
|
||||
items: GridAlgorithmItem[];
|
||||
width: number;
|
||||
previousWidth: number;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
interface GridAlgorithmOutput {
|
||||
height: number;
|
||||
items: GridAlgorithmItem[];
|
||||
}
|
||||
|
||||
export const generateResponsiveGridFor = ({
|
||||
items,
|
||||
previousWidth,
|
||||
width,
|
||||
sectionId,
|
||||
}: GridAlgorithmInput): GridAlgorithmOutput => {
|
||||
const itemsOfCurrentSection = items
|
||||
.filter((item) => item.sectionId === sectionId)
|
||||
.toSorted((itemA, itemB) =>
|
||||
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
|
||||
);
|
||||
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
|
||||
|
||||
if (itemsOfCurrentSection.length === 0) {
|
||||
return {
|
||||
height: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const newItems: GridAlgorithmItem[] = [];
|
||||
|
||||
// A container must remain tall enough for its recursively reflowed contents.
|
||||
const containerHeightMap = new Map<string, number>();
|
||||
const containersOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
|
||||
for (const container of containersOfCurrentSection) {
|
||||
const result = generateResponsiveGridFor({
|
||||
items,
|
||||
previousWidth: container.previousWidth,
|
||||
width: container.width,
|
||||
sectionId: container.id,
|
||||
});
|
||||
newItems.push(...result.items);
|
||||
containerHeightMap.set(container.id, result.height);
|
||||
}
|
||||
|
||||
// Return same positions for items in the current section
|
||||
if (width >= previousWidth) {
|
||||
return {
|
||||
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
|
||||
items: newItems.concat(normalizedItems),
|
||||
};
|
||||
}
|
||||
|
||||
const occupied2d: boolean[][] = [];
|
||||
|
||||
for (const item of normalizedItems) {
|
||||
const itemWithHeight = {
|
||||
...item,
|
||||
height: item.type === "section" ? Math.max(containerHeightMap.get(item.id) ?? 1, item.height) : item.height,
|
||||
};
|
||||
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
|
||||
if (!position) throw new Error("No free spot available");
|
||||
|
||||
addItemToOccupied(occupied2d, itemWithHeight, position, width);
|
||||
newItems.push({
|
||||
...itemWithHeight,
|
||||
xOffset: position.x,
|
||||
yOffset: position.y,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
height: occupied2d.length,
|
||||
items: newItems,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces the width of the items to fit the new column count.
|
||||
* @param items items to normalize
|
||||
* @param columnCount new column count
|
||||
*/
|
||||
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
|
||||
return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds the item to the occupied spots.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param position position to place the item
|
||||
*/
|
||||
const addItemToOccupied = (
|
||||
occupied2d: boolean[][],
|
||||
item: GridAlgorithmItem,
|
||||
position: { x: number; y: number },
|
||||
columnCount: number,
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < item.height; yOffset++) {
|
||||
let row = occupied2d[position.y + yOffset];
|
||||
if (!row) {
|
||||
addRow(occupied2d, columnCount);
|
||||
// After adding it, it must exist
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
row = occupied2d[position.y + yOffset]!;
|
||||
}
|
||||
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) {
|
||||
row[position.x + xOffset] = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new row to the grid.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param columnCount column count of section
|
||||
*/
|
||||
const addRow = (occupied2d: boolean[][], columnCount: number) => {
|
||||
occupied2d.push(new Array<boolean>(columnCount).fill(false));
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for the next free spot in the grid.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param columnCount column count of section
|
||||
* @returns the position of the next free spot or null if no spot is available
|
||||
*/
|
||||
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
|
||||
for (let offsetY = 0; offsetY < 99999; offsetY++) {
|
||||
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
|
||||
if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
|
||||
return { x: offsetX, y: offsetY };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the item fits into the grid horizontally.
|
||||
* @param columnCount available width
|
||||
* @param item item to place
|
||||
* @param offsetX current x position
|
||||
* @returns true if the item fits horizontally
|
||||
*/
|
||||
const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
|
||||
return offsetX + item.width <= columnCount;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the spot is free.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param position position to check
|
||||
* @returns true if the spot is free
|
||||
*/
|
||||
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
|
||||
for (let yOffset = 0; yOffset < item.height; yOffset++) {
|
||||
const row = occupied2d[position.y + yOffset];
|
||||
if (!row) return true; // Empty row is free
|
||||
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) {
|
||||
if (row[position.x + xOffset]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GridAlgorithmItem } from "../grid-algorithm";
|
||||
import { generateResponsiveGridFor } from "../grid-algorithm";
|
||||
import { generateResponsiveGridFor } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
|
||||
const ROOT_SECTION_ID = "section";
|
||||
|
||||
@@ -78,6 +78,26 @@ describe("Grid Algorithm", () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("stops projecting a corrupted recursive section graph", () => {
|
||||
const recursiveSection = algoItem({ id: "recursive", type: "section", width: 3 });
|
||||
const duplicateRecursiveLayout = algoItem({
|
||||
id: "recursive",
|
||||
type: "section",
|
||||
width: 3,
|
||||
sectionId: "recursive",
|
||||
});
|
||||
|
||||
const result = generateResponsiveGridFor({
|
||||
items: [recursiveSection, duplicateRecursiveLayout],
|
||||
width: 3,
|
||||
previousWidth: 12,
|
||||
sectionId: ROOT_SECTION_ID,
|
||||
});
|
||||
|
||||
expect(result.items).toContainEqual(expect.objectContaining({ id: "recursive", sectionId: ROOT_SECTION_ID }));
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
const algoItem = (item: Partial<GridAlgorithmItem>): GridAlgorithmItem => ({
|
||||
|
||||
@@ -294,9 +294,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const section = board.sections.find((boardSection) => boardSection.kind === "empty");
|
||||
const layout = board.layouts[0];
|
||||
|
||||
if (section && layout) {
|
||||
if (section && board.layouts.length > 0) {
|
||||
const existingItems = await db.query.items.findMany({
|
||||
where: eq(items.boardId, board.id),
|
||||
});
|
||||
@@ -310,12 +308,12 @@ export const onboardRouter = createTRPCRouter({
|
||||
|
||||
await placeAllWidgetsAsync(
|
||||
db,
|
||||
{
|
||||
board.layouts.map((layout) => ({
|
||||
boardId: board.id,
|
||||
sectionId: section.id,
|
||||
layoutId: layout.id,
|
||||
columnCount: layout.columnCount,
|
||||
},
|
||||
})),
|
||||
allIntegrations,
|
||||
allApps,
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database, InferInsertModel } from "@homarr/db";
|
||||
import { and, eq, not } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
boards,
|
||||
@@ -325,10 +325,13 @@ describe("createBoard should create a new board", () => {
|
||||
expect(firstSection?.xOffset).toBe(0);
|
||||
expect(firstSection?.yOffset).toBe(0);
|
||||
|
||||
expect(dbBoard?.layouts.length).toBe(1);
|
||||
const firstLayout = dbBoard?.layouts.at(0);
|
||||
expect(firstLayout?.columnCount).toBe(24);
|
||||
expect(firstLayout?.breakpoint).toBe(0);
|
||||
expect(dbBoard?.layouts.length).toBe(2);
|
||||
expect(dbBoard?.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error when user has no board-create permission", async () => {
|
||||
@@ -344,6 +347,36 @@ describe("createBoard should create a new board", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateBoard should preserve protected layout invariants", () => {
|
||||
test("normalizes a legacy single-layout board while preserving Base positions", async () => {
|
||||
const db = createDb();
|
||||
const session = {
|
||||
...defaultSession,
|
||||
user: {
|
||||
...defaultSession.user,
|
||||
permissions: ["board-create"] satisfies GroupPermissionKey[],
|
||||
},
|
||||
};
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { boardId } = await createFullBoardAsync(db, "source");
|
||||
|
||||
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
|
||||
|
||||
const copiedBoard = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, result.boardId),
|
||||
with: { layouts: true, items: { with: { layouts: true } } },
|
||||
});
|
||||
expect(copiedBoard?.layouts).toHaveLength(2);
|
||||
expect(copiedBoard?.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
expect(copiedBoard?.items.at(0)?.layouts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename board should rename board", () => {
|
||||
test("should rename board", async () => {
|
||||
// Arrange
|
||||
@@ -569,6 +602,13 @@ describe("getBoardByName should return board by name", () => {
|
||||
name,
|
||||
...fullBoardProps,
|
||||
});
|
||||
expect(result.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "mobile", breakpoint: 0, columnCount: 3 }),
|
||||
expect.objectContaining({ id: fullBoardProps.layoutId, role: "base", breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
expect(result.items.at(0)?.layouts).toHaveLength(2);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||
});
|
||||
|
||||
@@ -1330,12 +1370,12 @@ describe("saveBoard should save full board", () => {
|
||||
expect(firstItem.id).toBe(itemId);
|
||||
expect(firstItem.kind).toBe("clock");
|
||||
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
|
||||
const firstLayout = expectToBeDefined(firstItem.layouts[0]);
|
||||
expect(firstLayout.sectionId).toBe(sectionId);
|
||||
expect(firstLayout.height).toBe(3);
|
||||
expect(firstLayout.width).toBe(2);
|
||||
expect(firstLayout.xOffset).toBe(7);
|
||||
expect(firstLayout.yOffset).toBe(5);
|
||||
const updatedLayout = expectToBeDefined(firstItem.layouts.find((layout) => layout.layoutId === layoutId));
|
||||
expect(updatedLayout.sectionId).toBe(sectionId);
|
||||
expect(updatedLayout.height).toBe(3);
|
||||
expect(updatedLayout.width).toBe(2);
|
||||
expect(updatedLayout.xOffset).toBe(7);
|
||||
expect(updatedLayout.yOffset).toBe(5);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
});
|
||||
it("should fail when board not found", async () => {
|
||||
@@ -1522,7 +1562,17 @@ const createExistingLayout = (id: string) => ({
|
||||
columnCount: 10,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
});
|
||||
const createMobileLayout = (id: string) => ({
|
||||
id,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
});
|
||||
const createNewLayout = (columnCount: number) => ({
|
||||
id: createId(),
|
||||
@@ -1530,8 +1580,23 @@ const createNewLayout = (columnCount: number) => ({
|
||||
columnCount,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 1400,
|
||||
breakpoint: 480,
|
||||
role: "custom" as const,
|
||||
});
|
||||
const addMobileLayoutAsync = async (db: Database, boardId: string) => {
|
||||
const mobileLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
boardId,
|
||||
});
|
||||
return mobileLayoutId;
|
||||
};
|
||||
describe("saveLayouts should save layout changes", () => {
|
||||
test("should add layout when not present in database", async () => {
|
||||
// Arrange
|
||||
@@ -1539,23 +1604,26 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const newLayout = createNewLayout(12);
|
||||
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
const savedLayouts = await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createExistingLayout(layoutId), newLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const layout = await db.query.layouts.findFirst({
|
||||
where: not(eq(layouts.id, layoutId)),
|
||||
where: eq(layouts.role, "custom"),
|
||||
});
|
||||
|
||||
const definedLayout = expectToBeDefined(layout);
|
||||
expect(definedLayout.name).toBe(newLayout.name);
|
||||
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
|
||||
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
|
||||
expect(definedLayout.role).toBe("custom");
|
||||
expect(savedLayouts.find((candidate) => candidate.role === "custom")?.id).toBe(definedLayout.id);
|
||||
});
|
||||
test("should add items and containers generated from grid-algorithm when new layout is added", async () => {
|
||||
// Arrange
|
||||
@@ -1563,6 +1631,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const assignments = await createItemsAndSectionsAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
@@ -1573,12 +1642,12 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createExistingLayout(layoutId), newLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const layout = await db.query.layouts.findFirst({
|
||||
where: not(eq(layouts.id, layoutId)),
|
||||
where: eq(layouts.role, "custom"),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
|
||||
@@ -1594,6 +1663,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const updatedLayout = createExistingLayout(layoutId);
|
||||
updatedLayout.breakpoint = 1400;
|
||||
updatedLayout.name = "Updated layout";
|
||||
@@ -1601,7 +1671,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [updatedLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -1620,6 +1690,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const assignments = await createItemsAndSectionsAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
@@ -1631,7 +1702,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [updatedLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -1645,6 +1716,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "gutters");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
|
||||
await db
|
||||
.update(itemLayouts)
|
||||
@@ -1656,6 +1728,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [
|
||||
createMobileLayout(mobileLayoutId),
|
||||
{
|
||||
...createExistingLayout(layoutId),
|
||||
leftGutterColumnCount: 2,
|
||||
@@ -1684,7 +1757,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
});
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createExistingLayout(layoutId)],
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
|
||||
});
|
||||
|
||||
const returnedItem = expectToBeDefined(
|
||||
@@ -1698,9 +1771,11 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "concurrent-gutters");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const input = {
|
||||
id: boardId,
|
||||
layouts: [
|
||||
createMobileLayout(mobileLayoutId),
|
||||
{
|
||||
...createExistingLayout(layoutId),
|
||||
leftGutterColumnCount: 2,
|
||||
@@ -1715,22 +1790,73 @@ describe("saveLayouts should save layout changes", () => {
|
||||
});
|
||||
expect(leftRoots).toHaveLength(1);
|
||||
});
|
||||
test("should project a new custom layout from the resized Base layout", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const secondItemId = createId();
|
||||
|
||||
await db.update(itemLayouts).set({ width: 4, xOffset: 0 }).where(eq(itemLayouts.itemId, itemId));
|
||||
await db.insert(items).values({
|
||||
id: secondItemId,
|
||||
kind: "clock",
|
||||
boardId,
|
||||
options: SuperJSON.stringify({ is24HourFormat: true }),
|
||||
});
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId: secondItemId,
|
||||
layoutId,
|
||||
sectionId,
|
||||
width: 4,
|
||||
height: 1,
|
||||
xOffset: 6,
|
||||
yOffset: 0,
|
||||
});
|
||||
|
||||
const resizedBase = { ...createExistingLayout(layoutId), columnCount: 6 };
|
||||
const savedLayouts = await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createMobileLayout(mobileLayoutId), createNewLayout(8), resizedBase],
|
||||
});
|
||||
const customLayoutId = expectToBeDefined(savedLayouts.find((layout) => layout.role === "custom")).id;
|
||||
|
||||
const basePosition = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, layoutId)),
|
||||
});
|
||||
const customPosition = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, customLayoutId)),
|
||||
});
|
||||
|
||||
expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
|
||||
expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
|
||||
});
|
||||
test("should remove layout when not present in input", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const customLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: customLayoutId,
|
||||
name: "Custom",
|
||||
columnCount: 6,
|
||||
breakpoint: 480,
|
||||
role: "custom",
|
||||
boardId,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createNewLayout(12)],
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const layout = await db.query.layouts.findFirst({
|
||||
where: eq(layouts.id, layoutId),
|
||||
where: eq(layouts.id, customLayoutId),
|
||||
});
|
||||
expect(layout).toBeUndefined();
|
||||
});
|
||||
@@ -1745,12 +1871,73 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const actAsync = async () =>
|
||||
await caller.saveLayouts({
|
||||
id: createId(),
|
||||
layouts: [createExistingLayout(layoutId)],
|
||||
layouts: [createMobileLayout(createId()), createExistingLayout(layoutId)],
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrowError("Board not found");
|
||||
});
|
||||
test("should reject changing protected layout roles", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
|
||||
const actAsync = async () =>
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [
|
||||
{ ...createExistingLayout(layoutId), role: "mobile", breakpoint: 0 },
|
||||
{ ...createMobileLayout(mobileLayoutId), role: "base", breakpoint: 768 },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(actAsync()).rejects.toThrowError("Layout roles cannot be changed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetLayout should rebuild a layout from Base", () => {
|
||||
test("should replace target item positions and preserve layout metadata", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
await addMobileLayoutAsync(db, boardId);
|
||||
const customLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: customLayoutId,
|
||||
name: "Tablet",
|
||||
columnCount: 6,
|
||||
breakpoint: 480,
|
||||
role: "custom",
|
||||
boardId,
|
||||
});
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId: customLayoutId,
|
||||
width: 2,
|
||||
height: 2,
|
||||
xOffset: 3,
|
||||
yOffset: 4,
|
||||
});
|
||||
|
||||
await caller.resetLayout({ boardId, layoutId: customLayoutId });
|
||||
|
||||
const resetPosition = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, customLayoutId)),
|
||||
});
|
||||
expect(resetPosition).toMatchObject({ width: 1, height: 1, xOffset: 0, yOffset: 0, sectionId });
|
||||
const customLayout = await db.query.layouts.findFirst({ where: eq(layouts.id, customLayoutId) });
|
||||
expect(customLayout).toMatchObject({ name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" });
|
||||
});
|
||||
|
||||
test("should reject resetting Base", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await expect(caller.resetLayout({ boardId, layoutId })).rejects.toThrowError("Base layout cannot be reset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Custom Widget placement permissions", () => {
|
||||
@@ -1848,7 +2035,8 @@ const createFullBoardAsync = async (db: Database, name: string) => {
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
});
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"board_deleteBoard",
|
||||
"board_duplicateBoard",
|
||||
"board_renameBoard",
|
||||
"board_resetLayout",
|
||||
"board_savePartialBoardSettings",
|
||||
"board_setHomeBoard",
|
||||
"board_setMobileHomeBoard",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { updateBoardName } from "./updater";
|
||||
|
||||
const BoardContext = createContext<{
|
||||
board: RouterOutputs["board"]["getBoardByName"];
|
||||
layoutOverrideId: string | null;
|
||||
currentLayout: string;
|
||||
initialViewportWidth: number;
|
||||
} | null>(null);
|
||||
@@ -41,10 +42,12 @@ export const BoardProvider = ({
|
||||
initialBoard,
|
||||
initialLayoutId,
|
||||
initialViewportWidth,
|
||||
layoutOverrideId = null,
|
||||
}: PropsWithChildren<{
|
||||
initialBoard: RouterOutputs["board"]["getBoardByName"];
|
||||
initialLayoutId: string;
|
||||
initialViewportWidth: number;
|
||||
layoutOverrideId?: string | null;
|
||||
}>) => {
|
||||
const { data } = clientApi.board.getBoardByName.useQuery(
|
||||
{ name: initialBoard.name },
|
||||
@@ -58,8 +61,8 @@ export const BoardProvider = ({
|
||||
);
|
||||
const currentLayout = useSyncExternalStore(
|
||||
subscribeToViewport,
|
||||
() => getCurrentLayout(data),
|
||||
() => initialLayoutId,
|
||||
() => getCurrentLayout(data, layoutOverrideId),
|
||||
() => layoutOverrideId ?? initialLayoutId,
|
||||
);
|
||||
|
||||
// Update the board name so it can be used within updateBoard method
|
||||
@@ -83,6 +86,7 @@ export const BoardProvider = ({
|
||||
<BoardContext.Provider
|
||||
value={{
|
||||
board: data,
|
||||
layoutOverrideId,
|
||||
currentLayout,
|
||||
initialViewportWidth,
|
||||
}}
|
||||
@@ -108,9 +112,17 @@ export const useOptionalBoard = () => {
|
||||
return context?.board ?? null;
|
||||
};
|
||||
|
||||
export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
|
||||
export const useLayoutOverride = () => useContext(BoardContext)?.layoutOverrideId ?? null;
|
||||
|
||||
export const getCurrentLayout = (
|
||||
board: RouterOutputs["board"]["getBoardByName"],
|
||||
layoutOverrideId: string | null = null,
|
||||
) => {
|
||||
const layouts = board.layouts;
|
||||
if (layoutOverrideId && layouts.some((layout) => layout.id === layoutOverrideId)) return layoutOverrideId;
|
||||
|
||||
const viewportWidth = typeof window === "undefined" ? 0 : getViewportWidth();
|
||||
return getLayoutIdForViewportWidth(board.layouts, viewportWidth);
|
||||
return getLayoutIdForViewportWidth(layouts, viewportWidth);
|
||||
};
|
||||
|
||||
export const useCurrentLayout = () => {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { createContext, useContext, useEffect } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
const EditModeContext = createContext<ReturnType<typeof useDisclosure> | null>(null);
|
||||
|
||||
export const EditModeProvider = ({ children }: PropsWithChildren) => {
|
||||
const editModeDisclosure = useDisclosure(false);
|
||||
export const EditModeProvider = ({ children, initialOpen = false }: PropsWithChildren<{ initialOpen?: boolean }>) => {
|
||||
const editModeDisclosure = useDisclosure(initialOpen);
|
||||
const [, { open }] = editModeDisclosure;
|
||||
|
||||
useEffect(() => {
|
||||
if (initialOpen) open();
|
||||
}, [initialOpen, open]);
|
||||
|
||||
return <EditModeContext.Provider value={editModeDisclosure}>{children}</EditModeContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
export interface GridAlgorithmItem {
|
||||
id: string;
|
||||
type: "item" | "section";
|
||||
width: number;
|
||||
height: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
interface GridAlgorithmInput {
|
||||
items: GridAlgorithmItem[];
|
||||
width: number;
|
||||
previousWidth: number;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
interface GridAlgorithmOutput {
|
||||
height: number;
|
||||
items: GridAlgorithmItem[];
|
||||
}
|
||||
|
||||
export const generateResponsiveGridFor = ({
|
||||
items,
|
||||
previousWidth,
|
||||
width,
|
||||
sectionId,
|
||||
}: GridAlgorithmInput): GridAlgorithmOutput =>
|
||||
generateResponsiveGridForSection({ items, previousWidth, width, sectionId }, new Set());
|
||||
|
||||
const generateResponsiveGridForSection = (
|
||||
{ items, previousWidth, width, sectionId }: GridAlgorithmInput,
|
||||
ancestorSectionIds: ReadonlySet<string>,
|
||||
): GridAlgorithmOutput => {
|
||||
if (ancestorSectionIds.has(sectionId)) return { height: 0, items: [] };
|
||||
|
||||
const nextAncestorSectionIds = new Set(ancestorSectionIds).add(sectionId);
|
||||
const itemsOfCurrentSection = items
|
||||
.filter((item) => item.sectionId === sectionId)
|
||||
.toSorted((itemA, itemB) =>
|
||||
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
|
||||
);
|
||||
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
|
||||
|
||||
if (itemsOfCurrentSection.length === 0) return { height: 0, items: [] };
|
||||
|
||||
const newItems: GridAlgorithmItem[] = [];
|
||||
const dynamicSectionHeightMap = new Map<string, number>();
|
||||
for (const dynamicSection of normalizedItems.filter((item) => item.type === "section")) {
|
||||
const result = generateResponsiveGridForSection(
|
||||
{
|
||||
items,
|
||||
previousWidth: dynamicSection.previousWidth,
|
||||
width: dynamicSection.width,
|
||||
sectionId: dynamicSection.id,
|
||||
},
|
||||
nextAncestorSectionIds,
|
||||
);
|
||||
newItems.push(...result.items);
|
||||
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
|
||||
}
|
||||
|
||||
if (width >= previousWidth) {
|
||||
return {
|
||||
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
|
||||
items: newItems.concat(normalizedItems),
|
||||
};
|
||||
}
|
||||
|
||||
const occupied2d: boolean[][] = [];
|
||||
for (const item of normalizedItems) {
|
||||
const itemWithHeight = {
|
||||
...item,
|
||||
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
|
||||
};
|
||||
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
|
||||
if (!position) throw new Error("No free spot available");
|
||||
|
||||
addItemToOccupied(occupied2d, itemWithHeight, position, width);
|
||||
newItems.push({ ...itemWithHeight, xOffset: position.x, yOffset: position.y });
|
||||
}
|
||||
|
||||
return { height: occupied2d.length, items: newItems };
|
||||
};
|
||||
|
||||
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) =>
|
||||
items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
|
||||
|
||||
const addItemToOccupied = (
|
||||
occupied2d: boolean[][],
|
||||
item: GridAlgorithmItem,
|
||||
position: { x: number; y: number },
|
||||
columnCount: number,
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < item.height; yOffset++) {
|
||||
let row = occupied2d[position.y + yOffset];
|
||||
if (!row) {
|
||||
row = Array.from({ length: columnCount }, () => false);
|
||||
occupied2d.push(row);
|
||||
}
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) row[position.x + xOffset] = true;
|
||||
}
|
||||
};
|
||||
|
||||
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
|
||||
for (let offsetY = 0; offsetY < 99999; offsetY++) {
|
||||
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
|
||||
if (offsetX + item.width <= columnCount && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
|
||||
return { x: offsetX, y: offsetY };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
|
||||
for (let yOffset = 0; yOffset < item.height; yOffset++) {
|
||||
const row = occupied2d[position.y + yOffset];
|
||||
if (!row) return true;
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) {
|
||||
if (row[position.x + xOffset]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -11,4 +11,5 @@ export * from "./number";
|
||||
export * from "./error";
|
||||
export * from "./theme";
|
||||
export * from "./function";
|
||||
export * from "./grid-algorithm";
|
||||
export * from "./id";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `layout` ADD `role` varchar(16) DEFAULT 'custom' NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -323,6 +323,13 @@
|
||||
"when": 1786212549933,
|
||||
"tag": "0045_add_layout_gutters",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "5",
|
||||
"when": 1786215199760,
|
||||
"tag": "0046_add_layout_role",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "layout" ADD COLUMN "role" varchar(16) DEFAULT 'custom' NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -85,6 +85,13 @@
|
||||
"when": 1786212457549,
|
||||
"tag": "0011_add_layout_gutters",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1786215201393,
|
||||
"tag": "0012_add_layout_role",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, itemLayouts, items, layouts, sections } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { emptySuperJSON } from "@homarr/definitions";
|
||||
|
||||
import { seedProtectedBoardLayoutsAsync } from "./seed";
|
||||
|
||||
describe("protected board layout migration", () => {
|
||||
test("turns a single saved layout into Base and generates Mobile", async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
const baseLayoutId = createId();
|
||||
const sectionId = createId();
|
||||
const itemId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "single" });
|
||||
await db.insert(layouts).values({
|
||||
id: baseLayoutId,
|
||||
boardId,
|
||||
name: "Wide",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
});
|
||||
await db.insert(sections).values({ id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 });
|
||||
await db.insert(items).values({ id: itemId, boardId, kind: "clock", options: emptySuperJSON });
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId: baseLayoutId,
|
||||
width: 5,
|
||||
height: 2,
|
||||
xOffset: 6,
|
||||
yOffset: 1,
|
||||
});
|
||||
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
|
||||
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
|
||||
expect(savedLayouts).toHaveLength(2);
|
||||
expect(savedLayouts.find((layout) => layout.role === "base")).toMatchObject({
|
||||
id: baseLayoutId,
|
||||
name: "Wide",
|
||||
columnCount: 12,
|
||||
breakpoint: 768,
|
||||
});
|
||||
const mobileLayout = savedLayouts.find((layout) => layout.role === "mobile");
|
||||
expect(mobileLayout).toMatchObject({ name: "Mobile", columnCount: 3, breakpoint: 0 });
|
||||
const mobileItemPosition = await db.query.itemLayouts.findFirst({
|
||||
where: eq(itemLayouts.layoutId, mobileLayout?.id ?? ""),
|
||||
});
|
||||
expect(mobileItemPosition).toMatchObject({ width: 3, height: 2, xOffset: 0, yOffset: 0 });
|
||||
});
|
||||
|
||||
test("assigns roles and resolves duplicate breakpoints without rebuilding existing layouts", async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "responsive" });
|
||||
await db.insert(layouts).values([
|
||||
{ id: "layout-mobile", boardId, name: "Phone", columnCount: 3, breakpoint: 0 },
|
||||
{ id: "layout-tablet-a", boardId, name: "Tablet A", columnCount: 6, breakpoint: 480 },
|
||||
{ id: "layout-tablet-b", boardId, name: "Tablet B", columnCount: 8, breakpoint: 480 },
|
||||
{ id: "layout-wide", boardId, name: "Wide", columnCount: 12, breakpoint: 1200 },
|
||||
]);
|
||||
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
|
||||
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
|
||||
expect(savedLayouts.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint)).toMatchObject([
|
||||
{ id: "layout-mobile", name: "Phone", columnCount: 3, breakpoint: 0, role: "mobile" },
|
||||
{ id: "layout-tablet-a", name: "Tablet A", columnCount: 6, breakpoint: 480, role: "custom" },
|
||||
{ id: "layout-tablet-b", name: "Tablet B", columnCount: 8, breakpoint: 481, role: "custom" },
|
||||
{ id: "layout-wide", name: "Wide", columnCount: 12, breakpoint: 1200, role: "base" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates both protected layouts for a board with no layouts", async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "empty" });
|
||||
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
|
||||
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
|
||||
expect(savedLayouts).toHaveLength(2);
|
||||
expect(savedLayouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
+234
-11
@@ -1,6 +1,6 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { createId, objectKeys } from "@homarr/common";
|
||||
import { createId, generateResponsiveGridFor, objectKeys } from "@homarr/common";
|
||||
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
createDocumentationLink,
|
||||
@@ -11,11 +11,12 @@ import {
|
||||
getIntegrationName,
|
||||
integrationDefs,
|
||||
integrationKinds,
|
||||
normalizeBoardLayoutRoles,
|
||||
} from "@homarr/definitions";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
import type { Database } from "..";
|
||||
import type { Database, InferInsertModel } from "..";
|
||||
import { eq, inArray } from "..";
|
||||
import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
|
||||
import {
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
onboarding,
|
||||
searchEngines,
|
||||
sections,
|
||||
sectionLayouts,
|
||||
users,
|
||||
} from "../schema";
|
||||
import type { Integration } from "../schema";
|
||||
@@ -65,6 +67,215 @@ export const seedDataAsync = async (db: Database) => {
|
||||
if (isTruthyEnv(process.env.DEMO_MODE)) {
|
||||
await seedDemoUserAsync(db);
|
||||
}
|
||||
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
};
|
||||
|
||||
export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: string) => {
|
||||
const dbBoards = await db.query.boards.findMany({
|
||||
where: boardId ? eq(boards.id, boardId) : undefined,
|
||||
with: {
|
||||
layouts: true,
|
||||
items: { with: { layouts: true } },
|
||||
sections: { with: { layouts: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const board of dbBoards) {
|
||||
if (board.layouts.length === 0) {
|
||||
await db.insert(layouts).values([
|
||||
{
|
||||
id: createId(),
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId: board.id,
|
||||
},
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (board.layouts.length === 1) {
|
||||
const [baseLayout] = board.layouts;
|
||||
if (!baseLayout) continue;
|
||||
|
||||
const mobileLayout = {
|
||||
id: createId(),
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
boardId: board.id,
|
||||
};
|
||||
|
||||
await db.update(layouts).set({ role: "base", breakpoint: 768 }).where(eq(layouts.id, baseLayout.id));
|
||||
await db.insert(layouts).values(mobileLayout);
|
||||
await insertMissingProjectedPositionsAsync(
|
||||
db,
|
||||
board,
|
||||
{ ...baseLayout, role: "base", breakpoint: 768 },
|
||||
mobileLayout,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedLayouts = normalizeBoardLayoutRoles(board.layouts);
|
||||
const alreadyNormalized = normalizedLayouts.every((layout) => {
|
||||
const previousLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||
return layout.breakpoint === previousLayout?.breakpoint && previousLayout.role === layout.role;
|
||||
});
|
||||
|
||||
if (!alreadyNormalized) {
|
||||
for (const layout of normalizedLayouts) {
|
||||
await db
|
||||
.update(layouts)
|
||||
.set({ breakpoint: layout.breakpoint, role: layout.role })
|
||||
.where(eq(layouts.id, layout.id));
|
||||
}
|
||||
}
|
||||
|
||||
const mobileLayout = normalizedLayouts[0];
|
||||
const baseLayout = normalizedLayouts.at(-1);
|
||||
if (mobileLayout && baseLayout) {
|
||||
await insertMissingProjectedPositionsAsync(
|
||||
db,
|
||||
board,
|
||||
{ ...baseLayout, role: "base" },
|
||||
{ ...mobileLayout, role: "mobile" },
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface BoardWithLayoutPositions {
|
||||
items: Array<{
|
||||
id: string;
|
||||
layouts: Array<{
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
}>;
|
||||
}>;
|
||||
sections: Array<{
|
||||
id: string;
|
||||
kind: string;
|
||||
layouts: Array<{
|
||||
layoutId: string;
|
||||
parentSectionId: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
const insertMissingProjectedPositionsAsync = async (
|
||||
db: Database,
|
||||
board: BoardWithLayoutPositions,
|
||||
sourceLayout: InferInsertModel<typeof layouts>,
|
||||
targetLayout: InferInsertModel<typeof layouts>,
|
||||
) => {
|
||||
const elements = [
|
||||
...board.items.flatMap((item) => {
|
||||
const layout = item.layouts.find((itemLayout) => itemLayout.layoutId === sourceLayout.id);
|
||||
return layout
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
sectionId: layout.sectionId,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
const layout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === sourceLayout.id);
|
||||
return layout?.parentSectionId
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
sectionId: layout.parentSectionId,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
|
||||
const projectedElements = board.sections
|
||||
.filter((section) => section.kind !== "dynamic")
|
||||
.flatMap(
|
||||
(section) =>
|
||||
generateResponsiveGridFor({
|
||||
items: elements,
|
||||
previousWidth: sourceLayout.columnCount,
|
||||
width: targetLayout.columnCount,
|
||||
sectionId: section.id,
|
||||
}).items,
|
||||
);
|
||||
|
||||
const existingItemIds = new Set(
|
||||
board.items
|
||||
.filter((item) => item.layouts.some((layout) => layout.layoutId === targetLayout.id))
|
||||
.map((item) => item.id),
|
||||
);
|
||||
const itemPositions = projectedElements
|
||||
.filter((element) => element.type === "item" && !existingItemIds.has(element.id))
|
||||
.map(
|
||||
(element): InferInsertModel<typeof itemLayouts> => ({
|
||||
itemId: element.id,
|
||||
layoutId: targetLayout.id,
|
||||
sectionId: element.sectionId,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
xOffset: element.xOffset,
|
||||
yOffset: element.yOffset,
|
||||
}),
|
||||
);
|
||||
|
||||
const existingSectionIds = new Set(
|
||||
board.sections
|
||||
.filter((section) => section.layouts.some((layout) => layout.layoutId === targetLayout.id))
|
||||
.map((section) => section.id),
|
||||
);
|
||||
const sectionPositions = projectedElements
|
||||
.filter((element) => element.type === "section" && !existingSectionIds.has(element.id))
|
||||
.map(
|
||||
(element): InferInsertModel<typeof sectionLayouts> => ({
|
||||
sectionId: element.id,
|
||||
layoutId: targetLayout.id,
|
||||
parentSectionId: element.sectionId,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
xOffset: element.xOffset,
|
||||
yOffset: element.yOffset,
|
||||
}),
|
||||
);
|
||||
|
||||
if (itemPositions.length > 0) await db.insert(itemLayouts).values(itemPositions);
|
||||
if (sectionPositions.length > 0) await db.insert(sectionLayouts).values(sectionPositions);
|
||||
};
|
||||
|
||||
const seedEveryoneGroupAsync = async (db: Database) => {
|
||||
@@ -271,13 +482,24 @@ const seedDefaultBoardAsync = async (db: Database) => {
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
await db.insert(layouts).values({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
});
|
||||
await db.insert(layouts).values([
|
||||
{
|
||||
id: createId(),
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
boardId,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
},
|
||||
]);
|
||||
|
||||
const everyoneGroupRow = await db.query.groups.findFirst({
|
||||
where: eq(groups.name, everyoneGroup),
|
||||
@@ -508,7 +730,8 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
});
|
||||
|
||||
@@ -598,7 +821,7 @@ const seedBoardWidgetsAsync = async (db: Database) => {
|
||||
if (!board) return;
|
||||
|
||||
const section = board.sections.find((sec) => sec.kind === "empty");
|
||||
const layout = board.layouts[0];
|
||||
const layout = board.layouts.find((candidate) => candidate.role === "base") ?? board.layouts[0];
|
||||
if (!section || !layout) return;
|
||||
|
||||
const allIntegrations = await db.query.integrations.findMany();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `layout` ADD `role` text DEFAULT 'custom' NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -309,6 +309,13 @@
|
||||
"when": 1786212459480,
|
||||
"tag": "0043_add_layout_gutters",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "6",
|
||||
"when": 1786215203092,
|
||||
"tag": "0044_add_layout_role",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./collection": "./collection.ts",
|
||||
"./migrations/seed": "./migrations/seed.ts",
|
||||
"./proxy-reader": "./proxy-reader.ts",
|
||||
"./schema": "./schema/index.ts",
|
||||
"./test": "./test/index.ts",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { boards, layouts, sections } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { placeAllWidgetsAsync } from "./widget-placement";
|
||||
|
||||
describe("placeAllWidgetsAsync", () => {
|
||||
test("creates independent positions for every target layout", async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
const sectionId = createId();
|
||||
const mobileLayoutId = createId();
|
||||
const baseLayoutId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "dashboard" });
|
||||
await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId });
|
||||
await db.insert(layouts).values([
|
||||
{
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
role: "mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
},
|
||||
{
|
||||
id: baseLayoutId,
|
||||
name: "Base",
|
||||
role: "base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
boardId,
|
||||
},
|
||||
]);
|
||||
|
||||
await placeAllWidgetsAsync(
|
||||
db,
|
||||
[
|
||||
{ boardId, sectionId, layoutId: mobileLayoutId, columnCount: 3 },
|
||||
{ boardId, sectionId, layoutId: baseLayoutId, columnCount: 10 },
|
||||
],
|
||||
[],
|
||||
[{ id: createId(), name: "Homarr" }],
|
||||
);
|
||||
|
||||
const placedItems = await db.query.items.findMany({ with: { layouts: true } });
|
||||
expect(placedItems.length).toBeGreaterThan(0);
|
||||
expect(placedItems.every((item) => item.layouts.length === 2)).toBe(true);
|
||||
expect(
|
||||
placedItems.every((item) =>
|
||||
item.layouts.some((layout) => layout.layoutId === mobileLayoutId && layout.width <= 3),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(placedItems.every((item) => item.layouts.some((layout) => layout.layoutId === baseLayoutId))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -30,46 +30,54 @@ interface PlacementContext extends BoardTarget {
|
||||
|
||||
const placeWidgetAsync = async (
|
||||
db: Database,
|
||||
ctx: PlacementContext,
|
||||
contexts: PlacementContext[],
|
||||
kind: WidgetKind,
|
||||
linkedIntegrationIds: string[],
|
||||
options?: string,
|
||||
size?: { width: number; height: number },
|
||||
) => {
|
||||
const itemWidth = size?.width ?? 2;
|
||||
const itemHeight = size?.height ?? 2;
|
||||
const boardId = contexts.at(0)?.boardId;
|
||||
if (!boardId) return;
|
||||
|
||||
if (ctx.xOffset + itemWidth > ctx.columnCount) {
|
||||
ctx.xOffset = 0;
|
||||
ctx.yOffset += ctx.rowMaxHeight;
|
||||
ctx.rowMaxHeight = 0;
|
||||
}
|
||||
const desiredItemWidth = size?.width ?? 2;
|
||||
const itemHeight = size?.height ?? 2;
|
||||
|
||||
const itemId = createId();
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId: ctx.boardId,
|
||||
boardId,
|
||||
kind,
|
||||
options: options ?? emptySuperJSON,
|
||||
advancedOptions: emptySuperJSON,
|
||||
});
|
||||
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId: ctx.sectionId,
|
||||
layoutId: ctx.layoutId,
|
||||
xOffset: ctx.xOffset,
|
||||
yOffset: ctx.yOffset,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
});
|
||||
await db.insert(itemLayouts).values(
|
||||
contexts.map((ctx) => {
|
||||
const itemWidth = Math.min(ctx.columnCount, desiredItemWidth);
|
||||
if (ctx.xOffset + itemWidth > ctx.columnCount) {
|
||||
ctx.xOffset = 0;
|
||||
ctx.yOffset += ctx.rowMaxHeight;
|
||||
ctx.rowMaxHeight = 0;
|
||||
}
|
||||
|
||||
const placement = {
|
||||
itemId,
|
||||
sectionId: ctx.sectionId,
|
||||
layoutId: ctx.layoutId,
|
||||
xOffset: ctx.xOffset,
|
||||
yOffset: ctx.yOffset,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
};
|
||||
ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
|
||||
ctx.xOffset += itemWidth;
|
||||
return placement;
|
||||
}),
|
||||
);
|
||||
|
||||
for (const integrationId of linkedIntegrationIds) {
|
||||
await db.insert(integrationItems).values({ itemId, integrationId });
|
||||
}
|
||||
|
||||
ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
|
||||
ctx.xOffset += itemWidth;
|
||||
};
|
||||
|
||||
interface App {
|
||||
@@ -79,16 +87,17 @@ interface App {
|
||||
|
||||
export const placeAllWidgetsAsync = async (
|
||||
db: Database,
|
||||
target: BoardTarget,
|
||||
target: BoardTarget | BoardTarget[],
|
||||
allIntegrations: Integration[],
|
||||
allApps: App[],
|
||||
) => {
|
||||
const ctx: PlacementContext = {
|
||||
...target,
|
||||
const targets = Array.isArray(target) ? target : [target];
|
||||
const contexts: PlacementContext[] = targets.map((currentTarget) => ({
|
||||
...currentTarget,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
rowMaxHeight: 0,
|
||||
};
|
||||
}));
|
||||
|
||||
const placedWidgets = new Set<WidgetKind>();
|
||||
|
||||
@@ -106,7 +115,7 @@ export const placeAllWidgetsAsync = async (
|
||||
const options = config?.options ? superjson.stringify(config.options) : undefined;
|
||||
await placeWidgetAsync(
|
||||
db,
|
||||
ctx,
|
||||
contexts,
|
||||
widgetKind,
|
||||
matchingIds,
|
||||
options,
|
||||
@@ -118,7 +127,7 @@ export const placeAllWidgetsAsync = async (
|
||||
for (const app of allApps) {
|
||||
await placeWidgetAsync(
|
||||
db,
|
||||
ctx,
|
||||
contexts,
|
||||
"app",
|
||||
[],
|
||||
superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }),
|
||||
@@ -138,7 +147,7 @@ export const placeAllWidgetsAsync = async (
|
||||
options = { ...options, items: bookmarkAppIds };
|
||||
}
|
||||
|
||||
await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, {
|
||||
await placeWidgetAsync(db, contexts, config.kind, [], options ? superjson.stringify(options) : undefined, {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
IntegrationKind,
|
||||
IntegrationPermission,
|
||||
IntegrationSecretKind,
|
||||
LayoutRole,
|
||||
OnboardingStep,
|
||||
SearchEngineType,
|
||||
SectionKind,
|
||||
@@ -343,6 +344,7 @@ export const layouts = mysqlTable("layout", {
|
||||
leftGutterColumnCount: tinyint().notNull().default(0),
|
||||
rightGutterColumnCount: tinyint().notNull().default(0),
|
||||
breakpoint: smallint().notNull().default(0),
|
||||
role: varchar({ length: 16 }).$type<LayoutRole>().notNull().default("custom"),
|
||||
});
|
||||
|
||||
export const itemLayouts = mysqlTable(
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
IntegrationKind,
|
||||
IntegrationPermission,
|
||||
IntegrationSecretKind,
|
||||
LayoutRole,
|
||||
OnboardingStep,
|
||||
SearchEngineType,
|
||||
SectionKind,
|
||||
@@ -342,6 +343,7 @@ export const layouts = pgTable("layout", {
|
||||
leftGutterColumnCount: smallint().notNull().default(0),
|
||||
rightGutterColumnCount: smallint().notNull().default(0),
|
||||
breakpoint: smallint().notNull().default(0),
|
||||
role: varchar({ length: 16 }).$type<LayoutRole>().notNull().default("custom"),
|
||||
});
|
||||
|
||||
export const itemLayouts = pgTable(
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
IntegrationKind,
|
||||
IntegrationPermission,
|
||||
IntegrationSecretKind,
|
||||
LayoutRole,
|
||||
OnboardingStep,
|
||||
SearchEngineType,
|
||||
SectionKind,
|
||||
@@ -327,6 +328,7 @@ export const layouts = sqliteTable("layout", {
|
||||
leftGutterColumnCount: int().notNull().default(0),
|
||||
rightGutterColumnCount: int().notNull().default(0),
|
||||
breakpoint: int().notNull().default(0),
|
||||
role: text().$type<LayoutRole>().notNull().default("custom"),
|
||||
});
|
||||
|
||||
export const itemLayouts = sqliteTable(
|
||||
|
||||
@@ -8,7 +8,49 @@ export const backgroundImageRepeats = createDefinition(["repeat", "repeat-x", "r
|
||||
export const backgroundImageSizes = createDefinition(["cover", "contain"], {
|
||||
defaultValue: "cover",
|
||||
});
|
||||
export const layoutRoles = createDefinition(["mobile", "base", "custom"], { defaultValue: "custom" });
|
||||
|
||||
export type BackgroundImageAttachment = inferDefinitionType<typeof backgroundImageAttachments>;
|
||||
export type BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
|
||||
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
|
||||
export type LayoutRole = inferDefinitionType<typeof layoutRoles>;
|
||||
|
||||
const MAX_LAYOUT_BREAKPOINT = 32767;
|
||||
const compareBoardLayouts = (
|
||||
layoutA: { id: string; breakpoint: number; columnCount: number },
|
||||
layoutB: { id: string; breakpoint: number; columnCount: number },
|
||||
) =>
|
||||
layoutA.breakpoint - layoutB.breakpoint ||
|
||||
layoutA.columnCount - layoutB.columnCount ||
|
||||
layoutA.id.localeCompare(layoutB.id);
|
||||
|
||||
export const normalizeBoardLayoutRoles = <
|
||||
TLayout extends { id: string; breakpoint: number; columnCount: number; role?: LayoutRole },
|
||||
>(
|
||||
sourceLayouts: TLayout[],
|
||||
): Array<TLayout & { role: LayoutRole }> => {
|
||||
const mobileLayouts = sourceLayouts.filter((layout) => layout.role === "mobile");
|
||||
const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
|
||||
const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
|
||||
const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
|
||||
const orderedLayouts =
|
||||
mobileLayout && baseLayout
|
||||
? [
|
||||
mobileLayout,
|
||||
...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts),
|
||||
baseLayout,
|
||||
]
|
||||
: sourceLayouts.toSorted(compareBoardLayouts);
|
||||
|
||||
let previousBreakpoint = -1;
|
||||
return orderedLayouts.map((layout, index) => {
|
||||
const remainingLayouts = orderedLayouts.length - index - 1;
|
||||
const breakpoint =
|
||||
index === 0
|
||||
? 0
|
||||
: Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
|
||||
const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom";
|
||||
previousBreakpoint = breakpoint;
|
||||
return { ...layout, breakpoint, role };
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LayoutRole } from "./board";
|
||||
|
||||
export const sectionKinds = ["empty", "container"] as const;
|
||||
export type SectionKind = (typeof sectionKinds)[number];
|
||||
|
||||
@@ -21,12 +23,17 @@ export const getBoardLaneColumnCount = (
|
||||
columnCount: number;
|
||||
leftGutterColumnCount?: number | null;
|
||||
rightGutterColumnCount?: number | null;
|
||||
role?: LayoutRole;
|
||||
},
|
||||
lane: BoardLane,
|
||||
) => {
|
||||
const total = Math.max(1, Math.floor(layout.columnCount));
|
||||
const left = Math.min(Math.max(0, Math.floor(layout.leftGutterColumnCount ?? 0)), total - 1);
|
||||
const right = Math.min(Math.max(0, Math.floor(layout.rightGutterColumnCount ?? 0)), total - left - 1);
|
||||
const left =
|
||||
layout.role === "mobile" ? 0 : Math.min(Math.max(0, Math.floor(layout.leftGutterColumnCount ?? 0)), total - 1);
|
||||
const right =
|
||||
layout.role === "mobile"
|
||||
? 0
|
||||
: Math.min(Math.max(0, Math.floor(layout.rightGutterColumnCount ?? 0)), total - left - 1);
|
||||
if (lane === "left") return left;
|
||||
if (lane === "right") return right;
|
||||
return total - left - right;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
|
||||
import { normalizeBoardLayoutRoles } from "@homarr/definitions";
|
||||
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
|
||||
import { boardSizes, getBoardSizeName } from "@homarr/old-schema";
|
||||
|
||||
@@ -88,14 +89,19 @@ export const createBoardInsertCollection = (
|
||||
{} as Record<BoardSize, string>,
|
||||
);
|
||||
|
||||
const preparedLayouts = boardSizes.map((size) => ({
|
||||
id: layoutMapping[size],
|
||||
boardId: mappedBoard.id,
|
||||
columnCount: mapColumnCount(board.config.settings.customization.gridstack, size),
|
||||
breakpoint: mapBreakpoint(size),
|
||||
name: getBoardSizeName(size),
|
||||
}));
|
||||
insertCollection.layouts.push(...preparedLayouts);
|
||||
const importedLayouts = normalizeBoardLayoutRoles(
|
||||
boardSizes.map((size) => ({
|
||||
id: layoutMapping[size],
|
||||
boardId: mappedBoard.id,
|
||||
columnCount: mapColumnCount(board.config.settings.customization.gridstack, size),
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: mapBreakpoint(size),
|
||||
name: getBoardSizeName(size),
|
||||
})),
|
||||
);
|
||||
|
||||
insertCollection.layouts.push(...importedLayouts);
|
||||
|
||||
const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories });
|
||||
|
||||
@@ -113,7 +119,7 @@ export const createBoardInsertCollection = (
|
||||
layoutMapping,
|
||||
mappedBoard.id,
|
||||
);
|
||||
insertCollection.sectionLayouts.push(...placePreparedSections(preparedSections, preparedItems, preparedLayouts));
|
||||
insertCollection.sectionLayouts.push(...placePreparedSections(preparedSections, preparedItems, importedLayouts));
|
||||
preparedItems.forEach(({ layouts, ...item }) => {
|
||||
insertCollection.items.push(item);
|
||||
insertCollection.itemLayouts.push(...layouts);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { generateResponsiveGridFor } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget, SizedShape } from "@homarr/old-schema";
|
||||
import { boardSizes } from "@homarr/old-schema";
|
||||
|
||||
import type { GridAlgorithmItem } from "../../../api/src/router/board/grid-algorithm";
|
||||
import { generateResponsiveGridFor } from "../../../api/src/router/board/grid-algorithm";
|
||||
import { mapColumnCount } from "../mappers/map-column-count";
|
||||
import { mapApp, mapWidget } from "../mappers/map-item";
|
||||
|
||||
|
||||
@@ -5320,9 +5320,44 @@
|
||||
"title": "Layout",
|
||||
"responsive": {
|
||||
"title": "Responsive layouts",
|
||||
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.",
|
||||
"action": {
|
||||
"add": "Add layout"
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"mobile": "Mobile",
|
||||
"base": "Base",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"custom": {
|
||||
"defaultName": "Custom layout"
|
||||
},
|
||||
"mobile": {
|
||||
"breakpointDescription": "Mobile is always used from 0px until the next layout breakpoint."
|
||||
},
|
||||
"preview": {
|
||||
"columns": "columns",
|
||||
"empty": "This layout is empty",
|
||||
"dynamicSection": "Dynamic section",
|
||||
"editorWidthLabel": "{layoutName} · {width}px"
|
||||
},
|
||||
"reset": {
|
||||
"action": "Reset from Base",
|
||||
"confirm": {
|
||||
"title": "Reset {layoutName}?",
|
||||
"message": "This replaces every saved position and size in this layout with a new layout generated from Base. This cannot be undone."
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"action": "Edit layout",
|
||||
"saveAndEdit": "Save and edit",
|
||||
"save": "Save layout",
|
||||
"cancel": {
|
||||
"action": "Cancel layout editing",
|
||||
"title": "Discard layout changes?",
|
||||
"message": "Your unsaved changes to this layout will be discarded."
|
||||
}
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("board layout gutters", () => {
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
}),
|
||||
).toMatchObject({
|
||||
leftGutterColumnCount: 0,
|
||||
@@ -26,6 +27,7 @@ describe("board layout gutters", () => {
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 2,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
@@ -36,6 +38,20 @@ describe("board layout gutters", () => {
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects sidebars on mobile layouts", () => {
|
||||
expect(
|
||||
boardLayoutSchema.safeParse({
|
||||
id: "mobile",
|
||||
name: "Mobile",
|
||||
columnCount: 6,
|
||||
leftGutterColumnCount: 1,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { responsiveBoardLayoutsSchema } from "./board";
|
||||
import { containerSectionOptionsDefaults, sectionSchema } from "./shared";
|
||||
|
||||
const mobile = {
|
||||
id: "mobile",
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
};
|
||||
const base = {
|
||||
id: "base",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
};
|
||||
|
||||
describe("responsiveBoardLayoutsSchema", () => {
|
||||
test("accepts protected layouts and custom layouts strictly between them", () => {
|
||||
expect(
|
||||
responsiveBoardLayoutsSchema.safeParse([
|
||||
mobile,
|
||||
{ id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" },
|
||||
base,
|
||||
]).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: "missing Mobile", layouts: [{ ...mobile, role: "custom" }, base] },
|
||||
{ name: "removable Base replacement", layouts: [mobile, { ...base, role: "custom" }] },
|
||||
{ name: "non-zero Mobile breakpoint", layouts: [{ ...mobile, breakpoint: 320 }, base] },
|
||||
{
|
||||
name: "custom layout at Base breakpoint",
|
||||
layouts: [mobile, { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 768, role: "custom" }, base],
|
||||
},
|
||||
{
|
||||
name: "duplicate layout ID",
|
||||
layouts: [mobile, { ...base, id: mobile.id }],
|
||||
},
|
||||
{
|
||||
name: "mobile sidebar",
|
||||
layouts: [{ ...mobile, leftGutterColumnCount: 1 }, base],
|
||||
},
|
||||
])("rejects $name", ({ layouts }) => {
|
||||
expect(responsiveBoardLayoutsSchema.safeParse(layouts).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("section behavior validation", () => {
|
||||
it("normalizes a container to the current behavior defaults", () => {
|
||||
test("normalizes a container to the current behavior defaults", () => {
|
||||
const result = sectionSchema.parse({
|
||||
id: "container",
|
||||
kind: "container",
|
||||
@@ -16,7 +64,7 @@ describe("section behavior validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves configured container behavior", () => {
|
||||
test("preserves configured container behavior", () => {
|
||||
const result = sectionSchema.parse({
|
||||
id: "container",
|
||||
kind: "container",
|
||||
@@ -44,7 +92,7 @@ describe("section behavior validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes the legacy dynamic kind to a container", () => {
|
||||
test("normalizes the legacy dynamic kind to a container", () => {
|
||||
expect(sectionSchema.parse({ id: "legacy", kind: "dynamic", layouts: [] })).toMatchObject({
|
||||
id: "legacy",
|
||||
kind: "container",
|
||||
@@ -52,11 +100,11 @@ describe("section behavior validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects the legacy category kind", () => {
|
||||
test("rejects the legacy category kind", () => {
|
||||
expect(() => sectionSchema.parse({ id: "legacy", kind: "category", layouts: [] })).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
test.each([
|
||||
{ xOffset: 0.5, yOffset: 0, width: 1, height: 1 },
|
||||
{ xOffset: -1, yOffset: 0, width: 1, height: 1 },
|
||||
{ xOffset: 0, yOffset: 0, width: 0, height: 1 },
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
boardPermissions,
|
||||
layoutRoles,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
@@ -78,16 +79,59 @@ export const boardLayoutSchema = z
|
||||
columnCount: boardColumnCountSchema,
|
||||
leftGutterColumnCount: boardGutterColumnCountSchema.default(0),
|
||||
rightGutterColumnCount: boardGutterColumnCountSchema.default(0),
|
||||
breakpoint: z.number().min(0).max(32767),
|
||||
breakpoint: z.number().int().min(0).max(32767),
|
||||
role: z.enum(layoutRoles.values),
|
||||
})
|
||||
.refine((layout) => layout.leftGutterColumnCount + layout.rightGutterColumnCount < layout.columnCount, {
|
||||
message: "Gutters must leave at least one dashboard column",
|
||||
path: ["columnCount"],
|
||||
})
|
||||
.refine(
|
||||
(layout) => layout.role !== "mobile" || (layout.leftGutterColumnCount === 0 && layout.rightGutterColumnCount === 0),
|
||||
{
|
||||
message: "Mobile layouts cannot have sidebars",
|
||||
path: ["leftGutterColumnCount"],
|
||||
},
|
||||
);
|
||||
|
||||
export const responsiveBoardLayoutsSchema = z
|
||||
.array(boardLayoutSchema)
|
||||
.min(2)
|
||||
.superRefine((layouts, ctx) => {
|
||||
const mobileLayouts = layouts.filter((layout) => layout.role === "mobile");
|
||||
const baseLayouts = layouts.filter((layout) => layout.role === "base");
|
||||
const mobileLayout = mobileLayouts.at(0);
|
||||
const baseLayout = baseLayouts.at(0);
|
||||
if (mobileLayouts.length !== 1 || baseLayouts.length !== 1 || !mobileLayout || !baseLayout) {
|
||||
ctx.addIssue({ code: "custom", message: "Boards require exactly one Mobile and one Base layout" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mobileLayout.breakpoint !== 0) {
|
||||
ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" });
|
||||
}
|
||||
|
||||
if (layouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) {
|
||||
ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" });
|
||||
}
|
||||
|
||||
if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) {
|
||||
ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" });
|
||||
}
|
||||
|
||||
if (new Set(layouts.map((layout) => layout.id)).size !== layouts.length) {
|
||||
ctx.addIssue({ code: "custom", message: "Layout IDs must be unique" });
|
||||
}
|
||||
});
|
||||
|
||||
export const boardSaveLayoutsSchema = z.object({
|
||||
id: z.string(),
|
||||
layouts: z.array(boardLayoutSchema),
|
||||
layouts: responsiveBoardLayoutsSchema,
|
||||
});
|
||||
|
||||
export const boardResetLayoutSchema = z.object({
|
||||
boardId: z.string(),
|
||||
layoutId: z.string(),
|
||||
});
|
||||
|
||||
export const boardSaveSchema = z.object({
|
||||
|
||||
Reference in new issue
Block a user