feat: add automatic mobile board layout
This commit is contained in:
1 parent
08588da0d1
commit
d63653142d
15 files changed
+1498
-323
No files matched your search
@@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database, InferInsertModel } from "@homarr/db";
|
||||
import { and, eq, not } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
boards,
|
||||
@@ -325,10 +325,13 @@ describe("createBoard should create a new board", () => {
|
||||
expect(firstSection?.xOffset).toBe(0);
|
||||
expect(firstSection?.yOffset).toBe(0);
|
||||
|
||||
expect(dbBoard?.layouts.length).toBe(1);
|
||||
const firstLayout = dbBoard?.layouts.at(0);
|
||||
expect(firstLayout?.columnCount).toBe(24);
|
||||
expect(firstLayout?.breakpoint).toBe(0);
|
||||
expect(dbBoard?.layouts.length).toBe(2);
|
||||
expect(dbBoard?.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error when user has no board-create permission", async () => {
|
||||
@@ -344,6 +347,36 @@ describe("createBoard should create a new board", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateBoard should preserve protected layout invariants", () => {
|
||||
test("normalizes a legacy single-layout board while preserving Base positions", async () => {
|
||||
const db = createDb();
|
||||
const session = {
|
||||
...defaultSession,
|
||||
user: {
|
||||
...defaultSession.user,
|
||||
permissions: ["board-create"] satisfies GroupPermissionKey[],
|
||||
},
|
||||
};
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { boardId } = await createFullBoardAsync(db, "source");
|
||||
|
||||
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
|
||||
|
||||
const copiedBoard = await db.query.boards.findFirst({
|
||||
where: eq(boards.id, result.boardId),
|
||||
with: { layouts: true, items: { with: { layouts: true } } },
|
||||
});
|
||||
expect(copiedBoard?.layouts).toHaveLength(2);
|
||||
expect(copiedBoard?.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
|
||||
expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
expect(copiedBoard?.items.at(0)?.layouts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rename board should rename board", () => {
|
||||
test("should rename board", async () => {
|
||||
// Arrange
|
||||
@@ -569,6 +602,13 @@ describe("getBoardByName should return board by name", () => {
|
||||
name,
|
||||
...fullBoardProps,
|
||||
});
|
||||
expect(result.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "mobile", breakpoint: 0, columnCount: 3 }),
|
||||
expect.objectContaining({ id: fullBoardProps.layoutId, role: "base", breakpoint: 768 }),
|
||||
]),
|
||||
);
|
||||
expect(result.items.at(0)?.layouts).toHaveLength(2);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||
});
|
||||
|
||||
@@ -1330,12 +1370,12 @@ describe("saveBoard should save full board", () => {
|
||||
expect(firstItem.id).toBe(itemId);
|
||||
expect(firstItem.kind).toBe("clock");
|
||||
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
|
||||
const firstLayout = expectToBeDefined(firstItem.layouts[0]);
|
||||
expect(firstLayout.sectionId).toBe(sectionId);
|
||||
expect(firstLayout.height).toBe(3);
|
||||
expect(firstLayout.width).toBe(2);
|
||||
expect(firstLayout.xOffset).toBe(7);
|
||||
expect(firstLayout.yOffset).toBe(5);
|
||||
const updatedLayout = expectToBeDefined(firstItem.layouts.find((layout) => layout.layoutId === layoutId));
|
||||
expect(updatedLayout.sectionId).toBe(sectionId);
|
||||
expect(updatedLayout.height).toBe(3);
|
||||
expect(updatedLayout.width).toBe(2);
|
||||
expect(updatedLayout.xOffset).toBe(7);
|
||||
expect(updatedLayout.yOffset).toBe(5);
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||
});
|
||||
it("should fail when board not found", async () => {
|
||||
@@ -1522,7 +1562,17 @@ const createExistingLayout = (id: string) => ({
|
||||
columnCount: 10,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
});
|
||||
const createMobileLayout = (id: string) => ({
|
||||
id,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
});
|
||||
const createNewLayout = (columnCount: number) => ({
|
||||
id: createId(),
|
||||
@@ -1530,8 +1580,23 @@ const createNewLayout = (columnCount: number) => ({
|
||||
columnCount,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 1400,
|
||||
breakpoint: 480,
|
||||
role: "custom" as const,
|
||||
});
|
||||
const addMobileLayoutAsync = async (db: Database, boardId: string) => {
|
||||
const mobileLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
boardId,
|
||||
});
|
||||
return mobileLayoutId;
|
||||
};
|
||||
describe("saveLayouts should save layout changes", () => {
|
||||
test("should add layout when not present in database", async () => {
|
||||
// Arrange
|
||||
@@ -1539,23 +1604,26 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const newLayout = createNewLayout(12);
|
||||
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
const savedLayouts = await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createExistingLayout(layoutId), newLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const layout = await db.query.layouts.findFirst({
|
||||
where: not(eq(layouts.id, layoutId)),
|
||||
where: eq(layouts.role, "custom"),
|
||||
});
|
||||
|
||||
const definedLayout = expectToBeDefined(layout);
|
||||
expect(definedLayout.name).toBe(newLayout.name);
|
||||
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
|
||||
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
|
||||
expect(definedLayout.role).toBe("custom");
|
||||
expect(savedLayouts.find((candidate) => candidate.role === "custom")?.id).toBe(definedLayout.id);
|
||||
});
|
||||
test("should add items and containers generated from grid-algorithm when new layout is added", async () => {
|
||||
// Arrange
|
||||
@@ -1563,6 +1631,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const assignments = await createItemsAndSectionsAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
@@ -1573,12 +1642,12 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createExistingLayout(layoutId), newLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const layout = await db.query.layouts.findFirst({
|
||||
where: not(eq(layouts.id, layoutId)),
|
||||
where: eq(layouts.role, "custom"),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
|
||||
@@ -1594,6 +1663,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const updatedLayout = createExistingLayout(layoutId);
|
||||
updatedLayout.breakpoint = 1400;
|
||||
updatedLayout.name = "Updated layout";
|
||||
@@ -1601,7 +1671,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [updatedLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -1620,6 +1690,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const assignments = await createItemsAndSectionsAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
@@ -1631,7 +1702,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [updatedLayout],
|
||||
layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -1645,6 +1716,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "gutters");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
|
||||
await db
|
||||
.update(itemLayouts)
|
||||
@@ -1656,6 +1728,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [
|
||||
createMobileLayout(mobileLayoutId),
|
||||
{
|
||||
...createExistingLayout(layoutId),
|
||||
leftGutterColumnCount: 2,
|
||||
@@ -1684,7 +1757,7 @@ describe("saveLayouts should save layout changes", () => {
|
||||
});
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createExistingLayout(layoutId)],
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
|
||||
});
|
||||
|
||||
const returnedItem = expectToBeDefined(
|
||||
@@ -1698,9 +1771,11 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "concurrent-gutters");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const input = {
|
||||
id: boardId,
|
||||
layouts: [
|
||||
createMobileLayout(mobileLayoutId),
|
||||
{
|
||||
...createExistingLayout(layoutId),
|
||||
leftGutterColumnCount: 2,
|
||||
@@ -1715,22 +1790,73 @@ describe("saveLayouts should save layout changes", () => {
|
||||
});
|
||||
expect(leftRoots).toHaveLength(1);
|
||||
});
|
||||
test("should project a new custom layout from the resized Base layout", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const secondItemId = createId();
|
||||
|
||||
await db.update(itemLayouts).set({ width: 4, xOffset: 0 }).where(eq(itemLayouts.itemId, itemId));
|
||||
await db.insert(items).values({
|
||||
id: secondItemId,
|
||||
kind: "clock",
|
||||
boardId,
|
||||
options: SuperJSON.stringify({ is24HourFormat: true }),
|
||||
});
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId: secondItemId,
|
||||
layoutId,
|
||||
sectionId,
|
||||
width: 4,
|
||||
height: 1,
|
||||
xOffset: 6,
|
||||
yOffset: 0,
|
||||
});
|
||||
|
||||
const resizedBase = { ...createExistingLayout(layoutId), columnCount: 6 };
|
||||
const savedLayouts = await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createMobileLayout(mobileLayoutId), createNewLayout(8), resizedBase],
|
||||
});
|
||||
const customLayoutId = expectToBeDefined(savedLayouts.find((layout) => layout.role === "custom")).id;
|
||||
|
||||
const basePosition = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, layoutId)),
|
||||
});
|
||||
const customPosition = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, customLayoutId)),
|
||||
});
|
||||
|
||||
expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
|
||||
expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
|
||||
});
|
||||
test("should remove layout when not present in input", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
const customLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: customLayoutId,
|
||||
name: "Custom",
|
||||
columnCount: 6,
|
||||
breakpoint: 480,
|
||||
role: "custom",
|
||||
boardId,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createNewLayout(12)],
|
||||
layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const layout = await db.query.layouts.findFirst({
|
||||
where: eq(layouts.id, layoutId),
|
||||
where: eq(layouts.id, customLayoutId),
|
||||
});
|
||||
expect(layout).toBeUndefined();
|
||||
});
|
||||
@@ -1745,12 +1871,73 @@ describe("saveLayouts should save layout changes", () => {
|
||||
const actAsync = async () =>
|
||||
await caller.saveLayouts({
|
||||
id: createId(),
|
||||
layouts: [createExistingLayout(layoutId)],
|
||||
layouts: [createMobileLayout(createId()), createExistingLayout(layoutId)],
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrowError("Board not found");
|
||||
});
|
||||
test("should reject changing protected layout roles", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
|
||||
|
||||
const actAsync = async () =>
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [
|
||||
{ ...createExistingLayout(layoutId), role: "mobile", breakpoint: 0 },
|
||||
{ ...createMobileLayout(mobileLayoutId), role: "base", breakpoint: 768 },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(actAsync()).rejects.toThrowError("Layout roles cannot be changed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetLayout should rebuild a layout from Base", () => {
|
||||
test("should replace target item positions and preserve layout metadata", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
await addMobileLayoutAsync(db, boardId);
|
||||
const customLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: customLayoutId,
|
||||
name: "Tablet",
|
||||
columnCount: 6,
|
||||
breakpoint: 480,
|
||||
role: "custom",
|
||||
boardId,
|
||||
});
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId: customLayoutId,
|
||||
width: 2,
|
||||
height: 2,
|
||||
xOffset: 3,
|
||||
yOffset: 4,
|
||||
});
|
||||
|
||||
await caller.resetLayout({ boardId, layoutId: customLayoutId });
|
||||
|
||||
const resetPosition = await db.query.itemLayouts.findFirst({
|
||||
where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, customLayoutId)),
|
||||
});
|
||||
expect(resetPosition).toMatchObject({ width: 1, height: 1, xOffset: 0, yOffset: 0, sectionId });
|
||||
const customLayout = await db.query.layouts.findFirst({ where: eq(layouts.id, customLayoutId) });
|
||||
expect(customLayout).toMatchObject({ name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" });
|
||||
});
|
||||
|
||||
test("should reject resetting Base", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await expect(caller.resetLayout({ boardId, layoutId })).rejects.toThrowError("Base layout cannot be reset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Custom Widget placement permissions", () => {
|
||||
@@ -1848,7 +2035,8 @@ const createFullBoardAsync = async (db: Database, name: string) => {
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
boardId,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user