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

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