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..536334bc5e --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/F0GridStack.tsx @@ -0,0 +1,166 @@ +import { + GridItemHTMLElement, + GridStackOptions, + GridStackWidget, +} from "gridstack" +import "gridstack/dist/gridstack.css" +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" + +export type GridStackReactOptions = Omit + +export type GridStackReactSize = { w: number; h: number } + +export interface GridStackReactWidget extends GridStackWidget { + id: Required["id"] + allowedSizes?: GridStackReactSize[] + 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 + widgets: GridStackReactWidget[] + onChange?: (layout: GridStackWidgetPosition[]) => void +} + +/** + * 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, + () => ({ + addWidget: context.addWidget, + removeWidget: context.removeWidget, + addSubGrid: context.addSubGrid, + removeAll: context.removeAll, + }), + [context] + ) + + 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 + } + + 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 ( + + + + + + + ) + } +) + +F0GridStack.displayName = "F0GridStack" 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..cc90ff9a2c --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/__stories__/F0GridStack.mdx @@ -0,0 +1,212 @@ +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 new file mode 100644 index 0000000000..5b2377640b --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/__stories__/GridStackReact.stories.tsx @@ -0,0 +1,227 @@ +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 { cloneElement, useCallback, useMemo, useRef, useState } from "react" +import { + F0GridStack, + GridStackReactWidget, + GridStackWidgetPosition, + type F0GridStackRef, +} 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; + }`, + }, + }, + }, + widgets: { + 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 function to render the node + renderFn?: () => React.ReactElement; + }`, + }, + }, + }, + onChange: { + description: "the callback function to run when the layout changes.", + }, + }, + decorators: [ + (Story, { args }) => { + const [positions, setPositions] = useState([]) + return ( +
+ { + setPositions(layout) + }, + }} + /> +
{JSON.stringify(positions, null, 2)}
+
+ ) + }, + ], +} 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, + }, + widgets: Array.from({ length: 10 }, (_, index) => ({ + id: `widget-${index}`, + w: 2, + h: 2, + renderFn: () => ( +
+ {getMockValue(mockComponents, index)} +
+ ), + })), + }, +} + +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/__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" + + + + 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..419887ce10 --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/__tests__/F0GridStack.test.tsx @@ -0,0 +1,584 @@ +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 { GridStackReactWidget } 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 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: ({ + 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 mockWidgets: GridStackReactWidget[] = [ + { + id: "node-1", + x: 0, + y: 0, + w: 2, + h: 2, + renderFn: () =>
Widget 1
, + }, + { + id: "node-2", + x: 2, + y: 0, + w: 3, + h: 1, + renderFn: () =>
Widget 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, + renderFn: () =>
Widget 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 diverseWidgets: GridStackReactWidget[] = [ + { + id: "small-node", + w: 1, + h: 1, + renderFn: () => Small, + }, + { + id: "large-node", + w: 6, + h: 4, + renderFn: () =>
Large
, + }, + { + id: "positioned-node", + x: 5, + y: 5, + w: 2, + h: 2, + renderFn: () =>

Positioned

, + }, + ] + + const { getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-render")).toBeTruthy() + }) + + it("should handle nodes with allowedSizes", () => { + const nodesWithAllowedSizes: GridStackReactWidget[] = [ + { + id: "constrained-node", + w: 2, + h: 2, + allowedSizes: [ + { w: 2, h: 2 }, + { w: 4, h: 4 }, + ], + renderFn: () =>
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: GridStackReactWidget[] = [ + { + id: "no-render", + w: 2, + h: 2, + }, + ] + + const { getByTestId } = zeroRender( + + ) + + expect(getByTestId("grid-stack-provider")).toBeTruthy() + }) + + it("should handle nodes without id", () => { + const nodesWithoutId: GridStackReactWidget[] = [ + { + id: "no-id", + w: 2, + h: 2, + renderFn: () =>
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 updatedWidgets = [ + ...mockWidgets, + { + id: "node-4", + w: 2, + h: 2, + renderFn: () =>
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..338672547f --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/__tests__/grid-stack-render-provider.test.tsx @@ -0,0 +1,148 @@ +import type { GridStack } from "gridstack" +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") + } +} + +interface TestWidget { + id: string + grid: GridStack +} + +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 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: 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) + 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 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: TestWidget) => { + 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 unknown as GridStack + const widget: TestWidget = { 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 unknown as GridStack + const widgets: TestWidget[] = [ + { 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..8ee06f4233 --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-context.ts @@ -0,0 +1,41 @@ +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: GridStackWidgetWithRequiredId) => void + removeWidget: (id: string) => void + addSubGrid: ( + subGrid: GridStackWidgetWithRequiredId & { + subGridOpts: Required["subGridOpts"] & { + children: Array + } + } + ) => void + 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..3f17711533 --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-provider.tsx @@ -0,0 +1,180 @@ +import type { + GridItemHTMLElement, + GridStack, + GridStackOptions, + 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: GridStackWidgetPosition[]) => 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.renderFn?.()) { + 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 + }) + + 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) return + + const handleResizeStop = (event: Event, el: GridItemHTMLElement) => { + onResizeStop?.(event, el) + } + + 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"] + } + ) => { + 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 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..3043bc894d --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render-provider.tsx @@ -0,0 +1,104 @@ +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) { + GridStack.renderCB = renderCBFn + return GridStack.init(optionsRef.current, containerRef.current) + } + 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..10bf5499ca --- /dev/null +++ b/packages/react/src/components/Utilities/F0GridStack/components/grid-stack-render.tsx @@ -0,0 +1,27 @@ +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 function GridStackRender() { + const { _rawWidgetMetaMap } = useGridStackContext() + const { getWidgetContainer } = useGridStackRenderContext() + + return ( + <> + {Array.from(_rawWidgetMetaMap.value.entries()).map(([id, meta]) => { + const widgetContainer = getWidgetContainer(id) + + if (!widgetContainer) { + return null + } + + return ( + + {createPortal(meta.renderFn?.(), 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/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 + } +} 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/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" 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