diff --git a/package-lock.json b/package-lock.json index bd6a40da..6d50ce10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "packages/*" ], "dependencies": { - "react-docgen-typescript": "^2.4.0" + "react-docgen-typescript": "^2.4.0", + "react-grid-layout": "^2.1.0" }, "devDependencies": { "@babel/plugin-transform-react-jsx": "^7.22.5", @@ -12947,10 +12948,9 @@ } }, "node_modules/react-grid-layout": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.2.tgz", - "integrity": "sha512-vT7xmQqszTT+sQw/LfisrEO4le1EPNnSEMVHy6sBZyzS3yGkMywdOd+5iEFFwQwt0NSaGkxuRmYwa1JsP6OJdw==", - "license": "MIT", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.1.0.tgz", + "integrity": "sha512-d2UOqsTokpua1iaVN6wpxHxum6OE3+DOEKFzDn3UEOsSHxnb9m4Lzwkh3FaNTvQd4Z/2gjcqt1dfy3AnBfZiQw==", "dependencies": { "clsx": "^2.1.1", "fast-equals": "^4.0.3", @@ -15701,6 +15701,23 @@ "@types/node": "^24.1.0", "@types/react-grid-layout": "^1.3.5" } + }, + "packages/frappe-ui-react/node_modules/react-grid-layout": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.3.tgz", + "integrity": "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } } } } diff --git a/package.json b/package.json index 6e1d9ea9..3f937311 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "*.yml": "prettier --write" }, "dependencies": { - "react-docgen-typescript": "^2.4.0" + "react-docgen-typescript": "^2.4.0", + "react-grid-layout": "^2.1.0" } } diff --git a/packages/frappe-ui-react/src/components/dashboard/Dashboard.stories.tsx b/packages/frappe-ui-react/src/components/dashboard/Dashboard.stories.tsx new file mode 100644 index 00000000..ce873b3f --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/Dashboard.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import Dashboard from "./Dashboard"; +import type { DashboardProps } from "./types"; +import Button from "../button/button"; + +const meta: Meta = { + title: "Components/Dashboard", + component: Dashboard, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, +}; + +export default meta; + +type Story = StoryObj; + +const ButtonWidget1: React.FC = () => ( +
+
+); + +const ButtonWidget2: React.FC = () => ( +
+
+); + +const widgets: DashboardProps["widgets"] = [ + { + id: "button1", + component: ButtonWidget1, + defaultSize: { w: 3, h: 2 }, + }, + { + id: "button2", + component: ButtonWidget2, + defaultSize: { w: 3, h: 2 }, + minSize: { w: 2, h: 2 }, + maxSize: { w: 6, h: 3 }, + }, +]; + +const layout: DashboardProps["layout"] = [ + { widgetId: "button1", x: 0, y: 0, w: 3, h: 2 }, + { widgetId: "button2", x: 3, y: 0, w: 3, h: 2 }, +]; + +export const Default: Story = { + args: { + widgets, + layout, + }, +}; + +export const Themed: Story = { + args: { + widgets, + layout, + className: "bg-surface-gray-2 border border-outline-gray-1", + }, +}; + + diff --git a/packages/frappe-ui-react/src/components/dashboard/Dashboard.tsx b/packages/frappe-ui-react/src/components/dashboard/Dashboard.tsx new file mode 100644 index 00000000..8dce7192 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/Dashboard.tsx @@ -0,0 +1,115 @@ +import React, { useCallback, useMemo } from "react"; +// react-grid-layout's type definitions use `export =` syntax, but the runtime +// supports named exports. We rely on the runtime shape here. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error react-grid-layout type definitions don't expose named exports +import { Responsive, WidthProvider } from "react-grid-layout"; +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; + +import type { + DashboardProps, + DashboardWidget, + DashboardLayoutItem, +} from "./types"; +import type { Layouts, Layout } from "../gridLayout/types"; + +const ResponsiveGridLayout = WidthProvider(Responsive); + +const mapWidgetsById = (widgets: DashboardWidget[]) => + new Map(widgets.map((w) => [w.id, w] as const)); + +const Dashboard: React.FC = ({ + widgets, + layout, + onLayoutChange, + className, +}) => { + const widgetsById = useMemo(() => mapWidgetsById(widgets), [widgets]); + + const layouts: Layouts = useMemo(() => { + const lg: Layout[] = layout.map((item) => { + const widget = widgetsById.get(item.widgetId); + + const base: Layout = { + i: item.widgetId, + x: item.x, + y: item.y, + w: item.w ?? widget?.defaultSize.w ?? 2, + h: item.h ?? widget?.defaultSize.h ?? 2, + }; + + if (widget?.minSize) { + base.minW = widget.minSize.w; + base.minH = widget.minSize.h; + } + + if (widget?.maxSize) { + base.maxW = widget.maxSize.w; + base.maxH = widget.maxSize.h; + } + + if (widget?.movable === false) { + base.isDraggable = false; + base.static = true; + } + + if (widget?.resizable === false) { + base.isResizable = false; + } + + return base; + }); + + return { lg }; + }, [layout, widgetsById]); + + const handleLayoutChange = useCallback( + (_current: Layout[], allLayouts: Layouts) => { + if (!onLayoutChange) return; + + const lgLayout = (allLayouts.lg ?? []) as Layout[]; + + const next: DashboardLayoutItem[] = lgLayout.map((item) => ({ + widgetId: item.i, + x: item.x, + y: item.y, + w: item.w, + h: item.h, + })); + + onLayoutChange(next); + }, + [onLayoutChange] + ); + + return ( + + {widgets.map((widget) => { + const WidgetComponent = widget.component; + return ( +
+ +
+ ); + })} +
+ ); +}; + +export default Dashboard; + + diff --git a/packages/frappe-ui-react/src/components/dashboard/index.ts b/packages/frappe-ui-react/src/components/dashboard/index.ts new file mode 100644 index 00000000..edba3f5a --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/index.ts @@ -0,0 +1,8 @@ +export { default as Dashboard } from "./Dashboard"; +export type { + DashboardProps, + DashboardWidget, + DashboardLayoutItem, +} from "./types"; + + diff --git a/packages/frappe-ui-react/src/components/dashboard/types.ts b/packages/frappe-ui-react/src/components/dashboard/types.ts new file mode 100644 index 00000000..af16df5a --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/types.ts @@ -0,0 +1,29 @@ +import type React from "react"; + +export interface DashboardWidget { + id: string; + component: React.ComponentType; + defaultSize: { w: number; h: number }; + minSize?: { w: number; h: number }; + maxSize?: { w: number; h: number }; + movable?: boolean; + resizable?: boolean; + removable?: boolean; +} + +export interface DashboardLayoutItem { + widgetId: string; + x: number; + y: number; + w: number; + h: number; +} + +export interface DashboardProps { + widgets: DashboardWidget[]; + layout: DashboardLayoutItem[]; + onLayoutChange?: (layout: DashboardLayoutItem[]) => void; + className?: string; +} + + diff --git a/packages/frappe-ui-react/src/components/gridLayout/gridLayout.tsx b/packages/frappe-ui-react/src/components/gridLayout/gridLayout.tsx index 08a41ab6..d9490ddf 100644 --- a/packages/frappe-ui-react/src/components/gridLayout/gridLayout.tsx +++ b/packages/frappe-ui-react/src/components/gridLayout/gridLayout.tsx @@ -1,9 +1,13 @@ import React, { useState, useMemo, useEffect, ReactNode } from "react"; -import { Responsive, WidthProvider, Layout, Layouts } from "react-grid-layout"; +// react-grid-layout's type definitions use `export =` syntax, but the runtime +// supports named exports. We rely on the runtime shape here. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error react-grid-layout type definitions don't expose named exports +import { Responsive, WidthProvider } from "react-grid-layout"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; -import type { GridLayoutProps } from "./types"; +import type { GridLayoutProps, Layouts, Layout } from "./types"; const ResponsiveGridLayout = WidthProvider(Responsive); @@ -54,9 +58,7 @@ const GridLayout: React.FC = ({ breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} {...options} > - {Object.values(layout) - .flat() - .map((l, index) => ( + {(Object.values(layout).flat() as Layout[]).map((l, index) => (
{layoutReady && renderItem({ diff --git a/packages/frappe-ui-react/src/components/gridLayout/types.ts b/packages/frappe-ui-react/src/components/gridLayout/types.ts index f674873c..8477d5d6 100644 --- a/packages/frappe-ui-react/src/components/gridLayout/types.ts +++ b/packages/frappe-ui-react/src/components/gridLayout/types.ts @@ -1,6 +1,21 @@ -import { Layouts, Layout as RGL_Layout } from "react-grid-layout"; +export interface Layout { + i: string; + x: number; + y: number; + w: number; + h: number; + minW?: number; + maxW?: number; + minH?: number; + maxH?: number; + isDraggable?: boolean; + isResizable?: boolean; + static?: boolean; +} -export type Layout = RGL_Layout; +export interface Layouts { + [breakpoint: string]: Layout[]; +} export interface GridLayoutProps { layout: Layouts; diff --git a/packages/frappe-ui-react/src/components/index.ts b/packages/frappe-ui-react/src/components/index.ts index dc7053e8..67c71602 100644 --- a/packages/frappe-ui-react/src/components/index.ts +++ b/packages/frappe-ui-react/src/components/index.ts @@ -18,6 +18,7 @@ export * from "./errorMessage"; export * from "./fileUploader"; export * from "./formControl"; export * from "./gridLayout"; +export * from "./dashboard"; export * from "./hooks"; export * from "./listview"; export * from "./password";