Compare commits

..
Author SHA1 Message Date
ajnart 37289a2247 test: remove obsolete advanced interactions flow 2026-08-14 15:14:36 +02:00
ajnart 7b672e180e test: remove obsolete advanced focus assertion 2026-08-14 14:46:01 +02:00
ajnart 854b59ede4 fix: honor requested board layout edits 2026-08-14 11:32:09 +02:00
ajnart eb918b805a chore: reconcile mobile layer after stack rebase 2026-08-14 11:32:09 +02:00
Thomas 4a4b04d471 fix(boards): disable mobile sidebars (#6565) 2026-08-14 11:32:09 +02:00
Thomas Camlong 3943741838 fix: refine responsive layout settings 2026-08-14 11:32:09 +02:00
Thomas Camlong e425e060d9 Rename layout role migrations 2026-08-14 11:32:09 +02:00
Thomas Camlong 6754e53f56 test(boards): include layout roles in gutter fixtures 2026-08-14 11:32:09 +02:00
Thomas Camlong 357adffb30 style: format mobile migration metadata 2026-08-14 11:32:09 +02:00
ajnart d24eca12ef fix: repair restored board layouts 2026-08-14 11:32:09 +02:00
ajnart dfcd1dbb64 fix: guard recursive section rendering 2026-08-14 11:32:09 +02:00
ajnart 252ce998ae fix: stabilize responsive layout editing 2026-08-14 11:32:09 +02:00
ajnart a940a8223d fix: improve layout preview density 2026-08-14 11:32:09 +02:00
ajnart bbd1abd4f0 fix: address responsive layout review 2026-08-14 11:32:09 +02:00
ajnart 7adc18b1ce fix: avoid nested board settings forms 2026-08-14 11:32:09 +02:00
ajnart 6a5162d615 feat: protect responsive board layouts 2026-08-14 11:32:09 +02:00
ajnart 02fe99d5aa refactor: simplify mobile board layouts 2026-08-14 11:32:09 +02:00
ajnart 7bbca3bd40 fix: refine mobile board preview 2026-08-14 11:32:09 +02:00
ajnart 923a528038 fix: address mobile board review 2026-08-14 11:32:09 +02:00
ajnart bb599e7fae test: cover tablet mobile boards 2026-08-14 11:32:09 +02:00
ajnart bb5df3a2e6 feat: add mobile layout preview 2026-08-14 11:32:09 +02:00
ajnart c805d5c631 fix: label board edit mode control 2026-08-14 11:32:09 +02:00
ajnart 747234c040 fix: keep mobile board widgets unchanged 2026-08-14 11:32:09 +02:00
ajnart b828f59568 Revert "feat: improve mobile board experience"
This reverts commit 7c0e98c55b.
2026-08-14 11:32:09 +02:00
ajnart 60ea03e72e Revert "feat: refine mobile dashboard experience"
This reverts commit 315520beca.
2026-08-14 11:32:09 +02:00
ajnart e9853f1fb1 Revert "style: auto-fix lint and format"
This reverts commit 4bd0d8e8dd.
2026-08-14 11:32:09 +02:00
ajnart f8b364dce2 Revert "fix: safely extract notebook summaries"
This reverts commit fa3d0375c5.
2026-08-14 11:32:09 +02:00
ajnart cd8773681f Revert "fix: address mobile dashboard review findings"
This reverts commit 1039bb18b3.
2026-08-14 11:32:09 +02:00
ajnart 2f99f1f34b Revert "fix: collect mobile board e2e test"
This reverts commit 6dbfbb1417.
2026-08-14 11:32:09 +02:00
ajnart 6fc851f054 Revert "fix: layer mobile widget details above header"
This reverts commit a540f07920.
2026-08-14 11:32:09 +02:00
ajnart 7bedf4a13d Revert "fix: keep interface layers server safe"
This reverts commit cf83e0d849.
2026-08-14 11:32:09 +02:00
ajnart a9c8b65c14 Revert "feat: complete automatic mobile board UX"
This reverts commit 7959c20d88.
2026-08-14 11:32:09 +02:00
ajnart e336059079 Revert "fix: harden automatic mobile boards"
This reverts commit d0b9348b86.
2026-08-14 11:32:09 +02:00
ajnart 708105ab3f fix: harden automatic mobile boards 2026-08-14 11:32:09 +02:00
ajnart e6bd1adfc2 feat: complete automatic mobile board UX 2026-08-14 11:32:09 +02:00
ajnart b217d8e026 fix: keep interface layers server safe 2026-08-14 11:32:09 +02:00
ajnart 35b7c4fc2d fix: layer mobile widget details above header 2026-08-14 11:32:09 +02:00
ajnart 251a4c8ae8 fix: collect mobile board e2e test 2026-08-14 11:32:09 +02:00
ajnart b4404113c4 fix: address mobile dashboard review findings 2026-08-14 11:32:09 +02:00
ajnart c1efaabd36 fix: safely extract notebook summaries 2026-08-14 11:32:09 +02:00
ajnart b75483982f style: auto-fix lint and format 2026-08-14 11:32:09 +02:00
ajnart c1da19c9b2 feat: refine mobile dashboard experience 2026-08-14 11:32:09 +02:00
ajnart 46898cd0ea feat: improve mobile board experience 2026-08-14 11:32:09 +02:00
ajnart 6f4cd7522f fix: align mobile root section ordering 2026-08-14 11:32:09 +02:00
ajnart eaaeaaccdb fix: address board review and CI feedback 2026-08-14 11:32:09 +02:00
ajnart d63653142d feat: add automatic mobile board layout 2026-08-14 11:32:09 +02:00
61 changed files with 10233 additions and 669 deletions

No files matched your search

+2
View File
@@ -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
+20 -13
View File
@@ -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.
![](./img/layout-settings.png)
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
View File
@@ -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,
);
+212 -24
View File
@@ -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,
});
+1
View File
@@ -67,6 +67,7 @@ const MCP_TOOL_ALLOWLIST = {
"board_deleteBoard",
"board_duplicateBoard",
"board_renameBoard",
"board_resetLayout",
"board_savePartialBoardSettings",
"board_setHomeBoard",
"board_setMobileHomeBoard",
+16 -4
View File
@@ -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 = () => {
+8 -3
View File
@@ -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>;
};
+125
View File
@@ -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;
};
+1
View File
@@ -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
}
]
}
+97
View File
@@ -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
View File
@@ -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
}
]
}
+1
View File
@@ -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);
});
});
+37 -28
View File
@@ -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,
});
+2
View File
@@ -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(
+2
View File
@@ -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(
+2
View File
@@ -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(
+42
View File
@@ -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 };
});
};
+9 -2
View File
@@ -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";
+35
View File
@@ -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);
});
+54 -6
View File
@@ -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 },
+46 -2
View File
@@ -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({