feat: protect responsive board layouts
This commit is contained in:
1 parent
fa9444323b
commit
6728228387
46 files changed
+8933
-477
No files matched your search
@@ -82,4 +82,6 @@ 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.
|
||||
@@ -9,11 +9,11 @@ tags:
|
||||
- Layout
|
||||
---
|
||||
|
||||
import iconColorImage from './img/icon-color.png'
|
||||
import itemRadiusImage from './img/item-radius.png'
|
||||
import layoutLargeImage from './img/layout-large.png'
|
||||
import layoutMediumImage from './img/layout-medium.png'
|
||||
import layoutSmallImage from './img/layout-small.png'
|
||||
import iconColorImage from "./img/icon-color.png";
|
||||
import itemRadiusImage from "./img/item-radius.png";
|
||||
import layoutLargeImage from "./img/layout-large.png";
|
||||
import layoutMediumImage from "./img/layout-medium.png";
|
||||
import layoutSmallImage from "./img/layout-small.png";
|
||||
|
||||
Each dashboard is called a "board".
|
||||
You can create as many boards as you want.
|
||||
@@ -24,24 +24,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home)
|
||||
Names of boards must be unique because this will be used in the URL to load the desired board.
|
||||
|
||||
## Create a new board
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
|
||||

|
||||
|
||||
### Name
|
||||
|
||||
The name must not include any special characters and must be at least one character long.
|
||||
Underscores and hyphen-minus are allowed.
|
||||
|
||||
### Column count
|
||||
|
||||
Homarr has a complex drag and drop system that can be used from the browser.
|
||||
The column count defines how many horizontal columns are available to drag and drop elements to.
|
||||
|
||||
### Public
|
||||
|
||||
This will enable public access for everyone - regardless of whether they are logged in or not.
|
||||
This can be really useful if you want to have public boards in your company or for family and fiends.
|
||||
Modifications for public boards must be allowed explicitly using user groups and their permissions.
|
||||
|
||||
## Board settings
|
||||
|
||||
You navigate to the board settings by clicking on these three dots:
|
||||
|
||||

|
||||
@@ -51,7 +56,9 @@ Alternatively, you can also navigate from the board page itself using the cog ic
|
||||

|
||||
|
||||
### General
|
||||
|
||||
#### Page title
|
||||
|
||||
This is the title that will be visible at the top left in the navigation bar:
|
||||
|
||||

|
||||
@@ -59,6 +66,7 @@ This is the title that will be visible at the top left in the navigation bar:
|
||||
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
|
||||
|
||||
#### Meta title
|
||||
|
||||
This is the secondary title of the page that is not directly visible in the page itself.
|
||||
Browser will use it to set the title of tabs:
|
||||
|
||||
@@ -67,78 +75,104 @@ Browser will use it to set the title of tabs:
|
||||
Homarr will fall back to the board name and a suffix if nothing is set.
|
||||
|
||||
#### Logo image URL
|
||||
|
||||
This is the URL that is used for the image at the top left.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
#### Favicon image URL
|
||||
|
||||
This is the URL that is used for the favicon in the tab title.
|
||||
Homarr will fall back to the Homarr logo if nothing is set.
|
||||
|
||||
### Layout
|
||||
|
||||
In this section you can configure the different responsive layouts. Depending on your screens width the layout will change.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
On each element the position and size of items can be changed seperately.
|
||||
When adding a new layout the position and sizes will automatically be generated depending on the column count.
|
||||
When adding a new item / dynamic section that will always be 1x1 on each layout and placed on the first available position.
|
||||
Every board has two protected layouts:
|
||||
|
||||
- **Mobile** starts at `0px` and uses three columns on new boards.
|
||||
- **Base** is the widest layout and starts at `768px` on new boards.
|
||||
|
||||
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. 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.
|
||||
|
||||
#### Name
|
||||
|
||||
The name of the layout can be choosen freely. It is only used for the management and will not be visible on the board.
|
||||
The name of the layout can be chosen freely. It is used for layout management and is not displayed on the board.
|
||||
|
||||
#### Column count
|
||||
|
||||
Amount of columns for this layout. Defines how many items can be placed in a row. When changed it will automatically adjust the position and size of the items.
|
||||
The number of columns in the layout. Changing it projects the saved positions and sizes into the new grid width.
|
||||
|
||||
#### Breakpoint
|
||||
|
||||
Minimum width of the screen where the layout will be used.
|
||||
Once the minmum 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.
|
||||
|
||||
#### Preview
|
||||
|
||||
<img src={layoutSmallImage} height={256} />
|
||||
<img src={layoutMediumImage} height={256} />
|
||||
<img src={layoutLargeImage} height={256} />
|
||||
Layout Settings shows a lightweight structural preview for every layout, including the saved spans, categories, dynamic sections, 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} />
|
||||
|
||||
### Background
|
||||
|
||||
#### Background image URL
|
||||
|
||||
Homarr can display a background image behind apps and widgets.
|
||||
This URL can be used to define what image should be shown.
|
||||
|
||||
#### Background image attachment
|
||||
|
||||
This defines whether the background should move when the user scrolls.
|
||||
|
||||
#### Background image size
|
||||
|
||||
This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page.
|
||||
|
||||
#### Background image repeat
|
||||
|
||||
This defines whether the background should be repeated if it cannot cover the entire page.
|
||||
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):
|
||||
|
||||

|
||||
|
||||
### Appearance
|
||||
|
||||
#### Primary color
|
||||
|
||||
Color that can be chosen freely to give accents to active elements.
|
||||
|
||||
#### Secondary color
|
||||
|
||||
Color that can be chosen freely to give accents.
|
||||
|
||||
#### Opacity
|
||||
|
||||
Opacity of the colors.
|
||||
|
||||
#### Icon color
|
||||
|
||||
Change the color of your bookmark / app icons.
|
||||
|
||||
<img src={iconColorImage} height={100} />
|
||||
<img src={iconColorImage} height={100} />
|
||||
|
||||
#### Item radius
|
||||
|
||||
Change the radius of your items / categories.
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
<img src={itemRadiusImage} height={100} />
|
||||
|
||||
### Custom CSS
|
||||
|
||||
This is the field where you can enter custom CSS.
|
||||
The styling will only be applied on the respective board page.
|
||||
|
||||
@@ -147,14 +181,16 @@ The styling will only be applied on the respective board page.
|
||||
### Behaviour
|
||||
|
||||
#### Disable app status
|
||||
|
||||
This will disable the status check for apps.
|
||||
|
||||
### Access control
|
||||
|
||||
With the access control, you can permit a single user or a group to your board.
|
||||
The users and groups can have one of these three levels of access:
|
||||
|
||||
| **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) |
|
||||
|----------------------|------------|-----------|----------|--------------------------|
|
||||
| -------------------- | ---------- | --------- | -------- | ------------------------ |
|
||||
| View board | X | | | |
|
||||
| Modify board | X | X | X | |
|
||||
| Full access | X | X | X | X |
|
||||
@@ -173,18 +209,23 @@ After editing permissions, the save button below the table must be pressed.
|
||||
Otherwise, all changes will be discarded.
|
||||
|
||||
### Danger zone
|
||||
|
||||
#### Rename
|
||||
|
||||
Renaming a board will break all existing links to it permanently with no redirect.
|
||||
|
||||
#### Board visibility
|
||||
|
||||
By default, boards will be private. This means only authenticated users that are authorized have access.
|
||||
Making a board public will expose it to everyone - including unauthenticated users.
|
||||
|
||||
#### Delete
|
||||
|
||||
This will permanently delete the board.
|
||||
This action is irreversible and cannot be undone.
|
||||
|
||||
## Delete boards
|
||||
|
||||
The deletion of boards is permanent and cannot be undone.
|
||||
There are two ways to delete a board:
|
||||
|
||||
@@ -196,7 +237,7 @@ Both actions will execute the same deletion process.
|
||||
## Duplicate boards
|
||||
|
||||
:::note
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
Duplicating a board will not copy the users and groups that have access to the board
|
||||
nor the integrations the user does not have access to.
|
||||
:::
|
||||
|
||||
@@ -206,47 +247,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an
|
||||

