fix: keep mobile board widgets unchanged
This commit is contained in:
1 parent
e93f14acdf
commit
9887c018d2
24 files changed
+719
-77
No files matched your search
@@ -80,7 +80,7 @@ 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 and status behavior
|
||||
- Configure global board defaults such as public home boards, automatic mobile layout mode, and status behavior
|
||||
|
||||
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ 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,
|
||||
},
|
||||
|
||||
@@ -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; xOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
|
||||
const isOccupied = elements.some(
|
||||
(element) =>
|
||||
element.yOffset < yOffset + size.height &&
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveAndResizeItemInput {
|
||||
itemId: string;
|
||||
layoutId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
@@ -11,10 +10,8 @@ export interface MoveAndResizeItemInput {
|
||||
}
|
||||
|
||||
export const moveAndResizeItemCallback =
|
||||
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||
({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) =>
|
||||
@@ -23,7 +20,7 @@ export const moveAndResizeItemCallback =
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== currentLayout
|
||||
layout.layoutId !== layoutId
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveItemToSectionInput {
|
||||
itemId: string;
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
@@ -12,10 +11,8 @@ export interface MoveItemToSectionInput {
|
||||
}
|
||||
|
||||
export const moveItemToSectionCallback =
|
||||
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||
({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||
(board: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(board);
|
||||
|
||||
return {
|
||||
...board,
|
||||
items: board.items.map((item) =>
|
||||
@@ -24,7 +21,7 @@ export const moveItemToSectionCallback =
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== currentLayout
|
||||
layout.layoutId !== layoutId
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
|
||||
@@ -106,6 +106,10 @@ 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[][]) => {
|
||||
const elements: (Pick<Item["layouts"][number], "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { moveAndResizeItemCallback } from "../move-and-resize-item";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
@@ -25,7 +21,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
@@ -43,8 +39,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
@@ -56,7 +50,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { moveItemToSectionCallback } from "../move-item-to-section";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
@@ -26,7 +22,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
@@ -44,8 +40,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
@@ -58,7 +52,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
|
||||
@@ -16,5 +16,4 @@
|
||||
.item > :global(.grid-stack-item-content) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -93,4 +93,25 @@ describe("createMobileBoardItems", () => {
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,6 +12,7 @@ interface MoveAndResizeInnerSection {
|
||||
}
|
||||
interface MoveInnerSectionToSection {
|
||||
innerSectionId: string;
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
@@ -23,7 +24,7 @@ export const useSectionActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const moveAndResizeInnerSection = useCallback(
|
||||
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
|
||||
({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
@@ -31,12 +32,10 @@ export const useSectionActions = () => {
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.layoutId !== currentLayout) return layout;
|
||||
if (layout.layoutId !== layoutId) return layout;
|
||||
return {
|
||||
...layout,
|
||||
...positionProps,
|
||||
@@ -50,7 +49,7 @@ export const useSectionActions = () => {
|
||||
);
|
||||
|
||||
const moveInnerSectionToSection = useCallback(
|
||||
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
|
||||
({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
@@ -59,12 +58,10 @@ export const useSectionActions = () => {
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.layoutId !== currentLayout) return layout;
|
||||
if (layout.layoutId !== layoutId) return layout;
|
||||
return {
|
||||
...layout,
|
||||
...positionProps,
|
||||
|
||||
@@ -4,6 +4,8 @@ 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);
|
||||
@@ -17,5 +19,8 @@ export const MobileBoardViewportProvider = ({
|
||||
|
||||
export const useIsMobileBoard = () => {
|
||||
const initialIsMobile = useContext(InitialMobileBoardContext);
|
||||
return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
|
||||
const { enableAutomaticMobileLayout } = useSettings();
|
||||
const isMobileViewport = useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
|
||||
|
||||
return enableAutomaticMobileLayout && isMobileViewport;
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { chromium, devices, expect } from "@playwright/test";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { 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 setAutomaticMobileLayoutAsync = async (page: Page, enabled: boolean) => {
|
||||
await page.goto(new URL("/manage/settings", page.url()).toString());
|
||||
const toggle = page.getByRole("switch", { name: "Use automatic mobile layout" });
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
if ((await toggle.isChecked()) !== enabled) {
|
||||
await toggle.setChecked(enabled);
|
||||
await toggle.locator("xpath=ancestor::form").getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(page.getByText("Settings saved successfully")).toBeVisible();
|
||||
}
|
||||
};
|
||||
|
||||
const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
|
||||
const boardId = createId();
|
||||
const desktopLayoutId = createId();
|
||||
const mobileLayoutId = createId();
|
||||
const sectionId = createId();
|
||||
const notebookId = createId();
|
||||
const clockId = 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,
|
||||
});
|
||||
await db.insert(sqliteSchema.items).values([
|
||||
{
|
||||
id: notebookId,
|
||||
boardId,
|
||||
kind: "notebook",
|
||||
options: stringify({ content: "<p>Normal notebook content</p>" }),
|
||||
advancedOptions: stringify({}),
|
||||
},
|
||||
{
|
||||
id: clockId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringify({}),
|
||||
advancedOptions: stringify({}),
|
||||
},
|
||||
]);
|
||||
await db.insert(sqliteSchema.itemLayouts).values([
|
||||
{
|
||||
itemId: notebookId,
|
||||
layoutId: desktopLayoutId,
|
||||
sectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
},
|
||||
{
|
||||
itemId: clockId,
|
||||
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: clockId,
|
||||
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));
|
||||
};
|
||||
|
||||
describe("Automatic mobile board", () => {
|
||||
test("squeezes unchanged widgets into a read-only two-column grid", 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 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 setAutomaticMobileLayoutAsync(loginPage, true);
|
||||
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(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: "Edit item", 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();
|
||||
|
||||
await setAutomaticMobileLayoutAsync(loginPage, false);
|
||||
|
||||
const legacyContext = await browser.newContext({
|
||||
storageState: await loginContext.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: "Edit item", exact: true })).toBeVisible();
|
||||
await legacyContext.close();
|
||||
} finally {
|
||||
await loginContext.close();
|
||||
await browser.close();
|
||||
await container.stop();
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -5,10 +5,10 @@ import { and, eq, inArray } from "@homarr/db";
|
||||
import {
|
||||
getServerSettingByKeyAsync,
|
||||
getServerSettingsAsync,
|
||||
insertServerSettingByKeyAsync,
|
||||
mergeServerSettingByKeyAsync,
|
||||
updateServerSettingByKeyAsync,
|
||||
} from "@homarr/db/queries";
|
||||
import { boards, serverSettings } from "@homarr/db/schema";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
import { settingsInitSchema } from "@homarr/validation/settings";
|
||||
@@ -19,6 +19,7 @@ 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"]>;
|
||||
@@ -39,7 +40,7 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
|
||||
"Get global board defaults, including desktop/mobile home board IDs, automatic mobile layout mode, and status behavior. Requires admin permission",
|
||||
},
|
||||
})
|
||||
.input(z.void())
|
||||
@@ -54,7 +55,7 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
|
||||
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
|
||||
},
|
||||
})
|
||||
.input(boardServerSettingsUpdateSchema)
|
||||
@@ -81,17 +82,9 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
const next = { ...current, ...input };
|
||||
const existing = await ctx.db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, "board"),
|
||||
});
|
||||
await mergeServerSettingByKeyAsync(ctx.db, "board", next, input);
|
||||
|
||||
if (existing) {
|
||||
await updateServerSettingByKeyAsync(ctx.db, "board", next);
|
||||
} else {
|
||||
await insertServerSettingByKeyAsync(ctx.db, "board", next);
|
||||
}
|
||||
|
||||
return next;
|
||||
return await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
}),
|
||||
saveSettings: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -103,10 +96,11 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
|
||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
||||
const next = {
|
||||
...current,
|
||||
...input.value,
|
||||
} as ServerSettings[keyof ServerSettings]);
|
||||
} as ServerSettings[keyof ServerSettings];
|
||||
await mergeServerSettingByKeyAsync(ctx.db, input.settingsKey, next, input.value);
|
||||
}),
|
||||
initSettings: onboardingProcedure
|
||||
.requiresStep("settings")
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { boards, serverSettings } from "@homarr/db/schema";
|
||||
import { boards, layouts, serverSettings } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
@@ -56,6 +56,33 @@ 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({
|
||||
@@ -89,6 +116,33 @@ 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", () => {
|
||||
@@ -103,6 +157,36 @@ 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({
|
||||
@@ -145,6 +229,7 @@ describe("board settings API", () => {
|
||||
await caller.updateBoardSettings({ homeBoardId: boardId });
|
||||
const result = await caller.updateBoardSettings({
|
||||
mobileHomeBoardId: boardId,
|
||||
enableAutomaticMobileLayout: false,
|
||||
enableStatusByDefault: false,
|
||||
});
|
||||
|
||||
@@ -152,6 +237,7 @@ describe("board settings API", () => {
|
||||
...defaultServerSettings.board,
|
||||
homeBoardId: boardId,
|
||||
mobileHomeBoardId: boardId,
|
||||
enableAutomaticMobileLayout: false,
|
||||
enableStatusByDefault: false,
|
||||
});
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
@@ -163,6 +249,25 @@ 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({
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { objectEntries } from "@homarr/common";
|
||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
||||
|
||||
import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver";
|
||||
import * as schema from "./schema";
|
||||
import { schema } from "./schema";
|
||||
|
||||
type TableKey = {
|
||||
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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", (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("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,6 +21,7 @@ import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
|
||||
import {
|
||||
getServerSettingByKeyAsync,
|
||||
insertServerSettingByKeyAsync,
|
||||
shouldEnableAutomaticMobileLayoutForUpgrade,
|
||||
updateServerSettingByKeyAsync,
|
||||
} from "../queries/server-setting";
|
||||
|
||||
@@ -155,19 +156,33 @@ const seedDefaultSearchEnginesAsync = async (db: Database) => {
|
||||
console.log("Set Homarr docs as the default search engine");
|
||||
};
|
||||
|
||||
const seedServerSettingsAsync = async (db: Database) => {
|
||||
export 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, defaultServerSettings[settingsKey]);
|
||||
await insertServerSettingByKeyAsync(db, settingsKey, defaultsForThisInstance[settingsKey]);
|
||||
console.log(`Created serverSetting through seed key=${settingsKey}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentSettings = await getServerSettingByKeyAsync(db, settingsKey);
|
||||
const defaultSettings = defaultServerSettings[settingsKey];
|
||||
const currentSettings = SuperJSON.parse<(typeof defaultServerSettings)[typeof settingsKey]>(currentDbEntry.value);
|
||||
const defaultSettings = defaultsForThisInstance[settingsKey];
|
||||
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
|
||||
|
||||
if (missingKeys.length === 0) {
|
||||
|
||||
@@ -1,29 +1,80 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { parse, stringify } from "superjson";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
|
||||
import type { Database } from "..";
|
||||
import { eq } from "..";
|
||||
import { eq, sql } from "..";
|
||||
import { isMysql, isPostgresql } from "../collection";
|
||||
import type { HomarrDatabaseMysql, HomarrDatabasePostgresql } from "../driver";
|
||||
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"]> = {};
|
||||
|
||||
return defaultServerSettingsKeys.reduce((acc, settingKey) => {
|
||||
const setting = settings.find((setting) => setting.settingKey === settingKey);
|
||||
const mergedSettings = defaultServerSettingsKeys.reduce((acc, settingKey) => {
|
||||
const setting = settings.find((candidate) => candidate.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],
|
||||
...SuperJSON.parse(setting.value),
|
||||
};
|
||||
...persistedSettings,
|
||||
} as never;
|
||||
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) => {
|
||||
@@ -32,10 +83,31 @@ 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];
|
||||
}
|
||||
|
||||
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
|
||||
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;
|
||||
};
|
||||
|
||||
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
|
||||
@@ -46,7 +118,7 @@ export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSet
|
||||
await db
|
||||
.update(serverSettings)
|
||||
.set({
|
||||
value: SuperJSON.stringify(value),
|
||||
value: stringify(value),
|
||||
})
|
||||
.where(eq(serverSettings.settingKey, key));
|
||||
};
|
||||
@@ -58,6 +130,56 @@ export const insertServerSettingByKeyAsync = async <TKey extends keyof ServerSet
|
||||
) => {
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: key,
|
||||
value: SuperJSON.stringify(value),
|
||||
value: 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})`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -27,6 +27,7 @@ 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"], "enableStatusByDefault" | "forceDisableStatus"> &
|
||||
Pick<ServerSettings["board"], "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"> &
|
||||
Pick<ServerSettings["user"], "enableGravatar">;
|
||||
|
||||
export interface PublicServerSettings {
|
||||
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
|
||||
board: Pick<
|
||||
ServerSettings["board"],
|
||||
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
|
||||
"homeBoardId" | "mobileHomeBoardId" | "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"
|
||||
>;
|
||||
user: Pick<ServerSettings["user"], "enableGravatar">;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ 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,
|
||||
|
||||
Generated
+3
@@ -1574,6 +1574,9 @@ 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