Merge branch 'feat/dnd-kit-grid' into feat/widget-modernization

This commit is contained in:
Thomas Camlong committed 2026-08-10 20:13:00 +02:00
commit bc687b90ad
68 files changed
+10264 -664

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
+19 -12
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,24 +104,27 @@ 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 and rails can be changed, but they cannot be removed. 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
@@ -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} />
@@ -93,6 +93,8 @@ Custom Widget imports detect required bearer, basic, header, or query credential
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo.
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Head from "@docusaurus/Head";
import Link from "@docusaurus/Link";
import { useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
@@ -69,6 +70,7 @@ import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils";
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
const typeSocialLabels: Record<SubmissionType, string> = { customCss: "Custom CSS", customWidget: "Custom widget" };
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
const contentLanguages: Record<SubmissionType, string> = { customCss: "css", customWidget: "json" };
const copyState = [
@@ -536,9 +538,31 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const options = widgetDefinition ? Object.entries(widgetDefinition.options) : [];
const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth));
const displayedContent = formatWorkshopContent(submission.type, submission.content);
const socialTitle = `${submission.title} · Homarr Workshop`;
const socialDescription = `${typeSocialLabels[submission.type]} for Homarr. ${submission.description}`.trim();
const socialUrl = `${window.location.origin}${window.location.pathname}`;
const socialImage = screenshotUrls[0] ?? `${window.location.origin}/img/logo.png`;
return (
<div className="mx-auto max-w-[90rem] px-4 pb-20 pt-8 sm:px-6 lg:px-8">
<Head>
<title>{socialTitle}</title>
<meta name="description" content={socialDescription} />
<link rel="canonical" href={socialUrl} />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Homarr Workshop" />
<meta property="og:title" content={socialTitle} />
<meta property="og:description" content={socialDescription} />
<meta property="og:url" content={socialUrl} />
<meta property="og:image" content={socialImage} />
<meta property="og:image:alt" content={`${submission.title} preview`} />
<meta property="article:section" content={typeSocialLabels[submission.type]} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={socialTitle} />
<meta name="twitter:description" content={socialDescription} />
<meta name="twitter:image" content={socialImage} />
<meta name="twitter:image:alt" content={`${submission.title} preview`} />
</Head>
<Toaster position="bottom-right" richColors />
<Link
to="/workshop"
@@ -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,293 @@ 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);
}}
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>
<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)}
/>
</Input.Wrapper>
</Grid.Col>
</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 +336,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 +344,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 +379,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")}>
@@ -122,46 +122,44 @@ 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 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 +177,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,43 @@
"use client";
import type { PropsWithChildren } from "react";
import { useSearchParams } from "next/navigation";
import { useMediaQuery } from "@mantine/hooks";
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 isDesktop = useMediaQuery("(min-width: 64em)");
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 && isDesktop ? 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(
@@ -205,7 +205,7 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
if (!session || !settings.enableRightClickOnWidgets || isEditMode) return <>{children}</>;
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
@@ -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);
@@ -104,6 +106,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;
};
+32
View File
@@ -10,6 +10,37 @@ const escapeHtml = (value) =>
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const renderWorkshopSocialHtml = (html, metadata) => {
const title = escapeHtml(metadata.title);
const description = escapeHtml(metadata.description);
const url = escapeHtml(metadata.url);
const image = escapeHtml(metadata.image);
const imageAlt = escapeHtml(`${metadata.submissionTitle} preview`);
const section = escapeHtml(metadata.section);
const tags = [
`<title>${title}</title>`,
`<meta name="description" content="${description}">`,
`<link rel="canonical" href="${url}">`,
`<meta property="og:type" content="article">`,
`<meta property="og:site_name" content="Homarr Workshop">`,
`<meta property="og:title" content="${title}">`,
`<meta property="og:description" content="${description}">`,
`<meta property="og:url" content="${url}">`,
`<meta property="article:section" content="${section}">`,
`<meta name="twitter:card" content="summary_large_image">`,
`<meta name="twitter:title" content="${title}">`,
`<meta name="twitter:description" content="${description}">`,
`<meta property="og:image" content="${image}">`,
`<meta property="og:image:alt" content="${imageAlt}">`,
`<meta name="twitter:image" content="${image}">`,
`<meta name="twitter:image:alt" content="${imageAlt}">`,
].join("\n");
const replaceableTags =
/<title\b[^>]*>[\s\S]*?<\/title>|<meta\b[^>]*(?:name|property)=["'](?:description|og:(?:title|description|url|image|image:alt|type|site_name)|twitter:(?:card|title|description|image|image:alt)|article:section)["'][^>]*>|<link\b[^>]*rel=["']canonical["'][^>]*>/gi;
return html.replace(replaceableTags, "").replace("<head>", `<head>\n${tags}`);
};
const emailTemplate = (filename) => {
const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || __hooks;
return $os.readFile($filepath.join(hooksPath, filename));
@@ -32,5 +63,6 @@ module.exports = {
emailTemplate,
escapeHtml,
rejectRequest,
renderWorkshopSocialHtml,
sendEmail,
};
+40
View File
@@ -17,6 +17,46 @@ onBootstrap((event) => {
console.log(JSON.stringify({ event: "workshop_oauth_synchronized", enabled: configured }));
});
const workshopDetailPage = (event) => {
const id = event.request.pathValue("id");
if (id === "admin") return event.fileFS($os.dirFS("/pb_public"), "workshop/admin/index.html");
const indexHtml = toString($os.readFile("/pb_public/index.html"));
try {
const { renderWorkshopSocialHtml } = require(`${__hooks}/workshop-utils.js`);
const submission = event.app.findRecordById("submissions", id);
const type = submission.getString("type");
const section = type === "customCss" ? "Custom CSS" : "Custom widget";
const submissionTitle = submission.getString("title");
const title = `${submissionTitle} · Homarr Workshop`;
const description = `${section} for Homarr. ${submission.getString("description")}`.trim();
const workshopUrl = $os.getenv("WORKSHOP_WEB_URL").replace(/\/$/, "");
const apiUrl = $os.getenv("WORKSHOP_API_URL").replace(/\/$/, "");
const websiteUrl = $os.getenv("HOMARR_WEBSITE_URL").replace(/\/$/, "");
const screenshot = submission.getStringSlice("screenshots")[0];
const image = screenshot
? `${apiUrl}/api/files/submissions/${submission.id}/${encodeURIComponent(screenshot)}`
: `${websiteUrl}/img/logo.png`;
return event.html(
200,
renderWorkshopSocialHtml(indexHtml, {
title,
description,
url: `${workshopUrl}/${submission.id}`,
image,
section,
submissionTitle,
}),
);
} catch {
return event.html(200, indexHtml);
}
};
routerAdd("GET", "/workshop/{id}", workshopDetailPage);
routerAdd("GET", "/workshop/{id}/{$}", workshopDetailPage);
onRecordCreateRequest((event) => {
event.record.set("revision", 1);
event.record.set("expectedRevision", 0);
+7
View File
@@ -38,6 +38,13 @@ WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \
EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
node apps/workshop/tests/runtime-config.integration.mjs
docker exec "$WORKSHOP_IMAGE_TEST_NAME" pocketbase superuser create \
workshop-image@example.invalid 'WorkshopImageTest123!' --dir=/pb_data
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \
EXPECTED_WORKSHOP_API_URL="$PREVIEW_ORIGIN" \
EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
node apps/workshop/tests/workshop-social-metadata.integration.mjs
curl --fail --location --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/workshop" >/dev/null
curl --fail --silent \
"http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" >/dev/null
@@ -1,5 +1,6 @@
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import vm from "node:vm";
const read = (path) => readFile(resolve(path), "utf8");
@@ -39,6 +40,34 @@ for (const artifact of [
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
}
if (!hook.includes('routerAdd("GET", "/workshop/{id}"') || !hook.includes('getStringSlice("screenshots")')) {
throw new Error("Workshop detail routes must render server-side social metadata with submission screenshots");
}
const hookUtilsModule = { exports: {} };
vm.runInNewContext(await read("apps/workshop/pb_hooks/workshop-utils.js"), { module: hookUtilsModule });
const socialHtml = hookUtilsModule.exports.renderWorkshopSocialHtml(
'<html><head><title data-rh="true">Homarr documentation</title><meta data-rh="true" property="og:title" content="Homarr documentation"><meta data-rh="true" name="description" content="Generic"><link data-rh="true" rel="canonical" href="https://homarr.dev/"></head></html>',
{
title: "Ocean <Glow> · Homarr Workshop",
description: "Custom CSS for Homarr. Calm & readable.",
url: "https://preview.example/workshop/abc",
image: "https://preview.example/api/files/submissions/abc/preview.png",
section: "Custom CSS",
submissionTitle: "Ocean <Glow>",
},
);
for (const expected of [
"<title>Ocean &lt;Glow&gt; · Homarr Workshop</title>",
'property="og:description" content="Custom CSS for Homarr. Calm &amp; readable."',
'rel="canonical" href="https://preview.example/workshop/abc"',
'property="og:image" content="https://preview.example/api/files/submissions/abc/preview.png"',
'name="twitter:card" content="summary_large_image"',
'property="article:section" content="Custom CSS"',
]) {
if (!socialHtml.includes(expected)) throw new Error(`Workshop social metadata is missing: ${expected}`);
}
if (socialHtml.includes("Homarr documentation")) throw new Error("Workshop social metadata must replace generic tags");
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) {
@@ -0,0 +1,55 @@
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18091";
const publicWorkshopUrl = process.env.EXPECTED_WORKSHOP_WEB_URL ?? "https://v2.preview.homarr.dev/workshop";
const publicApiUrl = process.env.EXPECTED_WORKSHOP_API_URL ?? "https://v2.preview.homarr.dev";
const request = async (path, init = {}) => {
const response = await fetch(`${baseUrl}${path}`, init);
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
return body;
};
const root = await request("/api/collections/_superusers/auth-with-password", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ identity: "workshop-image@example.invalid", password: "WorkshopImageTest123!" }),
});
const headers = { authorization: `Bearer ${root.token}` };
const author = await request("/api/collections/users/records", {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({
email: "social-author@example.invalid",
password: "WorkshopSocialTest123!",
passwordConfirm: "WorkshopSocialTest123!",
name: "social-author",
}),
});
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
const data = new FormData();
data.set("type", "customCss");
data.set("title", "Ocean <Glow>");
data.set("description", "Calm & readable.");
data.set("widgetSchema", "homarr-custom-css-v1");
data.set("content", "body { color: white; }");
data.set("author", author.id);
data.set("screenshots", new File([png], "preview.png", { type: "image/png" }));
const submission = await request("/api/collections/submissions/records", { method: "POST", headers, body: data });
const screenshot = encodeURIComponent(submission.screenshots[0]);
const html = await fetch(`${baseUrl}/workshop/${submission.id}`).then((response) => response.text());
for (const expected of [
"<title>Ocean &lt;Glow&gt; · Homarr Workshop</title>",
'property="og:description" content="Custom CSS for Homarr. Calm &amp; readable."',
`rel="canonical" href="${publicWorkshopUrl}/${submission.id}"`,
`property="og:image" content="${publicApiUrl}/api/files/submissions/${submission.id}/${screenshot}"`,
'name="twitter:card" content="summary_large_image"',
'property="article:section" content="Custom CSS"',
]) {
if (!html.includes(expected)) throw new Error(`Rendered Workshop page is missing social metadata: ${expected}`);
}
console.log("Workshop social metadata integration passed");
+7
View File
@@ -151,6 +151,13 @@ describe("Board grid", () => {
await nestedAppLink.click();
await expect(page).toHaveURL(/#board-app-opened$/);
await page.evaluate(() => history.replaceState(null, "", `${location.pathname}${location.search}`));
await firstItem.click({ button: "right" });
await expect(page.getByRole("menu")).toBeVisible();
await page.keyboard.press("Escape");
await nestedItem.click({ button: "right" });
await expect(page.getByRole("menu")).toHaveCount(0);
const normalZoomTileBox = await expectBoundingBoxAsync(logicalTile);
const secondItemBox = await expectBoundingBoxAsync(secondItem);
expect(secondItemBox.x - (normalZoomTileBox.x + normalZoomTileBox.width)).toBeCloseTo(
+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 };
});
};
@@ -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
@@ -5224,9 +5224,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,7 @@ describe("board layout gutters", () => {
leftGutterColumnCount: 3,
rightGutterColumnCount: 3,
breakpoint: 0,
role: "base",
}).success,
).toBe(false);
});
+50 -6
View File
@@ -1,9 +1,53 @@
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 }],
},
])("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 +60,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 +88,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 +96,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 },
+39 -2
View File
@@ -5,6 +5,7 @@ import {
backgroundImageRepeats,
backgroundImageSizes,
boardPermissions,
layoutRoles,
widgetKinds,
} from "@homarr/definitions";
@@ -78,16 +79,52 @@ 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"],
});
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({