From 672822838721b1cc9ae9f6845d045ffffac85301 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 8 Aug 2026 15:41:14 +0200 Subject: [PATCH] feat: protect responsive board layouts --- apps/docs/docs/management/api/index.mdx | 2 + apps/docs/docs/management/boards/index.mdx | 117 +- .../app/[locale]/boards/(content)/_client.tsx | 32 +- .../boards/(content)/_header-actions.tsx | 59 +- .../settings/_layout-preview.module.css | 39 + .../[name]/settings/_layout-preview.tsx | 126 + .../boards/[name]/settings/_layout.tsx | 317 ++- .../[locale]/boards/[name]/settings/page.tsx | 2 +- .../app/[locale]/boards/_layout-creator.tsx | 37 +- .../app/[locale]/boards/_layout-utils.spec.ts | 75 + .../src/app/[locale]/boards/_layout-utils.ts | 74 + .../src/app/[locale]/boards/_providers.tsx | 30 + .../board/items/actions/create-item.ts | 10 +- .../items/actions/test/create-item.spec.ts | 14 + .../items/actions/test/mocks/board-mock.ts | 3 +- .../items/actions/test/mocks/layout-mock.ts | 3 +- packages/api/src/router/board.ts | 570 ++-- .../router/board/test/grid-algorithm.spec.ts | 4 +- .../api/src/router/onboard/onboard-router.ts | 8 +- packages/api/src/router/test/board.spec.ts | 166 +- packages/boards/src/context.tsx | 22 +- packages/boards/src/edit-mode.tsx | 11 +- .../board => common/src}/grid-algorithm.ts | 99 +- packages/common/src/index.ts | 1 + .../migrations/mysql/0044_add_layout_role.sql | 1 + .../migrations/mysql/meta/0044_snapshot.json | 2379 +++++++++++++++++ .../db/migrations/mysql/meta/_journal.json | 7 + .../postgresql/0010_add_layout_role.sql | 1 + .../postgresql/meta/0010_snapshot.json | 2254 ++++++++++++++++ .../migrations/postgresql/meta/_journal.json | 7 + packages/db/migrations/seed.spec.ts | 97 + packages/db/migrations/seed.ts | 244 +- .../sqlite/0042_add_layout_role.sql | 1 + .../migrations/sqlite/meta/0042_snapshot.json | 2289 ++++++++++++++++ .../db/migrations/sqlite/meta/_journal.json | 7 + packages/db/queries/widget-placement.spec.ts | 57 + packages/db/queries/widget-placement.ts | 65 +- packages/db/schema/mysql.ts | 2 + packages/db/schema/postgresql.ts | 2 + packages/db/schema/sqlite.ts | 2 + packages/definitions/src/board.ts | 42 + .../import/collections/board-collection.ts | 7 +- .../old-import/src/prepare/prepare-items.ts | 4 +- packages/translation/src/lang/en.json | 34 + packages/validation/src/board.spec.ts | 34 + packages/validation/src/board.ts | 53 +- 46 files changed, 8933 insertions(+), 477 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.module.css create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts create mode 100644 apps/nextjs/src/app/[locale]/boards/_layout-utils.ts create mode 100644 apps/nextjs/src/app/[locale]/boards/_providers.tsx rename packages/{api/src/router/board => common/src}/grid-algorithm.ts (51%) create mode 100644 packages/db/migrations/mysql/0044_add_layout_role.sql create mode 100644 packages/db/migrations/mysql/meta/0044_snapshot.json create mode 100644 packages/db/migrations/postgresql/0010_add_layout_role.sql create mode 100644 packages/db/migrations/postgresql/meta/0010_snapshot.json create mode 100644 packages/db/migrations/seed.spec.ts create mode 100644 packages/db/migrations/sqlite/0042_add_layout_role.sql create mode 100644 packages/db/migrations/sqlite/meta/0042_snapshot.json create mode 100644 packages/db/queries/widget-placement.spec.ts create mode 100644 packages/validation/src/board.spec.ts diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 2c5bd42e2..d92550641 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -82,4 +82,6 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio - Set desktop and mobile home boards for the current user - Configure global board defaults such as public home boards 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. + Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index f1eb3c572..5076c3cba 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -9,11 +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 layoutMediumImage from './img/layout-medium.png' -import layoutSmallImage from './img/layout-small.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. @@ -24,24 +24,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) Names of boards must be unique because this will be used in the URL to load the desired board. ## Create a new board + When creating a board, the availability of the board name will be checked for you. ![](./img/create-board-modal.png) ### Name + The name must not include any special characters and must be at least one character long. Underscores and hyphen-minus are allowed. ### Column count + Homarr has a complex drag and drop system that can be used from the browser. The column count defines how many horizontal columns are available to drag and drop elements to. ### Public + This will enable public access for everyone - regardless of whether they are logged in or not. This can be really useful if you want to have public boards in your company or for family and fiends. Modifications for public boards must be allowed explicitly using user groups and their permissions. ## Board settings + You navigate to the board settings by clicking on these three dots: ![](./img/navigate-to-settings.png) @@ -51,7 +56,9 @@ Alternatively, you can also navigate from the board page itself using the cog ic ![](./img/navigate-to-settings-2.png) ### General + #### Page title + This is the title that will be visible at the top left in the navigation bar: ![](./img/page-title.png) @@ -59,6 +66,7 @@ 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: @@ -67,78 +75,104 @@ 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 -In this section you can configure the different responsive layouts. Depending on your screens width the layout will change. +In this section you can configure the different responsive layouts. Homarr selects the layout with the highest breakpoint that is not wider than the current screen. ![](./img/layout-settings.png) -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. +Every board has two protected layouts: + +- **Mobile** starts at `0px` and uses three columns on new boards. +- **Base** is the widest layout and starts at `768px` on new boards. + +Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. You can add removable custom layouts between their breakpoints. + +Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in Base or any custom layout. New custom layouts are generated from Base and scaled to their selected column count. + +Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept. #### Name -The name of the layout can be choosen freely. It is only used for the management and will not be visible on the board. +The name of the layout can be chosen freely. It is used for layout management and is not displayed on the board. #### Column count -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. +The number of columns in the layout. Changing it projects the saved positions and sizes into the new grid width. #### Breakpoint -Minimum width of the screen where the layout will be used. -Once the minmum of the next layout is reached the layout will change. +The minimum screen width where the layout is used. Mobile is fixed at `0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint. #### Preview - - - +Layout Settings shows a lightweight structural preview for every layout, including the saved spans, categories, dynamic sections, widget labels, and app icons. The canvas width represents the screen range where that layout is active. + +Choose **Edit layout** to open the real board editor at that representative width on desktop. Save returns to Layout Settings. Cancel discards the local changes. On phones and tablets, the normal board editor continues to use the layout selected by the device width. + + + + ### Background + #### Background image URL + Homarr can display a background image behind apps and widgets. This URL can be used to define what image should be shown. #### Background image attachment + This defines whether the background should move when the user scrolls. #### Background image size + This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page. #### Background image repeat + This defines whether the background should be repeated if it cannot cover the entire page. This option can be useful if you have patterns that can be repeated easily (e.g. patterns): ![](./img/background-repeat.png) ### Appearance + #### Primary color + Color that can be chosen freely to give accents to active elements. + #### Secondary color + Color that can be chosen freely to give accents. + #### Opacity + Opacity of the colors. + #### Icon color + Change the color of your bookmark / app icons. - + #### Item radius + Change the radius of your items / categories. - - + ### Custom CSS + This is the field where you can enter custom CSS. The styling will only be applied on the respective board page. @@ -147,14 +181,16 @@ 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 | @@ -173,18 +209,23 @@ 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: @@ -196,7 +237,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. ::: @@ -206,47 +247,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an ![](./img/duplicate-board-modal.png) ## Setting boards as your home board + The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL. All the following paths will lead to the home board: -- ``my-homarr.com`` -- ``my-homarr.com/boards`` + +- `my-homarr.com` +- `my-homarr.com/boards` Your home board will have a special small badge at the top right in the list of boards: ![](./img/home-board-flag.png) ### For yourself + To set the home board yourself, the easiest option is to set it directly in the list of boards: ![](./img/set-as-home-board-menu.png) Alternatively, you can also define it via your user profile: + 1. Click on your user profile at the top right -2. Click on ``Your preferences`` -3. Change the home board in the ``home board`` dropdown and click on ``Save``. +2. Click on `Your preferences` +3. Change the home board in the `home board` dropdown and click on `Save`. ### For other users -1. Navigate to the users: ``Management`` > ``Users`` > ``Manage`` +1. Navigate to the users: `Management` > `Users` > `Manage` 2. Click on the username of the user that you want to adjust -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-other-user.png) +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-other-user.png) ### For user groups -1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage`` -2. Go to tab ``Settings`` -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-user-group.png) +1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage` +2. Go to tab `Settings` +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-user-group.png) ### For whole server You can also set a global home board that will be used for all users that have not set a home board themselves. It will also be used for anonymous users. -1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards`` -2. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-global.png) +1. Navigate to the settings: `Management` > `Settings` > `Boards` +2. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-global.png) :::note For the global home board you can only select boards that are public. @@ -255,4 +300,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) \ No newline at end of file +It will be used for devices that are detected as mobile devices. (Using User-Agent header) diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 1b4308d38..dd8c6057b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,9 +1,10 @@ "use client"; -import { Box, Stack } from "@mantine/core"; +import { Box, Paper, Stack, Text } from "@mantine/core"; -import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { useCurrentLayout, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context"; +import { getRepresentativeLayoutWidth } from "../_layout-utils"; import { BoardCategorySection } from "~/components/board/sections/category-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardBackgroundVideo } from "~/components/layout/background"; @@ -11,12 +12,13 @@ import { BoardBackgroundVideo } from "~/components/layout/background"; export const ClientBoard = () => { const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); + const layoutOverrideId = useLayoutOverride(); const fullWidthSortedSections = board.sections .filter((section) => section.kind === "empty" || section.kind === "category") .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); - return ( + const content = ( @@ -30,4 +32,28 @@ export const ClientBoard = () => { ); + + if (!layoutOverrideId) return content; + + const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId); + if (!currentLayout) return content; + + const representativeWidth = getRepresentativeLayoutWidth(currentLayout, board.layouts); + return ( + + + {currentLayout.name} · {representativeWidth}px + + + {content} + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 6036a4905..5c5a3db0c 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -2,10 +2,19 @@ import { useCallback, useEffect, useState } from "react"; import dynamic from "next/dynamic"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; -import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react"; +import { + IconDeviceFloppy, + IconLayoutBoard, + IconPencil, + IconPencilOff, + IconPlus, + IconReplace, + IconSettings, + IconX, +} from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; @@ -64,7 +73,16 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode, { open, close }] = useEditMode(); const board = useRequiredBoard(); const utils = clientApi.useUtils(); + const router = useRouter(); + const searchParams = useSearchParams(); + const { openConfirmModal } = useConfirmModal(); + const commonT = useI18n(); const t = useScopedI18n("board.action.edit"); + const returnToLayoutSettings = + isEditMode && + searchParams.get("edit") === "true" && + searchParams.get("layout") !== null && + searchParams.get("returnTo") === "settings"; const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({ onSuccess() { showSuccessNotification({ @@ -74,6 +92,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { void utils.board.getBoardByName.invalidate({ name: board.name }); void revalidatePathActionAsync(`/boards/${board.name}`); close(); + if (returnToLayoutSettings) router.push(`/boards/${board.name}/settings?tab=layout`); }, onError() { showErrorNotification({ @@ -96,9 +115,45 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { open(); }, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]); + const cancelLayoutEdit = useCallback(() => { + openConfirmModal({ + title: commonT("board.setting.section.layout.edit.cancel.title"), + children: commonT("board.setting.section.layout.edit.cancel.message"), + confirmProps: { children: commonT("common.action.discard"), color: "red" }, + onConfirm() { + void (async () => { + await utils.board.getBoardByName.invalidate({ name: board.name }); + close(); + router.push(`/boards/${board.name}/settings?tab=layout`); + })(); + }, + }); + }, [board.name, close, commonT, openConfirmModal, router, utils.board.getBoardByName]); + useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); usePreventLeaveWithDirty(isEditMode); + if (returnToLayoutSettings) { + return ( + <> + saveBoard(board)} + loading={isPending} + aria-label={commonT("board.setting.section.layout.edit.save")} + > + + + + + + + ); + } + return ( ; + apps: RouterOutputs["app"]["byIds"]; +} + +export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => { + const t = useI18n(); + const elements = projectBoardLayout(board, sourceLayout, layout); + const representativeWidth = getRepresentativeLayoutWidth(layout, layouts); + const largestRepresentativeWidth = Math.max( + ...layouts.map((candidate) => getRepresentativeLayoutWidth(candidate, layouts)), + ); + const previewWidth = `${(representativeWidth / largestRepresentativeWidth) * 100}%`; + const appsById = new Map(apps.map((app) => [app.id, app])); + const rootSections = board.sections + .filter((section) => section.kind !== "dynamic") + .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); + + return ( + + + + {representativeWidth}px + + + {layout.columnCount} {t("board.setting.section.layout.preview.columns")} + + + + {elements.length === 0 ? ( + + {t("board.setting.section.layout.preview.empty")} + + ) : ( + + {rootSections.map((section) => { + const sectionElements = elements.filter((element) => element.sectionId === section.id); + if (sectionElements.length === 0) return null; + + return ( + + {section.kind === "category" && ( + + {section.name} + + )} + + {sectionElements.map((element) => { + const item = + element.type === "item" ? board.items.find((candidate) => candidate.id === element.id) : null; + const appId = + item?.kind === "app" && typeof item.options.appId === "string" ? item.options.appId : null; + const app = appId ? appsById.get(appId) : undefined; + const dynamicSection = + element.type === "section" + ? board.sections.find((candidate) => candidate.id === element.id) + : undefined; + const dynamicSectionLabel = + dynamicSection?.kind === "dynamic" && dynamicSection.options.title + ? dynamicSection.options.title + : t("board.setting.section.layout.preview.dynamicSection"); + const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : dynamicSectionLabel); + const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid; + + return ( + + + + {app ? ( + + ) : ( + + )} + + + {label} + + + + ); + })} + + + ); + })} + + )} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index 97a576a30..77158853a 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -1,111 +1,272 @@ "use client"; -import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core"; +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Badge, + Button, + Fieldset, + Grid, + Group, + Input, + NumberInput, + Slider, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { IconEdit, IconRefresh } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { createId } from "@homarr/common"; import { useZodForm } from "@homarr/form"; +import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; import { boardSaveLayoutsSchema } from "@homarr/validation/board"; import type { Board } from "../../_types"; +import { LayoutPreview } from "./_layout-preview"; interface Props { board: Board; } + export const LayoutSettingsContent = ({ board }: Props) => { const t = useI18n(); + const router = useRouter(); const utils = clientApi.useUtils(); - const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({ + const { openConfirmModal } = useConfirmModal(); + const [editingLayoutId, setEditingLayoutId] = useState(null); + const [resettingLayoutId, setResettingLayoutId] = useState(null); + const { mutateAsync: saveLayouts, isPending: isSaving } = clientApi.board.saveLayouts.useMutation({ onSettled() { void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getHomeBoard.invalidate(); }, }); + const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation(); const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), { - initialValues: { - layouts: board.layouts, - }, + initialValues: { layouts: board.layouts }, }); + const appIds = useMemo( + () => + Array.from( + new Set( + board.items.flatMap((item) => + item.kind === "app" && typeof item.options.appId === "string" ? [item.options.appId] : [], + ), + ), + ), + [board.items], + ); + const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 }); + + const persistLayoutsAsync = async () => { + const savedLayouts = await saveLayouts({ id: board.id, layouts: form.values.layouts }); + form.setValues({ layouts: savedLayouts }); + form.resetDirty({ layouts: savedLayouts }); + return savedLayouts; + }; + + const openLayoutEditorAsync = async (layout: (typeof form.values.layouts)[number]) => { + setEditingLayoutId(layout.id); + try { + const isNew = !board.layouts.some((candidate) => candidate.id === layout.id); + const savedLayouts = form.isDirty() || isNew ? await persistLayoutsAsync() : form.values.layouts; + const canonicalLayout = + savedLayouts.find((candidate) => candidate.id === layout.id) ?? + savedLayouts.find((candidate) => candidate.breakpoint === layout.breakpoint && candidate.role === layout.role); + if (!canonicalLayout) return; + + router.push(`/boards/${board.name}?layout=${encodeURIComponent(canonicalLayout.id)}&edit=true&returnTo=settings`); + } finally { + setEditingLayoutId(null); + } + }; + + const confirmReset = (layout: (typeof form.values.layouts)[number]) => { + openConfirmModal({ + title: t("board.setting.section.layout.reset.confirm.title", { layoutName: layout.name }), + children: t("board.setting.section.layout.reset.confirm.message"), + confirmProps: { children: t("board.setting.section.layout.reset.action"), color: "red" }, + onConfirm() { + setResettingLayoutId(layout.id); + void (async () => { + try { + const savedLayouts = form.isDirty() ? await persistLayoutsAsync() : form.values.layouts; + const canonicalLayout = + savedLayouts.find((candidate) => candidate.id === layout.id) ?? + savedLayouts.find( + (candidate) => candidate.breakpoint === layout.breakpoint && candidate.role === layout.role, + ); + if (!canonicalLayout) return; + + await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); + await utils.board.getBoardByName.invalidate({ name: board.name }); + router.refresh(); + } finally { + setResettingLayoutId(null); + } + })(); + }, + }); + }; + + const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); + const baseLayout = form.values.layouts.find((layout) => layout.role === "base"); + const sortedLayouts = form.values.layouts + .map((layout, index) => ({ layout, index })) + .toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint); return (
{ - saveLayouts({ - id: board.id, - ...values, - }); + onSubmit={form.onSubmit(async () => { + await persistLayoutsAsync(); })} > - - - + + + {t("board.setting.section.layout.responsive.title")} - + + + {sortedLayouts.map(({ layout, index }) => { + const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id); + const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base"); + const customBreakpoints = form.values.layouts + .filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id) + .map((candidate) => candidate.breakpoint); + const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768; + + return ( +
+ {layout.name || t(`board.setting.section.layout.role.${layout.role}`)} + + {t(`board.setting.section.layout.role.${layout.role}`)} + + + } + bg="transparent" > - {t("board.setting.section.layout.responsive.action.add")} - - - - {form.values.layouts.map((layout, index) => ( -
- - - + + + + + + ({ value, label: String(value) }))} + styles={{ + markLabel: { color: "light-dark(var(--mantine-color-black), var(--mantine-color-white))" }, + }} + {...form.getInputProps(`layouts.${index}.columnCount`)} + /> + + breakpoint + 1)) + : 1 + } + max={layout.role === "custom" ? baseBreakpoint - 1 : 32767} + /> + - - - - - - - - - + + {sourceLayout && ( + + )} - {form.values.layouts.length >= 2 && ( - - + + + + {layout.role !== "base" && persistedLayout && ( + + )} + {layout.role === "custom" && ( + + )} - )} + +
- ))} - + ); + })} - @@ -113,3 +274,19 @@ export const LayoutSettingsContent = ({ board }: Props) => { ); }; + +const getNextCustomBreakpoint = (layouts: Array<{ breakpoint: number }>) => { + const sortedBreakpoints = layouts + .map((layout) => layout.breakpoint) + .toSorted((breakpointA, breakpointB) => breakpointA - breakpointB); + let largestGap: { start: number; end: number } | null = null; + + for (let index = 0; index < sortedBreakpoints.length - 1; index++) { + const start = sortedBreakpoints[index]; + const end = sortedBreakpoints[index + 1]; + if (start === undefined || end === undefined || end - start <= 1) continue; + if (!largestGap || end - start > largestGap.end - largestGap.start) largestGap = { start, end }; + } + + return largestGap ? Math.floor((largestGap.start + largestGap.end) / 2) : null; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx index a728e8949..33c3d7555 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx @@ -82,7 +82,7 @@ export default async function BoardSettingsPage(props: Props) { } return ( - + {t("title", { boardName: capitalize(board.name) })} diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 24ebe3d03..140726004 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -5,8 +5,7 @@ import { TRPCError } from "@trpc/server"; 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 { constructBoardPermissions } from "@homarr/auth/shared"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { MainHeader } from "~/components/layout/header"; @@ -16,6 +15,7 @@ import { BoardTourGate } from "~/components/onboarding/board-tour-gate"; import { env } from "~/env"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import type { Board } from "./_types"; +import { BoardProviders } from "./_providers"; import type { Params } from "./(content)/_creator"; import { CustomCss } from "./(content)/_custom-css"; import { BoardReadyProvider } from "./(content)/_ready-context"; @@ -83,27 +83,26 @@ export const createBoardLayout = ({ throw error; } const initialBoard = initialBoardResult.board; + const { hasChangeAccess } = constructBoardPermissions(initialBoard, session); return ( - + - - - - - - } - actions={headerActions} - hasNavigation={false} - /> - {children} - - - - + + + + + } + actions={headerActions} + hasNavigation={false} + /> + {children} + + + - + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts b/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts new file mode 100644 index 000000000..c0add2c85 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "vitest"; + +import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock"; +import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock"; +import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock"; +import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock"; +import { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils"; + +const mobile = { id: "mobile", name: "Mobile", columnCount: 3, breakpoint: 0, role: "mobile" as const }; +const custom = { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" as const }; +const base = { id: "base", name: "Base", columnCount: 12, breakpoint: 768, role: "base" as const }; + +describe("layout representative widths", () => { + test("uses a phone width, activation midpoint, and desktop minimum", () => { + const layouts = [mobile, custom, base]; + expect(getRepresentativeLayoutWidth(mobile, layouts)).toBe(390); + expect(getRepresentativeLayoutWidth(custom, layouts)).toBe(624); + expect(getRepresentativeLayoutWidth(base, layouts)).toBe(1280); + }); +}); + +describe("projectBoardLayout", () => { + test("uses production ordering and sizing for items and dynamic sections", () => { + const rootSection = new EmptySectionMockBuilder({ id: "root" }).build(); + const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" }) + .addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 }) + .build(); + const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" }) + .addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 }) + .build(); + const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" }) + .addLayout({ layoutId: base.id, sectionId: dynamicSection.id, width: 4, height: 1, xOffset: 0, yOffset: 0 }) + .build(); + const board = new BoardMockBuilder() + .addLayout(mobile) + .addSections([rootSection, dynamicSection]) + .addItems([rootItem, nestedItem]) + .build(); + + const projected = projectBoardLayout(board, base, mobile); + + expect(projected.find((element) => element.id === rootItem.id)).toMatchObject({ + type: "item", + width: 3, + height: 2, + xOffset: 0, + yOffset: 0, + sectionId: rootSection.id, + }); + expect(projected.find((element) => element.id === dynamicSection.id)).toMatchObject({ + type: "section", + width: 3, + xOffset: 0, + yOffset: 2, + sectionId: rootSection.id, + }); + expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({ + type: "item", + width: 3, + sectionId: dynamicSection.id, + }); + }); + + test("ignores missing saved positions and supports empty boards", () => { + const rootSection = new EmptySectionMockBuilder({ id: "root" }).build(); + const itemWithoutBasePosition = new ItemMockBuilder({ id: "missing", kind: "clock" }).build(); + const board = new BoardMockBuilder() + .addLayout(mobile) + .addSection(rootSection) + .addItem(itemWithoutBasePosition) + .build(); + + expect(projectBoardLayout(board, base, mobile)).toEqual([]); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts b/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts new file mode 100644 index 000000000..24dc6bd34 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts @@ -0,0 +1,74 @@ +import { generateResponsiveGridFor } from "@homarr/common"; +import type { GridAlgorithmItem } from "@homarr/common"; + +import type { Board } from "./_types"; + +export type BoardLayout = Board["layouts"][number]; + +export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => { + if (layout.role === "mobile") return 390; + if (layout.role === "base") return Math.max(1280, layout.breakpoint); + + const nextLayout = allLayouts + .filter((candidate) => candidate.breakpoint > layout.breakpoint) + .toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint) + .at(0); + + return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint); +}; + +export const projectBoardLayout = ( + board: Board, + sourceLayout: Pick, + targetLayout: Pick, +) => { + const elements = getElementsForLayout(board, sourceLayout.id); + + return board.sections + .filter((section) => section.kind !== "dynamic") + .flatMap( + (section) => + generateResponsiveGridFor({ + items: elements, + previousWidth: sourceLayout.columnCount, + width: targetLayout.columnCount, + sectionId: section.id, + }).items, + ); +}; + +const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [ + ...board.items.flatMap((item) => { + const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId); + return layout + ? [ + { + id: item.id, + type: "item" as const, + height: layout.height, + width: layout.width, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + sectionId: layout.sectionId, + }, + ] + : []; + }), + ...board.sections.flatMap((section) => { + if (section.kind !== "dynamic") return []; + const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId); + return layout?.parentSectionId + ? [ + { + id: section.id, + type: "section" as const, + height: layout.height, + width: layout.width, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + sectionId: layout.parentSectionId, + }, + ] + : []; + }), +]; diff --git a/apps/nextjs/src/app/[locale]/boards/_providers.tsx b/apps/nextjs/src/app/[locale]/boards/_providers.tsx new file mode 100644 index 000000000..cdf917deb --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_providers.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { useSearchParams } from "next/navigation"; +import { useMediaQuery } from "@mantine/hooks"; + +import { BoardProvider } from "@homarr/boards/context"; +import { EditModeProvider } from "@homarr/boards/edit-mode"; + +import type { Board } from "./_types"; + +interface Props { + initialBoard: Board; + canModify: boolean; +} + +export const BoardProviders = ({ children, initialBoard, canModify }: PropsWithChildren) => { + const searchParams = useSearchParams(); + const isDesktop = useMediaQuery("(min-width: 64em)"); + const requestedLayoutId = searchParams.get("layout"); + const isRequestedLayoutValid = initialBoard.layouts.some((layout) => layout.id === requestedLayoutId); + const isLayoutEditRequest = canModify && searchParams.get("edit") === "true" && isRequestedLayoutValid; + const layoutOverrideId = isLayoutEditRequest && isDesktop ? requestedLayoutId : null; + + return ( + + {children} + + ); +}; diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index d4b2cb24d..2dfa66a9c 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -60,9 +60,13 @@ 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 sizeForLayout = { + ...itemSize, + width: Math.min(boardLayout?.columnCount ?? itemSize.width, itemSize.width), + }; const emptyPosition = boardLayout - ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize) + ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, sizeForLayout) : { xOffset: 0, yOffset: 0 }; if (!emptyPosition) { @@ -70,8 +74,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid } return { - width: itemSize.width, - height: itemSize.height, + width: sizeForLayout.width, + height: sizeForLayout.height, ...emptyPosition, sectionId: currentSection.id, layoutId, diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index e2a0ff93c..df7725a53 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -10,6 +10,20 @@ import { ItemMockBuilder } from "./mocks/item-mock"; import { LayoutMockBuilder } from "./mocks/layout-mock"; describe("item actions create-item", () => { + test("clips oversized widgets to each layout's column count", () => { + const board = new BoardMockBuilder() + .addLayout({ id: "mobile", name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }) + .addEmptySection({ id: "section" }) + .build(); + const baseLayout = board.layouts.find((layout) => layout.role === "base"); + + const result = createItemCallback({ kind: "mediaMissing" })(board); + + const createdItem = result.items.at(0); + expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3); + expect(createdItem?.layouts.find((layout) => layout.layoutId === baseLayout?.id)?.width).toBe(4); + }); + test("should add it to first section", () => { // Arrange const itemKind = "clock"; diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts index 9a687c2e4..f69d94c73 100644 --- a/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts +++ b/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts @@ -45,7 +45,8 @@ export class BoardMockBuilder { id: createId(), name: "Base", columnCount: 12, - breakpoint: 0, + breakpoint: 768, + role: "base", }, ], ...board, diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts index 8297062f6..5688a7db5 100644 --- a/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts +++ b/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts @@ -10,7 +10,8 @@ export class LayoutMockBuilder { id: createId(), name: "Base", columnCount: 12, - breakpoint: 0, + breakpoint: 768, + role: "base", ...layout, } satisfies Board["layouts"][0]; } diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 88a1c42be..e446a74bd 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -2,10 +2,10 @@ import { TRPCError } from "@trpc/server"; import superjson from "superjson"; import { z } from "zod/v4"; -import { createLogger } from "@homarr/core/infrastructure/logs"; import { constructBoardPermissions } from "@homarr/auth/shared"; -import { createId } from "@homarr/common"; +import { createId, generateResponsiveGridFor } from "@homarr/common"; import type { DeviceType } from "@homarr/common/server"; +import { createLogger } from "@homarr/core/infrastructure/logs"; import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db"; import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db"; import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection"; @@ -35,6 +35,7 @@ import { everyoneGroup, getPermissionsWithChildren, getPermissionsWithParents, + normalizeBoardLayoutRoles, widgetDefaultSizes, widgetKinds, } from "@homarr/definitions"; @@ -48,6 +49,7 @@ import { boardCreateSchema, boardDuplicateSchema, boardRenameSchema, + boardResetLayoutSchema, boardSaveLayoutsSchema, boardSavePartialSettingsSchema, boardSavePermissionsSchema, @@ -61,7 +63,6 @@ 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"; export const boardRouter = createTRPCRouter({ exists: permissionRequiredProcedure @@ -313,13 +314,24 @@ export const boardRouter = createTRPCRouter({ yOffset: 0, boardId, }); - createBoardCollection.layouts.push({ - id: createId(), - name: "Base", - columnCount: input.columnCount, - breakpoint: 0, - boardId, - }); + createBoardCollection.layouts.push( + { + id: createId(), + name: "Mobile", + columnCount: 3, + breakpoint: 0, + role: "mobile", + boardId, + }, + { + id: createId(), + name: "Base", + columnCount: input.columnCount, + breakpoint: 768, + role: "base", + boardId, + }, + ); await createBoardCollection.insertAllAsync(ctx.db); @@ -375,8 +387,49 @@ export const boardRouter = createTRPCRouter({ const newBoardId = createId(); - const layoutsMap = new Map(boardLayouts.map((layout) => [layout.id, createId()])); - const layoutsToInsert = boardLayouts.map((layout) => ({ + const generatedMobileLayoutId = createId(); + const generatedMobilePositions = + boardLayouts.length === 1 && boardLayouts[0] + ? getUpdatedBoardLayout(board, { + previous: { layoutId: boardLayouts[0].id, columnCount: boardLayouts[0].columnCount }, + current: { layoutId: generatedMobileLayoutId, columnCount: 3 }, + }) + : null; + const normalizedBoardLayouts = + boardLayouts.length === 0 + ? [ + { + id: generatedMobileLayoutId, + name: "Mobile", + columnCount: 3, + breakpoint: 0, + role: "mobile" as const, + boardId: board.id, + }, + { + id: createId(), + name: "Base", + columnCount: 10, + breakpoint: 768, + role: "base" as const, + boardId: board.id, + }, + ] + : boardLayouts.length === 1 && boardLayouts[0] + ? [ + { + id: generatedMobileLayoutId, + name: "Mobile", + columnCount: 3, + breakpoint: 0, + role: "mobile" as const, + boardId: board.id, + }, + { ...boardLayouts[0], breakpoint: 768, role: "base" as const }, + ] + : normalizeBoardLayoutRoles(boardLayouts); + const layoutsMap = new Map(normalizedBoardLayouts.map((layout) => [layout.id, createId()])); + const layoutsToInsert = normalizedBoardLayouts.map((layout) => ({ ...layout, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion id: layoutsMap.get(layout.id)!, @@ -393,9 +446,24 @@ export const boardRouter = createTRPCRouter({ }), ); - const sectionLayoutsToInsert: InferInsertModel[] = boardSections.flatMap((section) => - section.layouts.map( - (layoutSection): InferInsertModel => ({ + const sectionLayoutsToInsert: InferInsertModel[] = boardSections + .flatMap((section) => + section.layouts.map( + (layoutSection): InferInsertModel => ({ + ...layoutSection, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + layoutId: layoutsMap.get(layoutSection.layoutId)!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sectionId: sectionMap.get(layoutSection.sectionId)!, + parentSectionId: layoutSection.parentSectionId + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sectionMap.get(layoutSection.parentSectionId)! + : layoutSection.parentSectionId, + }), + ), + ) + .concat( + (generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({ ...layoutSection, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion layoutId: layoutsMap.get(layoutSection.layoutId)!, @@ -404,10 +472,9 @@ export const boardRouter = createTRPCRouter({ parentSectionId: layoutSection.parentSectionId ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion sectionMap.get(layoutSection.parentSectionId)! - : layoutSection.parentSectionId, - }), - ), - ); + : null, + })), + ); const sectionCollapseStatesToInsert: InferInsertModel[] = boardSections.flatMap( (section) => section.collapseStates.map( @@ -429,9 +496,22 @@ export const boardRouter = createTRPCRouter({ }), ); - const itemLayoutsToInsert: InferInsertModel[] = boardItems.flatMap((item) => - item.layouts.map( - (layoutSection): InferInsertModel => ({ + const itemLayoutsToInsert: InferInsertModel[] = boardItems + .flatMap((item) => + item.layouts.map( + (layoutSection): InferInsertModel => ({ + ...layoutSection, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sectionId: sectionMap.get(layoutSection.sectionId)!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + itemId: itemMap.get(layoutSection.itemId)!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + layoutId: layoutsMap.get(layoutSection.layoutId)!, + }), + ), + ) + .concat( + (generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({ ...layoutSection, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion sectionId: sectionMap.get(layoutSection.sectionId)!, @@ -439,9 +519,8 @@ export const boardRouter = createTRPCRouter({ itemId: itemMap.get(layoutSection.itemId)!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion layoutId: layoutsMap.get(layoutSection.layoutId)!, - }), - ), - ); + })), + ); // Creates a list with all integration ids the user has access to const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all"); @@ -682,130 +761,244 @@ export const boardRouter = createTRPCRouter({ return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null); }), - saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => { - await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); + 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 board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id); + const existingLayoutsById = new Map(board.layouts.map((layout) => [layout.id, layout])); + const addedLayouts = filterAddedItems(input.layouts, board.layouts); + const removedLayouts = filterRemovedItems(input.layouts, board.layouts); - const addedLayouts = filterAddedItems(input.layouts, board.layouts); - - const layoutsToInsert: InferInsertModel[] = []; - const itemSectionLayoutsToInsert: InferInsertModel[] = []; - const sectionLayoutsToInsert: InferInsertModel[] = []; - - for (const addedLayout of addedLayouts) { - const layoutId = createId(); - - layoutsToInsert.push({ - id: layoutId, - name: addedLayout.name, - columnCount: addedLayout.columnCount, - breakpoint: addedLayout.breakpoint, - boardId: board.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, - }, - current: { - layoutId, - columnCount: addedLayout.columnCount, - }, - }); - - itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts); - sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts); - } - - if (layoutsToInsert.length > 0) { - await ctx.db.insert(layouts).values(layoutsToInsert); - } - - if (itemSectionLayoutsToInsert.length > 0) { - await ctx.db.insert(itemLayouts).values(itemSectionLayoutsToInsert); - } - - if (sectionLayoutsToInsert.length > 0) { - await ctx.db.insert(sectionLayouts).values(sectionLayoutsToInsert); - } - - 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: dbLayout.id, - columnCount: dbLayout.columnCount, - }, - current: { - layoutId: dbLayout.id, - columnCount: updatedLayout.columnCount, - }, - }); - - 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), - ), - ); - } - - 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), - ), - ); - } + if (addedLayouts.some((layout) => layout.role !== "custom")) { + throw new TRPCError({ code: "BAD_REQUEST", message: "New layouts must use the custom role" }); + } + if (removedLayouts.some((layout) => layout.role !== "custom")) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Mobile and Base layouts cannot be removed" }); + } + if ( + input.layouts.some((layout) => { + const existingLayout = existingLayoutsById.get(layout.id); + return existingLayout && existingLayout.role !== layout.role; + }) + ) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Layout roles cannot be changed" }); } - await ctx.db - .update(layouts) - .set({ - name: updatedLayout.name, - columnCount: updatedLayout.columnCount, - breakpoint: updatedLayout.breakpoint, - }) - .where(eq(layouts.id, updatedLayout.id)); + const baseLayout = board.layouts.find((layout) => layout.role === "base"); + if (!baseLayout) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" }); + } + + const layoutsToInsert: InferInsertModel[] = []; + const itemSectionLayoutsToInsert: InferInsertModel[] = []; + const sectionLayoutsToInsert: InferInsertModel[] = []; + const savedLayoutIds = new Map(); + + for (const addedLayout of addedLayouts) { + const layoutId = createId(); + savedLayoutIds.set(addedLayout.id, layoutId); + layoutsToInsert.push({ + id: layoutId, + name: addedLayout.name, + columnCount: addedLayout.columnCount, + breakpoint: addedLayout.breakpoint, + role: "custom", + boardId: board.id, + }); + + const projectedLayout = getUpdatedBoardLayout(board, { + previous: { layoutId: baseLayout.id, columnCount: baseLayout.columnCount }, + current: { layoutId, columnCount: addedLayout.columnCount }, + }); + itemSectionLayoutsToInsert.push(...projectedLayout.itemSectionLayouts); + sectionLayoutsToInsert.push(...projectedLayout.sectionLayouts); + } + + const itemSectionLayoutsToUpdate: InferInsertModel[] = []; + const sectionLayoutsToUpdate: InferInsertModel[] = []; + const layoutsToUpdate = filterUpdatedItems(input.layouts, board.layouts); + + for (const updatedLayout of layoutsToUpdate) { + const dbLayout = existingLayoutsById.get(updatedLayout.id); + if (!dbLayout || dbLayout.columnCount === updatedLayout.columnCount) continue; + + const projectedLayout = getUpdatedBoardLayout(board, { + previous: { layoutId: dbLayout.id, columnCount: dbLayout.columnCount }, + current: { layoutId: dbLayout.id, columnCount: updatedLayout.columnCount }, + }); + itemSectionLayoutsToUpdate.push(...projectedLayout.itemSectionLayouts); + sectionLayoutsToUpdate.push(...projectedLayout.sectionLayouts); + } + + const removedLayoutIds = removedLayouts.map((layout) => layout.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 itemLayout of itemSectionLayoutsToUpdate) { + await transaction + .update(schema.itemLayouts) + .set({ + height: itemLayout.height, + width: itemLayout.width, + xOffset: itemLayout.xOffset, + yOffset: itemLayout.yOffset, + sectionId: itemLayout.sectionId, + }) + .where( + and( + eq(schema.itemLayouts.itemId, itemLayout.itemId), + eq(schema.itemLayouts.layoutId, itemLayout.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 itemLayout of itemSectionLayoutsToUpdate) { + transaction + .update(itemLayouts) + .set({ + height: itemLayout.height, + width: itemLayout.width, + xOffset: itemLayout.xOffset, + yOffset: itemLayout.yOffset, + sectionId: itemLayout.sectionId, + }) + .where(and(eq(itemLayouts.itemId, itemLayout.itemId), eq(itemLayouts.layoutId, itemLayout.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, + role: existingLayoutsById.get(layout.id)?.role ?? "custom", + })) + .toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint); + }), + resetLayout: protectedProcedure.input(boardResetLayoutSchema).mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); + + const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.boardId), ctx.session.user.id); + const targetLayout = board.layouts.find((layout) => layout.id === input.layoutId); + const baseLayout = board.layouts.find((layout) => layout.role === "base"); + + if (!targetLayout) throw new TRPCError({ code: "NOT_FOUND", message: "Layout not found" }); + if (!baseLayout) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" }); + } + if (targetLayout.role === "base") { + throw new TRPCError({ code: "BAD_REQUEST", message: "The Base layout cannot be reset" }); } - 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)); - } + const projectedLayout = getUpdatedBoardLayout(board, { + previous: { layoutId: baseLayout.id, columnCount: baseLayout.columnCount }, + current: { layoutId: targetLayout.id, columnCount: targetLayout.columnCount }, + }); + + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (transaction) => { + await transaction.delete(schema.itemLayouts).where(eq(schema.itemLayouts.layoutId, targetLayout.id)); + await transaction.delete(schema.sectionLayouts).where(eq(schema.sectionLayouts.layoutId, targetLayout.id)); + if (projectedLayout.itemSectionLayouts.length > 0) { + await transaction.insert(schema.itemLayouts).values(projectedLayout.itemSectionLayouts); + } + if (projectedLayout.sectionLayouts.length > 0) { + await transaction.insert(schema.sectionLayouts).values(projectedLayout.sectionLayouts); + } + }); + }, + handleSync(db) { + db.transaction((transaction) => { + transaction.delete(itemLayouts).where(eq(itemLayouts.layoutId, targetLayout.id)).run(); + transaction.delete(sectionLayouts).where(eq(sectionLayouts.layoutId, targetLayout.id)).run(); + if (projectedLayout.itemSectionLayouts.length > 0) { + transaction.insert(itemLayouts).values(projectedLayout.itemSectionLayouts).run(); + } + if (projectedLayout.sectionLayouts.length > 0) { + transaction.insert(sectionLayouts).values(projectedLayout.sectionLayouts).run(); + } + }); + }, + }); }), savePartialBoardSettings: protectedProcedure .meta({ @@ -1504,13 +1697,14 @@ export const boardRouter = createTRPCRouter({ } const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 }; + const sizeForLayout = { ...defaultSize, width: Math.min(layout.columnCount, defaultSize.width) }; const fitsAt = (x: number, y: number) => { - if (x + defaultSize.width > layout.columnCount) return false; - for (let dy = 0; dy < defaultSize.height; dy++) { + if (x + sizeForLayout.width > layout.columnCount) return false; + for (let dy = 0; dy < sizeForLayout.height; dy++) { const row = occupied[y + dy]; if (!row) continue; - for (let dx = 0; dx < defaultSize.width; dx++) { + for (let dx = 0; dx < sizeForLayout.width; dx++) { if (row[x + dx]) return false; } } @@ -1528,8 +1722,8 @@ export const boardRouter = createTRPCRouter({ layoutId: layout.id, xOffset: x, yOffset: y, - width: defaultSize.width, - height: defaultSize.height, + width: sizeForLayout.width, + height: sizeForLayout.height, }); placed = true; } @@ -1639,8 +1833,34 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId } }; +interface BoardForLayoutProjection { + items: Array<{ + id: string; + layouts: Array<{ + layoutId: string; + sectionId: string; + width: number; + height: number; + xOffset: number; + yOffset: number; + }>; + }>; + sections: Array<{ + id: string; + kind: string; + layouts?: Array<{ + layoutId: string; + parentSectionId: string | null; + width: number; + height: number; + xOffset: number; + yOffset: number; + }>; + }>; +} + const getUpdatedBoardLayout = ( - board: Awaited>, + board: BoardForLayoutProjection, options: { previous: { layoutId: string; @@ -1720,37 +1940,41 @@ const getUpdatedBoardLayout = ( }; }; -const getElementsForLayout = (board: Awaited>, layoutId: string) => { +const getElementsForLayout = (board: BoardForLayoutProjection, 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)!; + .flatMap((section) => { + const clonedLayout = section.layouts?.find((sectionLayout) => sectionLayout.layoutId === layoutId); + if (!clonedLayout?.parentSectionId) return []; - return { - id: section.id, - type: "section" as const, + 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.flatMap((item) => { + const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId); + if (!clonedLayout) return []; + + return [ + { + id: item.id, + type: "item" 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, - }; + sectionId: clonedLayout.sectionId, + }, + ]; }); return [...itemElements, ...sectionElements]; diff --git a/packages/api/src/router/board/test/grid-algorithm.spec.ts b/packages/api/src/router/board/test/grid-algorithm.spec.ts index 6d73a4386..56cb6c54d 100644 --- a/packages/api/src/router/board/test/grid-algorithm.spec.ts +++ b/packages/api/src/router/board/test/grid-algorithm.spec.ts @@ -1,8 +1,8 @@ import { createId } from "@paralleldrive/cuid2"; import { describe, expect, test } from "vitest"; -import type { GridAlgorithmItem } from "../grid-algorithm"; -import { generateResponsiveGridFor } from "../grid-algorithm"; +import { generateResponsiveGridFor } from "@homarr/common"; +import type { GridAlgorithmItem } from "@homarr/common"; const ROOT_SECTION_ID = "section"; diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index 4f43a9533..c195c9815 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -294,9 +294,7 @@ export const onboardRouter = createTRPCRouter({ } const section = board.sections.find((boardSection) => boardSection.kind === "empty"); - const layout = board.layouts[0]; - - if (section && layout) { + if (section && board.layouts.length > 0) { const existingItems = await db.query.items.findMany({ where: eq(items.boardId, board.id), }); @@ -310,12 +308,12 @@ export const onboardRouter = createTRPCRouter({ await placeAllWidgetsAsync( db, - { + board.layouts.map((layout) => ({ boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount, - }, + })), allIntegrations, allApps, ); diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 44cc97adc..013fc1182 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -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 @@ -1328,14 +1361,35 @@ const createExistingLayout = (id: string) => ({ id, name: "Base", columnCount: 10, + breakpoint: 768, + role: "base" as const, +}); +const createMobileLayout = (id: string) => ({ + id, + name: "Mobile", + columnCount: 3, breakpoint: 0, + role: "mobile" as const, }); const createNewLayout = (columnCount: number) => ({ id: createId(), name: "New layout", columnCount, - 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, + breakpoint: 0, + role: "mobile", + boardId, + }); + return mobileLayoutId; +}; describe("saveLayouts should save layout changes", () => { test("should add layout when not present in database", async () => { // Arrange @@ -1343,23 +1397,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 dynamic sections generated from grid-algorithm when new layout is added", async () => { // Arrange @@ -1367,6 +1424,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, @@ -1377,12 +1435,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, { @@ -1398,6 +1456,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"; @@ -1405,7 +1464,7 @@ describe("saveLayouts should save layout changes", () => { // Act await caller.saveLayouts({ id: boardId, - layouts: [updatedLayout], + layouts: [createMobileLayout(mobileLayoutId), updatedLayout], }); // Assert @@ -1424,6 +1483,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, @@ -1435,7 +1495,7 @@ describe("saveLayouts should save layout changes", () => { // Act await caller.saveLayouts({ id: boardId, - layouts: [updatedLayout], + layouts: [createMobileLayout(mobileLayoutId), updatedLayout], }); // Assert @@ -1451,16 +1511,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 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(); }); @@ -1475,12 +1545,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"); + }); }); const expectInputToBeFullBoardWithName = ( @@ -1521,7 +1652,8 @@ const createFullBoardAsync = async (db: Database, name: string) => { id: layoutId, name: "Base", columnCount: 10, - breakpoint: 0, + breakpoint: 768, + role: "base", boardId, }); diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx index a74503b97..d1028b72c 100644 --- a/packages/boards/src/context.tsx +++ b/packages/boards/src/context.tsx @@ -11,13 +11,16 @@ import { updateBoardName } from "./updater"; const BoardContext = createContext<{ board: RouterOutputs["board"]["getBoardByName"]; + layoutOverrideId: string | null; } | null>(null); export const BoardProvider = ({ children, initialBoard, + layoutOverrideId = null, }: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["getBoardByName"]; + layoutOverrideId?: string | null; }>) => { const { data } = clientApi.board.getBoardByName.useQuery({ name: initialBoard.name }, { initialData: initialBoard }); @@ -39,6 +42,7 @@ export const BoardProvider = ({ {children} @@ -62,7 +66,14 @@ export const useOptionalBoard = () => { return context?.board ?? null; }; -export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => { +export const useLayoutOverride = () => useContext(BoardContext)?.layoutOverrideId ?? null; + +export const getCurrentLayout = ( + board: RouterOutputs["board"]["getBoardByName"], + layoutOverrideId: string | null = null, +) => { + if (layoutOverrideId && board.layouts.some((layout) => layout.id === layoutOverrideId)) return layoutOverrideId; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (typeof window === "undefined") return board.layouts.at(0)!.id; @@ -75,11 +86,12 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"] export const useCurrentLayout = () => { const board = useRequiredBoard(); - const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board)); + const layoutOverrideId = useLayoutOverride(); + const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board, layoutOverrideId)); const onResize = useCallback(() => { - setCurrentLayout(getCurrentLayout(board)); - }, [board]); + setCurrentLayout(getCurrentLayout(board, layoutOverrideId)); + }, [board, layoutOverrideId]); useEffect(() => { if (typeof window === "undefined") return; @@ -90,6 +102,8 @@ export const useCurrentLayout = () => { }; }, [onResize]); + useEffect(() => onResize(), [onResize]); + return currentLayout; }; diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx index 9b813b032..58288ce2a 100644 --- a/packages/boards/src/edit-mode.tsx +++ b/packages/boards/src/edit-mode.tsx @@ -1,13 +1,18 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useEffect } from "react"; import { useDisclosure } from "@mantine/hooks"; const EditModeContext = createContext | null>(null); -export const EditModeProvider = ({ children }: PropsWithChildren) => { - const editModeDisclosure = useDisclosure(false); +export const EditModeProvider = ({ children, initialOpen = false }: PropsWithChildren<{ initialOpen?: boolean }>) => { + const editModeDisclosure = useDisclosure(initialOpen); + const [, { open }] = editModeDisclosure; + + useEffect(() => { + if (initialOpen) open(); + }, [initialOpen, open]); return {children}; }; diff --git a/packages/api/src/router/board/grid-algorithm.ts b/packages/common/src/grid-algorithm.ts similarity index 51% rename from packages/api/src/router/board/grid-algorithm.ts rename to packages/common/src/grid-algorithm.ts index cccc61056..d25e8a685 100644 --- a/packages/api/src/router/board/grid-algorithm.ts +++ b/packages/common/src/grid-algorithm.ts @@ -28,24 +28,16 @@ export const generateResponsiveGridFor = ({ }: GridAlgorithmInput): GridAlgorithmOutput => { const itemsOfCurrentSection = items .filter((item) => item.sectionId === sectionId) - .sort((itemA, itemB) => + .toSorted((itemA, itemB) => itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset, ); const normalizedItems = normalizeItems(itemsOfCurrentSection, width); - if (itemsOfCurrentSection.length === 0) { - return { - height: 0, - items: [], - }; - } + if (itemsOfCurrentSection.length === 0) return { height: 0, items: [] }; const newItems: GridAlgorithmItem[] = []; - - // Fix height of dynamic sections const dynamicSectionHeightMap = new Map(); - const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section"); - for (const dynamicSection of dynamicSectionsOfCurrentSection) { + for (const dynamicSection of normalizedItems.filter((item) => item.type === "section")) { const result = generateResponsiveGridFor({ items, previousWidth: dynamicSection.previousWidth, @@ -56,7 +48,6 @@ export const generateResponsiveGridFor = ({ dynamicSectionHeightMap.set(dynamicSection.id, result.height); } - // Return same positions for items in the current section if (width >= previousWidth) { return { height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)), @@ -65,7 +56,6 @@ export const generateResponsiveGridFor = ({ } const occupied2d: boolean[][] = []; - for (const item of normalizedItems) { const itemWithHeight = { ...item, @@ -75,34 +65,15 @@ export const generateResponsiveGridFor = ({ if (!position) throw new Error("No free spot available"); addItemToOccupied(occupied2d, itemWithHeight, position, width); - newItems.push({ - ...itemWithHeight, - xOffset: position.x, - yOffset: position.y, - }); + newItems.push({ ...itemWithHeight, xOffset: position.x, yOffset: position.y }); } - return { - height: occupied2d.length, - items: newItems, - }; + return { height: occupied2d.length, items: newItems }; }; -/** - * Reduces the width of the items to fit the new column count. - * @param items items to normalize - * @param columnCount new column count - */ -const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => { - return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) })); -}; +const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => + items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) })); -/** - * Adds the item to the occupied spots. - * @param occupied2d array of occupied spots - * @param item item to place - * @param position position to place the item - */ const addItemToOccupied = ( occupied2d: boolean[][], item: GridAlgorithmItem, @@ -112,75 +83,31 @@ const addItemToOccupied = ( for (let yOffset = 0; yOffset < item.height; yOffset++) { let row = occupied2d[position.y + yOffset]; if (!row) { - addRow(occupied2d, columnCount); - // After adding it, it must exist - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - row = occupied2d[position.y + yOffset]!; - } - - for (let xOffset = 0; xOffset < item.width; xOffset++) { - row[position.x + xOffset] = true; + row = Array.from({ length: columnCount }, () => false); + occupied2d.push(row); } + for (let xOffset = 0; xOffset < item.width; xOffset++) row[position.x + xOffset] = true; } }; -/** - * Adds a new row to the grid. - * @param occupied2d array of occupied spots - * @param columnCount column count of section - */ -const addRow = (occupied2d: boolean[][], columnCount: number) => { - occupied2d.push(new Array(columnCount).fill(false)); -}; - -/** - * Searches for the next free spot in the grid. - * @param occupied2d array of occupied spots - * @param item item to place - * @param columnCount column count of section - * @returns the position of the next free spot or null if no spot is available - */ const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => { for (let offsetY = 0; offsetY < 99999; offsetY++) { for (let offsetX = 0; offsetX < columnCount; offsetX++) { - if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) { + if (offsetX + item.width <= columnCount && isFree(occupied2d, item, { x: offsetX, y: offsetY })) { return { x: offsetX, y: offsetY }; } } } - return null; }; -/** - * Check if the item fits into the grid horizontally. - * @param columnCount available width - * @param item item to place - * @param offsetX current x position - * @returns true if the item fits horizontally - */ -const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => { - return offsetX + item.width <= columnCount; -}; - -/** - * Check if the spot is free. - * @param occupied2d array of occupied spots - * @param item item to place - * @param position position to check - * @returns true if the spot is free - */ const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => { for (let yOffset = 0; yOffset < item.height; yOffset++) { const row = occupied2d[position.y + yOffset]; - if (!row) return true; // Empty row is free - + if (!row) return true; for (let xOffset = 0; xOffset < item.width; xOffset++) { - if (row[position.x + xOffset]) { - return false; - } + if (row[position.x + xOffset]) return false; } } - return true; }; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 5f82ea6a3..f67a771ad 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -11,4 +11,5 @@ export * from "./number"; export * from "./error"; export * from "./theme"; export * from "./function"; +export * from "./grid-algorithm"; export * from "./id"; diff --git a/packages/db/migrations/mysql/0044_add_layout_role.sql b/packages/db/migrations/mysql/0044_add_layout_role.sql new file mode 100644 index 000000000..c2a0a7a87 --- /dev/null +++ b/packages/db/migrations/mysql/0044_add_layout_role.sql @@ -0,0 +1 @@ +ALTER TABLE `layout` ADD `role` varchar(16) DEFAULT 'custom' NOT NULL; diff --git a/packages/db/migrations/mysql/meta/0044_snapshot.json b/packages/db/migrations/mysql/meta/0044_snapshot.json new file mode 100644 index 000000000..afa34befa --- /dev/null +++ b/packages/db/migrations/mysql/meta/0044_snapshot.json @@ -0,0 +1,2379 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "27318712-ab4a-4cc0-8087-c44f1d159025", + "prevId": "073d408e-ff80-4a57-8007-f1f32da1c71c", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "apiKey_id": { + "name": "apiKey_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_id": { + "name": "app_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": ["board_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": ["board_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('fixed')" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('no-repeat')" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('cover')" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fa5252')" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('#fd7e14')" + }, + "opacity": { + "name": "opacity", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('lg')" + }, + "disable_status": { + "name": "disable_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "board_id": { + "name": "board_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"] + } + }, + "checkConstraint": {} + }, + "cron_job_configuration": { + "name": "cron_job_configuration", + "columns": { + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "cron_job_configuration_name": { + "name": "cron_job_configuration_name", + "columns": ["name"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "custom_widget_definition": { + "name": "custom_widget_definition", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "header_name": { + "name": "header_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'GET'" + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_type": { + "name": "display_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'singleValue'" + }, + "display_config": { + "name": "display_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_definition_creator_id_user_id_fk": { + "name": "custom_widget_definition_creator_id_user_id_fk", + "tableFrom": "custom_widget_definition", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_definition_id": { + "name": "custom_widget_definition_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "custom_widget_secret": { + "name": "custom_widget_secret", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "definition_id": { + "name": "definition_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "cw_secret_definition_id_cw_definition_id_fk": { + "name": "cw_secret_definition_id_cw_definition_id_fk", + "tableFrom": "custom_widget_secret", + "tableTo": "custom_widget_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_secret_definition_id_kind_pk": { + "name": "custom_widget_secret_definition_id_kind_pk", + "columns": ["definition_id", "kind"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "name": "groupMember_group_id_user_id_pk", + "columns": ["group_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_id": { + "name": "group_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "group_name_unique": { + "name": "group_name_unique", + "columns": ["name"] + } + }, + "checkConstraint": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "iconRepository_id": { + "name": "iconRepository_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "icon_id": { + "name": "icon_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_group_permission__pk": { + "name": "integration_group_permission__pk", + "columns": ["integration_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": ["item_id", "integration_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": ["integration_id", "kind"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": ["integration_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_id": { + "name": "integration_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "invite_id": { + "name": "invite_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "name": "item_layout_item_id_section_id_layout_id_pk", + "columns": ["item_id", "section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_id": { + "name": "item_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breakpoint": { + "name": "breakpoint", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'custom'" + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "layout_id": { + "name": "layout_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "LONGBLOB", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_id": { + "name": "media_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onboarding_id": { + "name": "onboarding_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "search_engine_id": { + "name": "search_engine_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": ["short"] + } + }, + "checkConstraint": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "name": "section_collapse_state_user_id_section_id_pk", + "columns": ["user_id", "section_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "name": "section_layout_section_id_layout_id_pk", + "columns": ["section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_id": { + "name": "section_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"json\": {}}')" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "serverSetting_setting_key": { + "name": "serverSetting_setting_key", + "columns": ["setting_key"] + } + }, + "uniqueConstraints": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": ["setting_key"] + } + }, + "checkConstraint": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_session_token": { + "name": "session_session_token", + "columns": ["session_token"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_right_click_on_widgets": { + "name": "enable_right_click_on_widgets", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "name": "trusted_certificate_hostname_hostname_thumbprint_pk", + "columns": ["hostname", "thumbprint"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "widget_secret": { + "name": "widget_secret", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "widget_secret_item_id_item_id_fk": { + "name": "widget_secret_item_id_item_id_fk", + "tableFrom": "widget_secret", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "widget_secret_item_id_kind_pk": { + "columns": ["item_id", "kind"], + "name": "widget_secret_item_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index d1158b8bc..a47f2e9b5 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -309,6 +309,13 @@ "when": 1783852800000, "tag": "0043_add_right_click_preference", "breakpoints": true + }, + { + "idx": 44, + "version": "5", + "when": 1786190530046, + "tag": "0044_add_layout_role", + "breakpoints": true } ] } diff --git a/packages/db/migrations/postgresql/0010_add_layout_role.sql b/packages/db/migrations/postgresql/0010_add_layout_role.sql new file mode 100644 index 000000000..62f73fe1a --- /dev/null +++ b/packages/db/migrations/postgresql/0010_add_layout_role.sql @@ -0,0 +1 @@ +ALTER TABLE "layout" ADD COLUMN "role" varchar(16) DEFAULT 'custom' NOT NULL; diff --git a/packages/db/migrations/postgresql/meta/0010_snapshot.json b/packages/db/migrations/postgresql/meta/0010_snapshot.json new file mode 100644 index 000000000..9d8646c05 --- /dev/null +++ b/packages/db/migrations/postgresql/meta/0010_snapshot.json @@ -0,0 +1,2254 @@ +{ + "id": "d47caa0d-b494-4dbf-b68b-e308405c48ca", + "prevId": "97bda61f-0a71-4e41-8e60-874aafe17115", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "name": "account_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apiKey": { + "name": "apiKey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boardGroupPermission": { + "name": "boardGroupPermission", + "schema": "", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "name": "boardGroupPermission_board_id_group_id_permission_pk", + "columns": ["board_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boardUserPermission": { + "name": "boardUserPermission", + "schema": "", + "columns": { + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "name": "boardUserPermission_board_id_user_id_permission_pk", + "columns": ["board_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board": { + "name": "board", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lg'" + }, + "disable_status": { + "name": "disable_status", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "board_name_unique": { + "name": "board_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cron_job_configuration": { + "name": "cron_job_configuration", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_widget_definition": { + "name": "custom_widget_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "header_name": { + "name": "header_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'GET'" + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_type": { + "name": "display_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'singleValue'" + }, + "display_config": { + "name": "display_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_definition_creator_id_user_id_fk": { + "name": "custom_widget_definition_creator_id_user_id_fk", + "tableFrom": "custom_widget_definition", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_widget_secret": { + "name": "custom_widget_secret", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_secret_definition_id_custom_widget_definition_id_fk": { + "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk", + "tableFrom": "custom_widget_secret", + "tableTo": "custom_widget_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_secret_definition_id_kind_pk": { + "name": "custom_widget_secret_definition_id_kind_pk", + "columns": ["definition_id", "kind"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groupMember": { + "name": "groupMember", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "name": "groupMember_group_id_user_id_pk", + "columns": ["group_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groupPermission": { + "name": "groupPermission", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group": { + "name": "group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_name_unique": { + "name": "group_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.iconRepository": { + "name": "iconRepository", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.icon": { + "name": "icon", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "schema": "", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_group_permission__pk": { + "name": "integration_group_permission__pk", + "columns": ["integration_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_item": { + "name": "integration_item", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "name": "integration_item_item_id_integration_id_pk", + "columns": ["item_id", "integration_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationSecret": { + "name": "integrationSecret", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "name": "integrationSecret_integration_id_kind_pk", + "columns": ["integration_id", "kind"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrationUserPermission": { + "name": "integrationUserPermission", + "schema": "", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": ["integration_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "expiration_date": { + "name": "expiration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invite_token_unique": { + "name": "invite_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_layout": { + "name": "item_layout", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "name": "item_layout_item_id_section_id_layout_id_pk", + "columns": ["item_id", "section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item": { + "name": "item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.layout": { + "name": "layout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "column_count": { + "name": "column_count", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "breakpoint": { + "name": "breakpoint", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding": { + "name": "onboarding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "step": { + "name": "step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "previous_step": { + "name": "previous_step", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.search_engine": { + "name": "search_engine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "short": { + "name": "short", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "nullsNotDistinct": false, + "columns": ["short"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section_collapse_state": { + "name": "section_collapse_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "collapsed": { + "name": "collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "name": "section_collapse_state_user_id_section_id_pk", + "columns": ["user_id", "section_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section_layout": { + "name": "section_layout", + "schema": "", + "columns": { + "section_id": { + "name": "section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "layout_id": { + "name": "layout_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "name": "section_layout_section_id_layout_id_pk", + "columns": ["section_id", "layout_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.section": { + "name": "section", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serverSetting": { + "name": "serverSetting", + "schema": "", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "nullsNotDistinct": false, + "columns": ["setting_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "varchar(512)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "schema": "", + "columns": { + "hostname": { + "name": "hostname", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "thumbprint": { + "name": "thumbprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "name": "trusted_certificate_hostname_hostname_thumbprint_pk", + "columns": ["hostname", "thumbprint"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_right_click_on_widgets": { + "name": "enable_right_click_on_widgets", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.widget_secret": { + "name": "widget_secret", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "widget_secret_item_id_item_id_fk": { + "name": "widget_secret_item_id_item_id_fk", + "tableFrom": "widget_secret", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "widget_secret_item_id_kind_pk": { + "name": "widget_secret_item_id_kind_pk", + "columns": ["item_id", "kind"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/postgresql/meta/_journal.json b/packages/db/migrations/postgresql/meta/_journal.json index d143358e9..f007cf753 100644 --- a/packages/db/migrations/postgresql/meta/_journal.json +++ b/packages/db/migrations/postgresql/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1783786677702, "tag": "0009_add_right_click_preference", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1786190409743, + "tag": "0010_add_layout_role", + "breakpoints": true } ] } diff --git a/packages/db/migrations/seed.spec.ts b/packages/db/migrations/seed.spec.ts new file mode 100644 index 000000000..375849e29 --- /dev/null +++ b/packages/db/migrations/seed.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "vitest"; + +import { createId } from "@homarr/common"; +import { eq } from "@homarr/db"; +import { boards, itemLayouts, items, layouts, sections } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; +import { emptySuperJSON } from "@homarr/definitions"; + +import { seedProtectedBoardLayoutsAsync } from "./seed"; + +describe("protected board layout migration", () => { + test("turns a single saved layout into Base and generates Mobile", async () => { + const db = createDb(); + const boardId = createId(); + const baseLayoutId = createId(); + const sectionId = createId(); + const itemId = createId(); + await db.insert(boards).values({ id: boardId, name: "single" }); + await db.insert(layouts).values({ + id: baseLayoutId, + boardId, + name: "Wide", + columnCount: 12, + breakpoint: 0, + }); + await db.insert(sections).values({ id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 }); + await db.insert(items).values({ id: itemId, boardId, kind: "clock", options: emptySuperJSON }); + await db.insert(itemLayouts).values({ + itemId, + sectionId, + layoutId: baseLayoutId, + width: 5, + height: 2, + xOffset: 6, + yOffset: 1, + }); + + await seedProtectedBoardLayoutsAsync(db); + await seedProtectedBoardLayoutsAsync(db); + + const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) }); + expect(savedLayouts).toHaveLength(2); + expect(savedLayouts.find((layout) => layout.role === "base")).toMatchObject({ + id: baseLayoutId, + name: "Wide", + columnCount: 12, + breakpoint: 768, + }); + const mobileLayout = savedLayouts.find((layout) => layout.role === "mobile"); + expect(mobileLayout).toMatchObject({ name: "Mobile", columnCount: 3, breakpoint: 0 }); + const mobileItemPosition = await db.query.itemLayouts.findFirst({ + where: eq(itemLayouts.layoutId, mobileLayout?.id ?? ""), + }); + expect(mobileItemPosition).toMatchObject({ width: 3, height: 2, xOffset: 0, yOffset: 0 }); + }); + + test("assigns roles and resolves duplicate breakpoints without rebuilding existing layouts", async () => { + const db = createDb(); + const boardId = createId(); + await db.insert(boards).values({ id: boardId, name: "responsive" }); + await db.insert(layouts).values([ + { id: "layout-mobile", boardId, name: "Phone", columnCount: 3, breakpoint: 0 }, + { id: "layout-tablet-a", boardId, name: "Tablet A", columnCount: 6, breakpoint: 480 }, + { id: "layout-tablet-b", boardId, name: "Tablet B", columnCount: 8, breakpoint: 480 }, + { id: "layout-wide", boardId, name: "Wide", columnCount: 12, breakpoint: 1200 }, + ]); + + await seedProtectedBoardLayoutsAsync(db); + await seedProtectedBoardLayoutsAsync(db); + + const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) }); + expect(savedLayouts.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint)).toMatchObject([ + { id: "layout-mobile", name: "Phone", columnCount: 3, breakpoint: 0, role: "mobile" }, + { id: "layout-tablet-a", name: "Tablet A", columnCount: 6, breakpoint: 480, role: "custom" }, + { id: "layout-tablet-b", name: "Tablet B", columnCount: 8, breakpoint: 481, role: "custom" }, + { id: "layout-wide", name: "Wide", columnCount: 12, breakpoint: 1200, role: "base" }, + ]); + }); + + test("creates both protected layouts for a board with no layouts", async () => { + const db = createDb(); + const boardId = createId(); + await db.insert(boards).values({ id: boardId, name: "empty" }); + + await seedProtectedBoardLayoutsAsync(db); + await seedProtectedBoardLayoutsAsync(db); + + const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) }); + expect(savedLayouts).toHaveLength(2); + expect(savedLayouts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }), + expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }), + ]), + ); + }); +}); diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 6ec906cec..9cc5aeed0 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -1,6 +1,6 @@ import SuperJSON from "superjson"; -import { createId, objectKeys } from "@homarr/common"; +import { createId, generateResponsiveGridFor, objectKeys } from "@homarr/common"; import { customWidgetImportSchema } from "@homarr/validation/custom-widget"; import { createDocumentationLink, @@ -11,11 +11,12 @@ import { getIntegrationName, integrationDefs, integrationKinds, + normalizeBoardLayoutRoles, } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; -import type { Database } from ".."; +import type { Database, InferInsertModel } from ".."; import { eq, inArray } from ".."; import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries"; import { @@ -38,6 +39,7 @@ import { onboarding, searchEngines, sections, + sectionLayouts, users, customWidgetDefinitions, } from "../schema"; @@ -155,6 +157,214 @@ export const seedDataAsync = async (db: Database) => { if (isTruthyEnv(process.env.DEMO_MODE)) { await seedDemoUserAsync(db); } + + await seedProtectedBoardLayoutsAsync(db); +}; + +export const seedProtectedBoardLayoutsAsync = async (db: Database) => { + const dbBoards = await db.query.boards.findMany({ + with: { + layouts: true, + items: { with: { layouts: true } }, + sections: { with: { layouts: true } }, + }, + }); + + for (const board of dbBoards) { + if (board.layouts.length === 0) { + await db.insert(layouts).values([ + { + id: createId(), + name: "Mobile", + columnCount: 3, + breakpoint: 0, + role: "mobile", + boardId: board.id, + }, + { + id: createId(), + name: "Base", + columnCount: 10, + breakpoint: 768, + role: "base", + boardId: board.id, + }, + ]); + continue; + } + + if (board.layouts.length === 1) { + const [baseLayout] = board.layouts; + if (!baseLayout) continue; + + const mobileLayout = { + id: createId(), + name: "Mobile", + columnCount: 3, + breakpoint: 0, + role: "mobile" as const, + boardId: board.id, + }; + + await db.update(layouts).set({ role: "base", breakpoint: 768 }).where(eq(layouts.id, baseLayout.id)); + await db.insert(layouts).values(mobileLayout); + await insertMissingProjectedPositionsAsync( + db, + board, + { ...baseLayout, role: "base", breakpoint: 768 }, + mobileLayout, + ); + continue; + } + + const normalizedLayouts = normalizeBoardLayoutRoles(board.layouts); + const alreadyNormalized = normalizedLayouts.every((layout) => { + const previousLayout = board.layouts.find((candidate) => candidate.id === layout.id); + return layout.breakpoint === previousLayout?.breakpoint && previousLayout.role === layout.role; + }); + + if (!alreadyNormalized) { + for (const layout of normalizedLayouts) { + await db + .update(layouts) + .set({ breakpoint: layout.breakpoint, role: layout.role }) + .where(eq(layouts.id, layout.id)); + } + } + + const mobileLayout = normalizedLayouts[0]; + const baseLayout = normalizedLayouts.at(-1); + if (mobileLayout && baseLayout) { + await insertMissingProjectedPositionsAsync( + db, + board, + { ...baseLayout, role: "base" }, + { ...mobileLayout, role: "mobile" }, + ); + } + } +}; + +interface BoardWithLayoutPositions { + items: Array<{ + id: string; + layouts: Array<{ + layoutId: string; + sectionId: string; + width: number; + height: number; + xOffset: number; + yOffset: number; + }>; + }>; + sections: Array<{ + id: string; + kind: string; + layouts: Array<{ + layoutId: string; + parentSectionId: string | null; + width: number; + height: number; + xOffset: number; + yOffset: number; + }>; + }>; +} + +const insertMissingProjectedPositionsAsync = async ( + db: Database, + board: BoardWithLayoutPositions, + sourceLayout: InferInsertModel, + targetLayout: InferInsertModel, +) => { + const elements = [ + ...board.items.flatMap((item) => { + const layout = item.layouts.find((itemLayout) => itemLayout.layoutId === sourceLayout.id); + return layout + ? [ + { + id: item.id, + type: "item" as const, + width: layout.width, + height: layout.height, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + sectionId: layout.sectionId, + }, + ] + : []; + }), + ...board.sections.flatMap((section) => { + if (section.kind !== "dynamic") return []; + const layout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === sourceLayout.id); + return layout?.parentSectionId + ? [ + { + id: section.id, + type: "section" as const, + width: layout.width, + height: layout.height, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + sectionId: layout.parentSectionId, + }, + ] + : []; + }), + ]; + + const projectedElements = board.sections + .filter((section) => section.kind !== "dynamic") + .flatMap( + (section) => + generateResponsiveGridFor({ + items: elements, + previousWidth: sourceLayout.columnCount, + width: targetLayout.columnCount, + sectionId: section.id, + }).items, + ); + + const existingItemIds = new Set( + board.items + .filter((item) => item.layouts.some((layout) => layout.layoutId === targetLayout.id)) + .map((item) => item.id), + ); + const itemPositions = projectedElements + .filter((element) => element.type === "item" && !existingItemIds.has(element.id)) + .map( + (element): InferInsertModel => ({ + itemId: element.id, + layoutId: targetLayout.id, + sectionId: element.sectionId, + width: element.width, + height: element.height, + xOffset: element.xOffset, + yOffset: element.yOffset, + }), + ); + + const existingSectionIds = new Set( + board.sections + .filter((section) => section.layouts.some((layout) => layout.layoutId === targetLayout.id)) + .map((section) => section.id), + ); + const sectionPositions = projectedElements + .filter((element) => element.type === "section" && !existingSectionIds.has(element.id)) + .map( + (element): InferInsertModel => ({ + sectionId: element.id, + layoutId: targetLayout.id, + parentSectionId: element.sectionId, + width: element.width, + height: element.height, + xOffset: element.xOffset, + yOffset: element.yOffset, + }), + ); + + if (itemPositions.length > 0) await db.insert(itemLayouts).values(itemPositions); + if (sectionPositions.length > 0) await db.insert(sectionLayouts).values(sectionPositions); }; const seedEveryoneGroupAsync = async (db: Database) => { @@ -361,13 +571,24 @@ const seedDefaultBoardAsync = async (db: Database) => { yOffset: 0, boardId, }); - await db.insert(layouts).values({ - id: createId(), - name: "Base", - columnCount: 10, - breakpoint: 0, - boardId, - }); + await db.insert(layouts).values([ + { + id: createId(), + name: "Mobile", + columnCount: 3, + breakpoint: 0, + role: "mobile", + boardId, + }, + { + id: createId(), + name: "Base", + columnCount: 10, + breakpoint: 768, + role: "base", + boardId, + }, + ]); const everyoneGroupRow = await db.query.groups.findFirst({ where: eq(groups.name, everyoneGroup), @@ -598,7 +819,8 @@ const seedDemoUserAsync = async (db: Database) => { id: layoutId, name: "Base", columnCount: 12, - breakpoint: 0, + breakpoint: 768, + role: "base", boardId, }); @@ -695,7 +917,7 @@ const seedBoardWidgetsAsync = async (db: Database) => { if (!board) return; const section = board.sections.find((sec) => sec.kind === "empty"); - const layout = board.layouts[0]; + const layout = board.layouts.find((candidate) => candidate.role === "base") ?? board.layouts[0]; if (!section || !layout) return; const allIntegrations = await db.query.integrations.findMany(); diff --git a/packages/db/migrations/sqlite/0042_add_layout_role.sql b/packages/db/migrations/sqlite/0042_add_layout_role.sql new file mode 100644 index 000000000..d0a800e0d --- /dev/null +++ b/packages/db/migrations/sqlite/0042_add_layout_role.sql @@ -0,0 +1 @@ +ALTER TABLE `layout` ADD `role` text DEFAULT 'custom' NOT NULL; diff --git a/packages/db/migrations/sqlite/meta/0042_snapshot.json b/packages/db/migrations/sqlite/meta/0042_snapshot.json new file mode 100644 index 000000000..fce706f30 --- /dev/null +++ b/packages/db/migrations/sqlite/meta/0042_snapshot.json @@ -0,0 +1,2289 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "718b7096-50b8-4d10-a060-da16bd12d6b8", + "prevId": "852e049b-72cc-4d7f-8be9-931c042fe019", + "tables": { + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userId_idx": { + "name": "userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": ["provider", "provider_account_id"], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "apiKey_user_id_user_id_fk": { + "name": "apiKey_user_id_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "href": { + "name": "href", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ping_url": { + "name": "ping_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardGroupPermission": { + "name": "boardGroupPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardGroupPermission_board_id_board_id_fk": { + "name": "boardGroupPermission_board_id_board_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardGroupPermission_group_id_group_id_fk": { + "name": "boardGroupPermission_group_id_group_id_fk", + "tableFrom": "boardGroupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardGroupPermission_board_id_group_id_permission_pk": { + "columns": ["board_id", "group_id", "permission"], + "name": "boardGroupPermission_board_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "boardUserPermission": { + "name": "boardUserPermission", + "columns": { + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "boardUserPermission_board_id_board_id_fk": { + "name": "boardUserPermission_board_id_board_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "boardUserPermission_user_id_user_id_fk": { + "name": "boardUserPermission_user_id_user_id_fk", + "tableFrom": "boardUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boardUserPermission_board_id_user_id_permission_pk": { + "columns": ["board_id", "user_id", "permission"], + "name": "boardUserPermission_board_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "board": { + "name": "board", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_title": { + "name": "page_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_image_url": { + "name": "logo_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon_image_url": { + "name": "favicon_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_url": { + "name": "background_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "background_image_attachment": { + "name": "background_image_attachment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "background_image_repeat": { + "name": "background_image_repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'no-repeat'" + }, + "background_image_size": { + "name": "background_image_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cover'" + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fa5252'" + }, + "secondary_color": { + "name": "secondary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#fd7e14'" + }, + "opacity": { + "name": "opacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "custom_css": { + "name": "custom_css", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_color": { + "name": "icon_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_radius": { + "name": "item_radius", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lg'" + }, + "disable_status": { + "name": "disable_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "board_name_unique": { + "name": "board_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "board_creator_id_user_id_fk": { + "name": "board_creator_id_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cron_job_configuration": { + "name": "cron_job_configuration", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_widget_definition": { + "name": "custom_widget_definition", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "header_name": { + "name": "header_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'GET'" + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_type": { + "name": "display_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'singleValue'" + }, + "display_config": { + "name": "display_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_definition_creator_id_user_id_fk": { + "name": "custom_widget_definition_creator_id_user_id_fk", + "tableFrom": "custom_widget_definition", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_widget_secret": { + "name": "custom_widget_secret", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "custom_widget_secret_definition_id_custom_widget_definition_id_fk": { + "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk", + "tableFrom": "custom_widget_secret", + "tableTo": "custom_widget_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "custom_widget_secret_definition_id_kind_pk": { + "columns": ["definition_id", "kind"], + "name": "custom_widget_secret_definition_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_group_id_group_id_fk": { + "name": "groupMember_group_id_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_user_id_user_id_fk": { + "name": "groupMember_user_id_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_group_id_user_id_pk": { + "columns": ["group_id", "user_id"], + "name": "groupMember_group_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_group_id_group_id_fk": { + "name": "groupPermission_group_id_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "group_name_unique": { + "name": "group_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "group_owner_id_user_id_fk": { + "name": "group_owner_id_user_id_fk", + "tableFrom": "group", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_home_board_id_board_id_fk": { + "name": "group_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "group_mobile_home_board_id_board_id_fk": { + "name": "group_mobile_home_board_id_board_id_fk", + "tableFrom": "group", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_repository_id": { + "name": "icon_repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_icon_repository_id_iconRepository_id_fk": { + "name": "icon_icon_repository_id_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["icon_repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationGroupPermissions": { + "name": "integrationGroupPermissions", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationGroupPermissions_integration_id_integration_id_fk": { + "name": "integrationGroupPermissions_integration_id_integration_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationGroupPermissions_group_id_group_id_fk": { + "name": "integrationGroupPermissions_group_id_group_id_fk", + "tableFrom": "integrationGroupPermissions", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationGroupPermissions_integration_id_group_id_permission_pk": { + "columns": ["integration_id", "group_id", "permission"], + "name": "integrationGroupPermissions_integration_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_item": { + "name": "integration_item", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integration_item_item_id_item_id_fk": { + "name": "integration_item_item_id_item_id_fk", + "tableFrom": "integration_item", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_item_integration_id_integration_id_fk": { + "name": "integration_item_integration_id_integration_id_fk", + "tableFrom": "integration_item", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integration_item_item_id_integration_id_pk": { + "columns": ["item_id", "integration_id"], + "name": "integration_item_item_id_integration_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_secret__kind_idx": { + "name": "integration_secret__kind_idx", + "columns": ["kind"], + "isUnique": false + }, + "integration_secret__updated_at_idx": { + "name": "integration_secret__updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "integrationSecret_integration_id_integration_id_fk": { + "name": "integrationSecret_integration_id_integration_id_fk", + "tableFrom": "integrationSecret", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationSecret_integration_id_kind_pk": { + "columns": ["integration_id", "kind"], + "name": "integrationSecret_integration_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrationUserPermission": { + "name": "integrationUserPermission", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "integrationUserPermission_integration_id_integration_id_fk": { + "name": "integrationUserPermission_integration_id_integration_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integrationUserPermission_user_id_user_id_fk": { + "name": "integrationUserPermission_user_id_user_id_fk", + "tableFrom": "integrationUserPermission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "integrationUserPermission_integration_id_user_id_permission_pk": { + "columns": ["integration_id", "user_id", "permission"], + "name": "integrationUserPermission_integration_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": { + "integration_app_id_app_id_fk": { + "name": "integration_app_id_app_id_fk", + "tableFrom": "integration", + "tableTo": "app", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_token_unique": { + "name": "invite_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": { + "invite_creator_id_user_id_fk": { + "name": "invite_creator_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item_layout": { + "name": "item_layout", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_layout_item_id_item_id_fk": { + "name": "item_layout_item_id_item_id_fk", + "tableFrom": "item_layout", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_section_id_section_id_fk": { + "name": "item_layout_section_id_section_id_fk", + "tableFrom": "item_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_layout_layout_id_layout_id_fk": { + "name": "item_layout_layout_id_layout_id_fk", + "tableFrom": "item_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_layout_item_id_section_id_layout_id_pk": { + "columns": ["item_id", "section_id", "layout_id"], + "name": "item_layout_item_id_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + }, + "advanced_options": { + "name": "advanced_options", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "item_board_id_board_id_fk": { + "name": "item_board_id_board_id_fk", + "tableFrom": "item", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "layout": { + "name": "layout", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_count": { + "name": "column_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "breakpoint": { + "name": "breakpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'custom'" + } + }, + "indexes": {}, + "foreignKeys": { + "layout_board_id_board_id_fk": { + "name": "layout_board_id_board_id_fk", + "tableFrom": "layout", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_creator_id_user_id_fk": { + "name": "media_creator_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "onboarding": { + "name": "onboarding", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_step": { + "name": "previous_step", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_engine": { + "name": "search_engine", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "short": { + "name": "short", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_template": { + "name": "url_template", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "search_engine_short_unique": { + "name": "search_engine_short_unique", + "columns": ["short"], + "isUnique": true + } + }, + "foreignKeys": { + "search_engine_integration_id_integration_id_fk": { + "name": "search_engine_integration_id_integration_id_fk", + "tableFrom": "search_engine", + "tableTo": "integration", + "columnsFrom": ["integration_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_collapse_state": { + "name": "section_collapse_state", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collapsed": { + "name": "collapsed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_collapse_state_user_id_user_id_fk": { + "name": "section_collapse_state_user_id_user_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_collapse_state_section_id_section_id_fk": { + "name": "section_collapse_state_section_id_section_id_fk", + "tableFrom": "section_collapse_state", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_collapse_state_user_id_section_id_pk": { + "columns": ["user_id", "section_id"], + "name": "section_collapse_state_user_id_section_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section_layout": { + "name": "section_layout", + "columns": { + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "section_layout_section_id_section_id_fk": { + "name": "section_layout_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_layout_id_layout_id_fk": { + "name": "section_layout_layout_id_layout_id_fk", + "tableFrom": "section_layout", + "tableTo": "layout", + "columnsFrom": ["layout_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "section_layout_parent_section_id_section_id_fk": { + "name": "section_layout_parent_section_id_section_id_fk", + "tableFrom": "section_layout", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_layout_section_id_layout_id_pk": { + "columns": ["section_id", "layout_id"], + "name": "section_layout_section_id_layout_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "section": { + "name": "section", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "x_offset": { + "name": "x_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": {}, + "foreignKeys": { + "section_board_id_board_id_fk": { + "name": "section_board_id_board_id_fk", + "tableFrom": "section", + "tableTo": "board", + "columnsFrom": ["board_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "setting_key": { + "name": "setting_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": { + "serverSetting_settingKey_unique": { + "name": "serverSetting_settingKey_unique", + "columns": ["setting_key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_certificate_hostname": { + "name": "trusted_certificate_hostname", + "columns": { + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbprint": { + "name": "thumbprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate": { + "name": "certificate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trusted_certificate_hostname_hostname_thumbprint_pk": { + "columns": ["hostname", "thumbprint"], + "name": "trusted_certificate_hostname_hostname_thumbprint_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "home_board_id": { + "name": "home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mobile_home_board_id": { + "name": "mobile_home_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_search_engine_id": { + "name": "default_search_engine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_search_in_new_tab": { + "name": "open_search_in_new_tab", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ddg_bangs": { + "name": "ddg_bangs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color_scheme": { + "name": "color_scheme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "ping_icons_enabled": { + "name": "ping_icons_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_right_click_on_widgets": { + "name": "enable_right_click_on_widgets", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "completed_manage_tour": { + "name": "completed_manage_tour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_board_tour": { + "name": "completed_board_tour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_home_board_id_board_id_fk": { + "name": "user_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_mobile_home_board_id_board_id_fk": { + "name": "user_mobile_home_board_id_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["mobile_home_board_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_default_search_engine_id_search_engine_id_fk": { + "name": "user_default_search_engine_id_search_engine_id_fk", + "tableFrom": "user", + "tableTo": "search_engine", + "columnsFrom": ["default_search_engine_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": ["identifier", "token"], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "widget_secret": { + "name": "widget_secret", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "widget_secret_item_id_item_id_fk": { + "name": "widget_secret_item_id_item_id_fk", + "tableFrom": "widget_secret", + "tableTo": "item", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "widget_secret_item_id_kind_pk": { + "columns": ["item_id", "kind"], + "name": "widget_secret_item_id_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index 537209951..6929f6f6d 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -295,6 +295,13 @@ "when": 1783786658943, "tag": "0041_add_right_click_preference", "breakpoints": true + }, + { + "idx": 42, + "version": "6", + "when": 1786190387517, + "tag": "0042_add_layout_role", + "breakpoints": true } ] } diff --git a/packages/db/queries/widget-placement.spec.ts b/packages/db/queries/widget-placement.spec.ts new file mode 100644 index 000000000..b8acdc3b3 --- /dev/null +++ b/packages/db/queries/widget-placement.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest"; + +import { createId } from "@homarr/common"; +import { boards, layouts, sections } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; + +import { placeAllWidgetsAsync } from "./widget-placement"; + +describe("placeAllWidgetsAsync", () => { + test("creates independent positions for every target layout", async () => { + const db = createDb(); + const boardId = createId(); + const sectionId = createId(); + const mobileLayoutId = createId(); + const baseLayoutId = createId(); + await db.insert(boards).values({ id: boardId, name: "dashboard" }); + await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId }); + await db.insert(layouts).values([ + { + id: mobileLayoutId, + name: "Mobile", + role: "mobile", + columnCount: 3, + breakpoint: 0, + boardId, + }, + { + id: baseLayoutId, + name: "Base", + role: "base", + columnCount: 10, + breakpoint: 768, + boardId, + }, + ]); + + await placeAllWidgetsAsync( + db, + [ + { boardId, sectionId, layoutId: mobileLayoutId, columnCount: 3 }, + { boardId, sectionId, layoutId: baseLayoutId, columnCount: 10 }, + ], + [], + [{ id: createId(), name: "Homarr" }], + ); + + const placedItems = await db.query.items.findMany({ with: { layouts: true } }); + expect(placedItems.length).toBeGreaterThan(0); + expect(placedItems.every((item) => item.layouts.length === 2)).toBe(true); + expect( + placedItems.every((item) => + item.layouts.some((layout) => layout.layoutId === mobileLayoutId && layout.width <= 3), + ), + ).toBe(true); + expect(placedItems.every((item) => item.layouts.some((layout) => layout.layoutId === baseLayoutId))).toBe(true); + }); +}); diff --git a/packages/db/queries/widget-placement.ts b/packages/db/queries/widget-placement.ts index 1d0b529f9..c3a246b2f 100644 --- a/packages/db/queries/widget-placement.ts +++ b/packages/db/queries/widget-placement.ts @@ -30,46 +30,54 @@ interface PlacementContext extends BoardTarget { const placeWidgetAsync = async ( db: Database, - ctx: PlacementContext, + contexts: PlacementContext[], kind: WidgetKind, linkedIntegrationIds: string[], options?: string, size?: { width: number; height: number }, ) => { - const itemWidth = size?.width ?? 2; - const itemHeight = size?.height ?? 2; + const boardId = contexts.at(0)?.boardId; + if (!boardId) return; - if (ctx.xOffset + itemWidth > ctx.columnCount) { - ctx.xOffset = 0; - ctx.yOffset += ctx.rowMaxHeight; - ctx.rowMaxHeight = 0; - } + const desiredItemWidth = size?.width ?? 2; + const itemHeight = size?.height ?? 2; const itemId = createId(); await db.insert(items).values({ id: itemId, - boardId: ctx.boardId, + boardId, kind, options: options ?? emptySuperJSON, advancedOptions: emptySuperJSON, }); - await db.insert(itemLayouts).values({ - itemId, - sectionId: ctx.sectionId, - layoutId: ctx.layoutId, - xOffset: ctx.xOffset, - yOffset: ctx.yOffset, - width: itemWidth, - height: itemHeight, - }); + await db.insert(itemLayouts).values( + contexts.map((ctx) => { + const itemWidth = Math.min(ctx.columnCount, desiredItemWidth); + if (ctx.xOffset + itemWidth > ctx.columnCount) { + ctx.xOffset = 0; + ctx.yOffset += ctx.rowMaxHeight; + ctx.rowMaxHeight = 0; + } + + const placement = { + itemId, + sectionId: ctx.sectionId, + layoutId: ctx.layoutId, + xOffset: ctx.xOffset, + yOffset: ctx.yOffset, + width: itemWidth, + height: itemHeight, + }; + ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight); + ctx.xOffset += itemWidth; + return placement; + }), + ); for (const integrationId of linkedIntegrationIds) { await db.insert(integrationItems).values({ itemId, integrationId }); } - - ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight); - ctx.xOffset += itemWidth; }; interface App { @@ -79,16 +87,17 @@ interface App { export const placeAllWidgetsAsync = async ( db: Database, - target: BoardTarget, + target: BoardTarget | BoardTarget[], allIntegrations: Integration[], allApps: App[], ) => { - const ctx: PlacementContext = { - ...target, + const targets = Array.isArray(target) ? target : [target]; + const contexts: PlacementContext[] = targets.map((currentTarget) => ({ + ...currentTarget, xOffset: 0, yOffset: 0, rowMaxHeight: 0, - }; + })); const placedWidgets = new Set(); @@ -106,7 +115,7 @@ export const placeAllWidgetsAsync = async ( const options = config?.options ? superjson.stringify(config.options) : undefined; await placeWidgetAsync( db, - ctx, + contexts, widgetKind, matchingIds, options, @@ -118,7 +127,7 @@ export const placeAllWidgetsAsync = async ( for (const app of allApps) { await placeWidgetAsync( db, - ctx, + contexts, "app", [], superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }), @@ -138,7 +147,7 @@ export const placeAllWidgetsAsync = async ( options = { ...options, items: bookmarkAppIds }; } - await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, { + await placeWidgetAsync(db, contexts, config.kind, [], options ? superjson.stringify(options) : undefined, { width: config.width, height: config.height, }); diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index d21b32e3c..814226d3e 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -33,6 +33,7 @@ import type { IntegrationKind, IntegrationPermission, IntegrationSecretKind, + LayoutRole, OnboardingStep, SearchEngineType, SectionKind, @@ -345,6 +346,7 @@ export const layouts = mysqlTable("layout", { .references(() => boards.id, { onDelete: "cascade" }), columnCount: tinyint().notNull(), breakpoint: smallint().notNull().default(0), + role: varchar({ length: 16 }).$type().notNull().default("custom"), }); export const itemLayouts = mysqlTable( diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts index ad26fe5e7..e2defd730 100644 --- a/packages/db/schema/postgresql.ts +++ b/packages/db/schema/postgresql.ts @@ -32,6 +32,7 @@ import type { IntegrationKind, IntegrationPermission, IntegrationSecretKind, + LayoutRole, OnboardingStep, SearchEngineType, SectionKind, @@ -344,6 +345,7 @@ export const layouts = pgTable("layout", { .references(() => boards.id, { onDelete: "cascade" }), columnCount: smallint().notNull(), breakpoint: smallint().notNull().default(0), + role: varchar({ length: 16 }).$type().notNull().default("custom"), }); export const itemLayouts = pgTable( diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 6cfb6257a..018a84d13 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -21,6 +21,7 @@ import type { IntegrationKind, IntegrationPermission, IntegrationSecretKind, + LayoutRole, OnboardingStep, SearchEngineType, SectionKind, @@ -330,6 +331,7 @@ export const layouts = sqliteTable("layout", { .references(() => boards.id, { onDelete: "cascade" }), columnCount: int().notNull(), breakpoint: int().notNull().default(0), + role: text().$type().notNull().default("custom"), }); export const itemLayouts = sqliteTable( diff --git a/packages/definitions/src/board.ts b/packages/definitions/src/board.ts index 302008550..3bd5ffdd9 100644 --- a/packages/definitions/src/board.ts +++ b/packages/definitions/src/board.ts @@ -8,7 +8,49 @@ export const backgroundImageRepeats = createDefinition(["repeat", "repeat-x", "r export const backgroundImageSizes = createDefinition(["cover", "contain"], { defaultValue: "cover", }); +export const layoutRoles = createDefinition(["mobile", "base", "custom"], { defaultValue: "custom" }); export type BackgroundImageAttachment = inferDefinitionType; export type BackgroundImageRepeat = inferDefinitionType; export type BackgroundImageSize = inferDefinitionType; +export type LayoutRole = inferDefinitionType; + +const MAX_LAYOUT_BREAKPOINT = 32767; +const compareBoardLayouts = ( + layoutA: { id: string; breakpoint: number; columnCount: number }, + layoutB: { id: string; breakpoint: number; columnCount: number }, +) => + layoutA.breakpoint - layoutB.breakpoint || + layoutA.columnCount - layoutB.columnCount || + layoutA.id.localeCompare(layoutB.id); + +export const normalizeBoardLayoutRoles = < + TLayout extends { id: string; breakpoint: number; columnCount: number; role?: LayoutRole }, +>( + sourceLayouts: TLayout[], +): Array => { + const mobileLayouts = sourceLayouts.filter((layout) => layout.role === "mobile"); + const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base"); + const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined; + const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined; + const orderedLayouts = + mobileLayout && baseLayout + ? [ + mobileLayout, + ...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts), + baseLayout, + ] + : sourceLayouts.toSorted(compareBoardLayouts); + + let previousBreakpoint = -1; + return orderedLayouts.map((layout, index) => { + const remainingLayouts = orderedLayouts.length - index - 1; + const breakpoint = + index === 0 + ? 0 + : Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint)); + const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom"; + previousBreakpoint = breakpoint; + return { ...layout, breakpoint, role }; + }); +}; diff --git a/packages/old-import/src/import/collections/board-collection.ts b/packages/old-import/src/import/collections/board-collection.ts index 65bfb37f0..e69e422a9 100644 --- a/packages/old-import/src/import/collections/board-collection.ts +++ b/packages/old-import/src/import/collections/board-collection.ts @@ -1,6 +1,7 @@ import { createId } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { createDbInsertCollectionForTransaction } from "@homarr/db/collection"; +import { normalizeBoardLayoutRoles } from "@homarr/definitions"; import type { BoardSize, OldmarrConfig } from "@homarr/old-schema"; import { boardSizes, getBoardSizeName } from "@homarr/old-schema"; @@ -87,8 +88,8 @@ export const createBoardInsertCollection = ( {} as Record, ); - insertCollection.layouts.push( - ...boardSizes.map((size) => ({ + const importedLayouts = normalizeBoardLayoutRoles( + boardSizes.map((size) => ({ id: layoutMapping[size], boardId: mappedBoard.id, columnCount: mapColumnCount(board.config.settings.customization.gridstack, size), @@ -97,6 +98,8 @@ export const createBoardInsertCollection = ( })), ); + insertCollection.layouts.push(...importedLayouts); + const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories }); for (const section of preparedSections.values()) { diff --git a/packages/old-import/src/prepare/prepare-items.ts b/packages/old-import/src/prepare/prepare-items.ts index 0f32d6b00..61e2b5793 100644 --- a/packages/old-import/src/prepare/prepare-items.ts +++ b/packages/old-import/src/prepare/prepare-items.ts @@ -1,9 +1,9 @@ +import { generateResponsiveGridFor } from "@homarr/common"; +import type { GridAlgorithmItem } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget, SizedShape } from "@homarr/old-schema"; import { boardSizes } from "@homarr/old-schema"; -import type { GridAlgorithmItem } from "../../../api/src/router/board/grid-algorithm"; -import { generateResponsiveGridFor } from "../../../api/src/router/board/grid-algorithm"; import { mapColumnCount } from "../mappers/map-column-count"; import { mapApp, mapWidget } from "../mappers/map-item"; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 0c7b91dd9..ab35fe197 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4408,9 +4408,43 @@ "title": "Layout", "responsive": { "title": "Responsive layouts", + "description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.", "action": { "add": "Add layout" } + }, + "role": { + "mobile": "Mobile", + "base": "Base", + "custom": "Custom" + }, + "custom": { + "defaultName": "Custom layout" + }, + "mobile": { + "breakpointDescription": "Mobile is always used from 0px until the next layout breakpoint." + }, + "preview": { + "columns": "columns", + "empty": "This layout is empty", + "dynamicSection": "Dynamic section" + }, + "reset": { + "action": "Reset from Base", + "confirm": { + "title": "Reset {layoutName}?", + "message": "This replaces every saved position and size in this layout with a new layout generated from Base. This cannot be undone." + } + }, + "edit": { + "action": "Edit layout", + "saveAndEdit": "Save and edit", + "save": "Save layout", + "cancel": { + "action": "Cancel layout editing", + "title": "Discard layout changes?", + "message": "Your unsaved changes to this layout will be discarded." + } } }, "background": { diff --git a/packages/validation/src/board.spec.ts b/packages/validation/src/board.spec.ts new file mode 100644 index 000000000..d263a0b5f --- /dev/null +++ b/packages/validation/src/board.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; + +import { responsiveBoardLayoutsSchema } from "./board"; + +const mobile = { id: "mobile", name: "Mobile", columnCount: 3, breakpoint: 0, role: "mobile" as const }; +const base = { id: "base", name: "Base", columnCount: 12, breakpoint: 768, role: "base" as const }; + +describe("responsiveBoardLayoutsSchema", () => { + test("accepts protected layouts and custom layouts strictly between them", () => { + expect( + responsiveBoardLayoutsSchema.safeParse([ + mobile, + { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" }, + base, + ]).success, + ).toBe(true); + }); + + test.each([ + { name: "missing Mobile", layouts: [{ ...mobile, role: "custom" }, base] }, + { name: "removable Base replacement", layouts: [mobile, { ...base, role: "custom" }] }, + { name: "non-zero Mobile breakpoint", layouts: [{ ...mobile, breakpoint: 320 }, base] }, + { + name: "custom layout at Base breakpoint", + layouts: [mobile, { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 768, role: "custom" }, base], + }, + { + name: "duplicate layout ID", + layouts: [mobile, { ...base, id: mobile.id }], + }, + ])("rejects $name", ({ layouts }) => { + expect(responsiveBoardLayoutsSchema.safeParse(layouts).success).toBe(false); + }); +}); diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 6c9e1a650..6a9cc15bb 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -5,6 +5,7 @@ import { backgroundImageRepeats, backgroundImageSizes, boardPermissions, + layoutRoles, widgetKinds, } from "@homarr/definitions"; @@ -70,16 +71,52 @@ export const boardSavePartialSettingsSchema = z }) .partial(); +export const boardLayoutSchema = z.object({ + id: z.string(), + name: z.string().trim().nonempty().max(32), + columnCount: boardColumnCountSchema, + breakpoint: z.number().int().min(0).max(32767), + role: z.enum(layoutRoles.values), +}); + +export const responsiveBoardLayoutsSchema = z + .array(boardLayoutSchema) + .min(2) + .superRefine((layouts, ctx) => { + const mobileLayouts = layouts.filter((layout) => layout.role === "mobile"); + const baseLayouts = layouts.filter((layout) => layout.role === "base"); + const mobileLayout = mobileLayouts.at(0); + const baseLayout = baseLayouts.at(0); + if (mobileLayouts.length !== 1 || baseLayouts.length !== 1 || !mobileLayout || !baseLayout) { + ctx.addIssue({ code: "custom", message: "Boards require exactly one Mobile and one Base layout" }); + return; + } + + if (mobileLayout.breakpoint !== 0) { + ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" }); + } + + if (layouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) { + ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" }); + } + + if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) { + ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" }); + } + + if (new Set(layouts.map((layout) => layout.id)).size !== layouts.length) { + ctx.addIssue({ code: "custom", message: "Layout IDs must be unique" }); + } + }); + export const boardSaveLayoutsSchema = z.object({ id: z.string(), - layouts: z.array( - z.object({ - id: z.string(), - name: z.string().trim().nonempty().max(32), - columnCount: boardColumnCountSchema, - breakpoint: z.number().min(0).max(32767), - }), - ), + layouts: responsiveBoardLayoutsSchema, +}); + +export const boardResetLayoutSchema = z.object({ + boardId: z.string(), + layoutId: z.string(), }); export const boardSaveSchema = z.object({