|
||||
|
||||
## Setting boards as your home board
|
||||
|
||||
The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL.
|
||||
All the following paths will lead to the home board:
|
||||
- ``my-homarr.com``
|
||||
- ``my-homarr.com/boards``
|
||||
|
||||
- `my-homarr.com`
|
||||
- `my-homarr.com/boards`
|
||||
|
||||
Your home board will have a special small badge at the top right in the list of boards:
|
||||
|
||||

|
||||
|
||||
### For yourself
|
||||
|
||||
To set the home board yourself, the easiest option is to set it directly in the list of boards:
|
||||
|
||||

|
||||
|
||||
Alternatively, you can also define it via your user profile:
|
||||
|
||||
1. Click on your user profile at the top right
|
||||
2. Click on ``Your preferences``
|
||||
3. Change the home board in the ``home board`` dropdown and click on ``Save``.
|
||||
2. Click on `Your preferences`
|
||||
3. Change the home board in the `home board` dropdown and click on `Save`.
|
||||
|
||||
### For other users
|
||||
|
||||
1. Navigate to the users: ``Management`` > ``Users`` > ``Manage``
|
||||
1. Navigate to the users: `Management` > `Users` > `Manage`
|
||||
2. Click on the username of the user that you want to adjust
|
||||
3. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
3. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
### For user groups
|
||||
|
||||
1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage``
|
||||
2. Go to tab ``Settings``
|
||||
3. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage`
|
||||
2. Go to tab `Settings`
|
||||
3. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
### For whole server
|
||||
|
||||
You can also set a global home board that will be used for all users that have not set a home board themselves.
|
||||
It will also be used for anonymous users.
|
||||
|
||||
1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards``
|
||||
2. Select the desired home board via the dropdown and click on ``Save`` \
|
||||

|
||||
1. Navigate to the settings: `Management` > `Settings` > `Boards`
|
||||
2. Select the desired home board via the dropdown and click on `Save` \
|
||||

|
||||
|
||||
:::note
|
||||
For the global home board you can only select boards that are public.
|
||||
@@ -255,4 +300,4 @@ For the global home board you can only select boards that are public.
|
||||
### Mobile home board
|
||||
|
||||
Same as the home board, you can also set a mobile home board.
|
||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import { Box, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useCurrentLayout, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import { getRepresentativeLayoutWidth } from "../_layout-utils";
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
@@ -11,12 +12,13 @@ import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const layoutOverrideId = useLayoutOverride();
|
||||
|
||||
const fullWidthSortedSections = board.sections
|
||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<Stack h="100%">
|
||||
@@ -30,4 +32,28 @@ export const ClientBoard = () => {
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (!layoutOverrideId) return content;
|
||||
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
if (!currentLayout) return content;
|
||||
|
||||
const representativeWidth = getRepresentativeLayoutWidth(currentLayout, board.layouts);
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md" mih="100%">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{currentLayout.name} · {representativeWidth}px
|
||||
</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,19 @@
|
||||
|
||||
import { useCallback, useEffect, 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, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
|
||||
import {
|
||||
IconDeviceFloppy,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconSettings,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -64,7 +73,16 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
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 { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
@@ -74,6 +92,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void revalidatePathActionAsync(`/boards/${board.name}`);
|
||||
close();
|
||||
if (returnToLayoutSettings) router.push(`/boards/${board.name}/settings?tab=layout`);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
@@ -96,9 +115,45 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
open();
|
||||
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
|
||||
|
||||
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, 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,39 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.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(0.65rem, 1.7vw, 1.15rem);
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.tile {
|
||||
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.3rem;
|
||||
}
|
||||
|
||||
.dynamicTile {
|
||||
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,126 @@
|
||||
"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 { 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: Pick<BoardLayout, "id" | "columnCount">;
|
||||
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 !== "dynamic")
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
|
||||
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={previewWidth} miw="min(11rem, 100%)" 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);
|
||||
if (sectionElements.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack key={section.id} gap={4} className={classes.section}>
|
||||
{section.kind === "category" && (
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{section.name}
|
||||
</Text>
|
||||
)}
|
||||
<Box
|
||||
className={classes.grid}
|
||||
style={{ gridTemplateColumns: `repeat(${layout.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 dynamicSection =
|
||||
element.type === "section"
|
||||
? board.sections.find((candidate) => candidate.id === element.id)
|
||||
: undefined;
|
||||
const dynamicSectionLabel =
|
||||
dynamicSection?.kind === "dynamic" && dynamicSection.options.title
|
||||
? dynamicSection.options.title
|
||||
: t("board.setting.section.layout.preview.dynamicSection");
|
||||
const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : dynamicSectionLabel);
|
||||
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
|
||||
|
||||
return (
|
||||
<Tooltip key={`${element.type}-${element.id}`} label={label} openDelay={350}>
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
className={`${classes.tile} ${element.type === "section" ? classes.dynamicTile : ""}`}
|
||||
style={{
|
||||
gridColumn: `${element.xOffset + 1} / span ${element.width}`,
|
||||
gridRow: `${element.yOffset + 1} / span ${element.height}`,
|
||||
}}
|
||||
>
|
||||
<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 size="xs" fw={500} truncate>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,111 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Fieldset,
|
||||
Grid,
|
||||
Group,
|
||||
Input,
|
||||
NumberInput,
|
||||
Slider,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconEdit, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { LayoutPreview } from "./_layout-preview";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
}
|
||||
|
||||
export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [editingLayoutId, setEditingLayoutId] = useState<string | null>(null);
|
||||
const [resettingLayoutId, setResettingLayoutId] = useState<string | null>(null);
|
||||
const { mutateAsync: saveLayouts, isPending: isSaving } = clientApi.board.saveLayouts.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
});
|
||||
const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation();
|
||||
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
|
||||
initialValues: {
|
||||
layouts: board.layouts,
|
||||
},
|
||||
initialValues: { layouts: board.layouts },
|
||||
});
|
||||
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 persistLayoutsAsync = async () => {
|
||||
const savedLayouts = await saveLayouts({ id: board.id, layouts: form.values.layouts });
|
||||
form.setValues({ layouts: savedLayouts });
|
||||
form.resetDirty({ layouts: savedLayouts });
|
||||
return savedLayouts;
|
||||
};
|
||||
|
||||
const openLayoutEditorAsync = async (layout: (typeof form.values.layouts)[number]) => {
|
||||
setEditingLayoutId(layout.id);
|
||||
try {
|
||||
const isNew = !board.layouts.some((candidate) => candidate.id === layout.id);
|
||||
const savedLayouts = form.isDirty() || isNew ? await persistLayoutsAsync() : form.values.layouts;
|
||||
const canonicalLayout =
|
||||
savedLayouts.find((candidate) => candidate.id === layout.id) ??
|
||||
savedLayouts.find((candidate) => candidate.breakpoint === layout.breakpoint && candidate.role === layout.role);
|
||||
if (!canonicalLayout) return;
|
||||
|
||||
router.push(`/boards/${board.name}?layout=${encodeURIComponent(canonicalLayout.id)}&edit=true&returnTo=settings`);
|
||||
} finally {
|
||||
setEditingLayoutId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReset = (layout: (typeof form.values.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 savedLayouts = form.isDirty() ? await persistLayoutsAsync() : form.values.layouts;
|
||||
const canonicalLayout =
|
||||
savedLayouts.find((candidate) => candidate.id === layout.id) ??
|
||||
savedLayouts.find(
|
||||
(candidate) => candidate.breakpoint === layout.breakpoint && candidate.role === layout.role,
|
||||
);
|
||||
if (!canonicalLayout) return;
|
||||
|
||||
await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
|
||||
await utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
router.refresh();
|
||||
} 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 (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
saveLayouts({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
onSubmit={form.onSubmit(async () => {
|
||||
await persistLayoutsAsync();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={2}>
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues({
|
||||
layouts: [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: "",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
<Text size="sm">{t("board.setting.section.layout.responsive.description")}</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={nextBreakpoint === null || !baseLayout}
|
||||
onClick={() => {
|
||||
if (nextBreakpoint === null || !baseLayout) return;
|
||||
form.setFieldValue("layouts", [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: t("board.setting.section.layout.custom.defaultName"),
|
||||
columnCount: baseLayout.columnCount,
|
||||
breakpoint: nextBreakpoint,
|
||||
role: "custom",
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{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;
|
||||
|
||||
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"
|
||||
>
|
||||
{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 gap="xl" align="flex-start">
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<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))" },
|
||||
}}
|
||||
{...form.getInputProps(`layouts.${index}.columnCount`)}
|
||||
/>
|
||||
</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={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
<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 span={{ base: 12, lg: 7 }}>
|
||||
{sourceLayout && (
|
||||
<LayoutPreview
|
||||
board={board}
|
||||
layout={layout}
|
||||
layouts={form.values.layouts}
|
||||
sourceLayout={sourceLayout}
|
||||
apps={apps}
|
||||
/>
|
||||
)}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) =>
|
||||
previous.layouts !== undefined && previous.layouts.length >= 2
|
||||
? {
|
||||
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}
|
||||
: previous,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Group gap="xs">
|
||||
{layout.role !== "base" && persistedLayout && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="dark"
|
||||
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
|
||||
loading={resettingLayoutId === layout.id}
|
||||
onClick={() => confirmReset(layout)}
|
||||
>
|
||||
{t("board.setting.section.layout.reset.action")}
|
||||
</Button>
|
||||
)}
|
||||
{layout.role === "custom" && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
form.setFieldValue(
|
||||
"layouts",
|
||||
form.values.layouts.filter((candidate) => candidate.id !== layout.id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconEdit size={16} color="var(--mantine-color-red-5)" />}
|
||||
loading={editingLayoutId === layout.id}
|
||||
onClick={() => void openLayoutEditorAsync(layout)}
|
||||
>
|
||||
{form.isDirty() || !persistedLayout
|
||||
? t("board.setting.section.layout.edit.saveAndEdit")
|
||||
: t("board.setting.section.layout.edit.action")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Fieldset>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
<Button type="submit" loading={isSaving}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -113,3 +274,19 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -82,7 +82,7 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container size="xl">
|
||||
<Stack>
|
||||
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
|
||||
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
|
||||
|
||||
@@ -5,8 +5,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 { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
@@ -16,6 +15,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";
|
||||
@@ -83,27 +83,26 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
throw error;
|
||||
}
|
||||
const initialBoard = initialBoardResult.board;
|
||||
const { hasChangeAccess } = constructBoardPermissions(initialBoard, session);
|
||||
|
||||
return (
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardProviders initialBoard={initialBoard} canModify={hasChangeAccess}>
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourGate>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourGate>
|
||||
</BoardMantineProvider>
|
||||
</BoardReadyProvider>
|
||||
</BoardProvider>
|
||||
</BoardProviders>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils";
|
||||
|
||||
const mobile = { id: "mobile", name: "Mobile", columnCount: 3, breakpoint: 0, role: "mobile" as const };
|
||||
const custom = { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" as const };
|
||||
const base = { id: "base", name: "Base", columnCount: 12, 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("uses production ordering and sizing for items and dynamic sections", () => {
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
|
||||
.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: dynamicSection.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
|
||||
.build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(mobile)
|
||||
.addSections([rootSection, dynamicSection])
|
||||
.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 === dynamicSection.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: dynamicSection.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,74 @@
|
||||
import { generateResponsiveGridFor } from "@homarr/common";
|
||||
import type { GridAlgorithmItem } from "@homarr/common";
|
||||
|
||||
import type { Board } from "./_types";
|
||||
|
||||
export type BoardLayout = Board["layouts"][number];
|
||||
|
||||
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: Pick<BoardLayout, "id" | "columnCount">,
|
||||
targetLayout: Pick<BoardLayout, "id" | "columnCount">,
|
||||
) => {
|
||||
const elements = getElementsForLayout(board, sourceLayout.id);
|
||||
|
||||
return board.sections
|
||||
.filter((section) => section.kind !== "dynamic")
|
||||
.flatMap(
|
||||
(section) =>
|
||||
generateResponsiveGridFor({
|
||||
items: elements,
|
||||
previousWidth: sourceLayout.columnCount,
|
||||
width: targetLayout.columnCount,
|
||||
sectionId: section.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 !== "dynamic") 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,30 @@
|
||||
"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;
|
||||
canModify: boolean;
|
||||
}
|
||||
|
||||
export const BoardProviders = ({ children, initialBoard, 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} layoutOverrideId={layoutOverrideId}>
|
||||
<EditModeProvider initialOpen={isLayoutEditRequest}>{children}</EditModeProvider>
|
||||
</BoardProvider>
|
||||
);
|
||||
};
|
||||
@@ -60,9 +60,13 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
return layouts.map((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
const sizeForLayout = {
|
||||
...itemSize,
|
||||
width: Math.min(boardLayout?.columnCount ?? itemSize.width, itemSize.width),
|
||||
};
|
||||
|
||||
const emptyPosition = boardLayout
|
||||
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
|
||||
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, sizeForLayout)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
@@ -70,8 +74,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
}
|
||||
|
||||
return {
|
||||
width: itemSize.width,
|
||||
height: itemSize.height,
|
||||
width: sizeForLayout.width,
|
||||
height: sizeForLayout.height,
|
||||
...emptyPosition,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
|
||||
@@ -10,6 +10,20 @@ import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions create-item", () => {
|
||||
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";
|
||||
|
||||
@@ -45,7 +45,8 @@ export class BoardMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
},
|
||||
],
|
||||
...board,
|
||||
|
||||
@@ -10,7 +10,8 @@ export class LayoutMockBuilder {
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
...layout,
|
||||
} satisfies Board["layouts"][0];
|
||||
}
|
||||
|
||||
+397
-173
@@ -2,10 +2,10 @@ 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 { 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";
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
everyoneGroup,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
normalizeBoardLayoutRoles,
|
||||
widgetDefaultSizes,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
boardCreateSchema,
|
||||
boardDuplicateSchema,
|
||||
boardRenameSchema,
|
||||
boardResetLayoutSchema,
|
||||
boardSaveLayoutsSchema,
|
||||
boardSavePartialSettingsSchema,
|
||||
boardSavePermissionsSchema,
|
||||
@@ -61,7 +63,6 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { generateResponsiveGridFor } from "./board/grid-algorithm";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
exists: permissionRequiredProcedure
|
||||
@@ -313,13 +314,24 @@ export const boardRouter = createTRPCRouter({
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
createBoardCollection.layouts.push({
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: input.columnCount,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
});
|
||||
createBoardCollection.layouts.push(
|
||||
{
|
||||
id: createId(),
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
boardId,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: input.columnCount,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
},
|
||||
);
|
||||
|
||||
await createBoardCollection.insertAllAsync(ctx.db);
|
||||
|
||||
@@ -375,8 +387,49 @@ 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 },
|
||||
current: { layoutId: generatedMobileLayoutId, columnCount: 3 },
|
||||
})
|
||||
: null;
|
||||
const normalizedBoardLayouts =
|
||||
boardLayouts.length === 0
|
||||
? [
|
||||
{
|
||||
id: generatedMobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
boardId: board.id,
|
||||
},
|
||||
]
|
||||
: boardLayouts.length === 1 && boardLayouts[0]
|
||||
? [
|
||||
{
|
||||
id: generatedMobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
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)!,
|
||||
@@ -393,9 +446,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)!,
|
||||
@@ -404,10 +472,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(
|
||||
@@ -429,9 +496,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)!,
|
||||
@@ -439,9 +519,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");
|
||||
@@ -682,130 +761,244 @@ 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 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);
|
||||
|
||||
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
|
||||
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
|
||||
for (const addedLayout of addedLayouts) {
|
||||
const layoutId = createId();
|
||||
|
||||
layoutsToInsert.push({
|
||||
id: layoutId,
|
||||
name: addedLayout.name,
|
||||
columnCount: addedLayout.columnCount,
|
||||
breakpoint: addedLayout.breakpoint,
|
||||
boardId: board.id,
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
current: {
|
||||
layoutId,
|
||||
columnCount: addedLayout.columnCount,
|
||||
},
|
||||
});
|
||||
|
||||
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
|
||||
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
|
||||
}
|
||||
|
||||
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)!;
|
||||
|
||||
if (dbLayout.columnCount !== updatedLayout.columnCount) {
|
||||
const updatedBoardLayout = getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: dbLayout.columnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: updatedLayout.columnCount,
|
||||
},
|
||||
});
|
||||
|
||||
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 (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" });
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(layouts)
|
||||
.set({
|
||||
name: updatedLayout.name,
|
||||
columnCount: updatedLayout.columnCount,
|
||||
breakpoint: updatedLayout.breakpoint,
|
||||
})
|
||||
.where(eq(layouts.id, updatedLayout.id));
|
||||
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" });
|
||||
}
|
||||
|
||||
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
|
||||
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
const savedLayoutIds = new Map<string, string>();
|
||||
|
||||
for (const addedLayout of addedLayouts) {
|
||||
const layoutId = createId();
|
||||
savedLayoutIds.set(addedLayout.id, layoutId);
|
||||
layoutsToInsert.push({
|
||||
id: layoutId,
|
||||
name: addedLayout.name,
|
||||
columnCount: addedLayout.columnCount,
|
||||
breakpoint: addedLayout.breakpoint,
|
||||
role: "custom",
|
||||
boardId: board.id,
|
||||
});
|
||||
|
||||
const projectedLayout = getUpdatedBoardLayout(board, {
|
||||
previous: { layoutId: baseLayout.id, columnCount: baseLayout.columnCount },
|
||||
current: { layoutId, columnCount: addedLayout.columnCount },
|
||||
});
|
||||
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) continue;
|
||||
|
||||
const projectedLayout = getUpdatedBoardLayout(board, {
|
||||
previous: { layoutId: dbLayout.id, columnCount: dbLayout.columnCount },
|
||||
current: { layoutId: dbLayout.id, columnCount: updatedLayout.columnCount },
|
||||
});
|
||||
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, 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, breakpoint: layout.breakpoint })
|
||||
.where(eq(layouts.id, layout.id))
|
||||
.run();
|
||||
}
|
||||
if (removedLayoutIds.length > 0)
|
||||
transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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.input(boardResetLayoutSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
|
||||
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 (!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" });
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
const projectedLayout = getUpdatedBoardLayout(board, {
|
||||
previous: { layoutId: baseLayout.id, columnCount: baseLayout.columnCount },
|
||||
current: { layoutId: targetLayout.id, columnCount: targetLayout.columnCount },
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -1504,13 +1697,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;
|
||||
}
|
||||
}
|
||||
@@ -1528,8 +1722,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;
|
||||
}
|
||||
@@ -1639,8 +1833,34 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
|
||||
}
|
||||
};
|
||||
|
||||
interface BoardForLayoutProjection {
|
||||
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 getUpdatedBoardLayout = (
|
||||
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
|
||||
board: BoardForLayoutProjection,
|
||||
options: {
|
||||
previous: {
|
||||
layoutId: string;
|
||||
@@ -1720,37 +1940,41 @@ const getUpdatedBoardLayout = (
|
||||
};
|
||||
};
|
||||
|
||||
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
|
||||
const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string) => {
|
||||
const sectionElements = board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.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];
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -294,9 +294,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const section = board.sections.find((boardSection) => boardSection.kind === "empty");
|
||||
const layout = board.layouts[0];
|
||||
|
||||
if (section && layout) {
|
||||
if (section && board.layouts.length > 0) {
|
||||
const existingItems = await db.query.items.findMany({
|
||||
where: eq(items.boardId, board.id),
|
||||
});
|
||||
@@ -310,12 +308,12 @@ export const onboardRouter = createTRPCRouter({
|
||||
|
||||
await placeAllWidgetsAsync(
|
||||
db,
|
||||
{
|
||||
board.layouts.map((layout) => ({
|
||||
boardId: board.id,
|
||||
sectionId: section.id,
|
||||
layoutId: layout.id,
|
||||
columnCount: layout.columnCount,
|
||||
},
|
||||
})),
|
||||
allIntegrations,
|
||||
allApps,
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database, InferInsertModel } from "@homarr/db";
|
||||
import { and, eq, not } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
boards,
|
||||
@@ -325,10 +325,13 @@ describe("createBoard should create a new board", () => {
|
||||
expect(firstSection?.xOffset).toBe(0);
|
||||
expect(firstSection?.yOffset).toBe(0);
|
||||
|
||||
expect(dbBoard?.layouts.length).toBe(1);
|
||||
const firstLayout = dbBoard?.layouts.at(0);
|
||||
expect(firstLayout?.columnCount).toBe(24);
|
||||
expect(firstLayout?.breakpoint).toBe(0);
|
||||
expect(dbBoard?.layouts.length).toBe(2);
|
||||
expect(dbBoard?.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error when user has no board-create permission", async () => {
|
||||
@@ -344,6 +347,36 @@ describe("createBoard should create a new board", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateBoard should preserve protected layout invariants", () => {
|
||||
test("normalizes a legacy single-layout board while preserving Base positions", async () => {
|
||||
const db = createDb();
|
||||
const session = {
|
||||
...defaultSession,
|
||||
user: {
|
||||
...defaultSession.user,
|
||||
permissions: ["board-create"] satisfies GroupPermissionKey[],
|
||||
},
|
||||
};
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { boardId } = await createFullBoardAsync(db, "source");
|
||||
|
||||
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
|
||||
|
||||
const copiedBoard = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, result.boardId),
|
||||
with: { layouts: true, items: { with: { layouts: true } } },
|
||||
});
|
||||
expect(copiedBoard?.layouts).toHaveLength(2);
|
||||
expect(copiedBoard?.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
expect(copiedBoard?.items.at(0)?.layouts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename board should rename board", () => {
|
||||
test("should rename board", async () => {
|
||||
// Arrange
|
||||
@@ -1328,14 +1361,35 @@ const createExistingLayout = (id: string) => ({
|
||||
id,
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
});
|
||||
const createMobileLayout = (id: string) => ({
|
||||
id,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
});
|
||||
const createNewLayout = (columnCount: number) => ({
|
||||
id: createId(),
|
||||
name: "New layout",
|
||||
columnCount,
|
||||
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,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
boardId,
|
||||
});
|
||||
return mobileLayoutId;
|
||||
};
|
||||
describe("saveLayouts should save layout changes", () => {
|
||||
test("should add layout when not present in database", async () => {
|
||||
// Arrange
|
||||
@@ -1343,23 +1397,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 dynamic sections generated from grid-algorithm when new layout is added", async () => {
|
||||
// Arrange
|
||||
@@ -1367,6 +1424,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,
|
||||
@@ -1377,12 +1435,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, {
|
||||
@@ -1398,6 +1456,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";
|
||||
@@ -1405,7 +1464,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [updatedLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -1424,6 +1483,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,
|
||||
@@ -1435,7 +1495,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [updatedLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -1451,16 +1511,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 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();
|
||||
});
|
||||
@@ -1475,12 +1545,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");
|
||||
});
|
||||
});
|
||||
|
||||
const expectInputToBeFullBoardWithName = (
|
||||
@@ -1521,7 +1652,8 @@ const createFullBoardAsync = async (db: Database, name: string) => {
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,13 +11,16 @@ import { updateBoardName } from "./updater";
|
||||
|
||||
const BoardContext = createContext<{
|
||||
board: RouterOutputs["board"]["getBoardByName"];
|
||||
layoutOverrideId: string | null;
|
||||
} | null>(null);
|
||||
|
||||
export const BoardProvider = ({
|
||||
children,
|
||||
initialBoard,
|
||||
layoutOverrideId = null,
|
||||
}: PropsWithChildren<{
|
||||
initialBoard: RouterOutputs["board"]["getBoardByName"];
|
||||
layoutOverrideId?: string | null;
|
||||
}>) => {
|
||||
const { data } = clientApi.board.getBoardByName.useQuery({ name: initialBoard.name }, { initialData: initialBoard });
|
||||
|
||||
@@ -39,6 +42,7 @@ export const BoardProvider = ({
|
||||
<BoardContext.Provider
|
||||
value={{
|
||||
board: data,
|
||||
layoutOverrideId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -62,7 +66,14 @@ 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,
|
||||
) => {
|
||||
if (layoutOverrideId && board.layouts.some((layout) => layout.id === layoutOverrideId)) return layoutOverrideId;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (typeof window === "undefined") return board.layouts.at(0)!.id;
|
||||
|
||||
@@ -75,11 +86,12 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]
|
||||
|
||||
export const useCurrentLayout = () => {
|
||||
const board = useRequiredBoard();
|
||||
const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board));
|
||||
const layoutOverrideId = useLayoutOverride();
|
||||
const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board, layoutOverrideId));
|
||||
|
||||
const onResize = useCallback(() => {
|
||||
setCurrentLayout(getCurrentLayout(board));
|
||||
}, [board]);
|
||||
setCurrentLayout(getCurrentLayout(board, layoutOverrideId));
|
||||
}, [board, layoutOverrideId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -90,6 +102,8 @@ export const useCurrentLayout = () => {
|
||||
};
|
||||
}, [onResize]);
|
||||
|
||||
useEffect(() => onResize(), [onResize]);
|
||||
|
||||
return currentLayout;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
+13
-86
@@ -28,24 +28,16 @@ export const generateResponsiveGridFor = ({
|
||||
}: GridAlgorithmInput): GridAlgorithmOutput => {
|
||||
const itemsOfCurrentSection = items
|
||||
.filter((item) => item.sectionId === sectionId)
|
||||
.sort((itemA, itemB) =>
|
||||
.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: [],
|
||||
};
|
||||
}
|
||||
if (itemsOfCurrentSection.length === 0) return { height: 0, items: [] };
|
||||
|
||||
const newItems: GridAlgorithmItem[] = [];
|
||||
|
||||
// Fix height of dynamic sections
|
||||
const dynamicSectionHeightMap = new Map<string, number>();
|
||||
const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
|
||||
for (const dynamicSection of dynamicSectionsOfCurrentSection) {
|
||||
for (const dynamicSection of normalizedItems.filter((item) => item.type === "section")) {
|
||||
const result = generateResponsiveGridFor({
|
||||
items,
|
||||
previousWidth: dynamicSection.previousWidth,
|
||||
@@ -56,7 +48,6 @@ export const generateResponsiveGridFor = ({
|
||||
dynamicSectionHeightMap.set(dynamicSection.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)),
|
||||
@@ -65,7 +56,6 @@ export const generateResponsiveGridFor = ({
|
||||
}
|
||||
|
||||
const occupied2d: boolean[][] = [];
|
||||
|
||||
for (const item of normalizedItems) {
|
||||
const itemWithHeight = {
|
||||
...item,
|
||||
@@ -75,34 +65,15 @@ export const generateResponsiveGridFor = ({
|
||||
if (!position) throw new Error("No free spot available");
|
||||
|
||||
addItemToOccupied(occupied2d, itemWithHeight, position, width);
|
||||
newItems.push({
|
||||
...itemWithHeight,
|
||||
xOffset: position.x,
|
||||
yOffset: position.y,
|
||||
});
|
||||
newItems.push({ ...itemWithHeight, xOffset: position.x, yOffset: position.y });
|
||||
}
|
||||
|
||||
return {
|
||||
height: occupied2d.length,
|
||||
items: newItems,
|
||||
};
|
||||
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) }));
|
||||
};
|
||||
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) =>
|
||||
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,
|
||||
@@ -112,75 +83,31 @@ const addItemToOccupied = (
|
||||
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;
|
||||
row = Array.from({ length: columnCount }, () => false);
|
||||
occupied2d.push(row);
|
||||
}
|
||||
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 })) {
|
||||
if (offsetX + item.width <= columnCount && 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
|
||||
|
||||
if (!row) return true;
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) {
|
||||
if (row[position.x + xOffset]) {
|
||||
return false;
|
||||
}
|
||||
if (row[position.x + xOffset]) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -11,4 +11,5 @@ export * from "./number";
|
||||
export * from "./error";
|
||||
export * from "./theme";
|
||||
export * from "./function";
|
||||
export * from "./grid-algorithm";
|
||||
export * from "./id";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `layout` ADD `role` varchar(16) DEFAULT 'custom' NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -309,6 +309,13 @@
|
||||
"when": 1783852800000,
|
||||
"tag": "0043_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "5",
|
||||
"when": 1786190530046,
|
||||
"tag": "0044_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
@@ -71,6 +71,13 @@
|
||||
"when": 1783786677702,
|
||||
"tag": "0009_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1786190409743,
|
||||
"tag": "0010_add_layout_role",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, itemLayouts, items, layouts, sections } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { emptySuperJSON } from "@homarr/definitions";
|
||||
|
||||
import { seedProtectedBoardLayoutsAsync } from "./seed";
|
||||
|
||||
describe("protected board layout migration", () => {
|
||||
test("turns a single saved layout into Base and generates Mobile", async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
const baseLayoutId = createId();
|
||||
const sectionId = createId();
|
||||
const itemId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "single" });
|
||||
await db.insert(layouts).values({
|
||||
id: baseLayoutId,
|
||||
boardId,
|
||||
name: "Wide",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
});
|
||||
await db.insert(sections).values({ id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 });
|
||||
await db.insert(items).values({ id: itemId, boardId, kind: "clock", options: emptySuperJSON });
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId: baseLayoutId,
|
||||
width: 5,
|
||||
height: 2,
|
||||
xOffset: 6,
|
||||
yOffset: 1,
|
||||
});
|
||||
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
|
||||
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
|
||||
expect(savedLayouts).toHaveLength(2);
|
||||
expect(savedLayouts.find((layout) => layout.role === "base")).toMatchObject({
|
||||
id: baseLayoutId,
|
||||
name: "Wide",
|
||||
columnCount: 12,
|
||||
breakpoint: 768,
|
||||
});
|
||||
const mobileLayout = savedLayouts.find((layout) => layout.role === "mobile");
|
||||
expect(mobileLayout).toMatchObject({ name: "Mobile", columnCount: 3, breakpoint: 0 });
|
||||
const mobileItemPosition = await db.query.itemLayouts.findFirst({
|
||||
where: eq(itemLayouts.layoutId, mobileLayout?.id ?? ""),
|
||||
});
|
||||
expect(mobileItemPosition).toMatchObject({ width: 3, height: 2, xOffset: 0, yOffset: 0 });
|
||||
});
|
||||
|
||||
test("assigns roles and resolves duplicate breakpoints without rebuilding existing layouts", async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "responsive" });
|
||||
await db.insert(layouts).values([
|
||||
{ id: "layout-mobile", boardId, name: "Phone", columnCount: 3, breakpoint: 0 },
|
||||
{ id: "layout-tablet-a", boardId, name: "Tablet A", columnCount: 6, breakpoint: 480 },
|
||||
{ id: "layout-tablet-b", boardId, name: "Tablet B", columnCount: 8, breakpoint: 480 },
|
||||
{ id: "layout-wide", boardId, name: "Wide", columnCount: 12, breakpoint: 1200 },
|
||||
]);
|
||||
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
|
||||
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
|
||||
expect(savedLayouts.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint)).toMatchObject([
|
||||
{ id: "layout-mobile", name: "Phone", columnCount: 3, breakpoint: 0, role: "mobile" },
|
||||
{ id: "layout-tablet-a", name: "Tablet A", columnCount: 6, breakpoint: 480, role: "custom" },
|
||||
{ id: "layout-tablet-b", name: "Tablet B", columnCount: 8, breakpoint: 481, role: "custom" },
|
||||
{ id: "layout-wide", name: "Wide", columnCount: 12, breakpoint: 1200, role: "base" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates both protected layouts for a board with no layouts", async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "empty" });
|
||||
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
|
||||
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
|
||||
expect(savedLayouts).toHaveLength(2);
|
||||
expect(savedLayouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
+233
-11
@@ -1,6 +1,6 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { createId, objectKeys } from "@homarr/common";
|
||||
import { createId, generateResponsiveGridFor, objectKeys } from "@homarr/common";
|
||||
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
|
||||
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 {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
onboarding,
|
||||
searchEngines,
|
||||
sections,
|
||||
sectionLayouts,
|
||||
users,
|
||||
customWidgetDefinitions,
|
||||
} from "../schema";
|
||||
@@ -155,6 +157,214 @@ export const seedDataAsync = async (db: Database) => {
|
||||
if (isTruthyEnv(process.env.DEMO_MODE)) {
|
||||
await seedDemoUserAsync(db);
|
||||
}
|
||||
|
||||
await seedProtectedBoardLayoutsAsync(db);
|
||||
};
|
||||
|
||||
export const seedProtectedBoardLayoutsAsync = async (db: Database) => {
|
||||
const dbBoards = await db.query.boards.findMany({
|
||||
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) => {
|
||||
@@ -361,13 +571,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),
|
||||
@@ -598,7 +819,8 @@ const seedDemoUserAsync = async (db: Database) => {
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
});
|
||||
|
||||
@@ -695,7 +917,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
@@ -295,6 +295,13 @@
|
||||
"when": 1783786658943,
|
||||
"tag": "0041_add_right_click_preference",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "6",
|
||||
"when": 1786190387517,
|
||||
"tag": "0042_add_layout_role",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { boards, layouts, sections } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { placeAllWidgetsAsync } from "./widget-placement";
|
||||
|
||||
describe("placeAllWidgetsAsync", () => {
|
||||
test("creates independent positions for every target layout", async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
const sectionId = createId();
|
||||
const mobileLayoutId = createId();
|
||||
const baseLayoutId = createId();
|
||||
await db.insert(boards).values({ id: boardId, name: "dashboard" });
|
||||
await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId });
|
||||
await db.insert(layouts).values([
|
||||
{
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
role: "mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
boardId,
|
||||
},
|
||||
{
|
||||
id: baseLayoutId,
|
||||
name: "Base",
|
||||
role: "base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
boardId,
|
||||
},
|
||||
]);
|
||||
|
||||
await placeAllWidgetsAsync(
|
||||
db,
|
||||
[
|
||||
{ boardId, sectionId, layoutId: mobileLayoutId, columnCount: 3 },
|
||||
{ boardId, sectionId, layoutId: baseLayoutId, columnCount: 10 },
|
||||
],
|
||||
[],
|
||||
[{ id: createId(), name: "Homarr" }],
|
||||
);
|
||||
|
||||
const placedItems = await db.query.items.findMany({ with: { layouts: true } });
|
||||
expect(placedItems.length).toBeGreaterThan(0);
|
||||
expect(placedItems.every((item) => item.layouts.length === 2)).toBe(true);
|
||||
expect(
|
||||
placedItems.every((item) =>
|
||||
item.layouts.some((layout) => layout.layoutId === mobileLayoutId && layout.width <= 3),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(placedItems.every((item) => item.layouts.some((layout) => layout.layoutId === baseLayoutId))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -30,46 +30,54 @@ interface PlacementContext extends BoardTarget {
|
||||
|
||||
const placeWidgetAsync = async (
|
||||
db: Database,
|
||||
ctx: PlacementContext,
|
||||
contexts: PlacementContext[],
|
||||
kind: WidgetKind,
|
||||
linkedIntegrationIds: string[],
|
||||
options?: string,
|
||||
size?: { width: number; height: number },
|
||||
) => {
|
||||
const itemWidth = size?.width ?? 2;
|
||||
const itemHeight = size?.height ?? 2;
|
||||
const boardId = contexts.at(0)?.boardId;
|
||||
if (!boardId) return;
|
||||
|
||||
if (ctx.xOffset + itemWidth > ctx.columnCount) {
|
||||
ctx.xOffset = 0;
|
||||
ctx.yOffset += ctx.rowMaxHeight;
|
||||
ctx.rowMaxHeight = 0;
|
||||
}
|
||||
const desiredItemWidth = size?.width ?? 2;
|
||||
const itemHeight = size?.height ?? 2;
|
||||
|
||||
const itemId = createId();
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId: ctx.boardId,
|
||||
boardId,
|
||||
kind,
|
||||
options: options ?? emptySuperJSON,
|
||||
advancedOptions: emptySuperJSON,
|
||||
});
|
||||
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId: ctx.sectionId,
|
||||
layoutId: ctx.layoutId,
|
||||
xOffset: ctx.xOffset,
|
||||
yOffset: ctx.yOffset,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
});
|
||||
await db.insert(itemLayouts).values(
|
||||
contexts.map((ctx) => {
|
||||
const itemWidth = Math.min(ctx.columnCount, desiredItemWidth);
|
||||
if (ctx.xOffset + itemWidth > ctx.columnCount) {
|
||||
ctx.xOffset = 0;
|
||||
ctx.yOffset += ctx.rowMaxHeight;
|
||||
ctx.rowMaxHeight = 0;
|
||||
}
|
||||
|
||||
const placement = {
|
||||
itemId,
|
||||
sectionId: ctx.sectionId,
|
||||
layoutId: ctx.layoutId,
|
||||
xOffset: ctx.xOffset,
|
||||
yOffset: ctx.yOffset,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
};
|
||||
ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
|
||||
ctx.xOffset += itemWidth;
|
||||
return placement;
|
||||
}),
|
||||
);
|
||||
|
||||
for (const integrationId of linkedIntegrationIds) {
|
||||
await db.insert(integrationItems).values({ itemId, integrationId });
|
||||
}
|
||||
|
||||
ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
|
||||
ctx.xOffset += itemWidth;
|
||||
};
|
||||
|
||||
interface App {
|
||||
@@ -79,16 +87,17 @@ interface App {
|
||||
|
||||
export const placeAllWidgetsAsync = async (
|
||||
db: Database,
|
||||
target: BoardTarget,
|
||||
target: BoardTarget | BoardTarget[],
|
||||
allIntegrations: Integration[],
|
||||
allApps: App[],
|
||||
) => {
|
||||
const ctx: PlacementContext = {
|
||||
...target,
|
||||
const targets = Array.isArray(target) ? target : [target];
|
||||
const contexts: PlacementContext[] = targets.map((currentTarget) => ({
|
||||
...currentTarget,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
rowMaxHeight: 0,
|
||||
};
|
||||
}));
|
||||
|
||||
const placedWidgets = new Set<WidgetKind>();
|
||||
|
||||
@@ -106,7 +115,7 @@ export const placeAllWidgetsAsync = async (
|
||||
const options = config?.options ? superjson.stringify(config.options) : undefined;
|
||||
await placeWidgetAsync(
|
||||
db,
|
||||
ctx,
|
||||
contexts,
|
||||
widgetKind,
|
||||
matchingIds,
|
||||
options,
|
||||
@@ -118,7 +127,7 @@ export const placeAllWidgetsAsync = async (
|
||||
for (const app of allApps) {
|
||||
await placeWidgetAsync(
|
||||
db,
|
||||
ctx,
|
||||
contexts,
|
||||
"app",
|
||||
[],
|
||||
superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }),
|
||||
@@ -138,7 +147,7 @@ export const placeAllWidgetsAsync = async (
|
||||
options = { ...options, items: bookmarkAppIds };
|
||||
}
|
||||
|
||||
await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, {
|
||||
await placeWidgetAsync(db, contexts, config.kind, [], options ? superjson.stringify(options) : undefined, {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
IntegrationKind,
|
||||
IntegrationPermission,
|
||||
IntegrationSecretKind,
|
||||
LayoutRole,
|
||||
OnboardingStep,
|
||||
SearchEngineType,
|
||||
SectionKind,
|
||||
@@ -345,6 +346,7 @@ export const layouts = mysqlTable("layout", {
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
columnCount: tinyint().notNull(),
|
||||
breakpoint: smallint().notNull().default(0),
|
||||
role: varchar({ length: 16 }).$type<LayoutRole>().notNull().default("custom"),
|
||||
});
|
||||
|
||||
export const itemLayouts = mysqlTable(
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
IntegrationKind,
|
||||
IntegrationPermission,
|
||||
IntegrationSecretKind,
|
||||
LayoutRole,
|
||||
OnboardingStep,
|
||||
SearchEngineType,
|
||||
SectionKind,
|
||||
@@ -344,6 +345,7 @@ export const layouts = pgTable("layout", {
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
columnCount: smallint().notNull(),
|
||||
breakpoint: smallint().notNull().default(0),
|
||||
role: varchar({ length: 16 }).$type<LayoutRole>().notNull().default("custom"),
|
||||
});
|
||||
|
||||
export const itemLayouts = pgTable(
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
IntegrationKind,
|
||||
IntegrationPermission,
|
||||
IntegrationSecretKind,
|
||||
LayoutRole,
|
||||
OnboardingStep,
|
||||
SearchEngineType,
|
||||
SectionKind,
|
||||
@@ -330,6 +331,7 @@ export const layouts = sqliteTable("layout", {
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
columnCount: int().notNull(),
|
||||
breakpoint: int().notNull().default(0),
|
||||
role: text().$type<LayoutRole>().notNull().default("custom"),
|
||||
});
|
||||
|
||||
export const itemLayouts = sqliteTable(
|
||||
|
||||
@@ -8,7 +8,49 @@ export const backgroundImageRepeats = createDefinition(["repeat", "repeat-x", "r
|
||||
export const backgroundImageSizes = createDefinition(["cover", "contain"], {
|
||||
defaultValue: "cover",
|
||||
});
|
||||
export const layoutRoles = createDefinition(["mobile", "base", "custom"], { defaultValue: "custom" });
|
||||
|
||||
export type BackgroundImageAttachment = inferDefinitionType<typeof backgroundImageAttachments>;
|
||||
export type BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
|
||||
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
|
||||
export type LayoutRole = inferDefinitionType<typeof layoutRoles>;
|
||||
|
||||
const MAX_LAYOUT_BREAKPOINT = 32767;
|
||||
const compareBoardLayouts = (
|
||||
layoutA: { id: string; breakpoint: number; columnCount: number },
|
||||
layoutB: { id: string; breakpoint: number; columnCount: number },
|
||||
) =>
|
||||
layoutA.breakpoint - layoutB.breakpoint ||
|
||||
layoutA.columnCount - layoutB.columnCount ||
|
||||
layoutA.id.localeCompare(layoutB.id);
|
||||
|
||||
export const normalizeBoardLayoutRoles = <
|
||||
TLayout extends { id: string; breakpoint: number; columnCount: number; role?: LayoutRole },
|
||||
>(
|
||||
sourceLayouts: TLayout[],
|
||||
): Array<TLayout & { role: LayoutRole }> => {
|
||||
const mobileLayouts = sourceLayouts.filter((layout) => layout.role === "mobile");
|
||||
const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
|
||||
const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
|
||||
const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
|
||||
const orderedLayouts =
|
||||
mobileLayout && baseLayout
|
||||
? [
|
||||
mobileLayout,
|
||||
...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts),
|
||||
baseLayout,
|
||||
]
|
||||
: sourceLayouts.toSorted(compareBoardLayouts);
|
||||
|
||||
let previousBreakpoint = -1;
|
||||
return orderedLayouts.map((layout, index) => {
|
||||
const remainingLayouts = orderedLayouts.length - index - 1;
|
||||
const breakpoint =
|
||||
index === 0
|
||||
? 0
|
||||
: Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
|
||||
const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom";
|
||||
previousBreakpoint = breakpoint;
|
||||
return { ...layout, breakpoint, role };
|
||||
});
|
||||
};
|
||||
@@ -1,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";
|
||||
|
||||
@@ -87,8 +88,8 @@ export const createBoardInsertCollection = (
|
||||
{} as Record<BoardSize, string>,
|
||||
);
|
||||
|
||||
insertCollection.layouts.push(
|
||||
...boardSizes.map((size) => ({
|
||||
const importedLayouts = normalizeBoardLayoutRoles(
|
||||
boardSizes.map((size) => ({
|
||||
id: layoutMapping[size],
|
||||
boardId: mappedBoard.id,
|
||||
columnCount: mapColumnCount(board.config.settings.customization.gridstack, size),
|
||||
@@ -97,6 +98,8 @@ export const createBoardInsertCollection = (
|
||||
})),
|
||||
);
|
||||
|
||||
insertCollection.layouts.push(...importedLayouts);
|
||||
|
||||
const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories });
|
||||
|
||||
for (const section of preparedSections.values()) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -4408,9 +4408,43 @@
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { responsiveBoardLayoutsSchema } from "./board";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
boardPermissions,
|
||||
layoutRoles,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
@@ -70,16 +71,52 @@ export const boardSavePartialSettingsSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const boardLayoutSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().trim().nonempty().max(32),
|
||||
columnCount: boardColumnCountSchema,
|
||||
breakpoint: z.number().int().min(0).max(32767),
|
||||
role: z.enum(layoutRoles.values),
|
||||
});
|
||||
|
||||
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(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().trim().nonempty().max(32),
|
||||
columnCount: boardColumnCountSchema,
|
||||
breakpoint: z.number().min(0).max(32767),
|
||||
}),
|
||||
),
|
||||
layouts: responsiveBoardLayoutsSchema,
|
||||
});
|
||||
|
||||
export const boardResetLayoutSchema = z.object({
|
||||
boardId: z.string(),
|
||||
layoutId: z.string(),
|
||||
});
|
||||
|
||||
export const boardSaveSchema = z.object({
|
||||
|
||||
Reference in new issue
Block a user