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