refactor: simplify mobile board layouts

This commit is contained in:
ajnart committed 2026-08-08 13:54:42 +02:00
1 parent 7e7b338d18
commit fa9444323b
51 files changed
+383 -2472

No files matched your search

+1 -1
View File
@@ -80,6 +80,6 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Change board visibility
- Update board visual and behavior settings
- Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards, automatic mobile layout mode, and status behavior
- Configure global board defaults such as public home boards and status behavior
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
+41 -69
View File
@@ -9,9 +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 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.
@@ -22,29 +24,24 @@ 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.
![](./img/create-board-modal.png)
### 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:
![](./img/navigate-to-settings.png)
@@ -54,9 +51,7 @@ Alternatively, you can also navigate from the board page itself using the cog ic
![](./img/navigate-to-settings-2.png)
### General
#### Page title
This is the title that will be visible at the top left in the navigation bar:
![](./img/page-title.png)
@@ -64,7 +59,6 @@ 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:
@@ -73,89 +67,78 @@ 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
The available controls depend on the server-wide **Use automatic mobile layout** setting under `Management` > `Settings` > `Boards`.
In this section you can configure the different responsive layouts. Depending on your screens width the layout will change.
#### Automatic mobile layout
![](./img/layout-settings.png)
When automatic mode is enabled, this section configures the desktop column count. Items and sections can be moved and resized on desktop while the board is in edit mode.
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.
Automatic mode is enabled by default on fresh Homarr installations. It is a server-wide setting, so creating another account on an existing installation does not change the saved choice.
#### Name
Phones and small tablets display the same widgets in a generated, read-only two-column grid. Widget content and controls are unchanged. Items follow desktop visual order and retain their desktop footprint, clamped to two columns wide and three rows high.
The name of the layout can be choosen freely. It is only used for the management and will not be visible on the board.
Categories and dynamic sections are flattened: their items participate directly in the generated grid instead of rendering nested layout containers. To rearrange, add, remove, or resize items, open the board on a larger screen.
#### Column count
In desktop edit mode, select the floating **Mobile preview** button to expand a lightweight map of the generated layout. The map updates with unsaved board changes and can be collapsed back to the button without leaving edit mode.
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.
<img src={layoutLargeImage} height={256} />
#### Breakpoint
#### Saved responsive layouts
Minimum width of the screen where the layout will be used.
Once the minmum of the next layout is reached the layout will change.
When automatic mode is disabled, boards use their saved responsive layouts and breakpoint selection. These layouts can still be edited on mobile.
#### Preview
Switching automatic mode on does not delete saved responsive layouts. They remain available if the server is switched back.
<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):
![](./img/background-repeat.png)
### 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.
@@ -164,16 +147,14 @@ 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 |
@@ -192,23 +173,18 @@ 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:
@@ -220,7 +196,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.
:::
@@ -230,51 +206,47 @@ You can duplicate a board by clicking on the three dots in the list of boards an
![](./img/duplicate-board-modal.png)
## 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:
![](./img/home-board-flag.png)
### For yourself
To set the home board yourself, the easiest option is to set it directly in the list of boards:
![](./img/set-as-home-board-menu.png)
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` \
![](./img/set-home-board-other-user.png)
3. Select the desired home board via the dropdown and click on ``Save`` \
![](./img/set-home-board-other-user.png)
### 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` \
![](./img/set-home-board-user-group.png)
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`` \
![](./img/set-home-board-user-group.png)
### 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` \
![](./img/set-home-board-global.png)
1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards``
2. Select the desired home board via the dropdown and click on ``Save`` \
![](./img/set-home-board-global.png)
:::note
For the global home board you can only select boards that are public.
@@ -283,4 +255,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)
@@ -110,14 +110,6 @@ Homarr shows this board on mobile devices instead of the global home board.
Homarr detects mobile devices with the User-Agent header.
The board must be public.
### Automatic mobile layout
**Use automatic mobile layout** renders every board as a read-only two-column layout on phones and small tablets. Fresh Homarr installations enable this setting by default. Creating another account on an existing installation does not change it because this is a server-wide setting.
When upgrading, Homarr enables automatic mode only if every board still has its single canonical base layout. Installations with saved responsive layouts remain in legacy mode, and an explicit saved choice is never overwritten. Turning automatic mode off restores the saved responsive layouts and mobile editing.
While editing a board on desktop, use the floating **Mobile preview** control to inspect the automatic item order and sizing without loading every widget a second time.
### App status
#### Enable status by default
@@ -4,17 +4,13 @@ import { Box, Stack } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { MobileBoard } from "~/components/board/mobile/mobile-board";
import { MobileBoardPreview } from "~/components/board/mobile/mobile-preview";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
import { BoardBackgroundVideo } from "~/components/layout/background";
export const ClientBoard = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const isMobile = useIsMobileBoard();
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
@@ -23,20 +19,15 @@ export const ClientBoard = () => {
return (
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
{isMobile ? (
<MobileBoard />
) : (
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
)}
<MobileBoardPreview />
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
</Box>
);
};
@@ -19,7 +19,6 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
import { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target";
@@ -37,7 +36,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const isMobile = useIsMobileBoard();
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
@@ -45,9 +43,9 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return (
<>
{isEditMode && !isMobile && <BoardAddMenu />}
{isEditMode && <BoardAddMenu />}
<EditModeMenu demoReadOnly={demoReadOnly} hidden={isMobile} />
<EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && (
<TourTarget id="board-settings">
@@ -62,7 +60,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
);
};
const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: boolean }) => {
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
@@ -91,25 +89,21 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
}, [utils, board.name, close]);
const toggle = useCallback(() => {
if (hidden && !isEditMode) return;
if (isEditMode) {
if (demoReadOnly) return discardDemoChanges();
return saveBoard(board);
}
open();
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges, hidden]);
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
if (hidden && !isEditMode) return null;
return (
<TourTarget id="board-edit-mode">
<HeaderButton
onClick={toggle}
loading={isPending}
aria-label={t("label")}
onFocus={preloadBoardAddMenu}
onPointerEnter={preloadBoardAddMenu}
>
@@ -3,116 +3,30 @@
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { getDesktopLayout } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutSchema, boardSaveLayoutsSchema } from "@homarr/validation/board";
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
import type { Board } from "../../_types";
interface Props {
board: Board;
enableAutomaticMobileLayout: boolean;
}
const getNextAvailableBreakpoint = (layouts: Array<{ breakpoint: number }>) => {
const breakpoints = new Set(layouts.map((layout) => layout.breakpoint));
const commonBreakpoints = [0, 480, 768, 1024, 1200, 1440, 1920];
const commonBreakpoint = commonBreakpoints.find((breakpoint) => !breakpoints.has(breakpoint));
if (commonBreakpoint !== undefined) return commonBreakpoint;
for (let breakpoint = 0; breakpoint <= 32767; breakpoint++) {
if (!breakpoints.has(breakpoint)) return breakpoint;
}
throw new Error("No responsive layout breakpoints are available");
};
export const LayoutSettingsContent = ({ board, enableAutomaticMobileLayout }: Props) =>
enableAutomaticMobileLayout ? (
<AutomaticLayoutSettingsContent board={board} />
) : (
<ResponsiveLayoutSettingsContent board={board} />
);
const AutomaticLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
export const LayoutSettingsContent = ({ board }: Props) => {
const t = useI18n();
const utils = clientApi.useUtils();
const desktopLayout = getDesktopLayout(board);
const { mutate: saveLayout, isPending } = clientApi.board.saveLayout.useMutation({
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const form = useZodForm(boardSaveLayoutSchema.omit({ id: true }).required(), {
initialValues: {
columnCount: desktopLayout.columnCount,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
saveLayout({
id: board.id,
...values,
});
})}
>
<Stack>
<Fieldset legend={t("board.setting.section.layout.desktop.title")} bg="transparent">
<Stack gap="sm">
<Text c="dimmed" size="sm">
{t("board.setting.section.layout.desktop.description")}
</Text>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider
mt="xs"
min={1}
max={24}
step={1}
thumbLabel={t("layout.field.columnCount.label")}
{...form.getInputProps("columnCount")}
/>
</Input.Wrapper>
</Stack>
</Fieldset>
<Text c="dimmed" size="sm">
{t("board.setting.section.layout.mobile.automaticDescription")}
</Text>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
const t = useI18n();
const utils = clientApi.useUtils();
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
initialValues: {
layouts: board.layouts,
},
});
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
onSuccess(layouts) {
const values = { layouts };
form.setValues(values);
form.resetDirty(values);
},
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
return (
<form
@@ -124,9 +38,6 @@ const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
})}
>
<Stack>
<Text c="dimmed" size="sm">
{t("board.setting.section.layout.responsive.description")}
</Text>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
@@ -138,9 +49,9 @@ const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
...form.values.layouts,
{
id: createId(),
name: t("board.setting.section.layout.responsive.defaultName"),
name: "",
columnCount: 10,
breakpoint: getNextAvailableBreakpoint(form.values.layouts),
breakpoint: 0,
},
],
});
@@ -159,14 +70,7 @@ const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider
mt="xs"
min={1}
max={24}
step={1}
thumbLabel={t("layout.field.columnCount.label")}
{...form.getInputProps(`layouts.${index}.columnCount`)}
/>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
</Input.Wrapper>
</Grid.Col>
@@ -175,10 +79,6 @@ const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
description={t("layout.field.breakpoint.description")}
min={0}
max={32767}
allowDecimal={false}
allowNegative={false}
/>
</Grid.Col>
</Grid>
@@ -187,14 +87,13 @@ const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
<Button
variant="subtle"
onClick={() => {
form.setValues((previous) => {
const previousLayouts = previous.layouts ?? [];
return previousLayouts.length >= 2
form.setValues((previous) =>
previous.layouts !== undefined && previous.layouts.length >= 2
? {
layouts: previousLayouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}
: previous;
});
: previous,
);
}}
>
{t("common.action.remove")}
@@ -90,10 +90,7 @@ export default async function BoardSettingsPage(props: Props) {
<GeneralSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="layout" icon={IconLayout}>
<LayoutSettingsContent
board={board}
enableAutomaticMobileLayout={boardSettings.enableAutomaticMobileLayout}
/>
<LayoutSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="background" icon={IconPhoto}>
<BackgroundSettingsContent board={board} />
@@ -1,5 +1,4 @@
import type { JSX, PropsWithChildren } from "react";
import { headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
@@ -8,11 +7,9 @@ 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 { userAgent } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { MainHeader } from "~/components/layout/header";
import { MobileBoardViewportProvider } from "~/components/board/use-mobile-board";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
@@ -50,7 +47,6 @@ export const createBoardLayout = <TParams extends Params>({
(error: unknown) => ({ status: "rejected", error }) as const,
);
const colorSchemePromise = getCurrentColorSchemeAsync();
const requestHeadersPromise = headers();
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
if (!withTour || !session || env.DEMO_MODE) return false;
@@ -62,11 +58,10 @@ export const createBoardLayout = <TParams extends Params>({
return false;
}
});
const [session, initialBoardResult, colorScheme, requestHeaders, shouldRunBoardTour] = await Promise.all([
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
sessionPromise,
initialBoardPromise,
colorSchemePromise,
requestHeadersPromise,
shouldRunBoardTourPromise,
]);
if (initialBoardResult.status === "rejected") {
@@ -88,31 +83,27 @@ export const createBoardLayout = <TParams extends Params>({
throw error;
}
const initialBoard = initialBoardResult.board;
const deviceType = userAgent(new Headers(requestHeaders)).device.type;
const initialIsMobile = deviceType === "mobile" || deviceType === "tablet";
return (
<MobileBoardViewportProvider initialIsMobile={initialIsMobile}>
<BoardProvider initialBoard={initialBoard}>
<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>
</BoardReadyProvider>
</BoardProvider>
</MobileBoardViewportProvider>
<BoardProvider initialBoard={initialBoard}>
<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>
</BoardReadyProvider>
</BoardProvider>
);
};
-1
View File
@@ -121,7 +121,6 @@ export default async function Layout(props: {
board: {
homeBoardId: serverSettings.board.homeBoardId,
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
@@ -33,13 +33,6 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
{...form.getInputProps("mobileHomeBoardId")}
/>
<Text fw={500}>{tBoard("mobileLayout.title")}</Text>
<Switch
{...form.getInputProps("enableAutomaticMobileLayout", { type: "checkbox" })}
label={tBoard("mobileLayout.enable.label")}
description={tBoard("mobileLayout.enable.description")}
/>
<Text fw={500}>{tBoard("status.title")}</Text>
<Switch
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
@@ -1,7 +1,6 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
@@ -18,7 +17,6 @@ export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
defaultValues: ServerSettings[TKey];
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
}) => {
const router = useRouter();
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
@@ -26,7 +24,6 @@ export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
showSuccessNotification({
message: tSettings("notification.success.message"),
});
router.refresh();
},
onError() {
showErrorNotification({
@@ -60,15 +60,9 @@ 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 layoutItemSize = boardLayout
? {
...itemSize,
width: Math.min(itemSize.width, boardLayout.columnCount),
}
: itemSize;
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, layoutItemSize)
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -76,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
}
return {
width: layoutItemSize.width,
height: layoutItemSize.height,
width: itemSize.width,
height: itemSize.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
@@ -1,8 +1,9 @@
import { getCurrentLayout } from "@homarr/boards/context";
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveAndResizeItemInput {
itemId: string;
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -10,8 +11,10 @@ export interface MoveAndResizeItemInput {
}
export const moveAndResizeItemCallback =
({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
(previous: Board): Board => {
const currentLayout = getCurrentLayout(previous);
return {
...previous,
items: previous.items.map((item) =>
@@ -20,7 +23,7 @@ export const moveAndResizeItemCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== layoutId
layout.layoutId !== currentLayout
? layout
: {
...layout,
@@ -1,8 +1,9 @@
import { getCurrentLayout } from "@homarr/boards/context";
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveItemToSectionInput {
itemId: string;
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -11,8 +12,10 @@ export interface MoveItemToSectionInput {
}
export const moveItemToSectionCallback =
({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
(board: Board): Board => {
const currentLayout = getCurrentLayout(board);
return {
...board,
items: board.items.map((item) =>
@@ -21,7 +24,7 @@ export const moveItemToSectionCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== layoutId
layout.layoutId !== currentLayout
? layout
: {
...layout,
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
@@ -10,10 +10,6 @@ import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
describe("item actions create-item", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("should add it to first section", () => {
// Arrange
const itemKind = "clock";
@@ -112,24 +108,4 @@ describe("item actions create-item", () => {
{ height: 1, width: 1 },
);
});
test("clamps wide widgets to each retained layout", () => {
const desktopLayout = new LayoutMockBuilder({ id: "desktop", columnCount: 12 }).build();
const mobileLayout = new LayoutMockBuilder({ id: "mobile", columnCount: 1 }).build();
const board = new BoardMockBuilder()
.addLayout(desktopLayout)
.addLayout(mobileLayout)
.addEmptySection({ id: "root", yOffset: 0 })
.build();
const result = createItemCallback({ kind: "mediaMissing" })(board);
const item = result.items.at(0);
expect(item?.layouts).toEqual(
expect.arrayContaining([
expect.objectContaining({ layoutId: desktopLayout.id, width: 4 }),
expect.objectContaining({ layoutId: mobileLayout.id, width: 1 }),
]),
);
});
});
@@ -106,10 +106,6 @@ describe("get first empty position", () => {
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
test("does not place an item wider than the layout", () => {
expect(getFirstEmptyPosition([], 1, undefined, { width: 2, height: 1 })).toBeUndefined();
});
});
const createElementsFromLayout = (layout: string[][]) => {
@@ -1,4 +1,6 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { moveAndResizeItemCallback } from "../move-and-resize-item";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -9,6 +11,8 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -21,7 +25,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -39,6 +43,8 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -50,7 +56,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -1,4 +1,6 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { moveItemToSectionCallback } from "../move-item-to-section";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -9,6 +11,8 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -22,7 +26,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -40,6 +44,8 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
const spy = vi.spyOn(boardContext, "getCurrentLayout");
spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -52,7 +58,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -1,23 +0,0 @@
.container {
container-type: inline-size;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: min(calc((100cqw - 42px) / var(--mobile-column-count)), 12rem);
gap: 10px;
padding-bottom: 10px;
}
.item {
position: relative;
min-width: 0;
min-height: 0;
}
.item > :global(.grid-stack-item-content) {
width: 100%;
height: 100%;
}
@@ -1,56 +0,0 @@
"use client";
import type { CSSProperties, RefObject } from "react";
import { useMemo, useRef } from "react";
import { Box } from "@mantine/core";
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
import { ReadOnlyEditModeProvider } from "@homarr/boards/edit-mode";
import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homarr/gridstack";
import { BoardItemContent } from "../items/item-content";
import { SectionProvider } from "../sections/section-context";
import classes from "./mobile-board.module.css";
import { createMobileBoardItems, getMobileRootSection, mobileColumnCount } from "./mobile-layout";
export const MobileBoard = () => {
const board = useRequiredBoard();
const desktopLayout = getDesktopLayout(board);
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
const rootSection = getMobileRootSection(board);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
const gridstackRef = useRef<GridStackInstance | null>(null);
return (
<ReadOnlyEditModeProvider>
<SectionProvider
value={{
section: rootSection,
items,
innerSections: [],
refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef },
}}
>
<Box ref={wrapperRef} className={classes.container}>
<Box
className={classes.grid}
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
data-mobile-board
>
{items.map((item) => (
<Box
key={item.id}
className={classes.item}
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
data-mobile-board-item={item.id}
>
<BoardItemContent item={item} />
</Box>
))}
</Box>
</Box>
</SectionProvider>
</ReadOnlyEditModeProvider>
);
};
@@ -1,154 +0,0 @@
import { describe, expect, test } from "vitest";
import { CategorySectionMockBuilder } from "../items/actions/test/mocks/category-section-mock";
import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock";
import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock";
import { EmptySectionMockBuilder } from "../items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock";
import { createMobileBoardItems, createMobileBoardPreviewItems, getMobileRootSection } from "./mobile-layout";
describe("createMobileBoardItems", () => {
test("selects the first root section in visual order", () => {
const board = new BoardMockBuilder().build();
board.sections.push(
new EmptySectionMockBuilder({ id: "stored-first", xOffset: 0, yOffset: 1 }).build(),
new EmptySectionMockBuilder({ id: "visually-first", xOffset: 1, yOffset: 0 }).build(),
new EmptySectionMockBuilder({ id: "visually-second", xOffset: 2, yOffset: 0 }).build(),
);
expect(getMobileRootSection(board).id).toBe("visually-first");
});
test("flattens categories and dynamic sections in visual order", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const rootSection = new EmptySectionMockBuilder({ id: "root", yOffset: 0 }).build();
const category = new CategorySectionMockBuilder({ id: "category", yOffset: 1, collapsed: true }).build();
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
.addLayout({ layoutId: desktopLayoutId, parentSectionId: rootSection.id, xOffset: 0, yOffset: 1 })
.build();
const nestedDynamicSection = new DynamicSectionMockBuilder({ id: "nested-dynamic" })
.addLayout({ layoutId: desktopLayoutId, parentSectionId: dynamicSection.id, xOffset: 0, yOffset: 1 })
.build();
board.sections.push(rootSection, category, dynamicSection, nestedDynamicSection);
board.items.push(
new ItemMockBuilder({ id: "second" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, xOffset: 1, yOffset: 0 })
.build(),
new ItemMockBuilder({ id: "first" })
.addLayout({
layoutId: desktopLayoutId,
sectionId: rootSection.id,
xOffset: 0,
yOffset: 0,
width: 8,
height: 6,
})
.build(),
new ItemMockBuilder({ id: "inside-dynamic" })
.addLayout({ layoutId: desktopLayoutId, sectionId: dynamicSection.id })
.build(),
new ItemMockBuilder({ id: "inside-nested-dynamic" })
.addLayout({ layoutId: desktopLayoutId, sectionId: nestedDynamicSection.id })
.build(),
new ItemMockBuilder({ id: "inside-collapsed-category" })
.addLayout({ layoutId: desktopLayoutId, sectionId: category.id })
.build(),
);
const result = createMobileBoardItems(board, desktopLayoutId);
expect(result.map((item) => item.id)).toEqual([
"first",
"second",
"inside-dynamic",
"inside-nested-dynamic",
"inside-collapsed-category",
]);
expect(result[0]).toMatchObject({ width: 2, height: 3, xOffset: 0, yOffset: 0 });
});
test("uses the desktop layout and keeps orphaned items", () => {
const board = new BoardMockBuilder().build();
const legacyLayoutId = board.layouts.at(0)?.id;
if (!legacyLayoutId) throw new Error("Expected a legacy layout");
const desktopLayoutId = "desktop";
board.layouts.push({ id: desktopLayoutId, name: "Large", columnCount: 16, breakpoint: 1200 });
board.sections.push(new EmptySectionMockBuilder({ id: "root" }).build());
board.items.push(
new ItemMockBuilder({ id: "first" })
.addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 2 })
.addLayout({ layoutId: desktopLayoutId, sectionId: "missing-section", yOffset: 0, width: 0, height: 0 })
.build(),
new ItemMockBuilder({ id: "second" })
.addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 0 })
.addLayout({ layoutId: desktopLayoutId, sectionId: "root", yOffset: 1 })
.build(),
);
const result = createMobileBoardItems(board, desktopLayoutId);
expect(result.map((item) => item.id)).toEqual(["second", "first"]);
expect(result[1]).toMatchObject({ width: 1, height: 1 });
});
test("keeps useful desktop footprints within the two-by-three mobile limit", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
board.sections.push(rootSection);
board.items.push(
new ItemMockBuilder({ id: "two-by-two" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 2, height: 2 })
.build(),
new ItemMockBuilder({ id: "one-by-one" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, xOffset: 2, width: 1, height: 1 })
.build(),
);
expect(createMobileBoardItems(board, desktopLayoutId)).toMatchObject([
{ id: "two-by-two", width: 2, height: 2 },
{ id: "one-by-one", width: 1, height: 1 },
]);
});
});
describe("createMobileBoardPreviewItems", () => {
test("returns no tiles for an empty board", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([]);
});
test("uses the production mobile order, footprint, kind, app ID, and custom title", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
board.sections.push(rootSection);
board.items.push(
new ItemMockBuilder({ id: "wide", kind: "notebook" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 8, height: 6, yOffset: 1 })
.build(),
new ItemMockBuilder({ id: "first", kind: "app" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 1, height: 1, yOffset: 0 })
.build(),
);
const appItem = board.items.find((item) => item.id === "first");
if (!appItem || appItem.kind !== "app") throw new Error("Expected app board item");
appItem.options.appId = "actual-app";
const wideItem = board.items.find((item) => item.id === "wide");
if (!wideItem) throw new Error("Expected wide board item");
wideItem.advancedOptions.title = "Notes";
expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([
{ id: "first", kind: "app", appId: "actual-app", width: 1, height: 1, title: null },
{ id: "wide", kind: "notebook", appId: null, width: 2, height: 3, title: "Notes" },
]);
});
});
@@ -1,127 +0,0 @@
import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
export const mobileColumnCount = 2;
const mobileMaxHeight = 3;
type PositionedElement = DynamicSectionItem | SectionItem;
type PositionedLayout = {
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
height: number;
};
const comparePosition = (elementA: PositionedElement, elementB: PositionedElement) =>
elementA.yOffset - elementB.yOffset || elementA.xOffset - elementB.xOffset;
const getMobileRootSections = (board: Board) =>
board.sections
.filter((section) => section.kind !== "dynamic")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset || sectionA.xOffset - sectionB.xOffset);
export const getMobileRootSection = (board: Board) =>
getMobileRootSections(board)[0] ?? {
id: "mobile",
kind: "empty" as const,
xOffset: 0,
yOffset: 0,
};
const getLayout = <TLayout extends PositionedLayout>(
layouts: TLayout[],
desktopLayoutId: string,
layoutPriority: Map<string, number>,
) =>
layouts.find((layout) => layout.layoutId === desktopLayoutId) ??
layouts.toSorted(
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
)[0];
export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => {
const rootSections = getMobileRootSections(board);
const defaultSectionId = rootSections[0]?.id ?? "mobile";
const layoutPriority = new Map(
board.layouts
.toSorted(
(layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint || layoutA.columnCount - layoutB.columnCount,
)
.map((layout, index) => [layout.id, index]),
);
const items = board.items.map(({ layouts, ...item }, index): SectionItem => {
const layout =
getLayout(layouts, desktopLayoutId, layoutPriority) ??
({
layoutId: desktopLayoutId,
xOffset: 0,
yOffset: index,
width: 1,
height: 1,
sectionId: defaultSectionId,
} satisfies ItemLayout);
return { ...item, ...layout, type: "item" };
});
const dynamicSections = board.sections
.filter((section) => section.kind === "dynamic")
.map(({ layouts, ...section }): DynamicSectionItem | null => {
const layout = getLayout(layouts, desktopLayoutId, layoutPriority);
return layout ? { ...section, ...layout, type: "section" } : null;
})
.filter((section) => section !== null);
const elementsBySection = new Map<string, PositionedElement[]>();
for (const element of [...items, ...dynamicSections]) {
const parentSectionId = element.type === "item" ? element.sectionId : element.parentSectionId;
const elements = elementsBySection.get(parentSectionId) ?? [];
elements.push(element);
elementsBySection.set(parentSectionId, elements);
}
const result: SectionItem[] = [];
const visitedItems = new Set<string>();
const visitedSections = new Set<string>();
const appendSectionItems = (sectionId: string) => {
if (visitedSections.has(sectionId)) return;
visitedSections.add(sectionId);
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
if (element.type === "section") {
appendSectionItems(element.id);
continue;
}
if (!visitedItems.has(element.id)) {
visitedItems.add(element.id);
result.push(element);
}
}
};
rootSections.forEach((section) => appendSectionItems(section.id));
dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id));
items.toSorted(comparePosition).forEach((item) => {
if (!visitedItems.has(item.id)) result.push(item);
});
return result.map((item, index) => ({
...item,
xOffset: 0,
yOffset: index,
width: Math.max(1, Math.min(item.width, mobileColumnCount)),
height: Math.max(1, Math.min(item.height, mobileMaxHeight)),
}));
};
export const createMobileBoardPreviewItems = (board: Board, desktopLayoutId: string) =>
createMobileBoardItems(board, desktopLayoutId).map((item) => ({
id: item.id,
kind: item.kind,
appId: item.kind === "app" && typeof item.options.appId === "string" ? item.options.appId : null,
width: item.width,
height: item.height,
title: item.advancedOptions.title,
}));
@@ -1,44 +0,0 @@
.panel {
width: min(390px, calc(100vw - var(--mantine-spacing-xl)));
max-height: min(70vh, 700px);
overflow: hidden;
}
.viewport {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
}
.grid {
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: 42px;
gap: var(--mantine-spacing-xs);
}
.item {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
}
.itemContent {
min-width: 0;
}
.appIcon {
width: 14px;
height: 14px;
}
@media (prefers-reduced-motion: reduce) {
.panel,
.item {
scroll-behavior: auto;
transition: none;
}
}
@@ -1,128 +0,0 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { ActionIcon, Affix, Button, Center, Group, Paper, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconDeviceMobile, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { useIsMobileBoard } from "../use-mobile-board";
import classes from "./mobile-preview.module.css";
import { createMobileBoardPreviewItems, mobileColumnCount } from "./mobile-layout";
export const MobileBoardPreview = () => {
const [isEditMode] = useEditMode();
const [isExpanded, setIsExpanded] = useState(false);
const isMobileBoard = useIsMobileBoard();
const { enableAutomaticMobileLayout } = useSettings();
const t = useI18n();
useEffect(() => {
if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) {
setIsExpanded(false);
}
}, [enableAutomaticMobileLayout, isEditMode, isMobileBoard]);
if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) return null;
return (
<Affix position={{ bottom: "var(--mantine-spacing-md)", left: "var(--mantine-spacing-md)" }} zIndex={200}>
{isExpanded ? (
<Paper className={classes.panel} radius="md" shadow="md" data-mobile-board-preview>
<Group justify="space-between" wrap="nowrap" p="sm">
<Group gap="xs" wrap="nowrap">
<IconDeviceMobile size={20} aria-hidden />
<Text fw={600}>{t("board.mobilePreview.title")}</Text>
</Group>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setIsExpanded(false)}
aria-label={t("board.mobilePreview.collapse")}
>
<IconX size={18} />
</ActionIcon>
</Group>
<MobileBoardPreviewMap />
</Paper>
) : (
<Button
leftSection={<IconDeviceMobile size={20} />}
onClick={() => setIsExpanded(true)}
data-mobile-board-preview-toggle
>
{t("board.mobilePreview.action")}
</Button>
)}
</Affix>
);
};
const MobileBoardPreviewMap = () => {
const board = useRequiredBoard();
const desktopLayout = getDesktopLayout(board);
const items = useMemo(() => createMobileBoardPreviewItems(board, desktopLayout.id), [board, desktopLayout.id]);
const appIds = useMemo(
() => [...new Set(items.flatMap((item) => (item.appId === null ? [] : [item.appId])))],
[items],
);
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
const appsById = useMemo(() => new Map(apps.map((app) => [app.id, app])), [apps]);
const t = useI18n();
return (
<ScrollArea.Autosize className={classes.viewport} mah="calc(min(70vh, 700px) - 52px)" p="sm">
{items.length === 0 ? (
<Center mih={160}>
<Stack gap={4} align="center">
<IconDeviceMobile size={28} color="var(--mantine-color-dimmed)" aria-hidden />
<Text size="sm" c="dimmed" ta="center">
{t("board.mobilePreview.empty")}
</Text>
</Stack>
</Center>
) : (
<div
className={classes.grid}
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
data-mobile-board-preview-grid
>
{items.map((item) => {
const WidgetIcon = widgetCatalogIcons[item.kind];
const app = item.appId === null ? undefined : appsById.get(item.appId);
const label = app?.name ?? (item.title?.trim() || t(`widget.${item.kind}.name`));
return (
<div
key={item.id}
className={classes.item}
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
data-mobile-board-preview-item={item.id}
>
<Group className={classes.itemContent} gap={6} wrap="nowrap" px="xs">
<ThemeIcon variant="light" size="sm" data-mobile-board-preview-app-icon={app?.id}>
{app ? (
<MaskedOrNormalImage imageUrl={app.iconUrl} hasColor={false} alt="" className={classes.appIcon} />
) : (
<WidgetIcon size={14} aria-hidden />
)}
</ThemeIcon>
<Text size="xs" fw={500} truncate title={label}>
{label}
</Text>
</Group>
</div>
);
})}
</div>
)}
</ScrollArea.Autosize>
);
};
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -18,7 +18,6 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
const t = useI18n();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const createCategoryAtYOffset = useCallback(
(position: number) => {
@@ -105,6 +104,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board);
const appIds = (
await filterByItemKind(
board.items.filter(
@@ -132,7 +132,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
});
break;
}
}, [category, board, currentLayoutId, t, openConfirmModal, settings]);
}, [category, board, t, openConfirmModal, settings]);
return {
addCategoryAbove,
@@ -1,7 +1,7 @@
import { Badge, Box, Card } from "@mantine/core";
import combineClasses from "clsx";
import { useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
@@ -14,6 +14,7 @@ interface Props {
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const options = section.options;
return (
@@ -51,7 +52,8 @@ export const BoardDynamicSection = ({ section }: Props) => {
{options.title}
</Badge>
)}
<GridStack section={section} className="min-row" />
{/* Use unique key by layout to reinitialize gridstack */}
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
</Card>
<BoardDynamicSectionMenu section={section} />
</Box>
@@ -4,8 +4,6 @@ import type { BoxProps } from "@mantine/core";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
import { useCurrentLayout } from "@homarr/boards/context";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import { SectionContent } from "../content";
import { SectionProvider } from "../section-context";
@@ -17,12 +15,6 @@ interface Props extends BoxProps {
}
export const GridStack = ({ section, ...props }: Props) => {
const currentLayoutId = useCurrentLayout();
return <GridStackForLayout key={currentLayoutId} section={section} {...props} />;
};
const GridStackForLayout = ({ section, ...props }: Props) => {
const { items, innerSections } = useSectionItems(section.id);
const itemIds = [...items, ...innerSections].map((item) => item.id);
@@ -103,7 +103,6 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
// Updates the react-query state
moveAndResizeItem({
itemId: id,
layoutId: currentLayoutId,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -121,7 +120,6 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
if (type === "section") {
moveAndResizeInnerSection({
innerSectionId: id,
layoutId: currentLayoutId,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -138,7 +136,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`);
},
[currentLayoutId, moveAndResizeItem, moveAndResizeInnerSection],
[moveAndResizeItem, moveAndResizeInnerSection],
);
const onAdd = useCallback(
(addedNode: GridStackNode) => {
@@ -151,7 +149,6 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
// Updates the react-query state
moveItemToSection({
itemId: id,
layoutId: currentLayoutId,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
@@ -170,7 +167,6 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
if (type === "section") {
moveInnerSectionToSection({
innerSectionId: id,
layoutId: currentLayoutId,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
@@ -188,7 +184,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`);
},
[currentLayoutId, moveItemToSection, moveInnerSectionToSection, section.id],
[moveItemToSection, moveInnerSectionToSection, section.id],
);
// initialize the gridstack
@@ -1,10 +1,10 @@
import { useCallback } from "react";
import { getCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
interface MoveAndResizeInnerSection {
innerSectionId: string;
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -12,7 +12,6 @@ interface MoveAndResizeInnerSection {
}
interface MoveInnerSectionToSection {
innerSectionId: string;
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -24,7 +23,7 @@ export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const moveAndResizeInnerSection = useCallback(
({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((section) => {
@@ -32,10 +31,12 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== layoutId) return layout;
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
@@ -49,7 +50,7 @@ export const useSectionActions = () => {
);
const moveInnerSectionToSection = useCallback(
({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
return {
...previous,
@@ -58,10 +59,12 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== layoutId) return layout;
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
@@ -1,26 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import { useMediaQuery } from "@mantine/hooks";
import { useSettings } from "@homarr/settings";
export const mobileBoardMediaQuery = "(max-width: 48em)";
const InitialMobileBoardContext = createContext<boolean | undefined>(undefined);
export const MobileBoardViewportProvider = ({
initialIsMobile,
children,
}: PropsWithChildren<{ initialIsMobile: boolean }>) => (
<InitialMobileBoardContext.Provider value={initialIsMobile}>{children}</InitialMobileBoardContext.Provider>
);
export const useIsMobileBoard = () => {
const initialIsMobile = useContext(InitialMobileBoardContext);
const { enableAutomaticMobileLayout } = useSettings();
const isMobileViewport = useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
return enableAutomaticMobileLayout && isMobileViewport;
};
-339
View File
@@ -1,339 +0,0 @@
import { chromium, devices, expect } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import { parse, stringify } from "superjson";
import { describe, test } from "vitest";
import { eq } from "drizzle-orm";
import * as sqliteSchema from "../packages/db/schema/sqlite";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import type { SqliteDatabase } from "./shared/e2e-db";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const credentials = {
username: "admin",
password: "Comp(exP4sswOrd",
};
const boardName = "mobile-e2e";
const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
const boardId = createId();
const desktopLayoutId = createId();
const mobileLayoutId = createId();
const sectionId = createId();
const emptySectionId = createId();
const notebookId = createId();
const appItemId = createId();
const appId = createId();
await db.insert(sqliteSchema.boards).values({
id: boardId,
name: boardName,
creatorId: userId,
});
await db.insert(sqliteSchema.layouts).values([
{
id: desktopLayoutId,
name: "Desktop",
boardId,
columnCount: 12,
breakpoint: 1200,
},
{
id: mobileLayoutId,
name: "Mobile",
boardId,
columnCount: 4,
breakpoint: 0,
},
]);
await db.insert(sqliteSchema.sections).values([
{
id: sectionId,
boardId,
kind: "category",
name: "Dashboard",
xOffset: 0,
yOffset: 0,
},
{
id: emptySectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 1,
},
]);
await db.insert(sqliteSchema.apps).values({
id: appId,
name: "Media Server",
iconUrl: "/favicon.ico",
});
await db.insert(sqliteSchema.items).values([
{
id: notebookId,
boardId,
kind: "notebook",
options: stringify({ content: "<p>Normal notebook content</p>" }),
advancedOptions: stringify({}),
},
{
id: appItemId,
boardId,
kind: "app",
options: stringify({ appId }),
advancedOptions: stringify({}),
},
]);
await db.insert(sqliteSchema.itemLayouts).values([
{
itemId: notebookId,
layoutId: desktopLayoutId,
sectionId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 2,
},
{
itemId: appItemId,
layoutId: desktopLayoutId,
sectionId,
xOffset: 2,
yOffset: 0,
width: 1,
height: 1,
},
{
itemId: notebookId,
layoutId: mobileLayoutId,
sectionId,
xOffset: 0,
yOffset: 0,
width: 4,
height: 4,
},
{
itemId: appItemId,
layoutId: mobileLayoutId,
sectionId,
xOffset: 0,
yOffset: 4,
width: 2,
height: 2,
},
]);
await db
.update(sqliteSchema.users)
.set({
homeBoardId: boardId,
mobileHomeBoardId: boardId,
completedBoardTour: true,
})
.where(eq(sqliteSchema.users.id, userId));
return { appId };
};
describe("Automatic mobile board", () => {
test("enables automatic mobile layout for a fresh database", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const container = await createHomarrContainer({
mounts: {
"/appdata": localMountPath,
},
}).start();
try {
const boardSettings = await db.query.serverSettings.findFirst({
where: eq(sqliteSchema.serverSettings.settingKey, "board"),
});
if (!boardSettings) throw new Error("Expected board server settings to be seeded");
expect(parse<{ enableAutomaticMobileLayout: boolean }>(boardSettings.value).enableAutomaticMobileLayout).toBe(
true,
);
} finally {
await container.stop();
}
}, 90_000);
test("squeezes unchanged widgets into a read-only two-column grid", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const { userId } = await seedAdminUserAsync(db, credentials);
const { appId } = await seedBoardAsync(db, userId);
await db.insert(sqliteSchema.serverSettings).values({
settingKey: "board",
value: stringify({
homeBoardId: null,
mobileHomeBoardId: null,
enableAutomaticMobileLayout: true,
enableStatusByDefault: true,
forceDisableStatus: false,
}),
});
const container = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "credentials",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(7575)}`;
const browser = await chromium.launch();
const loginContext = await browser.newContext();
try {
const loginPage = await loginContext.newPage();
await loginPage.goto(`${baseUrl}/auth/login`);
await loginPage.getByLabel("Username").fill(credentials.username);
await loginPage.locator("#password").fill(credentials.password);
await loginPage.locator("button[type='submit']").click();
await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 });
await loginPage.goto(`${baseUrl}/boards/${boardName}`);
await expect(loginPage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0);
await loginPage.getByRole("button", { name: "Toggle board edit mode", exact: true }).click();
const previewToggle = loginPage.getByRole("button", { name: "Mobile preview", exact: true });
await expect(previewToggle).toBeVisible();
expect(await previewToggle.evaluate((element) => element.getBoundingClientRect().left)).toBeLessThan(32);
await previewToggle.click();
await expect(loginPage.locator("[data-mobile-board-preview]")).toBeVisible();
await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(2);
await expect(loginPage.getByText("Media Server", { exact: true })).toBeVisible();
await expect(loginPage.locator(`[data-mobile-board-preview-app-icon="${appId}"] img`)).toHaveAttribute(
"src",
"/favicon.ico",
);
await loginPage
.locator("header button")
.filter({ has: loginPage.locator("svg.tabler-icon-plus") })
.click();
await loginPage.getByRole("menuitem", { name: "New item", exact: true }).click();
await loginPage.getByPlaceholder("Filter items...").fill("Weather");
await loginPage.getByPlaceholder("Filter items...").press("Enter");
await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(3);
await loginPage.getByRole("button", { name: "Cancel", exact: true }).click();
await loginPage.getByRole("button", { name: "Collapse mobile preview", exact: true }).click();
await expect(loginPage.locator("[data-mobile-board-preview]")).toHaveCount(0);
await expect(previewToggle).toBeVisible();
const storageState = await loginContext.storageState();
const mobileContext = await browser.newContext({
storageState,
viewport: { width: 390, height: 844 },
userAgent: devices["iPhone 13"].userAgent,
hasTouch: true,
isMobile: true,
});
const mobilePage = await mobileContext.newPage();
await mobilePage.goto(`${baseUrl}/boards/${boardName}`);
const mobileBoard = mobilePage.locator("[data-mobile-board]");
const items = mobilePage.locator("[data-mobile-board-item]");
await expect(mobileBoard).toBeVisible({ timeout: 15_000 });
await expect(mobilePage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0);
await expect(items).toHaveCount(2);
await expect(mobilePage.locator(".grid-stack-item")).toHaveCount(0);
await expect(mobilePage.getByText("Normal notebook content")).toBeVisible();
await expect(mobilePage.getByRole("button", { name: "Toggle board edit mode", exact: true })).toHaveCount(0);
await expect(mobilePage.getByRole("button", { name: /open widget details/i })).toHaveCount(0);
await expect(mobilePage.getByRole("button", { name: /actions for/i })).toHaveCount(0);
await expect(mobilePage.locator("[data-mobile-generic-summary], [data-mobile-display-mode]")).toHaveCount(0);
const metrics = await mobilePage.evaluate(() => {
const grid = document.querySelector<HTMLElement>("[data-mobile-board]");
const itemElements = Array.from(document.querySelectorAll<HTMLElement>("[data-mobile-board-item]"));
if (!grid || itemElements.length !== 2) throw new Error("Expected mobile grid and two items");
const gridStyle = getComputedStyle(grid);
const first = itemElements[0]?.getBoundingClientRect();
const second = itemElements[1]?.getBoundingClientRect();
if (!first || !second) throw new Error("Expected item bounds");
return {
columnCount: gridStyle.gridTemplateColumns.split(" ").filter(Boolean).length,
firstToSecondWidthRatio: first.width / second.width,
firstToSecondHeightRatio: first.height / second.height,
horizontalOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(metrics.columnCount).toBe(2);
expect(metrics.firstToSecondWidthRatio).toBeGreaterThan(1.8);
expect(metrics.firstToSecondHeightRatio).toBeGreaterThan(1.8);
expect(metrics.horizontalOverflow).toBeLessThanOrEqual(0);
await mobileContext.close();
const tabletContext = await browser.newContext({
storageState,
viewport: { width: 768, height: 1024 },
userAgent: devices["iPad (gen 7)"].userAgent,
hasTouch: true,
isMobile: true,
});
const tabletPage = await tabletContext.newPage();
await tabletPage.goto(`${baseUrl}/boards/${boardName}`);
await expect(tabletPage.locator("[data-mobile-board]")).toBeVisible({ timeout: 15_000 });
await expect(tabletPage.locator("[data-mobile-board-item]")).toHaveCount(2);
await expect(tabletPage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0);
await expect(tabletPage.getByRole("button", { name: "Toggle board edit mode", exact: true })).toHaveCount(0);
await tabletContext.close();
} finally {
await loginContext.close();
await browser.close();
await container.stop();
}
}, 180_000);
test("keeps preview and generated mobile grid disabled in legacy mode", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const { userId } = await seedAdminUserAsync(db, credentials);
await seedBoardAsync(db, userId);
const container = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "credentials",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(7575)}`;
const browser = await chromium.launch();
const desktopContext = await browser.newContext();
try {
const desktopPage = await desktopContext.newPage();
await desktopPage.goto(`${baseUrl}/auth/login`);
await desktopPage.getByLabel("Username").fill(credentials.username);
await desktopPage.locator("#password").fill(credentials.password);
await desktopPage.locator("button[type='submit']").click();
await desktopPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 });
await desktopPage.goto(`${baseUrl}/boards/${boardName}`);
await desktopPage.getByRole("button", { name: "Toggle board edit mode", exact: true }).click();
await expect(desktopPage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0);
const legacyContext = await browser.newContext({
storageState: await desktopContext.storageState(),
viewport: { width: 390, height: 844 },
userAgent: devices["iPhone 13"].userAgent,
hasTouch: true,
isMobile: true,
});
const legacyPage = await legacyContext.newPage();
await legacyPage.goto(`${baseUrl}/boards/${boardName}`);
await expect(legacyPage.locator("[data-mobile-board]")).toHaveCount(0);
await expect(legacyPage.locator(".grid-stack-item")).toHaveCount(2);
await expect(legacyPage.getByRole("button", { name: "Toggle board edit mode", exact: true })).toBeVisible();
await legacyContext.close();
} finally {
await desktopContext.close();
await browser.close();
await container.stop();
}
}, 180_000);
});
+117 -364
View File
@@ -48,7 +48,6 @@ import {
boardCreateSchema,
boardDuplicateSchema,
boardRenameSchema,
boardSaveLayoutSchema,
boardSaveLayoutsSchema,
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
@@ -683,355 +682,131 @@ export const boardRouter = createTRPCRouter({
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
saveLayout: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Set a board's desktop column count while preserving saved responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)",
},
})
.input(boardSaveLayoutSchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).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 desktopLayout = board.layouts
.toSorted(
(layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint || layoutB.columnCount - layoutA.columnCount,
)
.at(0);
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
if (!desktopLayout) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a layout" });
}
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
const updatedBoardLayout =
desktopLayout.columnCount === input.columnCount
? null
: getUpdatedBoardLayout(board, {
previous: {
layoutId: desktopLayout.id,
columnCount: desktopLayout.columnCount,
},
current: {
layoutId: desktopLayout.id,
columnCount: input.columnCount,
},
});
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
for (const itemSectionLayout of updatedBoardLayout?.itemSectionLayouts ?? []) {
await transaction
.update(schema.itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(
and(
eq(schema.itemLayouts.itemId, itemSectionLayout.itemId),
eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId),
),
);
}
for (const addedLayout of addedLayouts) {
const layoutId = createId();
for (const sectionLayout of updatedBoardLayout?.sectionLayouts ?? []) {
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),
),
);
}
layoutsToInsert.push({
id: layoutId,
name: addedLayout.name,
columnCount: addedLayout.columnCount,
breakpoint: addedLayout.breakpoint,
boardId: board.id,
});
await transaction
.update(schema.layouts)
.set({ columnCount: input.columnCount })
.where(eq(schema.layouts.id, desktopLayout.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,
},
handleSync(db) {
db.transaction((transaction) => {
for (const itemSectionLayout of updatedBoardLayout?.itemSectionLayouts ?? []) {
transaction
.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),
),
)
.run();
}
for (const sectionLayout of updatedBoardLayout?.sectionLayouts ?? []) {
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();
}
transaction
.update(layouts)
.set({ columnCount: input.columnCount })
.where(eq(layouts.id, desktopLayout.id))
.run();
});
current: {
layoutId,
columnCount: addedLayout.columnCount,
},
});
}),
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 addedLayouts = filterAddedItems(input.layouts, board.layouts);
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
const savedLayoutIds = new Map<string, string>();
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
}
for (const addedLayout of addedLayouts) {
const layoutId = createId();
savedLayoutIds.set(addedLayout.id, layoutId);
if (layoutsToInsert.length > 0) {
await ctx.db.insert(layouts).values(layoutsToInsert);
}
layoutsToInsert.push({
id: layoutId,
name: addedLayout.name,
columnCount: addedLayout.columnCount,
breakpoint: addedLayout.breakpoint,
boardId: board.id,
});
if (itemSectionLayoutsToInsert.length > 0) {
await ctx.db.insert(itemLayouts).values(itemSectionLayoutsToInsert);
}
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
const layoutToClone =
sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1);
if (sectionLayoutsToInsert.length > 0) {
await ctx.db.insert(sectionLayouts).values(sectionLayoutsToInsert);
}
if (!layoutToClone) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a layout" });
}
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: layoutToClone.id,
columnCount: layoutToClone.columnCount,
layoutId: dbLayout.id,
columnCount: dbLayout.columnCount,
},
current: {
layoutId,
columnCount: addedLayout.columnCount,
layoutId: dbLayout.id,
columnCount: updatedLayout.columnCount,
},
});
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
}
const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts);
const itemSectionLayoutsToUpdate: InferInsertModel<typeof itemLayouts>[] = [];
const sectionLayoutsToUpdate: InferInsertModel<typeof sectionLayouts>[] = [];
const layoutsToUpdate: typeof input.layouts = [];
for (const updatedLayout of updatedLayouts) {
const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id);
if (!dbLayout) continue;
if (dbLayout.columnCount !== updatedLayout.columnCount) {
const updatedBoardLayout = getUpdatedBoardLayout(board, {
previous: {
layoutId: dbLayout.id,
columnCount: dbLayout.columnCount,
},
current: {
layoutId: dbLayout.id,
columnCount: updatedLayout.columnCount,
},
});
itemSectionLayoutsToUpdate.push(...updatedBoardLayout.itemSectionLayouts);
sectionLayoutsToUpdate.push(...updatedBoardLayout.sectionLayouts);
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),
),
);
}
layoutsToUpdate.push(updatedLayout);
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),
),
);
}
}
const removedLayoutIds = filterRemovedItems(input.layouts, board.layouts).map((layout) => layout.id);
await ctx.db
.update(layouts)
.set({
name: updatedLayout.name,
columnCount: updatedLayout.columnCount,
breakpoint: updatedLayout.breakpoint,
})
.where(eq(layouts.id, updatedLayout.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 itemSectionLayout of itemSectionLayoutsToUpdate) {
await transaction
.update(schema.itemLayouts)
.set({
height: itemSectionLayout.height,
width: itemSectionLayout.width,
xOffset: itemSectionLayout.xOffset,
yOffset: itemSectionLayout.yOffset,
sectionId: itemSectionLayout.sectionId,
})
.where(
and(
eq(schema.itemLayouts.itemId, itemSectionLayout.itemId),
eq(schema.itemLayouts.layoutId, itemSectionLayout.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 itemSectionLayout of itemSectionLayoutsToUpdate) {
transaction
.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),
),
)
.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,
}));
}),
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));
}
}),
savePartialBoardSettings: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
@@ -1700,16 +1475,16 @@ export const boardRouter = createTRPCRouter({
}
const itemId = createId();
const itemRow = {
await ctx.db.insert(items).values({
id: itemId,
boardId: input.boardId,
kind: input.kind,
options: superjson.stringify(input.options),
advancedOptions: emptySuperJSON,
};
});
const layoutRows: (typeof itemLayouts.$inferInsert)[] = [];
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
for (const layout of board.layouts) {
const existingInSection = board.items
@@ -1728,17 +1503,14 @@ export const boardRouter = createTRPCRouter({
}
}
const layoutItemSize = {
...defaultSize,
width: Math.min(defaultSize.width, layout.columnCount),
};
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
const fitsAt = (x: number, y: number) => {
if (x + layoutItemSize.width > layout.columnCount) return false;
for (let dy = 0; dy < layoutItemSize.height; dy++) {
if (x + defaultSize.width > layout.columnCount) return false;
for (let dy = 0; dy < defaultSize.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
for (let dx = 0; dx < layoutItemSize.width; dx++) {
for (let dx = 0; dx < defaultSize.width; dx++) {
if (row[x + dx]) return false;
}
}
@@ -1756,8 +1528,8 @@ export const boardRouter = createTRPCRouter({
layoutId: layout.id,
xOffset: x,
yOffset: y,
width: layoutItemSize.width,
height: layoutItemSize.height,
width: defaultSize.width,
height: defaultSize.height,
});
placed = true;
}
@@ -1772,32 +1544,15 @@ export const boardRouter = createTRPCRouter({
}
}
const integrationRows = input.integrationIds.map((integrationId) => ({ itemId, integrationId }));
if (layoutRows.length > 0) {
await ctx.db.insert(itemLayouts).values(layoutRows);
}
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
await transaction.insert(schema.items).values(itemRow);
if (layoutRows.length > 0) {
await transaction.insert(schema.itemLayouts).values(layoutRows);
}
if (integrationRows.length > 0) {
await transaction.insert(schema.integrationItems).values(integrationRows);
}
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.insert(items).values(itemRow).run();
if (layoutRows.length > 0) {
transaction.insert(itemLayouts).values(layoutRows).run();
}
if (integrationRows.length > 0) {
transaction.insert(integrationItems).values(integrationRows).run();
}
});
},
});
if (input.integrationIds.length > 0) {
await ctx.db
.insert(integrationItems)
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
}
return { itemId };
}),
@@ -1821,9 +1576,6 @@ const getHomeIdBoardAsync = async (
user: InferSelectModel<typeof users> | null,
deviceType: DeviceType,
) => {
// Mobile home-board selection remains device-based because the server does
// not know the resolved CSS viewport. Tablet rendering is resolved against
// 48em on the client and must not be guessed here.
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
if (!user) {
@@ -1845,7 +1597,8 @@ const getHomeIdBoardAsync = async (
and(
eq(groupMembers.userId, user.id),
not(eq(groups.name, everyoneGroup)),
or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))),
not(isNull(groups[settingKey])),
not(isNull(groups.homeBoardId)),
),
)
.orderBy(asc(groups.position))
+16 -10
View File
@@ -5,10 +5,10 @@ import { and, eq, inArray } from "@homarr/db";
import {
getServerSettingByKeyAsync,
getServerSettingsAsync,
mergeServerSettingByKeyAsync,
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "@homarr/db/queries";
import { boards } from "@homarr/db/schema";
import { boards, serverSettings } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { settingsInitSchema } from "@homarr/validation/settings";
@@ -19,7 +19,6 @@ import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
const boardServerSettingsSchema = z.object({
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
enableAutomaticMobileLayout: z.boolean(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
}) satisfies z.ZodType<ServerSettings["board"]>;
@@ -40,7 +39,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Get global board defaults, including desktop/mobile home board IDs, automatic mobile layout mode, and status behavior. Requires admin permission",
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
},
})
.input(z.void())
@@ -55,7 +54,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards returned by the public-board listing tool or API, using each board's id field, or be null",
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
},
})
.input(boardServerSettingsUpdateSchema)
@@ -82,9 +81,17 @@ export const serverSettingsRouter = createTRPCRouter({
const current = await getServerSettingByKeyAsync(ctx.db, "board");
const next = { ...current, ...input };
await mergeServerSettingByKeyAsync(ctx.db, "board", next, input);
const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "board"),
});
return await getServerSettingByKeyAsync(ctx.db, "board");
if (existing) {
await updateServerSettingByKeyAsync(ctx.db, "board", next);
} else {
await insertServerSettingByKeyAsync(ctx.db, "board", next);
}
return next;
}),
saveSettings: permissionRequiredProcedure
.requiresPermission("admin")
@@ -96,11 +103,10 @@ export const serverSettingsRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
const next = {
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
...current,
...input.value,
} as ServerSettings[keyof ServerSettings];
await mergeServerSettingByKeyAsync(ctx.db, input.settingsKey, next, input.value);
} as ServerSettings[keyof ServerSettings]);
}),
initSettings: onboardingProcedure
.requiresStep("settings")
+73 -240
View File
@@ -516,64 +516,6 @@ describe("getHomeBoard should return home board", () => {
});
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
test.each([
{ deviceType: "mobile", expectedBoard: "mobile-home" },
{ deviceType: "tablet", expectedBoard: "desktop-home" },
] as const)(
"should use the $expectedBoard preference for a $deviceType device",
async ({ deviceType, expectedBoard }) => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType, session: defaultSession });
const mobileBoardProps = await createFullBoardAsync(db, "mobile-home");
const desktopBoardProps = await createFullBoardAsync(db, "desktop-home");
await db
.update(users)
.set({
homeBoardId: desktopBoardProps.boardId,
mobileHomeBoardId: mobileBoardProps.boardId,
})
.where(eq(users.id, defaultCreatorId));
// Act
const result = await caller.getHomeBoard();
// Assert
expectInputToBeFullBoardWithName(result, {
...(expectedBoard === "mobile-home" ? mobileBoardProps : desktopBoardProps),
name: expectedBoard,
});
},
);
test("should return a mobile-only group home board", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: "mobile", session: defaultSession });
const mobileBoardProps = await createFullBoardAsync(db, "group-mobile-home");
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "mobile group",
position: 1,
homeBoardId: null,
mobileHomeBoardId: mobileBoardProps.boardId,
});
await db.insert(groupMembers).values({
userId: defaultCreatorId,
groupId,
});
// Act
const result = await caller.getHomeBoard();
// Assert
expectInputToBeFullBoardWithName(result, {
name: "group-mobile-home",
...mobileBoardProps,
});
});
test("should return global home board when user doesn't have one", async () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -1388,18 +1330,42 @@ const createExistingLayout = (id: string) => ({
columnCount: 10,
breakpoint: 0,
});
const createNewLayout = (columnCount: number) => ({
id: createId(),
name: "New layout",
columnCount,
breakpoint: 1400,
});
describe("saveLayouts should preserve legacy responsive layout editing", () => {
test("should add a layout and clone item and dynamic-section placements", async () => {
describe("saveLayouts should save layout changes", () => {
test("should add layout when not present in database", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const newLayout = createNewLayout(12);
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
});
const definedLayout = expectToBeDefined(layout);
expect(definedLayout.name).toBe(newLayout.name);
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
});
test("should add items and dynamic sections generated from grid-algorithm when new layout is added", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
@@ -1408,162 +1374,50 @@ describe("saveLayouts should preserve legacy responsive layout editing", () => {
});
const newLayout = createNewLayout(3);
const savedLayouts = await caller.saveLayouts({
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId), newLayout],
});
const layout = expectToBeDefined(
await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
}),
);
expect(layout).toMatchObject({
name: newLayout.name,
columnCount: newLayout.columnCount,
breakpoint: newLayout.breakpoint,
// Assert
const layout = await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
});
expect(savedLayouts).toEqual([createExistingLayout(layoutId), { ...newLayout, id: layout.id }]);
await expectLayoutForRootLayoutAsync(db, sectionId, layout.id, {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
...assignments.inRoot,
a: itemId,
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout.id, assignments.inDynamicSection);
await caller.saveLayouts({ id: boardId, layouts: savedLayouts });
await expect(db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) })).resolves.toHaveLength(2);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout!.id, assignments.inDynamicSection);
});
test("should update layout metadata and positions", async () => {
test("should update layout when present in input", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
sectionId,
});
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const updatedLayout = createExistingLayout(layoutId);
updatedLayout.breakpoint = 1400;
updatedLayout.name = "Updated layout";
// Act
await caller.saveLayouts({
id: boardId,
layouts: [
{
id: layoutId,
name: "Phone",
columnCount: 3,
breakpoint: 480,
},
],
layouts: [updatedLayout],
});
await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toMatchObject({
name: "Phone",
columnCount: 3,
breakpoint: 480,
// Assert
const layout = await db.query.layouts.findFirst({
where: eq(layouts.id, layoutId),
});
await expectLayoutForRootLayoutAsync(db, sectionId, layoutId, {
...assignments.inRoot,
a: itemId,
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
const definedLayout = expectToBeDefined(layout);
expect(definedLayout.name).toBe(updatedLayout.name);
expect(definedLayout.columnCount).toBe(updatedLayout.columnCount);
expect(definedLayout.breakpoint).toBe(updatedLayout.breakpoint);
});
test("should remove layouts omitted from the saved responsive configuration", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
await caller.saveLayouts({
id: boardId,
layouts: [createNewLayout(12)],
});
await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toBeUndefined();
});
test("should reject duplicate responsive breakpoints", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
await expect(
caller.saveLayouts({
id: boardId,
layouts: [
createExistingLayout(layoutId),
{
...createNewLayout(4),
breakpoint: 0,
},
],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
test("should reject duplicate responsive layout IDs", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const existingLayout = createExistingLayout(layoutId);
await expect(
caller.saveLayouts({
id: boardId,
layouts: [existingLayout, { ...existingLayout, name: "Phone", breakpoint: 480 }],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
test("should reject fractional responsive layout values", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
await expect(
caller.saveLayouts({
id: boardId,
layouts: [{ ...createExistingLayout(layoutId), breakpoint: 480.5 }],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
await expect(
caller.saveLayouts({
id: boardId,
layouts: [{ ...createExistingLayout(layoutId), columnCount: 3.5 }],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
describe("addItem should preserve every saved layout", () => {
test("should clamp wide widgets to a retained narrow layout", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const mobileLayoutId = createId();
await db.insert(layouts).values({
id: mobileLayoutId,
name: "Mobile",
columnCount: 1,
breakpoint: 480,
boardId,
});
const { itemId } = await caller.addItem({
boardId,
kind: "mediaMissing",
});
const savedItemLayouts = await db.query.itemLayouts.findMany({
where: eq(itemLayouts.itemId, itemId),
});
expect(savedItemLayouts).toHaveLength(2);
expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId, width: 4 }));
expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId: mobileLayoutId, width: 1 }));
});
});
describe("saveLayout should save the desktop layout", () => {
test("should update position of items when column count changes", async () => {
// Arrange
const db = createDb();
@@ -1575,10 +1429,13 @@ describe("saveLayout should save the desktop layout", () => {
layoutId,
sectionId,
});
const updatedLayout = createExistingLayout(layoutId);
updatedLayout.columnCount = 3;
// Act
await caller.saveLayout({
await caller.saveLayouts({
id: boardId,
columnCount: 3,
layouts: [updatedLayout],
});
// Assert
@@ -1588,58 +1445,37 @@ describe("saveLayout should save the desktop layout", () => {
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
});
test("should preserve legacy layouts and desktop metadata", async () => {
test("should remove layout when not present in input", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, itemId, sectionId } = await createFullBoardAsync(db, "default");
const desktopLayoutId = createId();
await db.insert(layouts).values({
id: desktopLayoutId,
boardId,
name: "Large",
columnCount: 16,
breakpoint: 1200,
});
await db.insert(itemLayouts).values({
itemId,
sectionId,
layoutId: desktopLayoutId,
xOffset: 0,
yOffset: 0,
width: 1,
height: 1,
});
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
// Act
await caller.saveLayout({
await caller.saveLayouts({
id: boardId,
columnCount: 14,
layouts: [createNewLayout(12)],
});
// Assert
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
expect(savedLayouts).toHaveLength(2);
expect(savedLayouts).toContainEqual(
expect.objectContaining({ id: layoutId, name: "Base", columnCount: 10, breakpoint: 0 }),
);
expect(savedLayouts).toContainEqual(
expect.objectContaining({ id: desktopLayoutId, name: "Large", columnCount: 14, breakpoint: 1200 }),
);
const layout = await db.query.layouts.findFirst({
where: eq(layouts.id, layoutId),
});
expect(layout).toBeUndefined();
});
test("should fail when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
await createFullBoardAsync(db, "default");
const { layoutId } = await createFullBoardAsync(db, "default");
// Act
const actAsync = async () =>
await caller.saveLayout({
await caller.saveLayouts({
id: createId(),
columnCount: 12,
layouts: [createExistingLayout(layoutId)],
});
// Assert
@@ -1669,12 +1505,9 @@ const expectInputToBeFullBoardWithName = (
};
const createFullBoardAsync = async (db: Database, name: string) => {
await db
.insert(users)
.values({
id: defaultCreatorId,
})
.onConflictDoNothing();
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { boards, layouts, serverSettings } from "@homarr/db/schema";
import { boards, serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -56,33 +56,6 @@ describe("getAll server settings", () => {
});
describe("saveSettings", () => {
test("saveSettings should persist settings when defaults are not seeded", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await caller.saveSettings({
settingsKey: "board",
value: {
enableAutomaticMobileLayout: false,
},
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
}),
},
]);
});
test("saveSettings should update settings and return true when it updated only one", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
@@ -116,33 +89,6 @@ describe("saveSettings", () => {
},
]);
});
test("saveSettings should preserve concurrent partial updates", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await Promise.all([
caller.saveSettings({
settingsKey: "analytics",
value: { enableGeneral: false },
}),
caller.saveSettings({
settingsKey: "analytics",
value: { instanceId: "concurrent-instance" },
}),
]);
await expect(caller.getAll()).resolves.toMatchObject({
analytics: {
enableGeneral: false,
instanceId: "concurrent-instance",
},
});
});
});
describe("board settings API", () => {
@@ -157,36 +103,6 @@ describe("board settings API", () => {
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
test("getBoardSettings should backfill the automatic layout default for legacy values", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "legacy", isPublic: false });
await db.insert(layouts).values([
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
{ id: createId(), boardId, name: "Mobile", columnCount: 4, breakpoint: 480 },
]);
const legacyBoardSettings = {
homeBoardId: defaultServerSettings.board.homeBoardId,
mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId,
enableStatusByDefault: defaultServerSettings.board.enableStatusByDefault,
forceDisableStatus: defaultServerSettings.board.forceDisableStatus,
};
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify(legacyBoardSettings),
});
await expect(caller.getBoardSettings()).resolves.toStrictEqual({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
});
});
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
@@ -229,7 +145,6 @@ describe("board settings API", () => {
await caller.updateBoardSettings({ homeBoardId: boardId });
const result = await caller.updateBoardSettings({
mobileHomeBoardId: boardId,
enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
@@ -237,7 +152,6 @@ describe("board settings API", () => {
...defaultServerSettings.board,
homeBoardId: boardId,
mobileHomeBoardId: boardId,
enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
@@ -249,25 +163,6 @@ describe("board settings API", () => {
]);
});
test("updateBoardSettings should preserve concurrent partial updates", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await Promise.all([
caller.updateBoardSettings({ forceDisableStatus: true }),
caller.updateBoardSettings({ enableStatusByDefault: false }),
]);
await expect(caller.getBoardSettings()).resolves.toMatchObject({
forceDisableStatus: true,
enableStatusByDefault: false,
});
});
test("updateBoardSettings should reject private home boards", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
-1
View File
@@ -33,7 +33,6 @@ test("MCP tools should contain expected procedures", () => {
expect(toolNames).toContain("app_byId");
expect(toolNames).toContain("app_create");
expect(toolNames).toContain("board_savePartialBoardSettings");
expect(toolNames).toContain("board_saveLayout");
expect(toolNames).toContain("board_duplicateBoard");
expect(toolNames).toContain("info_getInfo");
expect(toolNames).toContain("invite_getAll");
-1
View File
@@ -24,7 +24,6 @@
},
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"react": "catalog:",
"react-dom": "catalog:"
},
-47
View File
@@ -1,47 +0,0 @@
import { describe, expect, test } from "vitest";
import type { RouterOutputs } from "@homarr/api";
import { getBoardLayouts, getCurrentLayout, getDesktopLayout } from "./context";
const createBoard = () =>
({
layouts: [
{ id: "small", name: "Small", columnCount: 4, breakpoint: 0 },
{ id: "medium", name: "Medium", columnCount: 8, breakpoint: 768 },
{ id: "large", name: "Large", columnCount: 12, breakpoint: 1200 },
],
}) as RouterOutputs["board"]["getBoardByName"];
describe("board layout selection", () => {
test("automatic mode always uses the desktop layout", () => {
const board = createBoard();
expect(getDesktopLayout(board).id).toBe("large");
expect(getCurrentLayout(board, true, 390)).toBe("large");
});
test.each([
[390, "small"],
[768, "medium"],
[1024, "medium"],
[1440, "large"],
])("legacy mode selects the matching responsive layout at %spx", (viewportWidth, expectedLayoutId) => {
expect(getCurrentLayout(createBoard(), false, viewportWidth)).toBe(expectedLayoutId);
});
test("legacy mode falls back to the desktop layout before the client viewport is known", () => {
expect(getCurrentLayout(createBoard(), false, undefined)).toBe("large");
});
test("rejects boards without a layout", () => {
const board = { ...createBoard(), layouts: [] };
expect(() => getDesktopLayout(board)).toThrow("Board must have a layout");
expect(() => getCurrentLayout(board, false, 390)).toThrow("Board must have a layout");
});
test("keeps every saved layout available for reversible mutations", () => {
expect(getBoardLayouts(createBoard())).toEqual(["small", "medium", "large"]);
});
});
+14 -47
View File
@@ -6,7 +6,6 @@ import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSettings } from "@homarr/settings";
import { updateBoardName } from "./updater";
@@ -63,65 +62,33 @@ export const useOptionalBoard = () => {
return context?.board ?? null;
};
export const getDesktopLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
const layout = board.layouts
.toSorted(
(layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint || layoutB.columnCount - layoutA.columnCount,
)
.at(0);
if (!layout) {
throw new Error("Board must have a layout");
}
return layout;
};
export const getCurrentLayout = (
board: RouterOutputs["board"]["getBoardByName"],
enableAutomaticMobileLayout: boolean,
viewportWidth?: number,
) => {
if (enableAutomaticMobileLayout || viewportWidth === undefined) {
return getDesktopLayout(board).id;
}
export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (typeof window === "undefined") return board.layouts.at(0)!.id;
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
const layout = sortedLayouts.find((candidate) => candidate.breakpoint <= viewportWidth) ?? sortedLayouts.at(-1);
if (!layout) {
throw new Error("Board must have a layout");
}
return layout.id;
// Fallback to smallest if none exists with breakpoint smaller than window width
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return sortedLayouts.find((layout) => layout.breakpoint <= window.innerWidth)?.id ?? sortedLayouts.at(0)!.id;
};
export const useCurrentLayout = () => {
const board = useRequiredBoard();
const { enableAutomaticMobileLayout } = useSettings();
const [currentLayout, setCurrentLayout] = useState(() =>
getCurrentLayout(
board,
enableAutomaticMobileLayout,
!enableAutomaticMobileLayout && typeof window !== "undefined" ? window.innerWidth : undefined,
),
);
const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board));
const updateCurrentLayout = useCallback(() => {
setCurrentLayout(getCurrentLayout(board, enableAutomaticMobileLayout, window.innerWidth));
}, [board, enableAutomaticMobileLayout]);
const onResize = useCallback(() => {
setCurrentLayout(getCurrentLayout(board));
}, [board]);
useEffect(() => {
updateCurrentLayout();
if (enableAutomaticMobileLayout) return;
window.addEventListener("resize", updateCurrentLayout);
if (typeof window === "undefined") return;
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", updateCurrentLayout);
window.removeEventListener("resize", onResize);
};
}, [enableAutomaticMobileLayout, updateCurrentLayout]);
}, [onResize]);
return currentLayout;
};
-9
View File
@@ -6,21 +6,12 @@ import { useDisclosure } from "@mantine/hooks";
const EditModeContext = createContext<ReturnType<typeof useDisclosure> | null>(null);
const noop = () => undefined;
const readOnlyEditMode = [false, { open: noop, close: noop, toggle: noop, set: noop }] satisfies ReturnType<
typeof useDisclosure
>;
export const EditModeProvider = ({ children }: PropsWithChildren) => {
const editModeDisclosure = useDisclosure(false);
return <EditModeContext.Provider value={editModeDisclosure}>{children}</EditModeContext.Provider>;
};
export const ReadOnlyEditModeProvider = ({ children }: PropsWithChildren) => (
<EditModeContext.Provider value={readOnlyEditMode}>{children}</EditModeContext.Provider>
);
export const useEditMode = () => {
const context = useContext(EditModeContext);
+1 -1
View File
@@ -4,7 +4,7 @@ import { objectEntries } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver";
import { schema } from "./schema";
import * as schema from "./schema";
type TableKey = {
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
-170
View File
@@ -1,170 +0,0 @@
import { stringify } from "superjson";
import { describe, expect, test } from "vitest";
import { createId } from "@homarr/common";
import { eq } from "@homarr/db";
import {
getServerSettingByKeyAsync,
getServerSettingsAsync,
shouldEnableAutomaticMobileLayoutForUpgrade,
} from "@homarr/db/queries";
import { boards, layouts, serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings } from "@homarr/server-settings";
import { seedServerSettingsAsync } from "./seed";
describe("automatic mobile layout upgrade default", () => {
test("enables automatic mode for a new instance", () => {
expect(shouldEnableAutomaticMobileLayoutForUpgrade([])).toBe(true);
});
test("enables automatic mode when every existing board has only its canonical layout", () => {
expect(
shouldEnableAutomaticMobileLayoutForUpgrade([
{ boardId: "first", name: "Base", breakpoint: 0 },
{ boardId: "second", name: "Base", breakpoint: 0 },
]),
).toBe(true);
});
test("preserves legacy mode when a board has multiple responsive layouts", () => {
expect(
shouldEnableAutomaticMobileLayoutForUpgrade([
{ boardId: "board", name: "Base", breakpoint: 0 },
{ boardId: "board", name: "Mobile", breakpoint: 480 },
]),
).toBe(false);
});
test("preserves legacy mode when a board has duplicate canonical layouts", () => {
expect(
shouldEnableAutomaticMobileLayoutForUpgrade([
{ boardId: "board", name: "Base", breakpoint: 0 },
{ boardId: "board", name: "Base", breakpoint: 0 },
]),
).toBe(false);
});
test.each([
{ boardId: "board", name: "Phone", breakpoint: 0 },
{ boardId: "board", name: "Base", breakpoint: 480 },
])("preserves legacy mode for a noncanonical saved layout $name/$breakpoint", (existingLayout) => {
expect(shouldEnableAutomaticMobileLayoutForUpgrade([existingLayout])).toBe(false);
});
test("uses the upgrade heuristic when migrations and seeding are skipped", async () => {
const db = createDb();
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
await db.insert(layouts).values([
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
{ id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 },
]);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toMatchObject({
enableAutomaticMobileLayout: false,
});
await expect(getServerSettingsAsync(db)).resolves.toMatchObject({
board: {
enableAutomaticMobileLayout: false,
},
});
});
test("persists the enabled default when a fresh instance is seeded repeatedly", async () => {
const db = createDb();
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board);
});
test("backfills the enabled default for canonical layouts", async () => {
const db = createDb();
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
await db.insert(layouts).values({
id: createId(),
boardId,
name: "Base",
columnCount: 12,
breakpoint: 0,
});
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify({
homeBoardId: null,
mobileHomeBoardId: null,
enableStatusByDefault: true,
forceDisableStatus: false,
}),
});
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board);
});
test("does not override an explicit disabled choice", async () => {
const db = createDb();
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
}),
});
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
});
});
test("backfills legacy responsive instances once without overriding an explicit choice", async () => {
const db = createDb();
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
await db.insert(layouts).values([
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
{ id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 },
]);
await db.insert(serverSettings).values({
settingKey: "board",
value: stringify({
homeBoardId: null,
mobileHomeBoardId: null,
enableStatusByDefault: true,
forceDisableStatus: false,
}),
});
await seedServerSettingsAsync(db);
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
});
await db
.update(serverSettings)
.set({
value: stringify({
...defaultServerSettings.board,
enableAutomaticMobileLayout: true,
}),
})
.where(eq(serverSettings.settingKey, "board"));
await seedServerSettingsAsync(db);
await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board);
});
});
+4 -19
View File
@@ -21,7 +21,6 @@ import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import {
getServerSettingByKeyAsync,
insertServerSettingByKeyAsync,
shouldEnableAutomaticMobileLayoutForUpgrade,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
@@ -246,33 +245,19 @@ const seedDefaultSearchEnginesAsync = async (db: Database) => {
console.log("Set Homarr docs as the default search engine");
};
export const seedServerSettingsAsync = async (db: Database) => {
const seedServerSettingsAsync = async (db: Database) => {
const serverSettingsData = await db.query.serverSettings.findMany();
const existingLayouts = await db.query.layouts.findMany({
columns: {
boardId: true,
name: true,
breakpoint: true,
},
});
const defaultsForThisInstance = {
...defaultServerSettings,
board: {
...defaultServerSettings.board,
enableAutomaticMobileLayout: shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts),
},
};
for (const settingsKey of defaultServerSettingsKeys) {
const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey);
if (!currentDbEntry) {
await insertServerSettingByKeyAsync(db, settingsKey, defaultsForThisInstance[settingsKey]);
await insertServerSettingByKeyAsync(db, settingsKey, defaultServerSettings[settingsKey]);
console.log(`Created serverSetting through seed key=${settingsKey}`);
continue;
}
const currentSettings = SuperJSON.parse<(typeof defaultServerSettings)[typeof settingsKey]>(currentDbEntry.value);
const defaultSettings = defaultsForThisInstance[settingsKey];
const currentSettings = await getServerSettingByKeyAsync(db, settingsKey);
const defaultSettings = defaultServerSettings[settingsKey];
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
if (missingKeys.length === 0) {
+10 -132
View File
@@ -1,80 +1,29 @@
import { parse, stringify } from "superjson";
import SuperJSON from "superjson";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
import { eq, sql } from "..";
import { isMysql, isPostgresql } from "../collection";
import type { HomarrDatabaseMysql, HomarrDatabasePostgresql } from "../driver";
import { eq } from "..";
import { serverSettings } from "../schema";
import { serverSettings as mysqlServerSettings } from "../schema/mysql";
import { serverSettings as postgresqlServerSettings } from "../schema/postgresql";
interface ExistingBoardLayout {
boardId: string;
name: string;
breakpoint: number;
}
export const shouldEnableAutomaticMobileLayoutForUpgrade = (existingLayouts: ExistingBoardLayout[]) => {
const layoutCounts = new Map<string, number>();
for (const layout of existingLayouts) {
if (layout.name !== "Base" || layout.breakpoint !== 0) {
return false;
}
const nextCount = (layoutCounts.get(layout.boardId) ?? 0) + 1;
if (nextCount > 1) {
return false;
}
layoutCounts.set(layout.boardId, nextCount);
}
return true;
};
const getAutomaticMobileLayoutUpgradeDefaultAsync = async (db: Database) => {
const existingLayouts = await db.query.layouts.findMany({
columns: {
boardId: true,
name: true,
breakpoint: true,
},
});
return shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts);
};
export const getServerSettingsAsync = async (db: Database) => {
const settings = await db.query.serverSettings.findMany();
let parsedBoardSettings: Partial<ServerSettings["board"]> = {};
const mergedSettings = defaultServerSettingsKeys.reduce((acc, settingKey) => {
const setting = settings.find((candidate) => candidate.settingKey === settingKey);
return defaultServerSettingsKeys.reduce((acc, settingKey) => {
const setting = settings.find((setting) => setting.settingKey === settingKey);
if (!setting) {
// Typescript is not happy because the key is a union and it does not know that they are the same
acc[settingKey] = { ...defaultServerSettings[settingKey] } as never;
acc[settingKey] = defaultServerSettings[settingKey] as never;
return acc;
}
const persistedSettings = parse<Record<string, unknown>>(setting.value);
if (settingKey === "board") {
parsedBoardSettings = persistedSettings as Partial<ServerSettings["board"]>;
}
acc[settingKey] = {
...defaultServerSettings[settingKey],
...persistedSettings,
} as never;
...SuperJSON.parse(setting.value),
};
return acc;
}, {} as ServerSettings);
if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") {
mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
}
return mergedSettings;
};
export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(db: Database, key: TKey) => {
@@ -83,31 +32,10 @@ export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettin
});
if (!dbSettings) {
if (key === "board") {
return {
...defaultServerSettings.board,
enableAutomaticMobileLayout: await getAutomaticMobileLayoutUpgradeDefaultAsync(db),
} as ServerSettings[TKey];
}
return defaultServerSettings[key];
}
const persistedSettings = parse<Partial<ServerSettings[TKey]>>(dbSettings.value);
const mergedSettings = {
...defaultServerSettings[key],
...persistedSettings,
} as ServerSettings[TKey];
if (
key === "board" &&
typeof (persistedSettings as Partial<ServerSettings["board"]>).enableAutomaticMobileLayout !== "boolean"
) {
(mergedSettings as ServerSettings["board"]).enableAutomaticMobileLayout =
await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
}
return mergedSettings;
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
};
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
@@ -118,7 +46,7 @@ export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSet
await db
.update(serverSettings)
.set({
value: stringify(value),
value: SuperJSON.stringify(value),
})
.where(eq(serverSettings.settingKey, key));
};
@@ -130,56 +58,6 @@ export const insertServerSettingByKeyAsync = async <TKey extends keyof ServerSet
) => {
await db.insert(serverSettings).values({
settingKey: key,
value: stringify(value),
value: SuperJSON.stringify(value),
});
};
export const mergeServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
db: Database,
key: TKey,
value: ServerSettings[TKey],
patch: Partial<ServerSettings[TKey]>,
) => {
const serializedValue = stringify(value);
const serializedPatch = stringify(patch);
if (isMysql()) {
await (db as unknown as HomarrDatabaseMysql)
.insert(mysqlServerSettings)
.values({ settingKey: key, value: serializedValue })
.onDuplicateKeyUpdate({
set: {
value: sql`JSON_MERGE_PATCH(${mysqlServerSettings.value}, ${serializedPatch})`,
},
});
return;
}
if (isPostgresql()) {
await (db as unknown as HomarrDatabasePostgresql)
.insert(postgresqlServerSettings)
.values({ settingKey: key, value: serializedValue })
.onConflictDoUpdate({
target: postgresqlServerSettings.settingKey,
set: {
value: sql`jsonb_set(
${postgresqlServerSettings.value}::jsonb,
'{json}',
(${postgresqlServerSettings.value}::jsonb -> 'json') || (${serializedPatch}::jsonb -> 'json'),
true
)::text`,
},
});
return;
}
await db
.insert(serverSettings)
.values({ settingKey: key, value: serializedValue })
.onConflictDoUpdate({
target: serverSettings.settingKey,
set: {
value: sql`json_patch(${serverSettings.value}, ${serializedPatch})`,
},
});
};
@@ -77,31 +77,9 @@ const prepareHomeAssistantContainerAsync = async () => {
const startedContainer = await homeAssistantContainer.start();
await startedContainer.exec(["unzip", "-o", "/tmp/config.zip", "-d", "/config"]);
await startedContainer.restart();
await waitForHomeAssistantAsync(startedContainer);
return startedContainer;
};
const waitForHomeAssistantAsync = async (container: StartedTestContainer) => {
const url = `http://${container.getHost()}:${container.getMappedPort(8123)}/api/config`;
for (let attempt = 0; attempt < 40; attempt++) {
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${DEFAULT_API_KEY}` },
signal: AbortSignal.timeout(500),
});
if (response.ok) return;
} catch {
// Home Assistant is still restarting.
continue;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error("Home Assistant did not become ready after restart");
};
const createHomeAssistantContainer = () => {
return (
new GenericContainer(IMAGE_NAME)
-1
View File
@@ -27,7 +27,6 @@ export const defaultServerSettings = {
board: {
homeBoardId: null as string | null,
mobileHomeBoardId: null as string | null,
enableAutomaticMobileLayout: true,
enableStatusByDefault: true,
forceDisableStatus: false,
},
+2 -3
View File
@@ -12,14 +12,14 @@ export type SettingsContextProps = Pick<
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
> &
Pick<ServerSettings["board"], "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["user"], "enableGravatar">;
export interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
board: Pick<
ServerSettings["board"],
"homeBoardId" | "mobileHomeBoardId" | "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
>;
user: Pick<ServerSettings["user"], "enableGravatar">;
}
@@ -51,7 +51,6 @@ export const createSettings = ({
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar,
-23
View File
@@ -4183,12 +4183,6 @@
}
},
"board": {
"mobilePreview": {
"title": "Mobile preview",
"action": "Mobile preview",
"collapse": "Collapse mobile preview",
"empty": "Add items to the board to preview the automatic mobile layout."
},
"action": {
"duplicate": {
"title": "Duplicate board",
@@ -4205,7 +4199,6 @@
}
},
"edit": {
"label": "Toggle board edit mode",
"notification": {
"success": {
"title": "Changes applied successfully",
@@ -4413,17 +4406,8 @@
},
"layout": {
"title": "Layout",
"desktop": {
"title": "Desktop layout",
"description": "Configure the layout used on desktop and larger screens."
},
"mobile": {
"automaticDescription": "On mobile, the normal widgets are arranged in two columns and cannot be moved or resized. Categories and dynamic sections are flattened into the item list."
},
"responsive": {
"title": "Responsive layouts",
"description": "Automatic mobile layout is disabled for this server. Configure the saved layouts selected at each viewport breakpoint; these layouts can also be edited directly on mobile.",
"defaultName": "New layout",
"action": {
"add": "Add layout"
}
@@ -4876,13 +4860,6 @@
"mobileLabel": "Global mobile board",
"description": "Only public boards are available for selection"
},
"mobileLayout": {
"title": "Mobile layout",
"enable": {
"label": "Use automatic mobile layout",
"description": "Squeeze every board into a read-only two-column grid on phones and small tablets without changing widget content. Turn this off to use saved responsive layouts and mobile layout editing. Saved layouts are kept while automatic mode is enabled."
}
},
"status": {
"title": "App status",
"enableStatusByDefault": {
+5 -38
View File
@@ -24,7 +24,7 @@ export const boardNameSchema = z
.min(1)
.max(255)
.regex(/^[A-Za-z0-9-_]*$/);
export const boardColumnCountSchema = z.number().int().min(1).max(24);
export const boardColumnCountSchema = z.number().min(1).max(24);
export const boardByNameSchema = z.object({
name: boardNameSchema,
@@ -70,49 +70,16 @@ export const boardSavePartialSettingsSchema = z
})
.partial();
export const boardSaveLayoutSchema = z.object({
export const boardSaveLayoutsSchema = z.object({
id: z.string(),
columnCount: boardColumnCountSchema,
});
const responsiveBoardLayoutsSchema = z
.array(
layouts: z.array(
z.object({
id: z.string(),
name: z.string().trim().nonempty().max(32),
columnCount: boardColumnCountSchema,
breakpoint: z.number().int().min(0).max(32767),
breakpoint: z.number().min(0).max(32767),
}),
)
.min(1)
.superRefine((layouts, context) => {
const seenBreakpoints = new Set<number>();
const seenIds = new Set<string>();
layouts.forEach((layout, index) => {
if (seenIds.has(layout.id)) {
context.addIssue({
code: "custom",
message: "Each responsive layout must have a unique ID",
path: [index, "id"],
});
}
seenIds.add(layout.id);
if (seenBreakpoints.has(layout.breakpoint)) {
context.addIssue({
code: "custom",
message: "Each responsive layout must have a unique breakpoint",
path: [index, "breakpoint"],
});
}
seenBreakpoints.add(layout.breakpoint);
});
});
export const boardSaveLayoutsSchema = z.object({
id: z.string(),
layouts: responsiveBoardLayoutsSchema,
),
});
export const boardSaveSchema = z.object({
-3
View File
@@ -1430,9 +1430,6 @@ importers:
'@homarr/api':
specifier: workspace:^0.1.0
version: link:../api
'@homarr/settings':
specifier: workspace:^0.1.0
version: link:../settings
react:
specifier: 'catalog:'
version: 19.2.7