From de3e20e92cea4c573667a7a8945e7431884750fb Mon Sep 17 00:00:00 2001 From: Segio Carracedo Date: Wed, 19 Nov 2025 14:17:47 +0100 Subject: [PATCH 1/8] feat: add F0GridStack component --- packages/react/package.json | 1 + .../Utilities/F0GridStack/F0GridStack.tsx | 86 +++ .../__stories__/GridStackReact.stories.tsx | 110 ++++ .../__tests__/F0GridStack.test.tsx | 563 ++++++++++++++++++ .../grid-stack-render-provider.test.tsx | 142 +++++ .../components/grid-stack-context.ts | 42 ++ .../components/grid-stack-provider.tsx | 142 +++++ .../components/grid-stack-render-context.ts | 15 + .../components/grid-stack-render-provider.tsx | 111 ++++ .../components/grid-stack-render.tsx | 38 ++ .../components/grid-stack-widget-context.ts | 18 + .../components/Utilities/F0GridStack/index.ts | 8 + pnpm-lock.yaml | 8 + 13 files changed, 1284 insertions(+) create mode 100644 packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx create mode 100644 packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx create mode 100644 packages/react/src/components/Utilities/F0GridStack/__tests__/F0GridStack.test.tsx create mode 100644 packages/react/src/components/Utilities/F0GridStack/components/__tests__/grid-stack-render-provider.test.tsx create mode 100644 packages/react/src/components/Utilities/F0GridStack/components/grid-stack-context.ts create mode 100644 packages/react/src/components/Utilities/F0GridStack/components/grid-stack-provider.tsx create mode 100644 packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-context.ts create mode 100644 packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-provider.tsx create mode 100644 packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx create mode 100644 packages/react/src/components/Utilities/F0GridStack/components/grid-stack-widget-context.ts create mode 100644 packages/react/src/components/Utilities/F0GridStack/index.ts diff --git a/packages/react/package.json b/packages/react/package.json index 96bb62bca0..2d8982f845 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -236,6 +236,7 @@ "embla-carousel-autoplay": "^8.5.2", "embla-carousel-react": "^8.6.0", "embla-carousel-wheel-gestures": "^8.0.1", + "gridstack": "^12.3.3", "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.38.1", "react-remove-scroll": "^2.7.1", diff --git a/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx b/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx new file mode 100644 index 0000000000..5f97f9d209 --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx @@ -0,0 +1,86 @@ +import { + GridItemHTMLElement, + GridStackNode, + GridStackOptions, + GridStackWidget, +} from "gridstack" +import "gridstack/dist/gridstack.css" +import { useMemo } from "react" +import { GridStackProvider } from "./components/grid-stack-provider" +import { GridStackRender } from "./components/grid-stack-render" +import { GridStackRenderProvider } from "./components/grid-stack-render-provider" + +export type GridStackReactOptions = Omit + +export type GridStackReactSize = { w: number; h: number } + +export interface GridStackReactNode extends GridStackNode { + allowedSizes?: GridStackReactSize[] + render?: React.ReactNode +} + +export interface F0GridStackProps { + options: GridStackReactOptions + nodes: GridStackReactNode[] + onChange?: (layout: GridStackWidget[] | GridStackOptions) => void +} + +export const F0GridStack = ({ options, nodes, onChange }: F0GridStackProps) => { + const gridOptions = useMemo( + () => ({ + ...options, + children: nodes, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [nodes] + ) + + const closestAllowed = ( + w: number, + h: number, + allowed: { w: number; h: number }[] + ) => { + let best = allowed[0], + bestDist = Infinity + for (const a of allowed) { + const dx = a.w - w, + dy = a.h - h + const dist = dx * dx + dy * dy + if (dist < bestDist) { + bestDist = dist + best = a + } + } + return best + } + + const onResizeStop = (_: Event, el: GridItemHTMLElement) => { + // el is the DOM element of the grid item + const node = el.gridstackNode // node contains w,h,x,y + if (!node) return + + const allowed = el.gridstackNode?.allowedSizes ?? [] + if (allowed.length === 0) { + return + } + + const target = closestAllowed(node.w ?? 1, node.h ?? 1, allowed ?? []) + + if (node.w !== target.w || node.h !== target.h) { + // update will reposition if necessary + node.grid?.update(el, { w: target.w, h: target.h }) + } + } + + return ( + + + + + + ) +} diff --git a/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx b/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx new file mode 100644 index 0000000000..5dcd9f023d --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx @@ -0,0 +1,110 @@ +import { F0AvatarAlert } from "@/components/avatars/F0AvatarAlert" +import { OneCalendar } from "@/experimental/OneCalendar" +import { getMockValue } from "@/mocks" +import type { Meta, StoryObj } from "@storybook/react-vite" +import { F0GridStack } from "../F0GridStack" + +const meta = { + title: "Utilities/GridStack", + component: F0GridStack, + tags: ["autodocs", "experimental"], + parameters: { + docs: { + description: { + component: [ + "This is a react wrapper for the gridstack library", + "It allows you to create a resizable and draggable grid layout", + ] + .map((line) => `

${line}

`) + .join(""), + }, + }, + }, + argTypes: { + options: { + table: { + description: + "The options for the grid (check the gridstack documentation for more details)", + type: { + summary: "GridStackReactOptions", + detail: `type GridStackReactOptions = { + // Number of columns in the grid + column?: number; + // Number of rows in the grid + row?: number; + columnOpts?: GridStackReactColumnOpts; + rowOpts?: GridStackReactRowOpts; + acceptWidgets?: boolean; + margin?: number; + draggable?: boolean; + }`, + }, + }, + }, + nodes: { + table: { + type: { + summary: "GridStackReactNode[]", + detail: `type GridStackReactNode = { + // The id of the node + id: string; + // The width in columns of the node + w?: number; + // The height in rows of the node + h?: number; + // The x position in columns of the node + x?: number; + // The y position in rows of the node + y?: number; + // The allowed sizes of the node + allowedSizes?: GridStackReactSize[]; + // Whether the node can be resized + allowResize?: boolean; + // Whether the node can be moved + allowMove?: boolean; + // The React element to render in the node + render?: React.ReactNode; + }`, + }, + }, + }, + onChange: { + description: "the callback function to when the layout changes", + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +const mockComponents = [ +
+ This is a long text that will be truncated with an ellipsis if it + doesn't fit in the container width. Hover over it to see the full text + in a tooltip. +
, +
+ +
, +
+ +
, +] + +export const Default: Story = { + args: { + options: { + column: 12, + }, + nodes: Array.from({ length: 10 }, (_, index) => ({ + id: `node-${index}`, + w: 2, + h: 2, + render: ( +
+ {getMockValue(mockComponents, index)} +
+ ), + })), + }, +} diff --git a/packages/react/src/components/Utilities/F0GridStack/__tests__/F0GridStack.test.tsx b/packages/react/src/components/Utilities/F0GridStack/__tests__/F0GridStack.test.tsx new file mode 100644 index 0000000000..14df106795 --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/__tests__/F0GridStack.test.tsx @@ -0,0 +1,563 @@ +import { zeroRender } from "@/testing/test-utils" +import "@testing-library/jest-dom/vitest" +import type { GridItemHTMLElement, GridStackWidget } from "gridstack" +import React, { type ReactNode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { GridStackReactNode } from "../F0GridStack" +import { F0GridStack } from "../F0GridStack" + +// Prevent organize imports from removing React (needed for JSX in mocks) +const _React = React + +// Mock gridstack library +vi.mock("gridstack", () => ({ + GridStack: { + init: vi.fn(() => ({ + on: vi.fn().mockReturnThis(), + addWidget: vi.fn(), + removeWidget: vi.fn(), + removeAll: vi.fn(), + save: vi.fn(), + update: vi.fn(), + destroy: vi.fn(), + })), + renderCB: null, + }, +})) + +// Mock the sub-components +vi.mock("../components/grid-stack-provider", () => ({ + GridStackProvider: ({ + children, + onResizeStop, + onChange, + }: { + children: ReactNode + onResizeStop?: (event: Event, el: GridItemHTMLElement) => void + onChange?: (layout: GridStackWidget[]) => void + }) => ( +
+ {children} +
+ ), +})) + +vi.mock("../components/grid-stack-render-provider", () => ({ + GridStackRenderProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock("../components/grid-stack-render", () => ({ + GridStackRender: () => ( +
Grid Content
+ ), +})) + +describe("F0GridStack", () => { + const mockNodes: GridStackReactNode[] = [ + { + id: "node-1", + x: 0, + y: 0, + w: 2, + h: 2, + render:
Node 1
, + }, + { + id: "node-2", + x: 2, + y: 0, + w: 3, + h: 1, + render:
Node 2
, + allowedSizes: [ + { w: 2, h: 1 }, + { w: 3, h: 1 }, + { w: 4, h: 2 }, + ], + }, + { + id: "node-3", + x: 0, + y: 2, + w: 1, + h: 1, + render:
Node 3
, + }, + ] + + const mockOptions = { + column: 12, + cellHeight: 100, + margin: 10, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("Component Rendering", () => { + it("should render without crashing", () => { + const { getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + expect(getByTestId("grid-stack-render-provider")).toBeTruthy() + expect(getByTestId("grid-stack-render")).toBeTruthy() + }) + + it("should render with empty nodes array", () => { + const { getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + }) + + it("should render with minimal options", () => { + const minimalOptions = { column: 6 } + const { getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + }) + }) + + describe("Nodes Handling", () => { + it("should pass nodes as children in grid options", () => { + const { getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + }) + + it("should handle nodes with different configurations", () => { + const diverseNodes: GridStackReactNode[] = [ + { + id: "small-node", + w: 1, + h: 1, + render: Small, + }, + { + id: "large-node", + w: 6, + h: 4, + render:
Large
, + }, + { + id: "positioned-node", + x: 5, + y: 5, + w: 2, + h: 2, + render:

Positioned

, + }, + ] + + const { getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-render")).toBeTruthy() + }) + + it("should handle nodes with allowedSizes", () => { + const nodesWithAllowedSizes: GridStackReactNode[] = [ + { + id: "constrained-node", + w: 2, + h: 2, + allowedSizes: [ + { w: 2, h: 2 }, + { w: 4, h: 4 }, + ], + render:
Constrained
, + }, + ] + + const { getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + }) + }) + + describe("Callbacks", () => { + it("should pass onChange callback to provider", () => { + const onChange = vi.fn() + const { getByTestId } = zeroRender( + + ) + + const provider = getByTestId("grid-stack-provider") + expect(provider.getAttribute("data-onchange")).toBe("defined") + }) + + it("should work without onChange callback", () => { + const { getByTestId } = zeroRender( + + ) + + const provider = getByTestId("grid-stack-provider") + expect(provider.getAttribute("data-onchange")).toBe("undefined") + }) + + it("should pass onResizeStop callback to provider", () => { + const { getByTestId } = zeroRender( + + ) + + const provider = getByTestId("grid-stack-provider") + expect(provider.getAttribute("data-onresizestop")).toBe("defined") + }) + }) + + describe("onResizeStop Logic", () => { + it("should handle resize with allowedSizes constraint", () => { + // Import the component to access the internal logic + // We'll test the closest allowed logic separately + const closestAllowed = ( + w: number, + h: number, + allowed: { w: number; h: number }[] + ) => { + let best = allowed[0], + bestDist = Infinity + for (const a of allowed) { + const dx = a.w - w, + dy = a.h - h + const dist = dx * dx + dy * dy + if (dist < bestDist) { + bestDist = dist + best = a + } + } + return best + } + + const result = closestAllowed(2.5, 1.5, [ + { w: 2, h: 1 }, + { w: 3, h: 1 }, + { w: 4, h: 2 }, + ]) + + // Should snap to 2x1 as it's the first with minimum distance + // Distance to 2x1: (2.5-2)^2 + (1.5-1)^2 = 0.5 + // Distance to 3x1: (2.5-3)^2 + (1.5-1)^2 = 0.5 (same distance, but 2x1 comes first) + expect(result).toEqual({ w: 2, h: 1 }) + }) + + it("should find closest allowed size - exact match", () => { + const closestAllowed = ( + w: number, + h: number, + allowed: { w: number; h: number }[] + ) => { + let best = allowed[0], + bestDist = Infinity + for (const a of allowed) { + const dx = a.w - w, + dy = a.h - h + const dist = dx * dx + dy * dy + if (dist < bestDist) { + bestDist = dist + best = a + } + } + return best + } + + const result = closestAllowed(3, 1, [ + { w: 2, h: 1 }, + { w: 3, h: 1 }, + { w: 4, h: 2 }, + ]) + + expect(result).toEqual({ w: 3, h: 1 }) + }) + + it("should find closest allowed size - between two options", () => { + const closestAllowed = ( + w: number, + h: number, + allowed: { w: number; h: number }[] + ) => { + let best = allowed[0], + bestDist = Infinity + for (const a of allowed) { + const dx = a.w - w, + dy = a.h - h + const dist = dx * dx + dy * dy + if (dist < bestDist) { + bestDist = dist + best = a + } + } + return best + } + + const result = closestAllowed(2.8, 1.2, [ + { w: 2, h: 1 }, + { w: 3, h: 1 }, + { w: 4, h: 2 }, + ]) + + expect(result).toEqual({ w: 3, h: 1 }) + }) + + it("should handle single allowed size", () => { + const closestAllowed = ( + w: number, + h: number, + allowed: { w: number; h: number }[] + ) => { + let best = allowed[0], + bestDist = Infinity + for (const a of allowed) { + const dx = a.w - w, + dy = a.h - h + const dist = dx * dx + dy * dy + if (dist < bestDist) { + bestDist = dist + best = a + } + } + return best + } + + const result = closestAllowed(5, 5, [{ w: 2, h: 2 }]) + + expect(result).toEqual({ w: 2, h: 2 }) + }) + + it("should calculate distance correctly for diagonal differences", () => { + const closestAllowed = ( + w: number, + h: number, + allowed: { w: number; h: number }[] + ) => { + let best = allowed[0], + bestDist = Infinity + for (const a of allowed) { + const dx = a.w - w, + dy = a.h - h + const dist = dx * dx + dy * dy + if (dist < bestDist) { + bestDist = dist + best = a + } + } + return best + } + + // Testing with larger differences + const result = closestAllowed(5, 5, [ + { w: 2, h: 2 }, // distance = (5-2)^2 + (5-2)^2 = 9 + 9 = 18 + { w: 4, h: 4 }, // distance = (5-4)^2 + (5-4)^2 = 1 + 1 = 2 (closest) + { w: 6, h: 3 }, // distance = (5-6)^2 + (5-3)^2 = 1 + 4 = 5 + ]) + + expect(result).toEqual({ w: 4, h: 4 }) + }) + }) + + describe("Grid Options", () => { + it("should merge options with nodes as children", () => { + const customOptions = { + column: 8, + cellHeight: 80, + margin: 5, + } + + const { getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + }) + + it("should handle options updates", () => { + const { rerender, getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + + // Rerender with updated options + rerender() + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + }) + }) + + describe("Edge Cases", () => { + it("should handle nodes without render property", () => { + const nodesWithoutRender: GridStackReactNode[] = [ + { + id: "no-render", + w: 2, + h: 2, + }, + ] + + const { getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + }) + + it("should handle nodes without id", () => { + const nodesWithoutId: GridStackReactNode[] = [ + { + w: 2, + h: 2, + render:
No ID
, + }, + ] + + const { getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + }) + + it("should handle empty allowedSizes array", () => { + const mockElement = { + gridstackNode: { + w: 2, + h: 2, + allowedSizes: [], + grid: { update: vi.fn() }, + }, + } as unknown as GridItemHTMLElement + + // The onResizeStop should return early when allowedSizes is empty + // This is implicitly tested by the component logic + expect(mockElement.gridstackNode?.allowedSizes).toEqual([]) + }) + + it("should handle undefined gridstackNode", () => { + const mockElement = { + gridstackNode: undefined, + } as unknown as GridItemHTMLElement + + // The onResizeStop should return early when node is undefined + expect(mockElement.gridstackNode).toBeUndefined() + }) + + it("should not update grid when size matches target", () => { + const mockGrid = { + update: vi.fn(), + } + + const mockElement = { + gridstackNode: { + w: 3, + h: 1, + allowedSizes: [ + { w: 2, h: 1 }, + { w: 3, h: 1 }, + ], + grid: mockGrid, + }, + } as unknown as GridItemHTMLElement + + // If current size is 3x1 and closest is also 3x1, update should not be called + const closestAllowed = ( + w: number, + h: number, + allowed: { w: number; h: number }[] + ) => { + let best = allowed[0], + bestDist = Infinity + for (const a of allowed) { + const dx = a.w - w, + dy = a.h - h + const dist = dx * dx + dy * dy + if (dist < bestDist) { + bestDist = dist + best = a + } + } + return best + } + + const target = closestAllowed(3, 1, [ + { w: 2, h: 1 }, + { w: 3, h: 1 }, + ]) + + expect(target).toEqual({ w: 3, h: 1 }) + expect(mockElement.gridstackNode?.w).toBe(3) + expect(mockElement.gridstackNode?.h).toBe(1) + }) + }) + + describe("Component Re-rendering", () => { + it("should handle node updates", () => { + const { rerender, getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + + const updatedNodes = [ + ...mockNodes, + { + id: "node-4", + w: 2, + h: 2, + render:
New Node
, + }, + ] + + rerender() + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + }) + + it("should handle callback updates", () => { + const onChange1 = vi.fn() + const { rerender, getByTestId } = zeroRender( + + ) + + expect( + getByTestId("grid-stack-provider").getAttribute("data-onchange") + ).toBe("defined") + + const onChange2 = vi.fn() + rerender( + + ) + + expect( + getByTestId("grid-stack-provider").getAttribute("data-onchange") + ).toBe("defined") + }) + }) +}) diff --git a/packages/react/src/components/Utilities/F0GridStack/components/__tests__/grid-stack-render-provider.test.tsx b/packages/react/src/components/Utilities/F0GridStack/components/__tests__/grid-stack-render-provider.test.tsx new file mode 100644 index 0000000000..2664b255cd --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/__tests__/grid-stack-render-provider.test.tsx @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { gridWidgetContainersMap } from "../grid-stack-render-provider" + +// Mock GridStack type +class MockGridStack { + el: HTMLElement + constructor() { + this.el = document.createElement("div") + } +} + +describe("GridStackRenderProvider", () => { + beforeEach(() => { + // Clear the WeakMap before each test + gridWidgetContainersMap.constructor.prototype.clear?.call( + gridWidgetContainersMap + ) + }) + + it("should store widget containers in WeakMap for each grid instance", () => { + // Mock grid instances + const grid1 = new MockGridStack() as any + const grid2 = new MockGridStack() as any + const widget1 = { id: "1", grid: grid1 } + const widget2 = { id: "2", grid: grid2 } + const element1 = document.createElement("div") + const element2 = document.createElement("div") + + // Simulate renderCB + const renderCB = (element, widget) => { + if (widget.id && widget.grid) { + // Get or create the widget container map for this grid instance + let containers = gridWidgetContainersMap.get(widget.grid) + if (!containers) { + containers = new Map() + gridWidgetContainersMap.set(widget.grid, containers) + } + containers.set(widget.id, element) + } + } + + renderCB(element1, widget1) + renderCB(element2, widget2) + + const containers1 = gridWidgetContainersMap.get(grid1) + const containers2 = gridWidgetContainersMap.get(grid2) + + expect(containers1?.get("1")).toBe(element1) + expect(containers2?.get("2")).toBe(element2) + }) + + it("should not have containers for different grid instances mixed up", () => { + const grid1 = new MockGridStack() as any + const grid2 = new MockGridStack() as any + const widget1 = { id: "1", grid: grid1 } + const widget2 = { id: "2", grid: grid1 } + const widget3 = { id: "3", grid: grid2 } + const element1 = document.createElement("div") + const element2 = document.createElement("div") + const element3 = document.createElement("div") + + // Simulate renderCB + const renderCB = (element: HTMLElement, widget: any) => { + if (widget.id && widget.grid) { + let containers = gridWidgetContainersMap.get(widget.grid) + if (!containers) { + containers = new Map() + gridWidgetContainersMap.set(widget.grid, containers) + } + containers.set(widget.id, element) + } + } + + renderCB(element1, widget1) + renderCB(element2, widget2) + renderCB(element3, widget3) + + const containers1 = gridWidgetContainersMap.get(grid1) + const containers2 = gridWidgetContainersMap.get(grid2) + + // Grid1 should have widgets 1 and 2 + expect(containers1?.size).toBe(2) + expect(containers1?.get("1")).toBe(element1) + expect(containers1?.get("2")).toBe(element2) + expect(containers1?.get("3")).toBeUndefined() + + // Grid2 should only have widget 3 + expect(containers2?.size).toBe(1) + expect(containers2?.get("3")).toBe(element3) + expect(containers2?.get("1")).toBeUndefined() + expect(containers2?.get("2")).toBeUndefined() + }) + + it("should clean up when grid instance is deleted from WeakMap", () => { + const grid = new MockGridStack() as any + const widget = { id: "1", grid } + const element = document.createElement("div") + + // Add to WeakMap + const containers = new Map() + containers.set(widget.id, element) + gridWidgetContainersMap.set(grid, containers) + + // Verify it exists + expect(gridWidgetContainersMap.has(grid)).toBe(true) + + // Delete from WeakMap + gridWidgetContainersMap.delete(grid) + + // Verify it's gone + expect(gridWidgetContainersMap.has(grid)).toBe(false) + }) + + it("should handle multiple widgets in the same grid", () => { + const grid = new MockGridStack() as any + const widgets = [ + { id: "1", grid }, + { id: "2", grid }, + { id: "3", grid }, + ] + const elements = widgets.map(() => document.createElement("div")) + + // Simulate renderCB for all widgets + widgets.forEach((widget, index) => { + const element = elements[index] + if (widget.id && widget.grid) { + let containers = gridWidgetContainersMap.get(widget.grid) + if (!containers) { + containers = new Map() + gridWidgetContainersMap.set(widget.grid, containers) + } + containers.set(widget.id, element) + } + }) + + const containers = gridWidgetContainersMap.get(grid) + expect(containers?.size).toBe(3) + expect(containers?.get("1")).toBe(elements[0]) + expect(containers?.get("2")).toBe(elements[1]) + expect(containers?.get("3")).toBe(elements[2]) + }) +}) diff --git a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-context.ts b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-context.ts new file mode 100644 index 0000000000..8b99ea19d9 --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-context.ts @@ -0,0 +1,42 @@ +import type { GridStack, GridStackOptions, GridStackWidget } from "gridstack" +import { createContext, useContext } from "react" + +export const GridStackContext = createContext<{ + initialOptions: GridStackOptions + gridStack: GridStack | null + addWidget: ( + widget: GridStackWidget & { id: Required["id"] } + ) => void + removeWidget: (id: string) => void + addSubGrid: ( + subGrid: GridStackWidget & { + id: Required["id"] + subGridOpts: Required["subGridOpts"] & { + children: Array< + GridStackWidget & { id: Required["id"] } + > + } + } + ) => void + saveOptions: () => GridStackOptions | GridStackWidget[] | undefined + removeAll: () => void + + _gridStack: { + value: GridStack | null + set: React.Dispatch> + } + _rawWidgetMetaMap: { + value: Map + set: React.Dispatch>> + } +} | null>(null) + +export function useGridStackContext() { + const context = useContext(GridStackContext) + if (!context) { + throw new Error( + "useGridStackContext must be used within a GridStackProvider" + ) + } + return context +} diff --git a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-provider.tsx b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-provider.tsx new file mode 100644 index 0000000000..77c82de231 --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-provider.tsx @@ -0,0 +1,142 @@ +import type { + GridItemHTMLElement, + GridStack, + GridStackOptions, + GridStackWidget, +} from "gridstack" +import { type PropsWithChildren, useCallback, useEffect, useState } from "react" +import { GridStackContext } from "./grid-stack-context" + +interface GridStackProviderProps { + children: React.ReactNode + initialOptions: GridStackOptions + onResizeStop?: (event: Event, el: GridItemHTMLElement) => void + onChange?: (layout: GridStackWidget[] | GridStackOptions) => void +} + +export function GridStackProvider({ + children, + initialOptions, + onResizeStop, + onChange, +}: PropsWithChildren) { + const [gridStack, setGridStack] = useState(null) + const [rawWidgetMetaMap, setRawWidgetMetaMap] = useState(() => { + const map = new Map() + const deepFindNodeWithContent = (obj: GridStackWidget) => { + if (obj.id && obj.render) { + map.set(obj.id, obj) + } + if (obj.subGridOpts?.children) { + obj.subGridOpts.children.forEach((child: GridStackWidget) => { + deepFindNodeWithContent(child) + }) + } + } + initialOptions.children?.forEach((child: GridStackWidget) => { + deepFindNodeWithContent(child) + }) + return map + }) + + useEffect(() => { + if (gridStack) { + gridStack.on("resizestop", (event: Event, el: GridItemHTMLElement) => { + onResizeStop?.(event, el) + }) + gridStack.on("change", () => { + const layout = gridStack.save() + onChange?.(layout) + }) + } + }, [gridStack, onResizeStop, onChange]) + + const addWidget = useCallback( + (widget: GridStackWidget & { id: Required["id"] }) => { + gridStack?.addWidget(widget) + setRawWidgetMetaMap((prev) => { + const newMap = new Map(prev) + newMap.set(widget.id, widget) + return newMap + }) + }, + [gridStack] + ) + + const addSubGrid = useCallback( + ( + subGrid: GridStackWidget & { + id: Required["id"] + subGridOpts: Required["subGridOpts"] & { + children: Array< + GridStackWidget & { id: Required["id"] } + > + } + } + ) => { + gridStack?.addWidget(subGrid) + + setRawWidgetMetaMap((prev) => { + const newMap = new Map(prev) + subGrid.subGridOpts?.children?.forEach( + (meta: GridStackWidget & { id: Required["id"] }) => { + newMap.set(meta.id, meta) + } + ) + return newMap + }) + }, + [gridStack] + ) + + const removeWidget = useCallback( + (id: string) => { + const element = document.body.querySelector( + `[gs-id="${id}"]` + ) + if (element) gridStack?.removeWidget(element) + + setRawWidgetMetaMap((prev) => { + const newMap = new Map(prev) + newMap.delete(id) + return newMap + }) + }, + [gridStack] + ) + + const saveOptions = useCallback(() => { + return gridStack?.save(true, true, (_, widget) => widget) + }, [gridStack]) + + const removeAll = useCallback(() => { + gridStack?.removeAll() + setRawWidgetMetaMap(new Map()) + }, [gridStack]) + + return ( + + {children} + + ) +} diff --git a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-context.ts b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-context.ts new file mode 100644 index 0000000000..90ae178761 --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-context.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react" + +export const GridStackRenderContext = createContext<{ + getWidgetContainer: (widgetId: string) => HTMLElement | null +} | null>(null) + +export function useGridStackRenderContext() { + const context = useContext(GridStackRenderContext) + if (!context) { + throw new Error( + "useGridStackRenderContext must be used within a GridStackProvider" + ) + } + return context +} diff --git a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-provider.tsx b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-provider.tsx new file mode 100644 index 0000000000..05b115754e --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-provider.tsx @@ -0,0 +1,111 @@ +import { GridStack, GridStackOptions, GridStackWidget } from "gridstack" +import isEqual from "lodash/isEqual" +import { + PropsWithChildren, + useCallback, + useLayoutEffect, + useMemo, + useRef, +} from "react" +import { useGridStackContext } from "./grid-stack-context" +import { GridStackRenderContext } from "./grid-stack-render-context" + +// WeakMap to store widget containers for each grid instance +export const gridWidgetContainersMap = new WeakMap< + GridStack, + Map +>() + +export function GridStackRenderProvider({ children }: PropsWithChildren) { + const { + _gridStack: { value: gridStack, set: setGridStack }, + initialOptions, + } = useGridStackContext() + + const widgetContainersRef = useRef>(new Map()) + const containerRef = useRef(null) + const optionsRef = useRef(initialOptions) + + const renderCBFn = useCallback( + (element: HTMLElement, widget: GridStackWidget & { grid?: GridStack }) => { + if (widget.id && widget.grid) { + // Get or create the widget container map for this grid instance + let containers = gridWidgetContainersMap.get(widget.grid) + if (!containers) { + containers = new Map() + gridWidgetContainersMap.set(widget.grid, containers) + } + containers.set(widget.id, element) + + // Also update the local ref for backward compatibility + widgetContainersRef.current.set(widget.id, element) + } + }, + [] + ) + + const initGrid = useCallback(() => { + if (containerRef.current) { + console.log("initializing grid", optionsRef.current) + GridStack.renderCB = renderCBFn + return GridStack.init(optionsRef.current, containerRef.current) + // ! Change event not firing on nested grids (resize, move...) https://github.com/gridstack/gridstack.js/issues/2671 + // .on("change", () => { + // console.log("changed"); + // }) + // .on("resize", () => { + // console.log("resize"); + // }) + } + return null + }, [renderCBFn]) + useLayoutEffect(() => { + if (!isEqual(initialOptions, optionsRef.current) && gridStack) { + try { + gridStack.removeAll(false) + gridStack.destroy(false) + widgetContainersRef.current.clear() + // Clean up the WeakMap entry for this grid instance + gridWidgetContainersMap.delete(gridStack) + optionsRef.current = initialOptions + setGridStack(initGrid()) + } catch (e) { + console.error("Error reinitializing gridstack", e) + } + } + }, [initialOptions, gridStack, initGrid, setGridStack]) + + useLayoutEffect(() => { + if (!gridStack) { + try { + setGridStack(initGrid()) + } catch (e) { + console.error("Error initializing gridstack", e) + } + } + }, [gridStack, initGrid, setGridStack]) + + return ( + ({ + getWidgetContainer: (widgetId: string) => { + // First try to get from the current grid instance's map + if (gridStack) { + const containers = gridWidgetContainersMap.get(gridStack) + if (containers?.has(widgetId)) { + return containers.get(widgetId) || null + } + } + // Fallback to local ref for backward compatibility + return widgetContainersRef.current.get(widgetId) || null + }, + }), + // ! gridStack is required to reinitialize the grid when the options change + [gridStack] + )} + > +
{gridStack ? children : null}
+
+ ) +} diff --git a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx new file mode 100644 index 0000000000..b8f2f1801a --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx @@ -0,0 +1,38 @@ +import { ComponentType } from "react" +import { createPortal } from "react-dom" +import { useGridStackContext } from "./grid-stack-context" +import { useGridStackRenderContext } from "./grid-stack-render-context" +import { GridStackWidgetContext } from "./grid-stack-widget-context" + +export interface ComponentDataType { + name: string + props: T +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ComponentMap = Record> + +export function GridStackRender() { + const { _rawWidgetMetaMap } = useGridStackContext() + const { getWidgetContainer } = useGridStackRenderContext() + + return ( + <> + {Array.from(_rawWidgetMetaMap.value.entries()).map(([id, meta]) => { + const widgetContainer = getWidgetContainer(id) + + if (!widgetContainer) { + console.warn("Widget container not found for id: ", id) + return null + throw new Error(`Widget container not found for id: ${id}`) + } + + return ( + + {createPortal(meta.render, widgetContainer)} + + ) + })} + + ) +} diff --git a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-widget-context.ts b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-widget-context.ts new file mode 100644 index 0000000000..ff894b0913 --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-widget-context.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react" + +// TODO: support full widget metadata +export const GridStackWidgetContext = createContext<{ + widget: { + id: string + } +} | null>(null) + +export function useGridStackWidgetContext() { + const context = useContext(GridStackWidgetContext) + if (!context) { + throw new Error( + "useGridStackWidgetContext must be used within a GridStackWidgetProvider" + ) + } + return context +} diff --git a/packages/react/src/components/Utilities/F0GridStack/index.ts b/packages/react/src/components/Utilities/F0GridStack/index.ts new file mode 100644 index 0000000000..6538c12284 --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/index.ts @@ -0,0 +1,8 @@ +import { experimentalComponent } from "@/lib/experimental" +import { F0GridStack as F0GridStackComponent } from "./F0GridStack" +export * from "./F0GridStack" + +export const F0GridStack = experimentalComponent( + "F0GridStack", + F0GridStackComponent +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dab2993241..646f8e2035 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,6 +342,9 @@ importers: embla-carousel-wheel-gestures: specifier: ^8.0.1 version: 8.0.1(embla-carousel@8.6.0) + gridstack: + specifier: ^12.3.3 + version: 12.3.3 lucide-react: specifier: ^0.383.0 version: 0.383.0(react@18.3.1) @@ -8233,6 +8236,9 @@ packages: resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gridstack@12.3.3: + resolution: {integrity: sha512-Bboi4gj7HXGnx1VFXQNde4Nwi5srdUSuCCnOSszKhFjBs8EtMEWhsKX02BjIKkErq/FjQUkNUbXUYeQaVMQ0jQ==} + groq-sdk@0.5.0: resolution: {integrity: sha512-RVmhW7qZ+XZoy5fIuSdx/LGQJONpL8MHgZEW7dFwTdgkzStub2XQx6OKv28CHogijdwH41J+Npj/z2jBPu3vmw==} @@ -21919,6 +21925,8 @@ snapshots: graphql@16.11.0: {} + gridstack@12.3.3: {} + groq-sdk@0.5.0: dependencies: '@types/node': 18.19.118 From bb135e1f4a851eef39acf87de8845159f88695d1 Mon Sep 17 00:00:00 2001 From: Segio Carracedo Date: Thu, 20 Nov 2025 09:13:32 +0100 Subject: [PATCH 2/8] feat: add F0GridStack component --- .../Utilities/F0GridStack/F0GridStack.tsx | 184 +++++++++++++----- .../__stories__/GridStackReact.stories.tsx | 131 ++++++++++++- .../__tests__/F0GridStack.test.tsx | 91 +++++---- .../components/grid-stack-context.ts | 17 +- .../components/grid-stack-provider.tsx | 74 +++++-- .../components/grid-stack-render-provider.tsx | 9 +- .../components/grid-stack-render.tsx | 4 +- .../Utilities/F0GridStack/components/types.ts | 20 ++ 8 files changed, 398 insertions(+), 132 deletions(-) create mode 100644 packages/react/src/components/Utilities/F0GridStack/components/types.ts diff --git a/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx b/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx index 5f97f9d209..df384b9bd3 100644 --- a/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx +++ b/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx @@ -1,11 +1,11 @@ import { GridItemHTMLElement, - GridStackNode, GridStackOptions, GridStackWidget, } from "gridstack" import "gridstack/dist/gridstack.css" -import { useMemo } from "react" +import { forwardRef, useImperativeHandle, useMemo } from "react" +import { useGridStackContext } from "./components/grid-stack-context" import { GridStackProvider } from "./components/grid-stack-provider" import { GridStackRender } from "./components/grid-stack-render" import { GridStackRenderProvider } from "./components/grid-stack-render-provider" @@ -14,73 +14,153 @@ export type GridStackReactOptions = Omit export type GridStackReactSize = { w: number; h: number } -export interface GridStackReactNode extends GridStackNode { +export interface GridStackReactWidget extends GridStackWidget { + id: Required["id"] allowedSizes?: GridStackReactSize[] - render?: React.ReactNode + renderFn?: () => React.ReactElement +} + +/** + * Represents a node in the grid layout. + */ +export interface GridStackWidgetPosition { + id: string + w: number + h: number + x: number + y: number + meta?: Record } export interface F0GridStackProps { options: GridStackReactOptions - nodes: GridStackReactNode[] - onChange?: (layout: GridStackWidget[] | GridStackOptions) => void + widgets: GridStackReactWidget[] + onChange?: (layout: GridStackWidgetPosition[]) => void } -export const F0GridStack = ({ options, nodes, onChange }: F0GridStackProps) => { - const gridOptions = useMemo( +/** + * Methods exposed via ref to control the grid programmatically. + * @example + * ```tsx + * const gridRef = useRef(null) + * + * // Add a widget + * gridRef.current?.addWidget({ + * id: 'new-widget', + * w: 2, + * h: 2, + * renderFn: () =>
Content
+ * meta: { + * // Your metadata associated with the widget + * } + * }) + * + * // Remove a widget + * gridRef.current?.removeWidget('widget-id') + * + * // Remove all widgets + * gridRef.current?.removeAll() + * + * // Save current layout + * const layout = gridRef.current?.saveOptions() + * ``` + */ +export interface F0GridStackRef { + addWidget: (widget: GridStackReactWidget) => void + removeWidget: (id: string) => void + addSubGrid: ( + subGrid: GridStackReactWidget & { + id: Required["id"] + subGridOpts: Required["subGridOpts"] & { + children: Array< + GridStackWidget & { id: Required["id"] } + > + } + } + ) => void + removeAll: () => void +} + +const RefHandler = forwardRef((_, ref) => { + const context = useGridStackContext() + + useImperativeHandle( + ref, () => ({ - ...options, - children: nodes, + addWidget: context.addWidget, + removeWidget: context.removeWidget, + addSubGrid: context.addSubGrid, + removeAll: context.removeAll, }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [nodes] + [context] ) - const closestAllowed = ( - w: number, - h: number, - allowed: { w: number; h: number }[] - ) => { - let best = allowed[0], - bestDist = Infinity - for (const a of allowed) { - const dx = a.w - w, - dy = a.h - h - const dist = dx * dx + dy * dy - if (dist < bestDist) { - bestDist = dist - best = a + return null +}) + +RefHandler.displayName = "RefHandler" + +export const F0GridStack = forwardRef( + ({ options, widgets, onChange }, ref) => { + const gridOptions = useMemo( + () => ({ + ...options, + children: widgets, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [widgets] + ) + + const closestAllowed = ( + w: number, + h: number, + allowed: { w: number; h: number }[] + ) => { + let best = allowed[0], + bestDist = Infinity + for (const a of allowed) { + const dx = a.w - w, + dy = a.h - h + const dist = dx * dx + dy * dy + if (dist < bestDist) { + bestDist = dist + best = a + } } + return best } - return best - } - const onResizeStop = (_: Event, el: GridItemHTMLElement) => { - // el is the DOM element of the grid item - const node = el.gridstackNode // node contains w,h,x,y - if (!node) return + const onResizeStop = (_: Event, el: GridItemHTMLElement) => { + // el is the DOM element of the grid item + const node = el.gridstackNode // node contains w,h,x,y + if (!node) return - const allowed = el.gridstackNode?.allowedSizes ?? [] - if (allowed.length === 0) { - return - } + const allowed = el.gridstackNode?.allowedSizes ?? [] + if (allowed.length === 0) { + return + } - const target = closestAllowed(node.w ?? 1, node.h ?? 1, allowed ?? []) + const target = closestAllowed(node.w ?? 1, node.h ?? 1, allowed ?? []) - if (node.w !== target.w || node.h !== target.h) { - // update will reposition if necessary - node.grid?.update(el, { w: target.w, h: target.h }) + if (node.w !== target.w || node.h !== target.h) { + // update will reposition if necessary + node.grid?.update(el, { w: target.w, h: target.h }) + } } + + return ( + + + + + + + ) } +) - return ( - - - - - - ) -} +F0GridStack.displayName = "F0GridStack" diff --git a/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx b/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx index 5dcd9f023d..1fd584592b 100644 --- a/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx +++ b/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx @@ -1,8 +1,15 @@ import { F0AvatarAlert } from "@/components/avatars/F0AvatarAlert" +import { F0Button } from "@/components/F0Button" import { OneCalendar } from "@/experimental/OneCalendar" import { getMockValue } from "@/mocks" import type { Meta, StoryObj } from "@storybook/react-vite" -import { F0GridStack } from "../F0GridStack" +import { cloneElement, useCallback, useMemo, useRef, useState } from "react" +import { + F0GridStack, + GridStackReactWidget, + GridStackWidgetPosition, + type F0GridStackRef, +} from "../F0GridStack" const meta = { title: "Utilities/GridStack", @@ -41,7 +48,7 @@ const meta = { }, }, }, - nodes: { + widgets: { table: { type: { summary: "GridStackReactNode[]", @@ -62,8 +69,8 @@ const meta = { allowResize?: boolean; // Whether the node can be moved allowMove?: boolean; - // The React element to render in the node - render?: React.ReactNode; + // The function to render the node + renderFn?: () => React.ReactElement; }`, }, }, @@ -72,6 +79,24 @@ const meta = { description: "the callback function to when the layout changes", }, }, + decorators: [ + (Story, { args }) => { + const [positions, setPositions] = useState([]) + return ( +
+ { + setPositions(layout) + }, + }} + /> +
{JSON.stringify(positions, null, 2)}
+
+ ) + }, + ], } satisfies Meta export default meta @@ -96,11 +121,11 @@ export const Default: Story = { options: { column: 12, }, - nodes: Array.from({ length: 10 }, (_, index) => ({ - id: `node-${index}`, + widgets: Array.from({ length: 10 }, (_, index) => ({ + id: `widget-${index}`, w: 2, h: 2, - render: ( + renderFn: () => (
{getMockValue(mockComponents, index)}
@@ -108,3 +133,95 @@ export const Default: Story = { })), }, } + +export const WithRefMethods: Story = { + args: { + options: { + column: 12, + }, + widgets: [], + }, + render: () => { + const gridRef = useRef(null) + const [widgetCounter, setWidgetCounter] = useState(10) + + const handleAddWidget = useCallback(() => { + const newId = `node-${widgetCounter + 1}` + gridRef.current?.addWidget({ + id: newId, + w: 2, + h: 2, + meta: { + title: `New Widget ${newId}`, + }, + renderFn: () => ( +
+ New Widget {newId} +
+ ), + }) + setWidgetCounter(widgetCounter + 1) + }, [widgetCounter]) + + const handleRemoveWidget = () => { + if (widgetCounter > 0) { + const idToRemove = `node-${widgetCounter}` + gridRef.current?.removeWidget(idToRemove) + setWidgetCounter((prev) => prev - 1) + } + } + + const handleRemoveAll = () => { + gridRef.current?.removeAll() + setWidgetCounter(0) + } + + const widgets = useMemo( + () => + Array.from({ length: 10 }, (_, index) => ({ + id: `node-${index + 1}`, + w: 2, + h: 2, + meta: { + title: `Widget ${index + 1}`, + }, + renderFn: () => ( +
+ {cloneElement(getMockValue(mockComponents, index), { + key: `node-${index + 1}`, + })} +
+ ), + })), + [] + ) + + const [positions, setPositions] = useState([]) + + return ( +
+
+ + + +
+ { + console.log("layout", positions) + setPositions(positions) + }} + widgets={widgets} + /> + +
{JSON.stringify(positions, null, 2)}
+
+ ) + }, +} diff --git a/packages/react/src/components/Utilities/F0GridStack/__tests__/F0GridStack.test.tsx b/packages/react/src/components/Utilities/F0GridStack/__tests__/F0GridStack.test.tsx index 14df106795..419887ce10 100644 --- a/packages/react/src/components/Utilities/F0GridStack/__tests__/F0GridStack.test.tsx +++ b/packages/react/src/components/Utilities/F0GridStack/__tests__/F0GridStack.test.tsx @@ -3,7 +3,7 @@ import "@testing-library/jest-dom/vitest" import type { GridItemHTMLElement, GridStackWidget } from "gridstack" import React, { type ReactNode } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import type { GridStackReactNode } from "../F0GridStack" +import type { GridStackReactWidget } from "../F0GridStack" import { F0GridStack } from "../F0GridStack" // Prevent organize imports from removing React (needed for JSX in mocks) @@ -25,6 +25,26 @@ vi.mock("gridstack", () => ({ }, })) +// Mock the context hook +vi.mock("../components/grid-stack-context", () => ({ + useGridStackContext: vi.fn(() => ({ + initialOptions: {}, + gridStack: null, + addWidget: vi.fn(), + removeWidget: vi.fn(), + addSubGrid: vi.fn(), + removeAll: vi.fn(), + _gridStack: { + value: null, + set: vi.fn(), + }, + _rawWidgetMetaMap: { + value: new Map(), + set: vi.fn(), + }, + })), +})) + // Mock the sub-components vi.mock("../components/grid-stack-provider", () => ({ GridStackProvider: ({ @@ -59,14 +79,14 @@ vi.mock("../components/grid-stack-render", () => ({ })) describe("F0GridStack", () => { - const mockNodes: GridStackReactNode[] = [ + const mockWidgets: GridStackReactWidget[] = [ { id: "node-1", x: 0, y: 0, w: 2, h: 2, - render:
Node 1
, + renderFn: () =>
Widget 1
, }, { id: "node-2", @@ -74,7 +94,7 @@ describe("F0GridStack", () => { y: 0, w: 3, h: 1, - render:
Node 2
, + renderFn: () =>
Widget 2
, allowedSizes: [ { w: 2, h: 1 }, { w: 3, h: 1 }, @@ -87,7 +107,7 @@ describe("F0GridStack", () => { y: 2, w: 1, h: 1, - render:
Node 3
, + renderFn: () =>
Widget 3
, }, ] @@ -104,7 +124,7 @@ describe("F0GridStack", () => { describe("Component Rendering", () => { it("should render without crashing", () => { const { getByTestId } = zeroRender( - + ) expect(getByTestId("grid-stack-provider")).toBeTruthy() @@ -114,7 +134,7 @@ describe("F0GridStack", () => { it("should render with empty nodes array", () => { const { getByTestId } = zeroRender( - + ) expect(getByTestId("grid-stack-provider")).toBeTruthy() @@ -123,7 +143,7 @@ describe("F0GridStack", () => { it("should render with minimal options", () => { const minimalOptions = { column: 6 } const { getByTestId } = zeroRender( - + ) expect(getByTestId("grid-stack-provider")).toBeTruthy() @@ -133,25 +153,25 @@ describe("F0GridStack", () => { describe("Nodes Handling", () => { it("should pass nodes as children in grid options", () => { const { getByTestId } = zeroRender( - + ) expect(getByTestId("grid-stack-provider")).toBeTruthy() }) it("should handle nodes with different configurations", () => { - const diverseNodes: GridStackReactNode[] = [ + const diverseWidgets: GridStackReactWidget[] = [ { id: "small-node", w: 1, h: 1, - render: Small, + renderFn: () => Small, }, { id: "large-node", w: 6, h: 4, - render:
Large
, + renderFn: () =>
Large
, }, { id: "positioned-node", @@ -159,19 +179,19 @@ describe("F0GridStack", () => { y: 5, w: 2, h: 2, - render:

Positioned

, + renderFn: () =>

Positioned

, }, ] const { getByTestId } = zeroRender( - + ) expect(getByTestId("grid-stack-render")).toBeTruthy() }) it("should handle nodes with allowedSizes", () => { - const nodesWithAllowedSizes: GridStackReactNode[] = [ + const nodesWithAllowedSizes: GridStackReactWidget[] = [ { id: "constrained-node", w: 2, @@ -180,12 +200,12 @@ describe("F0GridStack", () => { { w: 2, h: 2 }, { w: 4, h: 4 }, ], - render:
Constrained
, + renderFn: () =>
Constrained
, }, ] const { getByTestId } = zeroRender( - + ) expect(getByTestId("grid-stack-provider")).toBeTruthy() @@ -198,7 +218,7 @@ describe("F0GridStack", () => { const { getByTestId } = zeroRender( ) @@ -209,7 +229,7 @@ describe("F0GridStack", () => { it("should work without onChange callback", () => { const { getByTestId } = zeroRender( - + ) const provider = getByTestId("grid-stack-provider") @@ -218,7 +238,7 @@ describe("F0GridStack", () => { it("should pass onResizeStop callback to provider", () => { const { getByTestId } = zeroRender( - + ) const provider = getByTestId("grid-stack-provider") @@ -384,7 +404,7 @@ describe("F0GridStack", () => { } const { getByTestId } = zeroRender( - + ) expect(getByTestId("grid-stack-provider")).toBeTruthy() @@ -392,13 +412,13 @@ describe("F0GridStack", () => { it("should handle options updates", () => { const { rerender, getByTestId } = zeroRender( - + ) expect(getByTestId("grid-stack-provider")).toBeTruthy() // Rerender with updated options - rerender() + rerender() expect(getByTestId("grid-stack-provider")).toBeTruthy() }) @@ -406,7 +426,7 @@ describe("F0GridStack", () => { describe("Edge Cases", () => { it("should handle nodes without render property", () => { - const nodesWithoutRender: GridStackReactNode[] = [ + const nodesWithoutRender: GridStackReactWidget[] = [ { id: "no-render", w: 2, @@ -415,23 +435,24 @@ describe("F0GridStack", () => { ] const { getByTestId } = zeroRender( - + ) expect(getByTestId("grid-stack-provider")).toBeTruthy() }) it("should handle nodes without id", () => { - const nodesWithoutId: GridStackReactNode[] = [ + const nodesWithoutId: GridStackReactWidget[] = [ { + id: "no-id", w: 2, h: 2, - render:
No ID
, + renderFn: () =>
No ID
, }, ] const { getByTestId } = zeroRender( - + ) expect(getByTestId("grid-stack-provider")).toBeTruthy() @@ -512,22 +533,22 @@ describe("F0GridStack", () => { describe("Component Re-rendering", () => { it("should handle node updates", () => { const { rerender, getByTestId } = zeroRender( - + ) expect(getByTestId("grid-stack-provider")).toBeTruthy() - const updatedNodes = [ - ...mockNodes, + const updatedWidgets = [ + ...mockWidgets, { id: "node-4", w: 2, h: 2, - render:
New Node
, + renderFn: () =>
New Node
, }, ] - rerender() + rerender() expect(getByTestId("grid-stack-provider")).toBeTruthy() }) @@ -537,7 +558,7 @@ describe("F0GridStack", () => { const { rerender, getByTestId } = zeroRender( ) @@ -550,7 +571,7 @@ describe("F0GridStack", () => { rerender( ) diff --git a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-context.ts b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-context.ts index 8b99ea19d9..8ee06f4233 100644 --- a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-context.ts +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-context.ts @@ -1,24 +1,23 @@ import type { GridStack, GridStackOptions, GridStackWidget } from "gridstack" import { createContext, useContext } from "react" +import "./types" + +export type GridStackWidgetWithRequiredId = GridStackWidget & { + id: Required["id"] +} export const GridStackContext = createContext<{ initialOptions: GridStackOptions gridStack: GridStack | null - addWidget: ( - widget: GridStackWidget & { id: Required["id"] } - ) => void + addWidget: (widget: GridStackWidgetWithRequiredId) => void removeWidget: (id: string) => void addSubGrid: ( - subGrid: GridStackWidget & { - id: Required["id"] + subGrid: GridStackWidgetWithRequiredId & { subGridOpts: Required["subGridOpts"] & { - children: Array< - GridStackWidget & { id: Required["id"] } - > + children: Array } } ) => void - saveOptions: () => GridStackOptions | GridStackWidget[] | undefined removeAll: () => void _gridStack: { diff --git a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-provider.tsx b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-provider.tsx index 77c82de231..3f17711533 100644 --- a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-provider.tsx +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-provider.tsx @@ -5,13 +5,15 @@ import type { GridStackWidget, } from "gridstack" import { type PropsWithChildren, useCallback, useEffect, useState } from "react" +import { GridStackWidgetPosition } from "../F0GridStack" import { GridStackContext } from "./grid-stack-context" +import "./types" interface GridStackProviderProps { children: React.ReactNode initialOptions: GridStackOptions onResizeStop?: (event: Event, el: GridItemHTMLElement) => void - onChange?: (layout: GridStackWidget[] | GridStackOptions) => void + onChange?: (layout: GridStackWidgetPosition[]) => void } export function GridStackProvider({ @@ -24,9 +26,10 @@ export function GridStackProvider({ const [rawWidgetMetaMap, setRawWidgetMetaMap] = useState(() => { const map = new Map() const deepFindNodeWithContent = (obj: GridStackWidget) => { - if (obj.id && obj.render) { + if (obj.id && obj.renderFn?.()) { map.set(obj.id, obj) } + if (obj.subGridOpts?.children) { obj.subGridOpts.children.forEach((child: GridStackWidget) => { deepFindNodeWithContent(child) @@ -39,20 +42,56 @@ export function GridStackProvider({ return map }) + const emitChange = useCallback(() => { + if (!gridStack) { + return + } + + const layout = gridStack.save() + + if (Array.isArray(layout)) { + onChange?.( + layout.map((item) => ({ + id: item.id ?? "", + meta: item.meta, + w: item.w ?? 1, + h: item.h ?? 1, + x: item.x ?? 0, + y: item.y ?? 0, + })) + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gridStack]) + useEffect(() => { - if (gridStack) { - gridStack.on("resizestop", (event: Event, el: GridItemHTMLElement) => { - onResizeStop?.(event, el) - }) - gridStack.on("change", () => { - const layout = gridStack.save() - onChange?.(layout) - }) + if (!gridStack) return + + const handleResizeStop = (event: Event, el: GridItemHTMLElement) => { + onResizeStop?.(event, el) } - }, [gridStack, onResizeStop, onChange]) + + gridStack.on("resizestop", handleResizeStop) + gridStack.on("change added removed", emitChange) + + return () => { + gridStack.off("resizestop") + gridStack.off("change added removed") + } + }, [gridStack, onResizeStop, emitChange]) + + useEffect(() => { + if (!gridStack) return + emitChange() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gridStack]) const addWidget = useCallback( - (widget: GridStackWidget & { id: Required["id"] }) => { + ( + widget: GridStackWidget & { + id: Required["id"] + } + ) => { gridStack?.addWidget(widget) setRawWidgetMetaMap((prev) => { const newMap = new Map(prev) @@ -79,7 +118,11 @@ export function GridStackProvider({ setRawWidgetMetaMap((prev) => { const newMap = new Map(prev) subGrid.subGridOpts?.children?.forEach( - (meta: GridStackWidget & { id: Required["id"] }) => { + ( + meta: GridStackWidget & { + id: Required["id"] + } + ) => { newMap.set(meta.id, meta) } ) @@ -105,10 +148,6 @@ export function GridStackProvider({ [gridStack] ) - const saveOptions = useCallback(() => { - return gridStack?.save(true, true, (_, widget) => widget) - }, [gridStack]) - const removeAll = useCallback(() => { gridStack?.removeAll() setRawWidgetMetaMap(new Map()) @@ -123,7 +162,6 @@ export function GridStackProvider({ addWidget, removeWidget, addSubGrid, - saveOptions, removeAll, _gridStack: { diff --git a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-provider.tsx b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-provider.tsx index 05b115754e..3043bc894d 100644 --- a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-provider.tsx +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-provider.tsx @@ -46,19 +46,12 @@ export function GridStackRenderProvider({ children }: PropsWithChildren) { const initGrid = useCallback(() => { if (containerRef.current) { - console.log("initializing grid", optionsRef.current) GridStack.renderCB = renderCBFn return GridStack.init(optionsRef.current, containerRef.current) - // ! Change event not firing on nested grids (resize, move...) https://github.com/gridstack/gridstack.js/issues/2671 - // .on("change", () => { - // console.log("changed"); - // }) - // .on("resize", () => { - // console.log("resize"); - // }) } return null }, [renderCBFn]) + useLayoutEffect(() => { if (!isEqual(initialOptions, optionsRef.current) && gridStack) { try { diff --git a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx index b8f2f1801a..b4ca114a68 100644 --- a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx @@ -22,14 +22,12 @@ export function GridStackRender() { const widgetContainer = getWidgetContainer(id) if (!widgetContainer) { - console.warn("Widget container not found for id: ", id) return null - throw new Error(`Widget container not found for id: ${id}`) } return ( - {createPortal(meta.render, widgetContainer)} + {createPortal(meta.renderFn?.(), widgetContainer)} ) })} diff --git a/packages/react/src/components/Utilities/F0GridStack/components/types.ts b/packages/react/src/components/Utilities/F0GridStack/components/types.ts new file mode 100644 index 0000000000..989245e084 --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/types.ts @@ -0,0 +1,20 @@ +import type React from "react" + +declare module "gridstack" { + interface GridStackWidget { + id?: string + allowedSizes?: Array<{ w: number; h: number }> + renderFn?: () => React.ReactElement | null + meta?: Record + } + + interface GridStackNode { + id?: string + w?: number + h?: number + x?: number + y?: number + allowedSizes?: Array<{ w: number; h: number }> + renderFn?: () => React.ReactElement | null + } +} From a1789f8175df262af8582290eff11c30db865aac Mon Sep 17 00:00:00 2001 From: Segio Carracedo Date: Thu, 20 Nov 2025 09:22:50 +0100 Subject: [PATCH 3/8] chore: remive any on tests --- .../grid-stack-render-provider.test.tsx | 36 +++++++++++-------- .../components/grid-stack-render.tsx | 9 ----- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/packages/react/src/components/Utilities/F0GridStack/components/__tests__/grid-stack-render-provider.test.tsx b/packages/react/src/components/Utilities/F0GridStack/components/__tests__/grid-stack-render-provider.test.tsx index 2664b255cd..338672547f 100644 --- a/packages/react/src/components/Utilities/F0GridStack/components/__tests__/grid-stack-render-provider.test.tsx +++ b/packages/react/src/components/Utilities/F0GridStack/components/__tests__/grid-stack-render-provider.test.tsx @@ -1,3 +1,4 @@ +import type { GridStack } from "gridstack" import { beforeEach, describe, expect, it } from "vitest" import { gridWidgetContainersMap } from "../grid-stack-render-provider" @@ -9,6 +10,11 @@ class MockGridStack { } } +interface TestWidget { + id: string + grid: GridStack +} + describe("GridStackRenderProvider", () => { beforeEach(() => { // Clear the WeakMap before each test @@ -19,15 +25,15 @@ describe("GridStackRenderProvider", () => { it("should store widget containers in WeakMap for each grid instance", () => { // Mock grid instances - const grid1 = new MockGridStack() as any - const grid2 = new MockGridStack() as any - const widget1 = { id: "1", grid: grid1 } - const widget2 = { id: "2", grid: grid2 } + const grid1 = new MockGridStack() as unknown as GridStack + const grid2 = new MockGridStack() as unknown as GridStack + const widget1: TestWidget = { id: "1", grid: grid1 } + const widget2: TestWidget = { id: "2", grid: grid2 } const element1 = document.createElement("div") const element2 = document.createElement("div") // Simulate renderCB - const renderCB = (element, widget) => { + const renderCB = (element: HTMLElement, widget: TestWidget) => { if (widget.id && widget.grid) { // Get or create the widget container map for this grid instance let containers = gridWidgetContainersMap.get(widget.grid) @@ -50,17 +56,17 @@ describe("GridStackRenderProvider", () => { }) it("should not have containers for different grid instances mixed up", () => { - const grid1 = new MockGridStack() as any - const grid2 = new MockGridStack() as any - const widget1 = { id: "1", grid: grid1 } - const widget2 = { id: "2", grid: grid1 } - const widget3 = { id: "3", grid: grid2 } + const grid1 = new MockGridStack() as unknown as GridStack + const grid2 = new MockGridStack() as unknown as GridStack + const widget1: TestWidget = { id: "1", grid: grid1 } + const widget2: TestWidget = { id: "2", grid: grid1 } + const widget3: TestWidget = { id: "3", grid: grid2 } const element1 = document.createElement("div") const element2 = document.createElement("div") const element3 = document.createElement("div") // Simulate renderCB - const renderCB = (element: HTMLElement, widget: any) => { + const renderCB = (element: HTMLElement, widget: TestWidget) => { if (widget.id && widget.grid) { let containers = gridWidgetContainersMap.get(widget.grid) if (!containers) { @@ -92,8 +98,8 @@ describe("GridStackRenderProvider", () => { }) it("should clean up when grid instance is deleted from WeakMap", () => { - const grid = new MockGridStack() as any - const widget = { id: "1", grid } + const grid = new MockGridStack() as unknown as GridStack + const widget: TestWidget = { id: "1", grid } const element = document.createElement("div") // Add to WeakMap @@ -112,8 +118,8 @@ describe("GridStackRenderProvider", () => { }) it("should handle multiple widgets in the same grid", () => { - const grid = new MockGridStack() as any - const widgets = [ + const grid = new MockGridStack() as unknown as GridStack + const widgets: TestWidget[] = [ { id: "1", grid }, { id: "2", grid }, { id: "3", grid }, diff --git a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx index b4ca114a68..10bf5499ca 100644 --- a/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx @@ -1,17 +1,8 @@ -import { ComponentType } from "react" import { createPortal } from "react-dom" import { useGridStackContext } from "./grid-stack-context" import { useGridStackRenderContext } from "./grid-stack-render-context" import { GridStackWidgetContext } from "./grid-stack-widget-context" -export interface ComponentDataType { - name: string - props: T -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ComponentMap = Record> - export function GridStackRender() { const { _rawWidgetMetaMap } = useGridStackContext() const { getWidgetContainer } = useGridStackRenderContext() From 39427ee2467d671200c00dc3f8b6736c6dd8f2c9 Mon Sep 17 00:00:00 2001 From: Segio Carracedo Date: Thu, 20 Nov 2025 09:30:40 +0100 Subject: [PATCH 4/8] chore: export F0GridStack --- packages/react/src/components/exports.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/src/components/exports.ts b/packages/react/src/components/exports.ts index 906095ae5c..d0d3d61210 100644 --- a/packages/react/src/components/exports.ts +++ b/packages/react/src/components/exports.ts @@ -12,5 +12,7 @@ export * from "./F0Link" export * from "./layouts/exports" export * from "./OneFilterPicker/exports" export * from "./tags/exports" +export * from "./Utilities/Await" +export * from "./Utilities/F0GridStack" export * from "./UpsellingKit/exports" From 3b3edf7b1d8d481dca0da7287119c1d0d6dd31c1 Mon Sep 17 00:00:00 2001 From: Segio Carracedo Date: Thu, 20 Nov 2025 09:38:17 +0100 Subject: [PATCH 5/8] docs: better docs for F0GridStack --- .../F0GridStack/__stories__/F0GridStack.mdx | 185 ++++++++++++++++++ .../__stories__/GridStackReact.stories.tsx | 2 +- .../F0GridStack/__stories__/controls.mdx | 7 + 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx create mode 100644 packages/react/src/components/Utilities/F0GridStack/__stories__/controls.mdx diff --git a/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx b/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx new file mode 100644 index 0000000000..33da92af1a --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx @@ -0,0 +1,185 @@ +import { Canvas, Meta, Controls } from "@storybook/addon-docs/blocks" + +import * as GridStackStories from "./GridStackReact.stories" + + + +# GridStack + +## Introduction + +### Definition + +F0GridStack is a React wrapper for the GridStack library that enables the creation of resizable and draggable grid layouts. It provides a flexible, interactive grid system where widgets can be dynamically arranged, resized, and repositioned by users or programmatically through ref methods. + +### Purpose + +The purpose of F0GridStack in our design system is as follows: + +- **Interactive Layouts**: Enables users to customize their dashboard or workspace layouts by dragging and resizing widgets +- **Dynamic Content Management**: Allows programmatic addition and removal of widgets at runtime +- **Responsive Grid System**: Provides a flexible grid that adapts to different screen sizes and widget configurations +- **Layout Persistence**: Supports saving and restoring widget positions and sizes through the onChange callback +- **Nested Grids**: Supports sub-grids for complex, hierarchical layouts + +## Basic Usage + +The default GridStack configuration demonstrates a simple grid layout with multiple widgets that can be dragged and resized. + + + + +## Programmatic Control with Ref Methods + +F0GridStack exposes several methods through a ref that allow you to programmatically control the grid layout. This is useful for dynamic widget management, such as adding or removing widgets based on user actions or application state. + +### Accessing Ref Methods + +To use the ref methods, create a ref using `useRef(null)` and attach it to the `F0GridStack` component: + +```tsx +import { useRef } from "react" +import { F0GridStack, type F0GridStackRef } from "@/components/Utilities/F0GridStack" + +const gridRef = useRef(null) + + +``` + +### Available Methods + +#### `addWidget` + +Adds a new widget to the grid programmatically. + +**Parameters:** +- `widget`: A `GridStackReactWidget` object with the following properties: + - `id`: Unique identifier for the widget (required) + - `w`: Width in grid columns + - `h`: Height in grid rows + - `x`: X position in grid columns (optional, will auto-place if not provided) + - `y`: Y position in grid rows (optional, will auto-place if not provided) + - `renderFn`: Function that returns the React element to render + - `meta`: Optional metadata object associated with the widget + - `allowedSizes`: Optional array of allowed size combinations + - `allowResize`: Whether the widget can be resized (default: true) + - `allowMove`: Whether the widget can be moved (default: true) + +**Example:** + +```tsx +gridRef.current?.addWidget({ + id: "new-widget-1", + w: 2, + h: 2, + meta: { + title: "New Widget", + type: "chart" + }, + renderFn: () => ( +
+ New Widget Content +
+ ) +}) +``` + +#### `removeWidget` + +Removes a widget from the grid by its ID. + +**Parameters:** +- `id`: The unique identifier of the widget to remove (string) + +**Example:** + +```tsx +gridRef.current?.removeWidget("widget-1") +``` + +#### `addSubGrid` + +Adds a nested grid (sub-grid) to the main grid. This is useful for creating hierarchical layouts where widgets can be grouped within a parent widget. + +**Parameters:** +- `subGrid`: A `GridStackReactWidget` with additional `subGridOpts` property containing: + - `children`: Array of widgets that will be placed inside the sub-grid + +**Example:** + +```tsx +gridRef.current?.addSubGrid({ + id: "subgrid-1", + w: 4, + h: 4, + subGridOpts: { + column: 6, + children: [ + { + id: "nested-widget-1", + w: 2, + h: 2, + renderFn: () =>
Nested Widget 1
+ }, + { + id: "nested-widget-2", + w: 2, + h: 2, + renderFn: () =>
Nested Widget 2
+ } + ] + }, + renderFn: () =>
Sub-grid Container
+}) +``` + +#### `removeAll` + +Removes all widgets from the grid at once. + +**Example:** + +```tsx +gridRef.current?.removeAll() +``` + +### Complete Example + +The following example demonstrates all ref methods in action: + + + +This example shows: +- **Add Widget**: Dynamically adds new widgets to the grid +- **Remove Last Widget**: Removes the most recently added widget +- **Remove All**: Clears all widgets from the grid + +## Best Practices + +1. **Widget IDs**: Always use unique, stable IDs for widgets to ensure proper tracking and removal +2. **Layout Persistence**: Use the `onChange` callback to save widget positions and restore them on component mount +3. **Performance**: For large numbers of widgets, consider debouncing the `onChange` callback +4. **Responsive Design**: Configure appropriate column counts for different screen sizes using the `options.column` property +5. **Widget Constraints**: Use `allowedSizes` to restrict widget dimensions to specific size combinations +6. **Metadata**: Store widget-specific data in the `meta` property for easy retrieval and persistence + +## Props + +### `options` + +Configuration object for the grid layout. + +- `column`: Number of columns in the grid (default: 12) +- `row`: Number of rows in the grid (optional) +- `margin`: Margin between widgets in pixels (optional) +- `draggable`: Whether widgets can be dragged (default: true) +- `acceptWidgets`: Whether the grid can accept widgets from other grids (default: false) + +### `widgets` + +Array of initial widgets to render in the grid. Each widget follows the `GridStackReactWidget` interface. + +### `onChange` + +Callback function that is called whenever the layout changes (widget moved, resized, added, or removed). Receives an array of `GridStackWidgetPosition` objects containing the current state of all widgets. + diff --git a/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx b/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx index 1fd584592b..5b2377640b 100644 --- a/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx +++ b/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx @@ -76,7 +76,7 @@ const meta = { }, }, onChange: { - description: "the callback function to when the layout changes", + description: "the callback function to run when the layout changes.", }, }, decorators: [ diff --git a/packages/react/src/components/Utilities/F0GridStack/__stories__/controls.mdx b/packages/react/src/components/Utilities/F0GridStack/__stories__/controls.mdx new file mode 100644 index 0000000000..a970e15f5d --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/__stories__/controls.mdx @@ -0,0 +1,7 @@ +import { Meta, Controls } from "@storybook/addon-docs/blocks" + +import * as Stories from "./GridStackReact.stories" + + + + From 694ed16fc83e9c70df2bf1f7bdc10ff593a8ef56 Mon Sep 17 00:00:00 2001 From: Segio Carracedo Date: Thu, 20 Nov 2025 09:44:58 +0100 Subject: [PATCH 6/8] docs: better docs for F0GridStack --- .../F0GridStack/__stories__/F0GridStack.mdx | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx b/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx index 33da92af1a..274128b5ca 100644 --- a/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx +++ b/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx @@ -10,32 +10,67 @@ import * as GridStackStories from "./GridStackReact.stories" ### Definition +<<<<<<< Updated upstream F0GridStack is a React wrapper for the GridStack library that enables the creation of resizable and draggable grid layouts. It provides a flexible, interactive grid system where widgets can be dynamically arranged, resized, and repositioned by users or programmatically through ref methods. +======= +F0GridStack is a React wrapper for the GridStack library that enables the +creation of resizable and draggable grid layouts. It provides a flexible, +interactive grid system where widgets can be dynamically arranged, resized, and +repositioned by users or programmatically through ref methods. +>>>>>>> Stashed changes ### Purpose The purpose of F0GridStack in our design system is as follows: +<<<<<<< Updated upstream - **Interactive Layouts**: Enables users to customize their dashboard or workspace layouts by dragging and resizing widgets - **Dynamic Content Management**: Allows programmatic addition and removal of widgets at runtime - **Responsive Grid System**: Provides a flexible grid that adapts to different screen sizes and widget configurations - **Layout Persistence**: Supports saving and restoring widget positions and sizes through the onChange callback +======= +- **Interactive Layouts**: Enables users to customize their dashboard or + workspace layouts by dragging and resizing widgets +- **Dynamic Content Management**: Allows programmatic addition and removal of + widgets at runtime +- **Responsive Grid System**: Provides a flexible grid that adapts to different + screen sizes and widget configurations +- **Layout Persistence**: Supports saving and restoring widget positions and + sizes through the onChange callback +>>>>>>> Stashed changes - **Nested Grids**: Supports sub-grids for complex, hierarchical layouts ## Basic Usage +<<<<<<< Updated upstream The default GridStack configuration demonstrates a simple grid layout with multiple widgets that can be dragged and resized. +======= +The default GridStack configuration demonstrates a simple grid layout with +multiple widgets that can be dragged and resized. +>>>>>>> Stashed changes ## Programmatic Control with Ref Methods +<<<<<<< Updated upstream F0GridStack exposes several methods through a ref that allow you to programmatically control the grid layout. This is useful for dynamic widget management, such as adding or removing widgets based on user actions or application state. ### Accessing Ref Methods To use the ref methods, create a ref using `useRef(null)` and attach it to the `F0GridStack` component: +======= +F0GridStack exposes several methods through a ref that allow you to +programmatically control the grid layout. This is useful for dynamic widget +management, such as adding or removing widgets based on user actions or +application state. + +### Accessing Ref Methods + +To use the ref methods, create a ref using `useRef(null)` and +attach it to the `F0GridStack` component: +>>>>>>> Stashed changes ```tsx import { useRef } from "react" @@ -53,6 +88,10 @@ const gridRef = useRef(null) Adds a new widget to the grid programmatically. **Parameters:** +<<<<<<< Updated upstream +======= + +>>>>>>> Stashed changes - `widget`: A `GridStackReactWidget` object with the following properties: - `id`: Unique identifier for the widget (required) - `w`: Width in grid columns @@ -74,6 +113,7 @@ gridRef.current?.addWidget({ h: 2, meta: { title: "New Widget", +<<<<<<< Updated upstream type: "chart" }, renderFn: () => ( @@ -81,6 +121,15 @@ gridRef.current?.addWidget({ New Widget Content ) +======= + type: "chart", + }, + renderFn: () => ( +
+ New Widget Content +
+ ), +>>>>>>> Stashed changes }) ``` @@ -89,6 +138,10 @@ gridRef.current?.addWidget({ Removes a widget from the grid by its ID. **Parameters:** +<<<<<<< Updated upstream +======= + +>>>>>>> Stashed changes - `id`: The unique identifier of the widget to remove (string) **Example:** @@ -99,10 +152,20 @@ gridRef.current?.removeWidget("widget-1") #### `addSubGrid` +<<<<<<< Updated upstream Adds a nested grid (sub-grid) to the main grid. This is useful for creating hierarchical layouts where widgets can be grouped within a parent widget. **Parameters:** - `subGrid`: A `GridStackReactWidget` with additional `subGridOpts` property containing: +======= +Adds a nested grid (sub-grid) to the main grid. This is useful for creating +hierarchical layouts where widgets can be grouped within a parent widget. + +**Parameters:** + +- `subGrid`: A `GridStackReactWidget` with additional `subGridOpts` property + containing: +>>>>>>> Stashed changes - `children`: Array of widgets that will be placed inside the sub-grid **Example:** @@ -119,17 +182,29 @@ gridRef.current?.addSubGrid({ id: "nested-widget-1", w: 2, h: 2, +<<<<<<< Updated upstream renderFn: () =>
Nested Widget 1
+======= + renderFn: () =>
Nested Widget 1
, +>>>>>>> Stashed changes }, { id: "nested-widget-2", w: 2, h: 2, +<<<<<<< Updated upstream renderFn: () =>
Nested Widget 2
} ] }, renderFn: () =>
Sub-grid Container
+======= + renderFn: () =>
Nested Widget 2
, + }, + ], + }, + renderFn: () =>
Sub-grid Container
, +>>>>>>> Stashed changes }) ``` @@ -150,18 +225,37 @@ The following example demonstrates all ref methods in action: This example shows: +<<<<<<< Updated upstream +======= + +>>>>>>> Stashed changes - **Add Widget**: Dynamically adds new widgets to the grid - **Remove Last Widget**: Removes the most recently added widget - **Remove All**: Clears all widgets from the grid ## Best Practices +<<<<<<< Updated upstream 1. **Widget IDs**: Always use unique, stable IDs for widgets to ensure proper tracking and removal 2. **Layout Persistence**: Use the `onChange` callback to save widget positions and restore them on component mount 3. **Performance**: For large numbers of widgets, consider debouncing the `onChange` callback 4. **Responsive Design**: Configure appropriate column counts for different screen sizes using the `options.column` property 5. **Widget Constraints**: Use `allowedSizes` to restrict widget dimensions to specific size combinations 6. **Metadata**: Store widget-specific data in the `meta` property for easy retrieval and persistence +======= +1. **Widget IDs**: Always use unique, stable IDs for widgets to ensure proper + tracking and removal +2. **Layout Persistence**: Use the `onChange` callback to save widget positions + and restore them on component mount +3. **Performance**: For large numbers of widgets, consider debouncing the + `onChange` callback +4. **Responsive Design**: Configure appropriate column counts for different + screen sizes using the `options.column` property +5. **Widget Constraints**: Use `allowedSizes` to restrict widget dimensions to + specific size combinations +6. **Metadata**: Store widget-specific data in the `meta` property for easy + retrieval and persistence +>>>>>>> Stashed changes ## Props @@ -173,6 +267,7 @@ Configuration object for the grid layout. - `row`: Number of rows in the grid (optional) - `margin`: Margin between widgets in pixels (optional) - `draggable`: Whether widgets can be dragged (default: true) +<<<<<<< Updated upstream - `acceptWidgets`: Whether the grid can accept widgets from other grids (default: false) ### `widgets` @@ -183,3 +278,18 @@ Array of initial widgets to render in the grid. Each widget follows the `GridSta Callback function that is called whenever the layout changes (widget moved, resized, added, or removed). Receives an array of `GridStackWidgetPosition` objects containing the current state of all widgets. +======= +- `acceptWidgets`: Whether the grid can accept widgets from other grids + (default: false) + +### `widgets` + +Array of initial widgets to render in the grid. Each widget follows the +`GridStackReactWidget` interface. + +### `onChange` + +Callback function that is called whenever the layout changes (widget moved, +resized, added, or removed). Receives an array of `GridStackWidgetPosition` +objects containing the current state of all widgets. +>>>>>>> Stashed changes From 3d2814ecddd13d7c7390485ced30c6c4c178df53 Mon Sep 17 00:00:00 2001 From: Sergio Carracedo Martinez Date: Thu, 20 Nov 2025 10:49:26 +0100 Subject: [PATCH 7/8] Update packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../react/src/components/Utilities/F0GridStack/F0GridStack.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx b/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx index df384b9bd3..536334bc5e 100644 --- a/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx +++ b/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx @@ -140,7 +140,7 @@ export const F0GridStack = forwardRef( return } - const target = closestAllowed(node.w ?? 1, node.h ?? 1, allowed ?? []) + const target = closestAllowed(node.w ?? 1, node.h ?? 1, allowed) if (node.w !== target.w || node.h !== target.h) { // update will reposition if necessary From 767b68bf27cd864f1e477abd2625e1f6a8cfc4bc Mon Sep 17 00:00:00 2001 From: Segio Carracedo Date: Thu, 20 Nov 2025 11:01:24 +0100 Subject: [PATCH 8/8] chore: fix mdx docs --- .../F0GridStack/__stories__/F0GridStack.mdx | 83 ------------------- 1 file changed, 83 deletions(-) diff --git a/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx b/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx index 274128b5ca..661821ee2a 100644 --- a/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx +++ b/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx @@ -10,25 +10,15 @@ import * as GridStackStories from "./GridStackReact.stories" ### Definition -<<<<<<< Updated upstream -F0GridStack is a React wrapper for the GridStack library that enables the creation of resizable and draggable grid layouts. It provides a flexible, interactive grid system where widgets can be dynamically arranged, resized, and repositioned by users or programmatically through ref methods. -======= F0GridStack is a React wrapper for the GridStack library that enables the creation of resizable and draggable grid layouts. It provides a flexible, interactive grid system where widgets can be dynamically arranged, resized, and repositioned by users or programmatically through ref methods. ->>>>>>> Stashed changes ### Purpose The purpose of F0GridStack in our design system is as follows: -<<<<<<< Updated upstream -- **Interactive Layouts**: Enables users to customize their dashboard or workspace layouts by dragging and resizing widgets -- **Dynamic Content Management**: Allows programmatic addition and removal of widgets at runtime -- **Responsive Grid System**: Provides a flexible grid that adapts to different screen sizes and widget configurations -- **Layout Persistence**: Supports saving and restoring widget positions and sizes through the onChange callback -======= - **Interactive Layouts**: Enables users to customize their dashboard or workspace layouts by dragging and resizing widgets - **Dynamic Content Management**: Allows programmatic addition and removal of @@ -37,30 +27,18 @@ The purpose of F0GridStack in our design system is as follows: screen sizes and widget configurations - **Layout Persistence**: Supports saving and restoring widget positions and sizes through the onChange callback ->>>>>>> Stashed changes - **Nested Grids**: Supports sub-grids for complex, hierarchical layouts ## Basic Usage -<<<<<<< Updated upstream -The default GridStack configuration demonstrates a simple grid layout with multiple widgets that can be dragged and resized. -======= The default GridStack configuration demonstrates a simple grid layout with multiple widgets that can be dragged and resized. ->>>>>>> Stashed changes ## Programmatic Control with Ref Methods -<<<<<<< Updated upstream -F0GridStack exposes several methods through a ref that allow you to programmatically control the grid layout. This is useful for dynamic widget management, such as adding or removing widgets based on user actions or application state. - -### Accessing Ref Methods - -To use the ref methods, create a ref using `useRef(null)` and attach it to the `F0GridStack` component: -======= F0GridStack exposes several methods through a ref that allow you to programmatically control the grid layout. This is useful for dynamic widget management, such as adding or removing widgets based on user actions or @@ -70,7 +48,6 @@ application state. To use the ref methods, create a ref using `useRef(null)` and attach it to the `F0GridStack` component: ->>>>>>> Stashed changes ```tsx import { useRef } from "react" @@ -88,10 +65,7 @@ const gridRef = useRef(null) Adds a new widget to the grid programmatically. **Parameters:** -<<<<<<< Updated upstream -======= ->>>>>>> Stashed changes - `widget`: A `GridStackReactWidget` object with the following properties: - `id`: Unique identifier for the widget (required) - `w`: Width in grid columns @@ -113,15 +87,6 @@ gridRef.current?.addWidget({ h: 2, meta: { title: "New Widget", -<<<<<<< Updated upstream - type: "chart" - }, - renderFn: () => ( -
- New Widget Content -
- ) -======= type: "chart", }, renderFn: () => ( @@ -129,7 +94,6 @@ gridRef.current?.addWidget({ New Widget Content ), ->>>>>>> Stashed changes }) ``` @@ -138,10 +102,7 @@ gridRef.current?.addWidget({ Removes a widget from the grid by its ID. **Parameters:** -<<<<<<< Updated upstream -======= ->>>>>>> Stashed changes - `id`: The unique identifier of the widget to remove (string) **Example:** @@ -152,12 +113,6 @@ gridRef.current?.removeWidget("widget-1") #### `addSubGrid` -<<<<<<< Updated upstream -Adds a nested grid (sub-grid) to the main grid. This is useful for creating hierarchical layouts where widgets can be grouped within a parent widget. - -**Parameters:** -- `subGrid`: A `GridStackReactWidget` with additional `subGridOpts` property containing: -======= Adds a nested grid (sub-grid) to the main grid. This is useful for creating hierarchical layouts where widgets can be grouped within a parent widget. @@ -165,7 +120,6 @@ hierarchical layouts where widgets can be grouped within a parent widget. - `subGrid`: A `GridStackReactWidget` with additional `subGridOpts` property containing: ->>>>>>> Stashed changes - `children`: Array of widgets that will be placed inside the sub-grid **Example:** @@ -182,29 +136,17 @@ gridRef.current?.addSubGrid({ id: "nested-widget-1", w: 2, h: 2, -<<<<<<< Updated upstream - renderFn: () =>
Nested Widget 1
-======= renderFn: () =>
Nested Widget 1
, ->>>>>>> Stashed changes }, { id: "nested-widget-2", w: 2, h: 2, -<<<<<<< Updated upstream - renderFn: () =>
Nested Widget 2
- } - ] - }, - renderFn: () =>
Sub-grid Container
-======= renderFn: () =>
Nested Widget 2
, }, ], }, renderFn: () =>
Sub-grid Container
, ->>>>>>> Stashed changes }) ``` @@ -225,24 +167,13 @@ The following example demonstrates all ref methods in action: This example shows: -<<<<<<< Updated upstream -======= ->>>>>>> Stashed changes - **Add Widget**: Dynamically adds new widgets to the grid - **Remove Last Widget**: Removes the most recently added widget - **Remove All**: Clears all widgets from the grid ## Best Practices -<<<<<<< Updated upstream -1. **Widget IDs**: Always use unique, stable IDs for widgets to ensure proper tracking and removal -2. **Layout Persistence**: Use the `onChange` callback to save widget positions and restore them on component mount -3. **Performance**: For large numbers of widgets, consider debouncing the `onChange` callback -4. **Responsive Design**: Configure appropriate column counts for different screen sizes using the `options.column` property -5. **Widget Constraints**: Use `allowedSizes` to restrict widget dimensions to specific size combinations -6. **Metadata**: Store widget-specific data in the `meta` property for easy retrieval and persistence -======= 1. **Widget IDs**: Always use unique, stable IDs for widgets to ensure proper tracking and removal 2. **Layout Persistence**: Use the `onChange` callback to save widget positions @@ -255,7 +186,6 @@ This example shows: specific size combinations 6. **Metadata**: Store widget-specific data in the `meta` property for easy retrieval and persistence ->>>>>>> Stashed changes ## Props @@ -267,18 +197,6 @@ Configuration object for the grid layout. - `row`: Number of rows in the grid (optional) - `margin`: Margin between widgets in pixels (optional) - `draggable`: Whether widgets can be dragged (default: true) -<<<<<<< Updated upstream -- `acceptWidgets`: Whether the grid can accept widgets from other grids (default: false) - -### `widgets` - -Array of initial widgets to render in the grid. Each widget follows the `GridStackReactWidget` interface. - -### `onChange` - -Callback function that is called whenever the layout changes (widget moved, resized, added, or removed). Receives an array of `GridStackWidgetPosition` objects containing the current state of all widgets. - -======= - `acceptWidgets`: Whether the grid can accept widgets from other grids (default: false) @@ -292,4 +210,3 @@ Array of initial widgets to render in the grid. Each widget follows the Callback function that is called whenever the layout changes (widget moved, resized, added, or removed). Receives an array of `GridStackWidgetPosition` objects containing the current state of all widgets. ->>>>>>> Stashed changes