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
This commit is contained in:
Meier Lukas
2024-08-10 12:37:16 +02:00
committed by GitHub
parent a9d87e4e6b
commit 9ce172e78a
38 changed files with 3765 additions and 395 deletions

2
.gitignore vendored
View File

@@ -48,7 +48,7 @@ yarn-error.log*
.turbo
# database
db.sqlite
*.sqlite
# logs
*.log

View File

@@ -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",

View File

@@ -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<HTMLDivElement>(null);
@@ -58,11 +60,11 @@ export const ClientBoard = () => {
h={fullHeightWithoutHeaderAndFooter}
/>
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
{sortedSections.map((section) =>
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={section.id} section={section} mainRef={ref} />
<BoardEmptySection key={section.id} section={section} />
) : (
<BoardCategorySection key={section.id} section={section} mainRef={ref} />
<BoardCategorySection key={section.id} section={section} />
),
)}
</Stack>

View File

@@ -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 = () => {
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
{t("section.category.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
{t("section.dynamic.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);

View File

@@ -7,5 +7,6 @@ export type Item = Section["items"][number];
export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>;
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;

View File

@@ -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;

View File

@@ -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<HTMLDivElement>();
const board = useRequiredBoard();
return (
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
withBorder
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} />
</Card>
);
};
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 (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
<WidgetError kind={item.kind} error={error as unknown} resetErrorBoundary={resetErrorBoundary} />
</>
)}
>
<BoardItemMenu offset={4} item={newItem} />
<Comp
options={options as never}
integrationIds={item.integrationIds}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
serverData={serverData?.data as never}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
{...dimensions}
/>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};

View File

@@ -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 (
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target>
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
<IconDotsVertical size={"1rem"} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
{tItem("action.edit")}
</Menu.Item>
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>{tItem("action.move")}</Menu.Item>
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
{tItem("action.remove")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};

View File

@@ -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<HTMLDivElement>;
}
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) => {
<CategoryMenu category={section} />
</Group>
<Collapse in={opened} p="sm" pt={0}>
<div className="grid-stack grid-stack-category" data-category data-section-id={section.id} ref={refs.wrapper}>
<SectionContent items={section.items} refs={refs} />
</div>
<GridStack section={section} />
</Collapse>
</Stack>
</Card>

View File

@@ -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,
})),
],
};

View File

