diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 37ffcd0bf..3116d968a 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -5,7 +5,7 @@ import { z } from "zod/v4"; import { constructBoardPermissions } from "@homarr/auth/shared"; import { createId } from "@homarr/common"; import type { DeviceType } from "@homarr/common/server"; -import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db"; +import type { Database, InferInsertModel, InferSelectModel } from "@homarr/db"; import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db"; import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection"; import { getServerSettingByKeyAsync } from "@homarr/db/queries"; @@ -27,13 +27,11 @@ import { sections, users, } from "@homarr/db/schema"; -import type { WidgetKind } from "@homarr/definitions"; import { emptySuperJSON, everyoneGroup, getPermissionsWithChildren, getPermissionsWithParents, - widgetKinds, } from "@homarr/definitions"; import { importOldmarrAsync } from "@homarr/old-import"; import { importJsonFileSchema } from "@homarr/old-import/shared"; @@ -50,13 +48,11 @@ import { boardSaveSchema, } from "@homarr/validation/board"; import { byIdSchema } from "@homarr/validation/common"; -import { zodUnionFromArray } from "@homarr/validation/enums"; -import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; -import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; import { throwIfActionForbiddenAsync } from "./board/board-access"; -import { generateResponsiveGridFor } from "./board/grid-algorithm"; +import { applyAutoLayoutToBoard, getUpdatedBoardLayout } from "./board/board-layout"; +import { getFullBoardWithWhereAsync } from "./board/full-board-query"; export const boardRouter = createTRPCRouter({ exists: permissionRequiredProcedure @@ -588,13 +584,15 @@ export const boardRouter = createTRPCRouter({ await throwIfActionForbiddenAsync(ctx, boardWhere, "view"); - return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null); + const board = await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null); + return applyAutoLayoutToBoard(board); }), getBoardByName: publicProcedure.input(boardByNameSchema).query(async ({ input, ctx }) => { const boardWhere = eq(sql`UPPER(${boards.name})`, input.name.toUpperCase()); await throwIfActionForbiddenAsync(ctx, boardWhere, "view"); - return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null); + const board = await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null); + return applyAutoLayoutToBoard(board); }), saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); @@ -1414,244 +1412,6 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId } }; -const getUpdatedBoardLayout = ( - board: Awaited>, - options: { - previous: { - layoutId: string; - columnCount: number; - }; - current: { - layoutId: string; - columnCount: number; - }; - }, -) => { - const itemSectionLayoutsCollection: InferInsertModel[] = []; - const sectionLayoutsCollection: InferInsertModel[] = []; - - const elements = getElementsForLayout(board, options.previous.layoutId); - const rootSections = board.sections.filter((section) => section.kind !== "dynamic"); - - for (const rootSection of rootSections) { - const result = generateResponsiveGridFor({ - items: elements, - previousWidth: options.previous.columnCount, - width: options.current.columnCount, - sectionId: rootSection.id, - }); - - itemSectionLayoutsCollection.push( - ...board.items - .map((item): InferInsertModel | null => { - const currentElement = result.items.find((element) => element.type === "item" && element.id === item.id); - - if (!currentElement) { - return null; - } - - return { - itemId: item.id, - layoutId: options.current.layoutId, - sectionId: currentElement.sectionId, - height: currentElement.height, - width: currentElement.width, - xOffset: currentElement.xOffset, - yOffset: currentElement.yOffset, - }; - }) - .filter((item) => item !== null), - ); - - sectionLayoutsCollection.push( - ...board.sections - .filter((section) => section.kind === "dynamic") - .map((section): InferInsertModel | null => { - const currentElement = result.items.find( - (element) => element.type === "section" && element.id === section.id, - ); - - if (!currentElement) { - return null; - } - - return { - layoutId: options.current.layoutId, - sectionId: section.id, - parentSectionId: currentElement.sectionId, - height: currentElement.height, - width: currentElement.width, - xOffset: currentElement.xOffset, - yOffset: currentElement.yOffset, - }; - }) - .filter((section) => section !== null), - ); - } - - return { - itemSectionLayouts: itemSectionLayoutsCollection, - sectionLayouts: sectionLayoutsCollection, - }; -}; - -const getElementsForLayout = (board: Awaited>, layoutId: string) => { - const sectionElements = board.sections - .filter((section) => section.kind === "dynamic") - .map((section) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!; - - return { - id: section.id, - type: "section" as const, - height: clonedLayout.height, - width: clonedLayout.width, - xOffset: clonedLayout.xOffset, - yOffset: clonedLayout.yOffset, - sectionId: clonedLayout.parentSectionId, - }; - }); - - const itemElements = board.items.map((item) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId)!; - - return { - id: item.id, - type: "item" as const, - height: clonedLayout.height, - width: clonedLayout.width, - xOffset: clonedLayout.xOffset, - yOffset: clonedLayout.yOffset, - sectionId: clonedLayout.sectionId, - }; - }); - - return [...itemElements, ...sectionElements]; -}; - -const getFullBoardWithWhereAsync = async (db: Database, where: SQL, userId: string | null) => { - const groupsOfCurrentUser = await db.query.groupMembers.findMany({ - where: eq(groupMembers.userId, userId ?? ""), - }); - const board = await db.query.boards.findFirst({ - where, - with: { - creator: { - columns: { - id: true, - name: true, - image: true, - }, - }, - sections: { - with: { - collapseStates: { - where: eq(sectionCollapseStates.userId, userId ?? ""), - }, - layouts: true, - }, - }, - items: { - with: { - integrations: { - with: { - integration: true, - }, - }, - layouts: true, - }, - }, - layouts: true, - userPermissions: { - where: eq(boardUserPermissions.userId, userId ?? ""), - columns: { - permission: true, - }, - }, - groupPermissions: { - where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")), - }, - }, - }); - - if (!board) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Board not found", - }); - } - - const { sections, items, layouts, ...otherBoardProperties } = board; - - return { - ...otherBoardProperties, - layouts: layouts - .map(({ boardId: _, ...layout }) => layout) - .sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint), - sections: sections.map(({ collapseStates, ...section }) => - parseSection({ - ...section, - xOffset: section.xOffset, - yOffset: section.yOffset, - options: superjson.parse(section.options ?? emptySuperJSON), - layouts: section.layouts.map((layout) => ({ - xOffset: layout.xOffset, - yOffset: layout.yOffset, - width: layout.width, - height: layout.height, - parentSectionId: layout.parentSectionId, - layoutId: layout.layoutId, - })), - collapsed: collapseStates.at(0)?.collapsed ?? false, - }), - ), - items: items.map(({ integrations: itemIntegrations, ...item }) => - parseItem({ - ...item, - layouts: item.layouts.map((layout) => ({ - xOffset: layout.xOffset, - yOffset: layout.yOffset, - width: layout.width, - height: layout.height, - layoutId: layout.layoutId, - sectionId: layout.sectionId, - })), - integrationIds: itemIntegrations.map((item) => item.integration.id), - advancedOptions: superjson.parse(item.advancedOptions), - options: superjson.parse>(item.options), - }), - ), - }; -}; - -const forKind = (kind: T) => - z.object({ - kind: z.literal(kind), - options: z.record(z.string(), z.unknown()), - }); - -const outputItemSchema = zodUnionFromArray(widgetKinds.map((kind) => forKind(kind))).and(sharedItemSchema); - -const parseItem = (item: unknown) => { - const result = outputItemSchema.safeParse(item); - - if (!result.success) { - throw new Error(result.error.message); - } - return result.data; -}; - -const parseSection = (section: unknown) => { - const result = sectionSchema.safeParse(section); - - if (!result.success) { - throw new Error(result.error.message); - } - return result.data; -}; - const filterAddedItems = (inputArray: TInput[], dbArray: TInput[]) => inputArray.filter((inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id)); diff --git a/packages/api/src/router/board/board-layout.ts b/packages/api/src/router/board/board-layout.ts new file mode 100644 index 000000000..5f88a5c18 --- /dev/null +++ b/packages/api/src/router/board/board-layout.ts @@ -0,0 +1,186 @@ +import type { Modify } from "@homarr/common/types"; +import type { InferInsertModel } from "@homarr/db"; +import type { itemLayouts, sectionLayouts } from "@homarr/db/schema"; + +import type { getFullBoardWithWhereAsync } from "./full-board-query"; +import { generateResponsiveGridFor } from "./grid-algorithm"; + +type DynamicSectionLayout = Modify, { parentSectionId: string }>; + +export const getUpdatedBoardLayout = ( + board: Awaited>, + options: { + previous: { + layoutId: string; + columnCount: number; + }; + current: { + layoutId: string; + columnCount: number; + }; + }, +) => { + const itemSectionLayoutsCollection: InferInsertModel[] = []; + const sectionLayoutsCollection: DynamicSectionLayout[] = []; + + const elements = getElementsForLayout(board, options.previous.layoutId); + const rootSections = board.sections.filter((section) => section.kind !== "dynamic"); + + for (const rootSection of rootSections) { + const result = generateResponsiveGridFor({ + items: elements, + previousWidth: options.previous.columnCount, + width: options.current.columnCount, + sectionId: rootSection.id, + }); + + itemSectionLayoutsCollection.push( + ...board.items + .map((item): InferInsertModel | null => { + const currentElement = result.items.find((element) => element.type === "item" && element.id === item.id); + + if (!currentElement) { + return null; + } + + return { + itemId: item.id, + layoutId: options.current.layoutId, + sectionId: currentElement.sectionId, + height: currentElement.height, + width: currentElement.width, + xOffset: currentElement.xOffset, + yOffset: currentElement.yOffset, + }; + }) + .filter((item) => item !== null), + ); + + sectionLayoutsCollection.push( + ...board.sections + .filter((section) => section.kind === "dynamic") + .map((section): DynamicSectionLayout | null => { + const currentElement = result.items.find( + (element) => element.type === "section" && element.id === section.id, + ); + + if (!currentElement) { + return null; + } + + return { + layoutId: options.current.layoutId, + sectionId: section.id, + parentSectionId: currentElement.sectionId, + height: currentElement.height, + width: currentElement.width, + xOffset: currentElement.xOffset, + yOffset: currentElement.yOffset, + }; + }) + .filter((section) => section !== null), + ); + } + + return { + itemSectionLayouts: itemSectionLayoutsCollection, + sectionLayouts: sectionLayoutsCollection, + }; +}; + +const getElementsForLayout = (board: Awaited>, layoutId: string) => { + const sectionElements = board.sections + .filter((section) => section.kind === "dynamic") + .map((section) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!; + + return { + id: section.id, + type: "section" as const, + height: clonedLayout.height, + width: clonedLayout.width, + xOffset: clonedLayout.xOffset, + yOffset: clonedLayout.yOffset, + sectionId: clonedLayout.parentSectionId, + }; + }); + + const itemElements = board.items.map((item) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId)!; + + return { + id: item.id, + type: "item" as const, + height: clonedLayout.height, + width: clonedLayout.width, + xOffset: clonedLayout.xOffset, + yOffset: clonedLayout.yOffset, + sectionId: clonedLayout.sectionId, + }; + }); + + return [...itemElements, ...sectionElements]; +}; + +export const applyAutoLayoutToBoard = ( + board: Awaited>, +): Awaited> => { + if (board.layoutMode === "custom") return board; + + const baseLayout = board.layouts.find((layout) => layout.id === board.baseLayoutId) ?? null; + if (!baseLayout) return board; + + const updatedLayoutMap = new Map>(); + for (const layout of board.layouts) { + if (layout.id === baseLayout.id) continue; + + updatedLayoutMap.set( + layout.id, + getUpdatedBoardLayout(board, { + previous: { + layoutId: baseLayout.id, + columnCount: baseLayout.columnCount, + }, + current: { + layoutId: layout.id, + columnCount: layout.columnCount, + }, + }), + ); + } + + return { + ...board, + items: board.items.map((item) => ({ + ...item, + layouts: item.layouts.map( + (layoutSection) => + updatedLayoutMap + .get(layoutSection.layoutId) + ?.itemSectionLayouts.find( + (updatedLayoutSection) => + updatedLayoutSection.itemId === item.id && updatedLayoutSection.layoutId === layoutSection.layoutId, + ) ?? layoutSection, + ), + })), + sections: board.sections.map((section) => + "layouts" in section + ? { + ...section, + layouts: section.layouts.map( + (sectionLayout) => + updatedLayoutMap + .get(sectionLayout.layoutId) + ?.sectionLayouts.find( + (updatedSectionLayout) => + updatedSectionLayout.sectionId === section.id && + updatedSectionLayout.layoutId === sectionLayout.layoutId, + ) ?? sectionLayout, + ), + } + : section, + ), + }; +}; diff --git a/packages/api/src/router/board/full-board-query.ts b/packages/api/src/router/board/full-board-query.ts new file mode 100644 index 000000000..1c19ae8c9 --- /dev/null +++ b/packages/api/src/router/board/full-board-query.ts @@ -0,0 +1,133 @@ +import { TRPCError } from "@trpc/server"; +import superjson from "superjson"; +import z from "zod"; + +import type { Database, SQL } from "@homarr/db"; +import { eq, inArray } from "@homarr/db"; +import { boardGroupPermissions, boardUserPermissions, groupMembers, sectionCollapseStates } from "@homarr/db/schema"; +import type { WidgetKind } from "@homarr/definitions"; +import { emptySuperJSON, widgetKinds } from "@homarr/definitions"; +import { zodUnionFromArray } from "@homarr/validation/enums"; +import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared"; +import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; + +export const getFullBoardWithWhereAsync = async (db: Database, where: SQL, userId: string | null) => { + const groupsOfCurrentUser = await db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, userId ?? ""), + }); + const board = await db.query.boards.findFirst({ + where, + with: { + creator: { + columns: { + id: true, + name: true, + image: true, + }, + }, + sections: { + with: { + collapseStates: { + where: eq(sectionCollapseStates.userId, userId ?? ""), + }, + layouts: true, + }, + }, + items: { + with: { + integrations: { + with: { + integration: true, + }, + }, + layouts: true, + }, + }, + layouts: true, + userPermissions: { + where: eq(boardUserPermissions.userId, userId ?? ""), + columns: { + permission: true, + }, + }, + groupPermissions: { + where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")), + }, + }, + }); + + if (!board) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Board not found", + }); + } + + const { sections, items, layouts, ...otherBoardProperties } = board; + + return { + ...otherBoardProperties, + layouts: layouts + .map(({ boardId: _, ...layout }) => layout) + .sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint), + sections: sections.map(({ collapseStates, ...section }) => + parseSection({ + ...section, + xOffset: section.xOffset, + yOffset: section.yOffset, + options: superjson.parse(section.options ?? emptySuperJSON), + layouts: section.layouts.map((layout) => ({ + xOffset: layout.xOffset, + yOffset: layout.yOffset, + width: layout.width, + height: layout.height, + parentSectionId: layout.parentSectionId, + layoutId: layout.layoutId, + })), + collapsed: collapseStates.at(0)?.collapsed ?? false, + }), + ), + items: items.map(({ integrations: itemIntegrations, ...item }) => + parseItem({ + ...item, + layouts: item.layouts.map((layout) => ({ + xOffset: layout.xOffset, + yOffset: layout.yOffset, + width: layout.width, + height: layout.height, + layoutId: layout.layoutId, + sectionId: layout.sectionId, + })), + integrationIds: itemIntegrations.map((item) => item.integration.id), + advancedOptions: superjson.parse(item.advancedOptions), + options: superjson.parse>(item.options), + }), + ), + }; +}; + +const forKind = (kind: T) => + z.object({ + kind: z.literal(kind), + options: z.record(z.string(), z.unknown()), + }); + +const outputItemSchema = zodUnionFromArray(widgetKinds.map((kind) => forKind(kind))).and(sharedItemSchema); + +const parseItem = (item: unknown) => { + const result = outputItemSchema.safeParse(item); + + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; +}; + +const parseSection = (section: unknown) => { + const result = sectionSchema.safeParse(section); + + if (!result.success) { + throw new Error(result.error.message); + } + return result.data; +}; diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx index a099f64c7..2f996b3e9 100644 --- a/packages/boards/src/context.tsx +++ b/packages/boards/src/context.tsx @@ -7,6 +7,7 @@ import { usePathname } from "next/navigation"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { useEditMode } from "./edit-mode"; import { updateBoardName } from "./updater"; const BoardContext = createContext<{ @@ -83,10 +84,12 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"] export const useCurrentLayout = () => { const board = useRequiredBoard(); const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board)); + const [isEditMode] = useEditMode(); const onResize = useCallback(() => { + if (isEditMode && board.layoutMode === "auto") return; setCurrentLayout(getCurrentLayout(board)); - }, [board]); + }, [board, isEditMode]); useEffect(() => { if (typeof window === "undefined") return; @@ -97,6 +100,10 @@ export const useCurrentLayout = () => { }; }, [onResize]); + if (board.layoutMode === "auto" && isEditMode && board.baseLayoutId !== null) { + return board.baseLayoutId; + } + return currentLayout; };