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

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