refactor: simplify mobile board layouts
This commit is contained in:
1 parent
7e7b338d18
commit
fa9444323b
51 files changed
+383
-2472
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||

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

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

|
||||
@@ -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
|
||||

|
||||
|
||||
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):
|
||||
|
||||

|
||||
|
||||
### 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
|
||||

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

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

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

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

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

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

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

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

|
||||
|
||||
:::note
|
||||
For the global home board you can only select boards that are public.
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -27,7 +27,6 @@ export const defaultServerSettings = {
|
||||
board: {
|
||||
homeBoardId: null as string | null,
|
||||
mobileHomeBoardId: null as string | null,
|
||||
enableAutomaticMobileLayout: true,
|
||||
enableStatusByDefault: true,
|
||||
forceDisableStatus: false,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Generated
-3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user