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:
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user