@@ -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({

View File

@@ -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) => (
<BoardItem key={item.id} refs={refs} item={item} opacity={board.opacity} />
{sortedItems.map((item) => (
<GridStackItem
key={item.id}
innerRef={refs.items.current[item.id]}
width={item.width}
height={item.height}
xOffset={item.xOffset}
yOffset={item.yOffset}
kind={item.kind}
id={item.id}
type={item.type}
minWidth={item.type === "section" ? getMinSize("x", item.items, board.sections, item.id) : undefined}
minHeight={item.type === "section" ? getMinSize("y", item.items, board.sections, item.id) : undefined}
>
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
))}
</>
);
};
interface ItemProps {
item: Item;
refs: UseGridstackRefs;
opacity: number;
}
const BoardItem = ({ refs, item, opacity }: ItemProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
return (
<div
key={item.id}
className="grid-stack-item"
data-id={item.id}
gs-x={item.xOffset}
gs-y={item.yOffset}
gs-w={item.width}
gs-h={item.height}
gs-min-w={1}
gs-min-h={1}
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
withBorder
styles={{
root: {
"--opacity": opacity / 100,
containerType: "size",
},
}}
p={0}
>
<BoardItemContent item={item} width={width} height={height} />
</Card>
</div>
);
};
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 (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<ItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
<WidgetError kind={item.kind} error={error as unknown} resetErrorBoundary={resetErrorBoundary} />
</>
)}
>
<ItemMenu offset={4} item={newItem} />
<Comp
options={options as never}
integrationIds={item.integrationIds}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
serverData={serverData?.data as never}
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
{...dimensions}
/>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};
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 (
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target>
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
<IconDotsVertical size={"1rem"} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
{tItem("action.edit")}
</Menu.Item>
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>{tItem("action.move")}</Menu.Item>
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
{tItem("action.remove")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
/**
* 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
);
};

View File

@@ -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 (
<Box className="grid-stack-item-content">
<Card
className={classes.itemCard}
w="100%"
h="100%"
withBorder
styles={{
root: {
"--opacity": board.opacity / 100,
overflow: "hidden",
},
}}
p={0}
>
<GridStack section={section} className="min-row" />
</Card>
<BoardDynamicSectionMenu section={section} />
</Box>
);
};

View File

@@ -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<DynamicSection, "xOffset" | "yOffset">;
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,
};
};

View File

@@ -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 (
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target>
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
<IconDotsVertical size={"1rem"} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
{tDynamic("action.remove")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};

View File

@@ -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<HTMLDivElement>;
}
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 (
<div
className={section.items.length > 0 || isEditMode ? defaultClasses : `${defaultClasses} gridstack-empty-wrapper`}
<GridStack
section={section}
style={{ transitionDuration: "0s" }}
data-empty
data-section-id={section.id}
ref={refs.wrapper}
>
<SectionContent items={section.items} refs={refs} />
</div>
className={combineClasses("min-row", itemIds.length > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
/>
);
};

View File

@@ -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<GridItemHTMLElement> | undefined;
}
export const GridStackItem = ({
id,
type,
kind,
xOffset,
yOffset,
width,
height,
minWidth = 1,
minHeight = 1,
innerRef,
children,
...boxProps
}: PropsWithChildren<Props>) => {
useEffect(() => {
if (!innerRef?.current?.gridstackNode) return;
if (type !== "section") return;
innerRef.current.gridstackNode.minW = minWidth;
innerRef.current.gridstackNode.minH = minHeight;
}, [minWidth, minHeight, innerRef]);
return (
<Box
{...boxProps}
className={combineClasses("grid-stack-item", boxProps.className)}
data-id={id}
data-type={type}
data-kind={kind}
gs-x={xOffset}
gs-y={yOffset}
gs-w={width}
gs-h={height}
gs-min-w={minWidth}
gs-min-h={minHeight}
ref={innerRef as React.RefObject<HTMLDivElement>}
>
{children}
</Box>
);
};

View File

@@ -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 (
<SectionProvider value={{ section, innerSections, refs }}>
<Box
{...props}
data-kind={section.kind}
data-section-id={section.id}
className={combineClasses(`grid-stack grid-stack-${section.kind}`, props.className)}
ref={refs.wrapper}
>
<SectionContent />
</Box>
</SectionProvider>
);
};

View File

@@ -6,7 +6,8 @@ import { GridStack } from "@homarr/gridstack";
import type { Section } from "~/app/[locale]/boards/_types";
interface InitializeGridstackProps {
section: Section;
section: Omit<Section, "items">;
itemIds: string[];
refs: {
wrapper: RefObject<HTMLDivElement>;
items: MutableRefObject<Record<string, RefObject<GridItemHTMLElement>>>;
@@ -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;

View File

@@ -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<HTMLDivElement>;
}
/**
* 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<Section, "items">, 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<HTMLDivElement>(null);
const { ref: wrapperRef, width, height } = useElementSize<HTMLDivElement>();
// references to the diffrent items contained in the gridstack
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement>>>({});
// reference of the gridstack object for modifications after initialization
const gridRef = useRef<GridStack>();
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<GridHTMLElement>('.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<HTMLDivElement>;
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]);
};

View File

@@ -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,
};
};

View File

@@ -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<Section, { kind: "category" } | { kind: "empty" }>[];
refs: UseGridstackRefs;
}
const SectionContext = createContext<SectionContextProps | null>(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;

View File

@@ -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<Section, { kind: "category" } | { kind: "empty" }> =>
innerSection.kind === "dynamic" && innerSection.parentSectionId === section.id,
);
return {
innerSections,
itemIds: section.items.map((item) => item.id).concat(innerSections.map((section) => section.id)),
};
};

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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,
});

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1722068832607,
"tag": "0005_soft_microbe",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1722517058725,
"tag": "0006_young_micromax",
"breakpoints": true
}
]
}

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -269,8 +269,14 @@ export const sections = mysqlTable("section", {
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
kind: text("kind").$type<SectionKind>().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", {

View File

@@ -272,8 +272,14 @@ export const sections = sqliteTable("section", {
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
kind: text("kind").$type<SectionKind>().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", {

View File

@@ -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];

View File

@@ -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: {

View File

@@ -41,7 +41,8 @@ const createCategorySchema = <TItemSchema extends z.ZodTypeAny>(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 = <TItemSchema extends z.ZodTypeAny>(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 = <TItemSchema extends z.ZodTypeAny>(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 = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema)]);
z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema), createDynamicSchema(itemSchema)]);

10
pnpm-lock.yaml generated
View File

@@ -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': {}