From 9ce172e78a142be71cfae8fdb581987fec9b78f0 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Aug 2024 12:37:16 +0200 Subject: [PATCH] feat: add dynamic section (#842) * chore: add parent_section_id and change position to x and y_offset for sqlite section table * chore: rename existing positions to x_offset and y_offset * chore: add related mysql migration * chore: add missing height and width to section table * fix: missing width and height in migration copy script * fix: typecheck issues * fix: test not working caused by unsimilar schemas * wip: add dynamic section * refactor: improve structure of gridstack sections * feat: add rendering of dynamic sections * feat: add saving of moved sections * wip: add static row count, restrict min-width and height * chore: address pull request feedback * fix: format issues * fix: size calculation within dynamic sections * fix: on resize not called when min width or height is reached * fix: size of items while dragging is to big * chore: temporarly remove migration files * chore: readd migrations * fix: format and deepsource issues * chore: remove db_dev.sqlite file * chore: add *.sqlite to .gitignore * chore: address pull request feedback * feat: add dynamic section actions for adding and removing them --- .gitignore | 2 +- apps/nextjs/package.json | 2 +- .../app/[locale]/boards/(content)/_client.tsx | 10 +- .../boards/(content)/_header-actions.tsx | 7 + apps/nextjs/src/app/[locale]/boards/_types.ts | 1 + .../components/board/items/item-actions.tsx | 2 +- .../components/board/items/item-content.tsx | 89 ++ .../src/components/board/items/item-menu.tsx | 110 ++ .../board/sections/category-section.tsx | 12 +- .../sections/category/category-actions.ts | 59 +- .../category/category-menu-actions.tsx | 16 +- .../src/components/board/sections/content.tsx | 275 +--- .../board/sections/dynamic-section.tsx | 35 + .../board/sections/dynamic/dynamic-actions.ts | 89 ++ .../board/sections/dynamic/dynamic-menu.tsx | 45 + .../board/sections/empty-section.tsx | 25 +- .../sections/gridstack/gridstack-item.tsx | 62 + .../board/sections/gridstack/gridstack.tsx | 35 + .../sections/gridstack/init-gridstack.ts | 12 +- .../board/sections/gridstack/use-gridstack.ts | 288 +++- .../board/sections/section-actions.tsx | 65 + .../board/sections/section-context.ts | 22 + .../board/sections/use-section-items.ts | 15 + apps/nextjs/src/styles/gridstack.scss | 33 +- packages/api/src/router/board.ts | 16 +- packages/api/src/router/test/board.spec.ts | 42 +- .../migrations/mysql/0006_young_micromax.sql | 6 + .../migrations/mysql/meta/0006_snapshot.json | 1367 +++++++++++++++++ .../db/migrations/mysql/meta/_journal.json | 7 + .../sqlite/0006_windy_doctor_faustus.sql | 35 + .../migrations/sqlite/meta/0006_snapshot.json | 1310 ++++++++++++++++ .../db/migrations/sqlite/meta/_journal.json | 7 + packages/db/schema/mysql.ts | 8 +- packages/db/schema/sqlite.ts | 8 +- packages/definitions/src/section.ts | 2 +- packages/translation/src/lang/en.ts | 11 + packages/validation/src/shared.ts | 20 +- pnpm-lock.yaml | 10 +- 38 files changed, 3765 insertions(+), 395 deletions(-) create mode 100644 apps/nextjs/src/components/board/items/item-content.tsx create mode 100644 apps/nextjs/src/components/board/items/item-menu.tsx create mode 100644 apps/nextjs/src/components/board/sections/dynamic-section.tsx create mode 100644 apps/nextjs/src/components/board/sections/dynamic/dynamic-actions.ts create mode 100644 apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx create mode 100644 apps/nextjs/src/components/board/sections/gridstack/gridstack-item.tsx create mode 100644 apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx create mode 100644 apps/nextjs/src/components/board/sections/section-actions.tsx create mode 100644 apps/nextjs/src/components/board/sections/section-context.ts create mode 100644 apps/nextjs/src/components/board/sections/use-section-items.ts create mode 100644 packages/db/migrations/mysql/0006_young_micromax.sql create mode 100644 packages/db/migrations/mysql/meta/0006_snapshot.json create mode 100644 packages/db/migrations/sqlite/0006_windy_doctor_faustus.sql create mode 100644 packages/db/migrations/sqlite/meta/0006_snapshot.json diff --git a/.gitignore b/.gitignore index f7d87aa53..d1b1eb8da 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,7 @@ yarn-error.log* .turbo # database -db.sqlite +*.sqlite # logs *.log diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 058c3a7c3..9224dece0 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -22,7 +22,7 @@ "@homarr/db": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0", - "@homarr/gridstack": "^1.0.0", + "@homarr/gridstack": "^1.0.3", "@homarr/integrations": "workspace:^0.1.0", "@homarr/log": "workspace:^", "@homarr/modals": "workspace:^0.1.0", diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index ea80dc68b..25a550dca 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -44,7 +44,9 @@ export const ClientBoard = () => { const board = useRequiredBoard(); const isReady = useIsBoardReady(); - const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position); + const fullWidthSortedSections = board.sections + .filter((section) => section.kind === "empty" || section.kind === "category") + .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); const ref = useRef(null); @@ -58,11 +60,11 @@ export const ClientBoard = () => { h={fullHeightWithoutHeaderAndFooter} /> - {sortedSections.map((section) => + {fullWidthSortedSections.map((section) => section.kind === "empty" ? ( - + ) : ( - + ), )} 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 9222b7d59..2e6db592b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -10,6 +10,7 @@ import { IconPencil, IconPencilOff, IconPlus, + IconResize, IconSettings, } from "@tabler/icons-react"; @@ -23,6 +24,7 @@ import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { useBoardPermissions } from "~/components/board/permissions/client"; import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; +import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { HeaderButton } from "~/components/layout/header/button"; import { useEditMode, useRequiredBoard } from "./_context"; @@ -52,6 +54,7 @@ const AddMenu = () => { const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { addCategoryToEnd } = useCategoryActions(); + const { addDynamicSection } = useDynamicSectionActions(); const t = useI18n(); const handleAddCategory = useCallback( @@ -99,6 +102,10 @@ const AddMenu = () => { } onClick={handleAddCategory}> {t("section.category.action.create")} + + } onClick={addDynamicSection}> + {t("section.dynamic.action.create")} + ); diff --git a/apps/nextjs/src/app/[locale]/boards/_types.ts b/apps/nextjs/src/app/[locale]/boards/_types.ts index 4d841d1ac..929ce8876 100644 --- a/apps/nextjs/src/app/[locale]/boards/_types.ts +++ b/apps/nextjs/src/app/[locale]/boards/_types.ts @@ -7,5 +7,6 @@ export type Item = Section["items"][number]; export type CategorySection = Extract; export type EmptySection = Extract; +export type DynamicSection = Extract; export type ItemOfKind = Extract; diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx index 937ca6246..d30ac2fed 100644 --- a/apps/nextjs/src/components/board/items/item-actions.tsx +++ b/apps/nextjs/src/components/board/items/item-actions.tsx @@ -57,7 +57,7 @@ export const useItemActions = () => { updateBoard((previous) => { const lastSection = previous.sections .filter((section): section is EmptySection => section.kind === "empty") - .sort((sectionA, sectionB) => sectionB.position - sectionA.position)[0]; + .sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)[0]; if (!lastSection) return previous; diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx new file mode 100644 index 000000000..28636d32b --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -0,0 +1,89 @@ +import { Card } from "@mantine/core"; +import { useElementSize } from "@mantine/hooks"; +import { QueryErrorResetBoundary } from "@tanstack/react-query"; +import combineClasses from "clsx"; +import { ErrorBoundary } from "react-error-boundary"; + +import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, useServerDataFor } from "@homarr/widgets"; +import { WidgetError } from "@homarr/widgets/errors"; + +import type { Item } from "~/app/[locale]/boards/_types"; +import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; +import classes from "../sections/item.module.css"; +import { BoardItemMenu } from "./item-menu"; + +interface BoardItemContentProps { + item: Item; +} + +export const BoardItemContent = ({ item }: BoardItemContentProps) => { + const { ref, width, height } = useElementSize(); + const board = useRequiredBoard(); + + return ( + + + + ); +}; + +interface InnerContentProps { + item: Item; + width: number; + height: number; +} + +const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { + const board = useRequiredBoard(); + const [isEditMode] = useEditMode(); + const serverData = useServerDataFor(item.id); + const Comp = loadWidgetDynamic(item.kind); + const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options); + const newItem = { ...item, options }; + + if (!serverData?.isReady) return null; + + return ( + + {({ reset }) => ( + ( + <> + + + + )} + > + + + + )} + + ); +}; diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx new file mode 100644 index 000000000..90a811fd5 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -0,0 +1,110 @@ +import { useEffect, useMemo, useRef } from "react"; +import { ActionIcon, Menu } from "@mantine/core"; +import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useConfirmModal, useModalAction } from "@homarr/modals"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { WidgetEditModal, widgetImports } from "@homarr/widgets"; + +import type { Item } from "~/app/[locale]/boards/_types"; +import { useEditMode } from "~/app/[locale]/boards/(content)/_context"; +import { useItemActions } from "./item-actions"; + +export const BoardItemMenu = ({ + offset, + item, + resetErrorBoundary, +}: { + offset: number; + item: Item; + resetErrorBoundary?: () => void; +}) => { + const refResetErrorBoundaryOnNextRender = useRef(false); + const tItem = useScopedI18n("item"); + const t = useI18n(); + const { openModal } = useModalAction(WidgetEditModal); + const { openConfirmModal } = useConfirmModal(); + const [isEditMode] = useEditMode(); + const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = + useItemActions(); + const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); + const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); + + // Reset error boundary on next render if item has been edited + useEffect(() => { + if (refResetErrorBoundaryOnNextRender.current) { + resetErrorBoundary?.(); + refResetErrorBoundaryOnNextRender.current = false; + } + }, [item, resetErrorBoundary]); + + if (!isEditMode || isPending) return null; + + const openEditModal = () => { + openModal({ + kind: item.kind, + value: { + advancedOptions: item.advancedOptions, + options: item.options, + integrationIds: item.integrationIds, + }, + onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => { + updateItemOptions({ + itemId: item.id, + newOptions: options, + }); + updateItemAdvancedOptions({ + itemId: item.id, + newAdvancedOptions: advancedOptions, + }); + updateItemIntegrations({ + itemId: item.id, + newIntegrations: integrationIds, + }); + refResetErrorBoundaryOnNextRender.current = true; + }, + integrationData: (integrationData ?? []).filter( + (integration) => + "supportedIntegrations" in currentDefinition && + (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), + ), + integrationSupport: "supportedIntegrations" in currentDefinition, + }); + }; + + const openRemoveModal = () => { + openConfirmModal({ + title: tItem("remove.title"), + children: tItem("remove.message"), + onConfirm: () => { + removeItem({ itemId: item.id }); + }, + }); + }; + + return ( + + + + + + + + {tItem("menu.label.settings")} + } onClick={openEditModal}> + {tItem("action.edit")} + + }>{tItem("action.move")} + } onClick={() => duplicateItem({ itemId: item.id })}> + {tItem("action.duplicate")} + + + {t("common.dangerZone")} + } onClick={openRemoveModal}> + {tItem("action.remove")} + + + + ); +}; diff --git a/apps/nextjs/src/components/board/sections/category-section.tsx b/apps/nextjs/src/components/board/sections/category-section.tsx index f47f2e8ff..edd863671 100644 --- a/apps/nextjs/src/components/board/sections/category-section.tsx +++ b/apps/nextjs/src/components/board/sections/category-section.tsx @@ -1,20 +1,16 @@ -import type { RefObject } from "react"; import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import type { CategorySection } from "~/app/[locale]/boards/_types"; import { CategoryMenu } from "./category/category-menu"; -import { SectionContent } from "./content"; -import { useGridstack } from "./gridstack/use-gridstack"; +import { GridStack } from "./gridstack/gridstack"; interface Props { section: CategorySection; - mainRef: RefObject; } -export const BoardCategorySection = ({ section, mainRef }: Props) => { - const { refs } = useGridstack({ section, mainRef }); +export const BoardCategorySection = ({ section }: Props) => { const [opened, { toggle }] = useDisclosure(false); return ( @@ -30,9 +26,7 @@ export const BoardCategorySection = ({ section, mainRef }: Props) => { -
- -
+
diff --git a/apps/nextjs/src/components/board/sections/category/category-actions.ts b/apps/nextjs/src/components/board/sections/category/category-actions.ts index c29751f38..a31d8306e 100644 --- a/apps/nextjs/src/components/board/sections/category/category-actions.ts +++ b/apps/nextjs/src/components/board/sections/category/category-actions.ts @@ -7,7 +7,7 @@ import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client"; interface AddCategory { name: string; - position: number; + yOffset: number; } interface RenameCategory { @@ -28,8 +28,8 @@ export const useCategoryActions = () => { const { updateBoard } = useUpdateBoard(); const addCategory = useCallback( - ({ name, position }: AddCategory) => { - if (position <= -1) { + ({ name, yOffset }: AddCategory) => { + if (yOffset <= -1) { return; } updateBoard((previous) => ({ @@ -37,32 +37,32 @@ export const useCategoryActions = () => { sections: [ // Place sections before the new category ...previous.sections.filter( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - (section) => (section.kind === "category" || section.kind === "empty") && section.position < position, + (section) => (section.kind === "category" || section.kind === "empty") && section.yOffset < yOffset, ), { id: createId(), name, kind: "category", - position, + yOffset, + xOffset: 0, items: [], }, { id: createId(), kind: "empty", - position: position + 1, + yOffset: yOffset + 1, + xOffset: 0, items: [], }, // Place sections after the new category ...previous.sections .filter( (section): section is CategorySection | EmptySection => - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - (section.kind === "category" || section.kind === "empty") && section.position >= position, + (section.kind === "category" || section.kind === "empty") && section.yOffset >= yOffset, ) .map((section) => ({ ...section, - position: section.position + 2, + yOffset: section.yOffset + 2, })), ], })); @@ -76,14 +76,13 @@ export const useCategoryActions = () => { const lastSection = previous.sections .filter( (section): section is CategorySection | EmptySection => - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition section.kind === "empty" || section.kind === "category", ) - .sort((sectionA, sectionB) => sectionB.position - sectionA.position) + .sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset) .at(0); if (!lastSection) return previous; - const lastPosition = lastSection.position; + const lastYOffset = lastSection.yOffset; return { ...previous, @@ -93,13 +92,15 @@ export const useCategoryActions = () => { id: createId(), name, kind: "category", - position: lastPosition + 1, + yOffset: lastYOffset + 1, + xOffset: 0, items: [], }, { id: createId(), kind: "empty", - position: lastPosition + 2, + yOffset: lastYOffset + 2, + xOffset: 0, items: [], }, ], @@ -133,40 +134,39 @@ export const useCategoryActions = () => { (section): section is CategorySection => section.kind === "category" && section.id === id, ); if (!currentCategory) return previous; - if (currentCategory.position === 1 && direction === "up") return previous; - if (currentCategory.position === previous.sections.length - 2 && direction === "down") return previous; + if (currentCategory.yOffset === 1 && direction === "up") return previous; + if (currentCategory.yOffset === previous.sections.length - 2 && direction === "down") return previous; return { ...previous, sections: previous.sections.map((section) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (section.kind !== "category" && section.kind !== "empty") return section; const offset = direction === "up" ? -2 : 2; // Move category and empty section - if (section.position === currentCategory.position || section.position - 1 === currentCategory.position) { + if (section.yOffset === currentCategory.yOffset || section.yOffset - 1 === currentCategory.yOffset) { return { ...section, - position: section.position + offset, + yOffset: section.yOffset + offset, }; } if ( direction === "up" && - (section.position === currentCategory.position - 2 || section.position === currentCategory.position - 1) + (section.yOffset === currentCategory.yOffset - 2 || section.yOffset === currentCategory.yOffset - 1) ) { return { ...section, - position: section.position + 2, + position: section.yOffset + 2, }; } if ( direction === "down" && - (section.position === currentCategory.position + 2 || section.position === currentCategory.position + 3) + (section.yOffset === currentCategory.yOffset + 2 || section.yOffset === currentCategory.yOffset + 3) ) { return { ...section, - position: section.position - 2, + position: section.yOffset - 2, }; } @@ -188,12 +188,12 @@ export const useCategoryActions = () => { const aboveWrapper = previous.sections.find( (section): section is EmptySection => - section.kind === "empty" && section.position === currentCategory.position - 1, + section.kind === "empty" && section.yOffset === currentCategory.yOffset - 1, ); const removedWrapper = previous.sections.find( (section): section is EmptySection => - section.kind === "empty" && section.position === currentCategory.position + 1, + section.kind === "empty" && section.yOffset === currentCategory.yOffset + 1, ); if (!aboveWrapper || !removedWrapper) return previous; @@ -214,19 +214,18 @@ export const useCategoryActions = () => { return { ...previous, sections: [ - ...previous.sections.filter((section) => section.position < currentCategory.position - 1), + ...previous.sections.filter((section) => section.yOffset < currentCategory.yOffset - 1), { ...aboveWrapper, items: [...aboveWrapper.items, ...previousCategoryItems, ...previousBelowWrapperItems], }, ...previous.sections .filter( - (section): section is CategorySection | EmptySection => - section.position >= currentCategory.position + 2, + (section): section is CategorySection | EmptySection => section.yOffset >= currentCategory.yOffset + 2, ) .map((section) => ({ ...section, - position: section.position - 2, + position: section.yOffset - 2, })), ], }; diff --git a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx index fc7610deb..5ac4c9e09 100644 --- a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx +++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx @@ -14,7 +14,7 @@ export const useCategoryMenuActions = (category: CategorySection) => { const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions(); const t = useI18n(); - const createCategoryAtPosition = useCallback( + const createCategoryAtYOffset = useCallback( (position: number) => { openModal( { @@ -25,7 +25,7 @@ export const useCategoryMenuActions = (category: CategorySection) => { onSuccess: (category) => { addCategory({ name: category.name, - position, + yOffset: position, }); }, submitLabel: t("section.category.create.submit"), @@ -40,15 +40,15 @@ export const useCategoryMenuActions = (category: CategorySection) => { // creates a new category above the current const addCategoryAbove = useCallback(() => { - const abovePosition = category.position; - createCategoryAtPosition(abovePosition); - }, [category.position, createCategoryAtPosition]); + const aboveYOffset = category.yOffset; + createCategoryAtYOffset(aboveYOffset); + }, [category.yOffset, createCategoryAtYOffset]); // creates a new category below the current const addCategoryBelow = useCallback(() => { - const belowPosition = category.position + 2; - createCategoryAtPosition(belowPosition); - }, [category.position, createCategoryAtPosition]); + const belowYOffset = category.yOffset + 2; + createCategoryAtYOffset(belowYOffset); + }, [category.yOffset, createCategoryAtYOffset]); const moveCategoryUp = useCallback(() => { moveCategory({ diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index cae6e6fb0..c9fe9feab 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -1,231 +1,70 @@ -import type { RefObject } from "react"; -import { useEffect, useMemo, useRef } from "react"; -import { ActionIcon, Card, Menu } from "@mantine/core"; -import { useElementSize } from "@mantine/hooks"; -import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; -import { QueryErrorResetBoundary } from "@tanstack/react-query"; -import combineClasses from "clsx"; -import { ErrorBoundary } from "react-error-boundary"; +import { useMemo } from "react"; -import { clientApi } from "@homarr/api/client"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import { - loadWidgetDynamic, - reduceWidgetOptionsWithDefaultValues, - useServerDataFor, - WidgetEditModal, - widgetImports, -} from "@homarr/widgets"; -import { WidgetError } from "@homarr/widgets/errors"; +import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types"; +import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; +import { BoardItemContent } from "../items/item-content"; +import { BoardDynamicSection } from "./dynamic-section"; +import { GridStackItem } from "./gridstack/gridstack-item"; +import { useSectionContext } from "./section-context"; -import type { Item } from "~/app/[locale]/boards/_types"; -import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; -import { useItemActions } from "../items/item-actions"; -import type { UseGridstackRefs } from "./gridstack/use-gridstack"; -import classes from "./item.module.css"; - -interface Props { - items: Item[]; - refs: UseGridstackRefs; -} - -export const SectionContent = ({ items, refs }: Props) => { +export const SectionContent = () => { + const { section, innerSections, refs } = useSectionContext(); const board = useRequiredBoard(); + const sortedItems = useMemo(() => { + return [ + ...section.items.map((item) => ({ ...item, type: "item" as const })), + ...innerSections.map((section) => ({ ...section, type: "section" as const })), + ].sort((itemA, itemB) => { + if (itemA.yOffset === itemB.yOffset) { + return itemA.xOffset - itemB.xOffset; + } + + return itemA.yOffset - itemB.xOffset; + }); + }, [section.items, innerSections]); return ( <> - {items.map((item) => ( - + {sortedItems.map((item) => ( + + {item.type === "item" ? : } + ))} ); }; -interface ItemProps { - item: Item; - refs: UseGridstackRefs; - opacity: number; -} - -const BoardItem = ({ refs, item, opacity }: ItemProps) => { - const { ref, width, height } = useElementSize(); - - return ( -
} - > - - - -
- ); -}; - -interface ItemContentProps { - item: Item; - width: number; - height: number; -} - -const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => { - const board = useRequiredBoard(); - const [isEditMode] = useEditMode(); - const serverData = useServerDataFor(item.id); - const Comp = loadWidgetDynamic(item.kind); - const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options); - const newItem = { ...item, options }; - - if (!serverData?.isReady) return null; - - return ( - - {({ reset }) => ( - ( - <> - - - - )} - > - - - - )} - - ); -}; - -const ItemMenu = ({ - offset, - item, - resetErrorBoundary, -}: { - offset: number; - item: Item; - resetErrorBoundary?: () => void; -}) => { - const refResetErrorBoundaryOnNextRender = useRef(false); - const tItem = useScopedI18n("item"); - const t = useI18n(); - const { openModal } = useModalAction(WidgetEditModal); - const { openConfirmModal } = useConfirmModal(); - const [isEditMode] = useEditMode(); - const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = - useItemActions(); - const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); - const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); - - // Reset error boundary on next render if item has been edited - useEffect(() => { - if (refResetErrorBoundaryOnNextRender.current) { - resetErrorBoundary?.(); - refResetErrorBoundaryOnNextRender.current = false; - } - }, [item, resetErrorBoundary]); - - if (!isEditMode || isPending) return null; - - const openEditModal = () => { - openModal({ - kind: item.kind, - value: { - advancedOptions: item.advancedOptions, - options: item.options, - integrationIds: item.integrationIds, - }, - onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => { - updateItemOptions({ - itemId: item.id, - newOptions: options, - }); - updateItemAdvancedOptions({ - itemId: item.id, - newAdvancedOptions: advancedOptions, - }); - updateItemIntegrations({ - itemId: item.id, - newIntegrations: integrationIds, - }); - refResetErrorBoundaryOnNextRender.current = true; - }, - integrationData: (integrationData ?? []).filter( - (integration) => - "supportedIntegrations" in currentDefinition && - (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), - ), - integrationSupport: "supportedIntegrations" in currentDefinition, - }); - }; - - const openRemoveModal = () => { - openConfirmModal({ - title: tItem("remove.title"), - children: tItem("remove.message"), - onConfirm: () => { - removeItem({ itemId: item.id }); - }, - }); - }; - - return ( - - - - - - - - {tItem("menu.label.settings")} - } onClick={openEditModal}> - {tItem("action.edit")} - - }>{tItem("action.move")} - } onClick={() => duplicateItem({ itemId: item.id })}> - {tItem("action.duplicate")} - - - {t("common.dangerZone")} - } onClick={openRemoveModal}> - {tItem("action.remove")} - - - +/** + * Calculates the min width / height of a section by taking the maximum of + * the sum of the offset and size of all items and dynamic sections inside. + * @param direction either "x" or "y" + * @param items items of the section + * @param sections sections of the board to look for dynamic sections + * @param parentSectionId the id of the section we want to calculate the min size for + * @returns the min size + */ +const getMinSize = (direction: "x" | "y", items: Item[], sections: Section[], parentSectionId: string) => { + const size = direction === "x" ? "width" : "height"; + return Math.max( + ...items.map((item) => item[`${direction}Offset`] + item[size]), + ...sections + .filter( + (section): section is DynamicSection => + section.kind === "dynamic" && section.parentSectionId === parentSectionId, + ) + .map((item) => item[`${direction}Offset`] + item[size]), + 1, // Minimum size ); }; diff --git a/apps/nextjs/src/components/board/sections/dynamic-section.tsx b/apps/nextjs/src/components/board/sections/dynamic-section.tsx new file mode 100644 index 000000000..6a858bd10 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/dynamic-section.tsx @@ -0,0 +1,35 @@ +import { Box, Card } from "@mantine/core"; + +import type { DynamicSection } from "~/app/[locale]/boards/_types"; +import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; +import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu"; +import { GridStack } from "./gridstack/gridstack"; +import classes from "./item.module.css"; + +interface Props { + section: DynamicSection; +} + +export const BoardDynamicSection = ({ section }: Props) => { + const board = useRequiredBoard(); + return ( + + + + + + + ); +}; diff --git a/apps/nextjs/src/components/board/sections/dynamic/dynamic-actions.ts b/apps/nextjs/src/components/board/sections/dynamic/dynamic-actions.ts new file mode 100644 index 000000000..dc8228b35 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/dynamic/dynamic-actions.ts @@ -0,0 +1,89 @@ +import { useCallback } from "react"; + +import { createId } from "@homarr/db/client"; + +import type { DynamicSection, EmptySection } from "~/app/[locale]/boards/_types"; +import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client"; + +interface RemoveDynamicSection { + id: string; +} + +export const useDynamicSectionActions = () => { + const { updateBoard } = useUpdateBoard(); + + const addDynamicSection = useCallback(() => { + updateBoard((previous) => { + const lastSection = previous.sections + .filter((section): section is EmptySection => section.kind === "empty") + .sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)[0]; + + if (!lastSection) return previous; + + const newSection = { + id: createId(), + kind: "dynamic", + height: 1, + width: 1, + items: [], + parentSectionId: lastSection.id, + // We omit xOffset and yOffset because gridstack will use the first available position + } satisfies Omit; + + return { + ...previous, + sections: previous.sections.concat(newSection as unknown as DynamicSection), + }; + }); + }, [updateBoard]); + + const removeDynamicSection = useCallback( + ({ id }: RemoveDynamicSection) => { + updateBoard((previous) => { + const sectionToRemove = previous.sections.find( + (section): section is DynamicSection => section.id === id && section.kind === "dynamic", + ); + if (!sectionToRemove) return previous; + + return { + ...previous, + sections: previous.sections + .filter((section) => section.id !== id) + .map((section) => { + if (section.id === sectionToRemove.parentSectionId) { + return { + ...section, + // Add items from the removed section to the parent section + items: section.items.concat( + sectionToRemove.items.map((item) => ({ + ...item, + xOffset: sectionToRemove.xOffset + item.xOffset, + yOffset: sectionToRemove.yOffset + item.yOffset, + })), + ), + }; + } + + if (section.kind === "dynamic" && section.parentSectionId === sectionToRemove.id) { + // Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section + return { + ...section, + parentSectionId: sectionToRemove.parentSectionId, + yOffset: section.yOffset + sectionToRemove.yOffset, + xOffset: section.xOffset + sectionToRemove.xOffset, + }; + } + + return section; + }), + }; + }); + }, + [updateBoard], + ); + + return { + addDynamicSection, + removeDynamicSection, + }; +}; diff --git a/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx b/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx new file mode 100644 index 000000000..1a07eb81e --- /dev/null +++ b/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx @@ -0,0 +1,45 @@ +import { ActionIcon, Menu } from "@mantine/core"; +import { IconDotsVertical, IconTrash } from "@tabler/icons-react"; + +import { useConfirmModal } from "@homarr/modals"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; + +import type { DynamicSection } from "~/app/[locale]/boards/_types"; +import { useEditMode } from "~/app/[locale]/boards/(content)/_context"; +import { useDynamicSectionActions } from "./dynamic-actions"; + +export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => { + const t = useI18n(); + const tDynamic = useScopedI18n("section.dynamic"); + const { removeDynamicSection } = useDynamicSectionActions(); + const { openConfirmModal } = useConfirmModal(); + const [isEditMode] = useEditMode(); + + if (!isEditMode) return null; + + const openRemoveModal = () => { + openConfirmModal({ + title: tDynamic("remove.title"), + children: tDynamic("remove.message"), + onConfirm: () => { + removeDynamicSection({ id: section.id }); + }, + }); + }; + + return ( + + + + + + + + {t("common.dangerZone")} + } onClick={openRemoveModal}> + {tDynamic("action.remove")} + + + + ); +}; diff --git a/apps/nextjs/src/components/board/sections/empty-section.tsx b/apps/nextjs/src/components/board/sections/empty-section.tsx index 9dc2a844c..3245b4e41 100644 --- a/apps/nextjs/src/components/board/sections/empty-section.tsx +++ b/apps/nextjs/src/components/board/sections/empty-section.tsx @@ -1,30 +1,23 @@ -import type { RefObject } from "react"; +import combineClasses from "clsx"; import type { EmptySection } from "~/app/[locale]/boards/_types"; import { useEditMode } from "~/app/[locale]/boards/(content)/_context"; -import { SectionContent } from "./content"; -import { useGridstack } from "./gridstack/use-gridstack"; +import { GridStack } from "./gridstack/gridstack"; +import { useSectionItems } from "./use-section-items"; interface Props { section: EmptySection; - mainRef: RefObject; } -const defaultClasses = "grid-stack grid-stack-empty min-row"; - -export const BoardEmptySection = ({ section, mainRef }: Props) => { - const { refs } = useGridstack({ section, mainRef }); +export const BoardEmptySection = ({ section }: Props) => { + const { itemIds } = useSectionItems(section); const [isEditMode] = useEditMode(); return ( -
0 || isEditMode ? defaultClasses : `${defaultClasses} gridstack-empty-wrapper`} + - -
+ className={combineClasses("min-row", itemIds.length > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")} + /> ); }; diff --git a/apps/nextjs/src/components/board/sections/gridstack/gridstack-item.tsx b/apps/nextjs/src/components/board/sections/gridstack/gridstack-item.tsx new file mode 100644 index 000000000..554a376fa --- /dev/null +++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack-item.tsx @@ -0,0 +1,62 @@ +import { useEffect } from "react"; +import type { PropsWithChildren } from "react"; +import type { BoxProps } from "@mantine/core"; +import { Box } from "@mantine/core"; +import combineClasses from "clsx"; + +import type { SectionKind, WidgetKind } from "@homarr/definitions"; +import type { GridItemHTMLElement } from "@homarr/gridstack"; + +interface Props extends BoxProps { + id: string; + type: "item" | "section"; + kind: WidgetKind | SectionKind; + xOffset: number; + yOffset: number; + width: number; + height: number; + minWidth?: number; + minHeight?: number; + innerRef: React.RefObject | undefined; +} + +export const GridStackItem = ({ + id, + type, + kind, + xOffset, + yOffset, + width, + height, + minWidth = 1, + minHeight = 1, + innerRef, + children, + ...boxProps +}: PropsWithChildren) => { + useEffect(() => { + if (!innerRef?.current?.gridstackNode) return; + if (type !== "section") return; + innerRef.current.gridstackNode.minW = minWidth; + innerRef.current.gridstackNode.minH = minHeight; + }, [minWidth, minHeight, innerRef]); + + return ( + } + > + {children} + + ); +}; diff --git a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx new file mode 100644 index 000000000..d2761cba7 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { BoxProps } from "@mantine/core"; +import { Box } from "@mantine/core"; +import combineClasses from "clsx"; + +import type { Section } from "~/app/[locale]/boards/_types"; +import { SectionContent } from "../content"; +import { SectionProvider } from "../section-context"; +import { useSectionItems } from "../use-section-items"; +import { useGridstack } from "./use-gridstack"; + +interface Props extends BoxProps { + section: Section; +} + +export const GridStack = ({ section, ...props }: Props) => { + const { itemIds, innerSections } = useSectionItems(section); + + const { refs } = useGridstack(section, itemIds); + + return ( + + + + + + ); +}; diff --git a/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts index 27d7db33f..2df87f412 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts @@ -6,7 +6,8 @@ import { GridStack } from "@homarr/gridstack"; import type { Section } from "~/app/[locale]/boards/_types"; interface InitializeGridstackProps { - section: Section; + section: Omit; + itemIds: string[]; refs: { wrapper: RefObject; items: MutableRefObject>>; @@ -15,20 +16,21 @@ interface InitializeGridstackProps { sectionColumnCount: number; } -export const initializeGridstack = ({ section, refs, sectionColumnCount }: InitializeGridstackProps) => { +export const initializeGridstack = ({ section, itemIds, refs, sectionColumnCount }: InitializeGridstackProps) => { if (!refs.wrapper.current) return false; // initialize gridstack const newGrid = refs.gridstack; newGrid.current = GridStack.init( { column: sectionColumnCount, - margin: Math.round(Math.max(Math.min(refs.wrapper.current.offsetWidth / 100, 10), 1)), + margin: 10, cellHeight: 128, float: true, alwaysShowResizeHandle: true, acceptWidgets: true, staticGrid: true, - minRow: 1, + minRow: section.kind === "dynamic" && "height" in section ? (section.height as number) : 1, + maxRow: section.kind === "dynamic" && "height" in section ? (section.height as number) : 0, animate: false, styleInHead: true, disableRemoveNodeOnDrop: true, @@ -43,7 +45,7 @@ export const initializeGridstack = ({ section, refs, sectionColumnCount }: Initi grid.batchUpdate(); grid.removeAll(false); - section.items.forEach(({ id }) => { + itemIds.forEach((id) => { const ref = refs.items.current[id]?.current; if (!ref) return; diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts index 9534234c2..f916bec92 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -1,11 +1,13 @@ import type { MutableRefObject, RefObject } from "react"; -import { createRef, useCallback, useEffect, useMemo, useRef } from "react"; +import { createRef, useCallback, useEffect, useRef } from "react"; +import { useElementSize } from "@mantine/hooks"; -import type { GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack"; +import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack"; import type { Section } from "~/app/[locale]/boards/_types"; import { useEditMode, useMarkSectionAsReady, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; import { useItemActions } from "../../items/item-actions"; +import { useSectionActions } from "../section-actions"; import { initializeGridstack } from "./init-gridstack"; export interface UseGridstackRefs { @@ -18,79 +20,171 @@ interface UseGristackReturnType { refs: UseGridstackRefs; } -interface UseGridstackProps { - section: Section; - mainRef?: RefObject; -} +/** + * When the size of a gridstack changes we need to update the css variables + * so the gridstack items are displayed correctly + * @param wrapper gridstack wrapper + * @param gridstack gridstack object + * @param width width of the section (column count) + * @param height height of the section (row count) + * @param isDynamic if the section is dynamic + */ +const handleResizeChange = ( + wrapper: HTMLDivElement, + gridstack: GridStack, + width: number, + height: number, + isDynamic: boolean, +) => { + wrapper.style.setProperty("--gridstack-column-count", width.toString()); + wrapper.style.setProperty("--gridstack-row-count", height.toString()); -export const useGridstack = ({ section, mainRef }: UseGridstackProps): UseGristackReturnType => { + let cellHeight = wrapper.clientWidth / width; + if (isDynamic) { + cellHeight = wrapper.clientHeight / height; + } + + if (!isDynamic) { + document.body.style.setProperty("--gridstack-cell-size", cellHeight.toString()); + } + + gridstack.cellHeight(cellHeight); +}; + +export const useGridstack = (section: Omit, itemIds: string[]): UseGristackReturnType => { const [isEditMode] = useEditMode(); const markAsReady = useMarkSectionAsReady(); const { moveAndResizeItem, moveItemToSection } = useItemActions(); + const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions(); + // define reference for wrapper - is used to calculate the width of the wrapper - const wrapperRef = useRef(null); + const { ref: wrapperRef, width, height } = useElementSize(); // references to the diffrent items contained in the gridstack const itemRefs = useRef>>({}); // reference of the gridstack object for modifications after initialization const gridRef = useRef(); - useCssVariableConfiguration({ mainRef, gridRef }); - const board = useRequiredBoard(); - const items = useMemo(() => section.items, [section.items]); + const columnCount = + section.kind === "dynamic" && "width" in section && typeof section.width === "number" + ? section.width + : board.columnCount; + + useCssVariableConfiguration({ + columnCount, + gridRef, + wrapperRef, + width, + height, + isDynamic: section.kind === "dynamic", + }); // define items in itemRefs for easy access and reference to items - if (Object.keys(itemRefs.current).length !== items.length) { - items.forEach(({ id }: { id: keyof typeof itemRefs.current }) => { + if (Object.keys(itemRefs.current).length !== itemIds.length) { + itemIds.forEach((id) => { itemRefs.current[id] = itemRefs.current[id] ?? createRef(); }); } + // Toggle the gridstack to be static or not based on the edit mode useEffect(() => { gridRef.current?.setStatic(!isEditMode); }, [isEditMode]); const onChange = useCallback( (changedNode: GridStackNode) => { - const itemId = changedNode.el?.getAttribute("data-id"); - if (!itemId) return; + const id = changedNode.el?.getAttribute("data-id"); + const type = changedNode.el?.getAttribute("data-type"); - // Updates the react-query state - moveAndResizeItem({ - itemId, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - xOffset: changedNode.x!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - yOffset: changedNode.y!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - width: changedNode.w!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - height: changedNode.h!, - }); + if (!id || !type) return; + + if (type === "item") { + // Updates the react-query state + moveAndResizeItem({ + itemId: id, + // We want the following properties to be null by default + // so the next free position is used from the gridstack + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + xOffset: changedNode.x!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + yOffset: changedNode.y!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + width: changedNode.w!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + height: changedNode.h!, + }); + return; + } + + if (type === "section") { + moveAndResizeInnerSection({ + innerSectionId: id, + // We want the following properties to be null by default + // so the next free position is used from the gridstack + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + xOffset: changedNode.x!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + yOffset: changedNode.y!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + width: changedNode.w!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + height: changedNode.h!, + }); + return; + } + + console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`); }, - [moveAndResizeItem], + [moveAndResizeItem, moveAndResizeInnerSection], ); const onAdd = useCallback( (addedNode: GridStackNode) => { - const itemId = addedNode.el?.getAttribute("data-id"); - if (!itemId) return; + const id = addedNode.el?.getAttribute("data-id"); + const type = addedNode.el?.getAttribute("data-type"); - // Updates the react-query state - moveItemToSection({ - itemId, - sectionId: section.id, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - xOffset: addedNode.x!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - yOffset: addedNode.y!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - width: addedNode.w!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - height: addedNode.h!, - }); + if (!id || !type) return; + + if (type === "item") { + // Updates the react-query state + moveItemToSection({ + itemId: id, + sectionId: section.id, + // We want the following properties to be null by default + // so the next free position is used from the gridstack + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + xOffset: addedNode.x!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + yOffset: addedNode.y!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + width: addedNode.w!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + height: addedNode.h!, + }); + return; + } + + if (type === "section") { + moveInnerSectionToSection({ + innerSectionId: id, + sectionId: section.id, + // We want the following properties to be null by default + // so the next free position is used from the gridstack + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + xOffset: addedNode.x!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + yOffset: addedNode.y!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + width: addedNode.w!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + height: addedNode.h!, + }); + return; + } + + console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`); }, - [moveItemToSection, section.id], + [moveItemToSection, moveInnerSectionToSection, section.id], ); useEffect(() => { @@ -99,6 +193,23 @@ export const useGridstack = ({ section, mainRef }: UseGridstackProps): UseGrista // Add listener for moving items around in a wrapper currentGrid?.on("change", (_, nodes) => { nodes.forEach(onChange); + + // For all dynamic section items that changed we want to update the inner gridstack + nodes + .filter((node) => node.el?.getAttribute("data-type") === "section") + .forEach((node) => { + const dynamicInnerGrid = node.el?.querySelector('.grid-stack[data-kind="dynamic"]'); + + if (!dynamicInnerGrid?.gridstack) return; + + handleResizeChange( + dynamicInnerGrid as HTMLDivElement, + dynamicInnerGrid.gridstack, + node.w ?? 1, + node.h ?? 1, + true, + ); + }); }); // Add listener for moving items in config from one wrapper to another @@ -116,20 +227,31 @@ export const useGridstack = ({ section, mainRef }: UseGridstackProps): UseGrista useEffect(() => { const isReady = initializeGridstack({ section, + itemIds, refs: { items: itemRefs, wrapper: wrapperRef, gridstack: gridRef, }, - sectionColumnCount: board.columnCount, + sectionColumnCount: columnCount, }); + // If the section is ready mark it as ready + // When all sections are ready the board is ready and will get visible if (isReady) { markAsReady(section.id); } // Only run this effect when the section items change - }, [items.length, section.items.length, board.columnCount]); + }, [itemIds.length, columnCount]); + + const sectionHeight = section.kind === "dynamic" && "height" in section ? (section.height as number) : null; + + // We want the amount of rows in a dynamic section to be the height of the section in the outer gridstack + useEffect(() => { + if (!sectionHeight) return; + gridRef.current?.row(sectionHeight); + }, [sectionHeight]); return { refs: { @@ -141,48 +263,80 @@ export const useGridstack = ({ section, mainRef }: UseGridstackProps): UseGrista }; interface UseCssVariableConfiguration { - mainRef?: RefObject; gridRef: UseGridstackRefs["gridstack"]; + wrapperRef: UseGridstackRefs["wrapper"]; + width: number; + height: number; + columnCount: number; + isDynamic: boolean; } /** * This hook is used to configure the css variables for the gridstack * Those css variables are used to define the size of the gridstack items * @see gridstack.scss - * @param mainRef reference to the main div wrapping all sections * @param gridRef reference to the gridstack object + * @param wrapperRef reference to the wrapper of the gridstack + * @param width width of the section + * @param height height of the section + * @param columnCount column count of the gridstack */ -const useCssVariableConfiguration = ({ mainRef, gridRef }: UseCssVariableConfiguration) => { - const board = useRequiredBoard(); +const useCssVariableConfiguration = ({ + gridRef, + wrapperRef, + width, + height, + columnCount, + isDynamic, +}: UseCssVariableConfiguration) => { + const onResize = useCallback(() => { + if (!wrapperRef.current) return; + if (!gridRef.current) return; + handleResizeChange( + wrapperRef.current, + gridRef.current, + gridRef.current.getColumn(), + gridRef.current.getRow(), + isDynamic, + ); + }, [wrapperRef, gridRef, isDynamic]); - // Get reference to the :root element - const typeofDocument = typeof document; - const root = useMemo(() => { - if (typeofDocument === "undefined") return; - return document.documentElement; - }, [typeofDocument]); + useCallback(() => { + if (!wrapperRef.current) return; + if (!gridRef.current) return; + + wrapperRef.current.style.setProperty("--gridstack-column-count", gridRef.current.getColumn().toString()); + wrapperRef.current.style.setProperty("--gridstack-row-count", gridRef.current.getRow().toString()); + + let cellHeight = wrapperRef.current.clientWidth / gridRef.current.getColumn(); + if (isDynamic) { + cellHeight = wrapperRef.current.clientHeight / gridRef.current.getRow(); + } + + gridRef.current.cellHeight(cellHeight); + }, [wrapperRef, gridRef, isDynamic]); // Define widget-width by calculating the width of one column with mainRef width and column count useEffect(() => { - if (typeof document === "undefined") return; - const onResize = () => { - if (!mainRef?.current) return; - const widgetWidth = mainRef.current.clientWidth / board.columnCount; - // widget width is used to define sizes of gridstack items within global.scss - root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString()); - gridRef.current?.cellHeight(widgetWidth); - }; onResize(); if (typeof window === "undefined") return; window.addEventListener("resize", onResize); + const wrapper = wrapperRef.current; + wrapper?.addEventListener("resize", onResize); return () => { if (typeof window === "undefined") return; window.removeEventListener("resize", onResize); + wrapper?.removeEventListener("resize", onResize); }; - }, [board.columnCount, mainRef, root, gridRef]); + }, [wrapperRef, gridRef, onResize]); + + // Handle resize of inner sections when there size changes + useEffect(() => { + onResize(); + }, [width, height, onResize]); // Define column count by using the sectionColumnCount useEffect(() => { - root?.style.setProperty("--gridstack-column-count", board.columnCount.toString()); - }, [board.columnCount, root]); + wrapperRef.current?.style.setProperty("--gridstack-column-count", columnCount.toString()); + }, [columnCount, wrapperRef]); }; diff --git a/apps/nextjs/src/components/board/sections/section-actions.tsx b/apps/nextjs/src/components/board/sections/section-actions.tsx new file mode 100644 index 000000000..b79ef88c5 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/section-actions.tsx @@ -0,0 +1,65 @@ +import { useCallback } from "react"; + +import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client"; + +interface MoveAndResizeInnerSection { + innerSectionId: string; + xOffset: number; + yOffset: number; + width: number; + height: number; +} +interface MoveInnerSectionToSection { + innerSectionId: string; + sectionId: string; + xOffset: number; + yOffset: number; + width: number; + height: number; +} + +export const useSectionActions = () => { + const { updateBoard } = useUpdateBoard(); + + const moveAndResizeInnerSection = useCallback( + ({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => { + updateBoard((previous) => ({ + ...previous, + sections: previous.sections.map((section) => { + // Return same section if section is not the one we're moving + if (section.id !== innerSectionId) return section; + return { + ...section, + ...positionProps, + }; + }), + })); + }, + [updateBoard], + ); + + const moveInnerSectionToSection = useCallback( + ({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => { + updateBoard((previous) => { + return { + ...previous, + sections: previous.sections.map((section) => { + // Return section without changes when not the section we're moving + if (section.id !== innerSectionId) return section; + return { + ...section, + ...positionProps, + parentSectionId: sectionId, + }; + }), + }; + }); + }, + [updateBoard], + ); + + return { + moveAndResizeInnerSection, + moveInnerSectionToSection, + }; +}; diff --git a/apps/nextjs/src/components/board/sections/section-context.ts b/apps/nextjs/src/components/board/sections/section-context.ts new file mode 100644 index 000000000..fa325ddcb --- /dev/null +++ b/apps/nextjs/src/components/board/sections/section-context.ts @@ -0,0 +1,22 @@ +import { createContext, useContext } from "react"; + +import type { Section } from "~/app/[locale]/boards/_types"; +import type { UseGridstackRefs } from "./gridstack/use-gridstack"; + +interface SectionContextProps { + section: Section; + innerSections: Exclude[]; + refs: UseGridstackRefs; +} + +const SectionContext = createContext(null); + +export const useSectionContext = () => { + const context = useContext(SectionContext); + if (!context) { + throw new Error("useSectionContext must be used within a SectionContext"); + } + return context; +}; + +export const SectionProvider = SectionContext.Provider; diff --git a/apps/nextjs/src/components/board/sections/use-section-items.ts b/apps/nextjs/src/components/board/sections/use-section-items.ts new file mode 100644 index 000000000..9989e0f85 --- /dev/null +++ b/apps/nextjs/src/components/board/sections/use-section-items.ts @@ -0,0 +1,15 @@ +import type { Section } from "~/app/[locale]/boards/_types"; +import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; + +export const useSectionItems = (section: Section) => { + const board = useRequiredBoard(); + const innerSections = board.sections.filter( + (innerSection): innerSection is Exclude => + innerSection.kind === "dynamic" && innerSection.parentSectionId === section.id, + ); + + return { + innerSections, + itemIds: section.items.map((item) => item.id).concat(innerSections.map((section) => section.id)), + }; +}; diff --git a/apps/nextjs/src/styles/gridstack.scss b/apps/nextjs/src/styles/gridstack.scss index e9906c7c1..ebc722fd7 100644 --- a/apps/nextjs/src/styles/gridstack.scss +++ b/apps/nextjs/src/styles/gridstack.scss @@ -1,8 +1,9 @@ @import "@homarr/gridstack/dist/gridstack.min.css"; :root { - --gridstack-widget-width: 64; --gridstack-column-count: 12; + --gridstack-row-count: 1; + --gridstack-cell-size: 0; } .grid-stack-placeholder > .placeholder-content { @@ -20,7 +21,23 @@ // Define min size for gridstack items .grid-stack > .grid-stack-item { min-width: calc(100% / var(--gridstack-column-count)); - min-height: calc(1px * var(--gridstack-widget-width)); + min-height: calc(100% / var(--gridstack-row-count)); +} + +.grid-stack > .grid-stack-item.ui-draggable-dragging { + min-width: calc(var(--gridstack-cell-size) * 1px) !important; + min-height: calc(var(--gridstack-cell-size) * 1px) !important; +} + +// Define fix size while dragging +@for $i from 1 to 96 { + .grid-stack > .grid-stack-item.ui-draggable-dragging[gs-w="#{$i}"] { + width: calc(var(--gridstack-cell-size) * #{$i} * 1px) !important; + } + + .grid-stack > .grid-stack-item.ui-draggable-dragging[gs-h="#{$i}"] { + height: calc(var(--gridstack-cell-size) * #{$i} * 1px) !important; + } } // Styling for grid-stack main area @@ -38,13 +55,13 @@ @for $i from 1 to 96 { .grid-stack > .grid-stack-item[gs-h="#{$i}"] { - height: calc(#{$i}px * #{var(--gridstack-widget-width)}); + height: calc(100% / var(--gridstack-row-count) * #{$i}); } .grid-stack > .grid-stack-item[gs-min-h="#{$i}"] { - min-height: calc(#{$i}px * #{var(--gridstack-widget-width)}); + min-height: calc(100% / var(--gridstack-row-count) * #{$i}); } .grid-stack > .grid-stack-item[gs-max-h="#{$i}"] { - max-height: calc(#{$i}px * #{var(--gridstack-widget-width)}); + max-height: calc(100% / var(--gridstack-row-count) * #{$i}); } } @@ -56,10 +73,14 @@ @for $i from 1 to 96 { .grid-stack > .grid-stack-item[gs-y="#{$i}"] { - top: calc(#{$i}px * #{var(--gridstack-widget-width)}); + top: calc(100% / var(--gridstack-row-count) * #{$i}); } } +.grid-stack[data-kind="dynamic"] { + height: 100% !important; +} + // General gridstack styling .grid-stack > .grid-stack-item > .grid-stack-item-content, .grid-stack > .grid-stack-item > .placeholder-content { diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index d4c42e44c..aa6464156 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -109,7 +109,8 @@ export const boardRouter = createTRPCRouter({ await transaction.insert(sections).values({ id: createId(), kind: "empty", - position: 0, + xOffset: 0, + yOffset: 0, boardId, }); }); @@ -206,7 +207,11 @@ export const boardRouter = createTRPCRouter({ addedSections.map((section) => ({ id: section.id, kind: section.kind, - position: section.position, + yOffset: section.yOffset, + xOffset: section.kind === "dynamic" ? section.xOffset : 0, + height: "height" in section ? section.height : null, + width: "width" in section ? section.width : null, + parentSectionId: "parentSectionId" in section ? section.parentSectionId : null, name: "name" in section ? section.name : null, boardId: dbBoard.id, })), @@ -292,7 +297,11 @@ export const boardRouter = createTRPCRouter({ await transaction .update(sections) .set({ - position: section.position, + yOffset: section.yOffset, + xOffset: section.xOffset, + height: prev?.kind === "dynamic" && "height" in section ? section.height : null, + width: prev?.kind === "dynamic" && "width" in section ? section.width : null, + parentSectionId: prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null, name: prev?.kind === "category" && "name" in section ? section.name : null, }) .where(eq(sections.id, section.id)); @@ -538,6 +547,7 @@ const outputItemSchema = zodUnionFromArray(widgetKinds.map((kind) => forKind(kin const parseSection = (section: unknown) => { const result = createSectionSchema(outputItemSchema).safeParse(section); + if (!result.success) { throw new Error(result.error.message); } diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 86e567965..c1d0f45bb 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -619,7 +619,8 @@ describe("saveBoard should save full board", () => { { id: createId(), kind: "empty", - position: 0, + yOffset: 0, + xOffset: 0, items: [], }, ], @@ -655,7 +656,8 @@ describe("saveBoard should save full board", () => { { id: sectionId, kind: "empty", - position: 0, + yOffset: 0, + xOffset: 0, items: [ { id: createId(), @@ -716,7 +718,8 @@ describe("saveBoard should save full board", () => { { id: sectionId, kind: "empty", - position: 0, + xOffset: 0, + yOffset: 0, items: [ { id: itemId, @@ -778,14 +781,16 @@ describe("saveBoard should save full board", () => { sections: [ { id: newSectionId, - position: 1, + xOffset: 0, + yOffset: 1, items: [], ...partialSection, }, { id: sectionId, kind: "empty", - position: 0, + xOffset: 0, + yOffset: 0, items: [], }, ], @@ -808,7 +813,7 @@ describe("saveBoard should save full board", () => { expect(addedSection).toBeDefined(); expect(addedSection.id).toBe(newSectionId); expect(addedSection.kind).toBe(partialSection.kind); - expect(addedSection.position).toBe(1); + expect(addedSection.yOffset).toBe(1); if ("name" in partialSection) { expect(addedSection.name).toBe(partialSection.name); } @@ -830,7 +835,8 @@ describe("saveBoard should save full board", () => { { id: sectionId, kind: "empty", - position: 0, + yOffset: 0, + xOffset: 0, items: [ { id: newItemId, @@ -899,7 +905,8 @@ describe("saveBoard should save full board", () => { { id: sectionId, kind: "empty", - position: 0, + xOffset: 0, + yOffset: 0, items: [ { id: itemId, @@ -956,7 +963,8 @@ describe("saveBoard should save full board", () => { id: newSectionId, kind: "category", name: "Before", - position: 1, + yOffset: 1, + xOffset: 0, boardId, }); @@ -966,7 +974,8 @@ describe("saveBoard should save full board", () => { { id: sectionId, kind: "category", - position: 1, + yOffset: 1, + xOffset: 0, name: "Test", items: [], }, @@ -974,7 +983,8 @@ describe("saveBoard should save full board", () => { id: newSectionId, kind: "category", name: "After", - position: 0, + yOffset: 0, + xOffset: 0, items: [], }, ], @@ -992,12 +1002,12 @@ describe("saveBoard should save full board", () => { const firstSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === sectionId)); expect(firstSection.id).toBe(sectionId); expect(firstSection.kind).toBe("empty"); - expect(firstSection.position).toBe(1); + expect(firstSection.yOffset).toBe(1); expect(firstSection.name).toBe(null); const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId)); expect(secondSection.id).toBe(newSectionId); expect(secondSection.kind).toBe("category"); - expect(secondSection.position).toBe(0); + expect(secondSection.yOffset).toBe(0); expect(secondSection.name).toBe("After"); }); it("should update item when present in input", async () => { @@ -1013,7 +1023,8 @@ describe("saveBoard should save full board", () => { { id: sectionId, kind: "empty", - position: 0, + yOffset: 0, + xOffset: 0, items: [ { id: itemId, @@ -1268,7 +1279,8 @@ const createFullBoardAsync = async (db: Database, name: string) => { await db.insert(sections).values({ id: sectionId, kind: "empty", - position: 0, + yOffset: 0, + xOffset: 0, boardId, }); diff --git a/packages/db/migrations/mysql/0006_young_micromax.sql b/packages/db/migrations/mysql/0006_young_micromax.sql new file mode 100644 index 000000000..9cf760f08 --- /dev/null +++ b/packages/db/migrations/mysql/0006_young_micromax.sql @@ -0,0 +1,6 @@ +ALTER TABLE `section` RENAME COLUMN `position` TO `y_offset`;--> statement-breakpoint +ALTER TABLE `section` ADD `x_offset` int NOT NULL;--> statement-breakpoint +ALTER TABLE `section` ADD `width` int;--> statement-breakpoint +ALTER TABLE `section` ADD `height` int;--> statement-breakpoint +ALTER TABLE `section` ADD `parent_section_id` text;--> statement-breakpoint +ALTER TABLE `section` ADD CONSTRAINT `section_parent_section_id_section_id_fk` FOREIGN KEY (`parent_section_id`) REFERENCES `section`(`id`) ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/migrations/mysql/meta/0006_snapshot.json b/packages/db/migrations/mysql/meta/0006_snapshot.json new file mode 100644 index 000000000..784cb4ef2 --- /dev/null +++ b/packages/db/migrations/mysql/meta/0006_snapshot.json @@ -0,0 +1,1367 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "67352107-06b7-4f5d-a4e0-4ba27327f588", + "prevId": "50ab4b07-6f46-438b-806d-27827f138a01", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "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 + }, + "providerAccountId": { + "name": "providerAccountId", + "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": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": ["provider", "providerAccountId"] + } + }, + "uniqueConstraints": {} + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_id": { + "name": "app_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "column_count": { + "name": "column_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + } + }, + "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"] + } + } + }, + "groupMember": { + "name": "groupMember", + "columns": { + "groupId": { + "name": "groupId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_groupId_group_id_fk": { + "name": "groupMember_groupId_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["groupId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_userId_user_id_fk": { + "name": "groupMember_userId_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_groupId_userId_pk": { + "name": "groupMember_groupId_userId_pk", + "columns": ["groupId", "userId"] + } + }, + "uniqueConstraints": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "groupId": { + "name": "groupId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_groupId_group_id_fk": { + "name": "groupPermission_groupId_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["groupId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + } + }, + "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" + } + }, + "compositePrimaryKeys": { + "group_id": { + "name": "group_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "iconRepository_id": { + "name": "iconRepository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconRepository_slug": { + "name": "iconRepository_slug", + "type": "varchar(150)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "iconRepository_iconRepository_id": { + "name": "iconRepository_iconRepository_id", + "columns": ["iconRepository_id"] + } + }, + "uniqueConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "icon_id": { + "name": "icon_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_name": { + "name": "icon_name", + "type": "varchar(250)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_checksum": { + "name": "icon_checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconRepository_id": { + "name": "iconRepository_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_iconRepository_id_iconRepository_iconRepository_id_fk": { + "name": "icon_iconRepository_id_iconRepository_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["iconRepository_id"], + "columnsTo": ["iconRepository_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "icon_icon_id": { + "name": "icon_icon_id", + "columns": ["icon_id"] + } + }, + "uniqueConstraints": {} + }, + "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": "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": { + "name": "integrationGroupPermissions_integration_id_group_id_permission_pk", + "columns": ["integration_id", "group_id", "permission"] + } + }, + "uniqueConstraints": {} + }, + "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": {} + }, + "integrationSecret": { + "name": "integrationSecret", + "columns": { + "kind": { + "name": "kind", + "type": "varchar(16)", + "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": {} + }, + "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": "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": { + "name": "integrationUserPermission_integration_id_user_id_permission_pk", + "columns": ["integration_id", "user_id", "permission"] + } + }, + "uniqueConstraints": {} + }, + "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 + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "integration_id": { + "name": "integration_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "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"] + } + } + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_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": 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 + }, + "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_section_id_section_id_fk": { + "name": "item_section_id_section_id_fk", + "tableFrom": "item", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "item_id": { + "name": "item_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "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": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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" + }, + "section_parent_section_id_section_id_fk": { + "name": "section_parent_section_id_section_id_fk", + "tableFrom": "section", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "section_id": { + "name": "section_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "key": { + "name": "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_key": { + "name": "serverSetting_key", + "columns": ["key"] + } + }, + "uniqueConstraints": { + "serverSetting_key_unique": { + "name": "serverSetting_key_unique", + "columns": ["key"] + } + } + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "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": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_sessionToken": { + "name": "session_sessionToken", + "columns": ["sessionToken"] + } + }, + "uniqueConstraints": {} + }, + "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 + }, + "emailVerified": { + "name": "emailVerified", + "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 + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "homeBoardId": { + "name": "homeBoardId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_homeBoardId_board_id_fk": { + "name": "user_homeBoardId_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["homeBoardId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {} + }, + "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": {} + } + }, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"section\".\"position\"": "\"section\".\"y_offset\"" + } + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index ead0f991c..1a45a04c0 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1722068832607, "tag": "0005_soft_microbe", "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1722517058725, + "tag": "0006_young_micromax", + "breakpoints": true } ] } diff --git a/packages/db/migrations/sqlite/0006_windy_doctor_faustus.sql b/packages/db/migrations/sqlite/0006_windy_doctor_faustus.sql new file mode 100644 index 000000000..06863f0f9 --- /dev/null +++ b/packages/db/migrations/sqlite/0006_windy_doctor_faustus.sql @@ -0,0 +1,35 @@ +COMMIT TRANSACTION; +--> statement-breakpoint +PRAGMA foreign_keys = OFF; +--> statement-breakpoint +BEGIN TRANSACTION; +--> statement-breakpoint +ALTER TABLE `section` RENAME TO `__section_old`; +--> statement-breakpoint +CREATE TABLE `section` ( + `id` text PRIMARY KEY NOT NULL, + `board_id` text NOT NULL, + `kind` text NOT NULL, + `x_offset` integer NOT NULL, + `y_offset` integer NOT NULL, + `width` integer, + `height` integer, + `name` text, + `parent_section_id` text, + FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade + FOREIGN KEY (`parent_section_id`) REFERENCES `section`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `section` SELECT `id`, `board_id`, `kind`, 0, `position`, null, null, `name`, null FROM `__section_old`; +--> statement-breakpoint +DROP TABLE `__section_old`; +--> statement-breakpoint +ALTER TABLE `section` RENAME TO `__section_old`; +--> statement-breakpoint +ALTER TABLE `__section_old` RENAME TO `section`; +--> statement-breakpoint +COMMIT TRANSACTION; +--> statement-breakpoint +PRAGMA foreign_keys = ON; +--> statement-breakpoint +BEGIN TRANSACTION; diff --git a/packages/db/migrations/sqlite/meta/0006_snapshot.json b/packages/db/migrations/sqlite/meta/0006_snapshot.json new file mode 100644 index 000000000..899500eb8 --- /dev/null +++ b/packages/db/migrations/sqlite/meta/0006_snapshot.json @@ -0,0 +1,1310 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "589ffaa4-4b95-4c6e-84b0-5b0d7959750b", + "prevId": "ca6ab27e-e943-4d66-ace2-ee3a3a70d52a", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "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 + }, + "providerAccountId": { + "name": "providerAccountId", + "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": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": ["provider", "providerAccountId"], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {} + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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": {} + }, + "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", "permission", "user_id"], + "name": "boardUserPermission_board_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {} + }, + "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 + }, + "column_count": { + "name": "column_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + } + }, + "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": {} + }, + "groupMember": { + "name": "groupMember", + "columns": { + "groupId": { + "name": "groupId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupMember_groupId_group_id_fk": { + "name": "groupMember_groupId_group_id_fk", + "tableFrom": "groupMember", + "tableTo": "group", + "columnsFrom": ["groupId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "groupMember_userId_user_id_fk": { + "name": "groupMember_userId_user_id_fk", + "tableFrom": "groupMember", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "groupMember_groupId_userId_pk": { + "columns": ["groupId", "userId"], + "name": "groupMember_groupId_userId_pk" + } + }, + "uniqueConstraints": {} + }, + "groupPermission": { + "name": "groupPermission", + "columns": { + "groupId": { + "name": "groupId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groupPermission_groupId_group_id_fk": { + "name": "groupPermission_groupId_group_id_fk", + "tableFrom": "groupPermission", + "tableTo": "group", + "columnsFrom": ["groupId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + } + }, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "iconRepository": { + "name": "iconRepository", + "columns": { + "iconRepository_id": { + "name": "iconRepository_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "iconRepository_slug": { + "name": "iconRepository_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "icon": { + "name": "icon", + "columns": { + "icon_id": { + "name": "icon_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "icon_name": { + "name": "icon_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_checksum": { + "name": "icon_checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconRepository_id": { + "name": "iconRepository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "icon_iconRepository_id_iconRepository_iconRepository_id_fk": { + "name": "icon_iconRepository_id_iconRepository_iconRepository_id_fk", + "tableFrom": "icon", + "tableTo": "iconRepository", + "columnsFrom": ["iconRepository_id"], + "columnsTo": ["iconRepository_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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": ["group_id", "integration_id", "permission"], + "name": "integrationGroupPermissions_integration_id_group_id_permission_pk" + } + }, + "uniqueConstraints": {} + }, + "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": ["integration_id", "item_id"], + "name": "integration_item_item_id_integration_id_pk" + } + }, + "uniqueConstraints": {} + }, + "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": {} + }, + "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", "permission", "user_id"], + "name": "integrationUserPermission_integration_id_user_id_permission_pk" + } + }, + "uniqueConstraints": {} + }, + "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 + } + }, + "indexes": { + "integration__kind_idx": { + "name": "integration__kind_idx", + "columns": ["kind"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_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": 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 + }, + "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_section_id_section_id_fk": { + "name": "item_section_id_section_id_fk", + "tableFrom": "item", + "tableTo": "section", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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": true, + "autoincrement": false + }, + "y_offset": { + "name": "y_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_section_id": { + "name": "parent_section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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" + }, + "section_parent_section_id_section_id_fk": { + "name": "section_parent_section_id_section_id_fk", + "tableFrom": "section", + "tableTo": "section", + "columnsFrom": ["parent_section_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "serverSetting": { + "name": "serverSetting", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"json\": {}}'" + } + }, + "indexes": { + "serverSetting_key_unique": { + "name": "serverSetting_key_unique", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "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": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "emailVerified": { + "name": "emailVerified", + "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 + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credentials'" + }, + "homeBoardId": { + "name": "homeBoardId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_homeBoardId_board_id_fk": { + "name": "user_homeBoardId_board_id_fk", + "tableFrom": "user", + "tableTo": "board", + "columnsFrom": ["homeBoardId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"section\".\"position\"": "\"section\".\"y_offset\"" + } + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index 59fbb7ccc..99839fc25 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1722014142492, "tag": "0005_lean_random", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1722517033483, + "tag": "0006_windy_doctor_faustus", + "breakpoints": true } ] } diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index d5312f503..e45186ac0 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -269,8 +269,14 @@ export const sections = mysqlTable("section", { .notNull() .references(() => boards.id, { onDelete: "cascade" }), kind: text("kind").$type().notNull(), - position: int("position").notNull(), + xOffset: int("x_offset").notNull(), + yOffset: int("y_offset").notNull(), + width: int("width"), + height: int("height"), name: text("name"), + parentSectionId: text("parent_section_id").references((): AnyMySqlColumn => sections.id, { + onDelete: "cascade", + }), }); export const items = mysqlTable("item", { diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 2945f95cd..197dd9c8e 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -272,8 +272,14 @@ export const sections = sqliteTable("section", { .notNull() .references(() => boards.id, { onDelete: "cascade" }), kind: text("kind").$type().notNull(), - position: int("position").notNull(), + xOffset: int("x_offset").notNull(), + yOffset: int("y_offset").notNull(), + width: int("width"), + height: int("height"), name: text("name"), + parentSectionId: text("parent_section_id").references((): AnySQLiteColumn => sections.id, { + onDelete: "cascade", + }), }); export const items = sqliteTable("item", { diff --git a/packages/definitions/src/section.ts b/packages/definitions/src/section.ts index 847581914..f61bd984f 100644 --- a/packages/definitions/src/section.ts +++ b/packages/definitions/src/section.ts @@ -1,2 +1,2 @@ -export const sectionKinds = ["category", "empty"] as const; +export const sectionKinds = ["category", "empty", "dynamic"] as const; export type SectionKind = (typeof sectionKinds)[number]; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index f3decae3b..63a906311 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -634,6 +634,17 @@ export default { mantineReactTable: MRT_Localization_EN, }, section: { + dynamic: { + action: { + create: "New dynamic section", + remove: "Remove dynamic section", + }, + remove: { + title: "Remove dynamic section", + message: + "Are you sure you want to remove this dynamic section? Items will be moved at the same location in the parent section.", + }, + }, category: { field: { name: { diff --git a/packages/validation/src/shared.ts b/packages/validation/src/shared.ts index b524f3716..36dcea110 100644 --- a/packages/validation/src/shared.ts +++ b/packages/validation/src/shared.ts @@ -41,7 +41,8 @@ const createCategorySchema = (itemSchema: TIte id: z.string(), name: z.string(), kind: z.literal("category"), - position: z.number(), + yOffset: z.number(), + xOffset: z.number(), items: z.array(itemSchema), }); @@ -49,9 +50,22 @@ const createEmptySchema = (itemSchema: TItemSc z.object({ id: z.string(), kind: z.literal("empty"), - position: z.number(), + yOffset: z.number(), + xOffset: z.number(), items: z.array(itemSchema), }); +const createDynamicSchema = (itemSchema: TItemSchema) => + z.object({ + id: z.string(), + kind: z.literal("dynamic"), + yOffset: z.number(), + xOffset: z.number(), + width: z.number(), + height: z.number(), + items: z.array(itemSchema), + parentSectionId: z.string(), + }); + export const createSectionSchema = (itemSchema: TItemSchema) => - z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema)]); + z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema), createDynamicSchema(itemSchema)]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dffbb7ab0..2c368e6ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,8 +75,8 @@ importers: specifier: workspace:^0.1.0 version: link:../../packages/form '@homarr/gridstack': - specifier: ^1.0.0 - version: 1.0.0 + specifier: ^1.0.3 + version: 1.0.3 '@homarr/integrations': specifier: workspace:^0.1.0 version: link:../../packages/integrations @@ -2117,8 +2117,8 @@ packages: '@floating-ui/utils@0.2.1': resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} - '@homarr/gridstack@1.0.0': - resolution: {integrity: sha512-KM9024BipLD9BmtM6jHI8OKLZ1Iy4vZdTfU53ww4qEda/330XQYhIC2SBcQgkNnDB2MTkn/laNSO5gTy+lJg9Q==} + '@homarr/gridstack@1.0.3': + resolution: {integrity: sha512-qgBYQUQ75mO51YSm/02aRmfJMRz7bWEqFQAQii5lwKb73hlAtDHTuGBeEL5H/mqxFIKEbxPtjeL/Eax9UvXUhA==} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -7222,7 +7222,7 @@ snapshots: '@floating-ui/utils@0.2.1': {} - '@homarr/gridstack@1.0.0': {} + '@homarr/gridstack@1.0.3': {} '@humanwhocodes/module-importer@1.0.1': {}