diff --git a/package-lock.json b/package-lock.json index 8d3cb015..dcc48102 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "frappe-ui-react", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frappe-ui-react", - "version": "1.0.1", + "version": "1.0.2", "workspaces": [ "packages/*" ], @@ -132,7 +132,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1940,7 +1939,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1964,11 +1962,63 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", @@ -5301,7 +5351,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5581,7 +5630,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5592,7 +5640,6 @@ "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5656,7 +5703,6 @@ "integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.0", @@ -5697,7 +5743,6 @@ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -6222,7 +6267,6 @@ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -6360,7 +6404,6 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -6419,7 +6462,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6952,7 +6994,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -8077,7 +8118,6 @@ "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8119,7 +8159,6 @@ "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.3.4" }, @@ -8156,7 +8195,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9924,7 +9962,6 @@ "version": "30.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -10854,7 +10891,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -12693,7 +12729,6 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -12812,7 +12847,6 @@ "node_modules/quill": { "version": "2.0.3", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "eventemitter3": "^5.0.1", "lodash-es": "^4.17.21", @@ -12826,7 +12860,6 @@ "node_modules/quill-delta": { "version": "5.1.0", "license": "MIT", - "peer": true, "dependencies": { "fast-diff": "^1.3.0", "lodash.clonedeep": "^4.5.0", @@ -12905,7 +12938,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12946,7 +12978,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12969,9 +13000,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==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.2.tgz", + "integrity": "sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==", "license": "MIT", "dependencies": { "clsx": "^2.1.1", @@ -13374,7 +13405,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -13911,7 +13941,6 @@ "integrity": "sha512-kfr6kxQAjA96ADlH6FMALJwJ+eM80UqXy106yVHNgdsAP/CdzkkicglRAhZAvUycXK9AeadF6KZ00CWLtVMN4w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", @@ -14719,7 +14748,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15006,7 +15034,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -15148,7 +15175,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -15694,9 +15720,11 @@ }, "packages/frappe-ui-react": { "name": "@rtcamp/frappe-ui-react", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@floating-ui/react": "^0.27.13", "@headlessui/react": "^2.2.6", "@popperjs/core": "^2.11.8", @@ -15719,7 +15747,7 @@ "quill-paste-smart": "^2.0.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-grid-layout": "^1.5.2", + "react-grid-layout": "^2.2.2", "react-quill-new": "^3.6.0", "react-resizable": "^3.0.5", "styled-components": "^6.1.19", diff --git a/packages/frappe-ui-react/package.json b/packages/frappe-ui-react/package.json index fd279ba5..ab91939d 100644 --- a/packages/frappe-ui-react/package.json +++ b/packages/frappe-ui-react/package.json @@ -3,6 +3,7 @@ "version": "1.0.2", "main": "dist/index.js", "module": "dist/index.js", + "moduleResolution": "bundler", "types": "dist-types/index.d.ts", "description": "Package for Frappe UI Components created in react.", "author": { @@ -38,6 +39,8 @@ "publish:remote": "npm publish" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@floating-ui/react": "^0.27.13", "@headlessui/react": "^2.2.6", "@popperjs/core": "^2.11.8", @@ -60,7 +63,7 @@ "quill-paste-smart": "^2.0.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-grid-layout": "^1.5.2", + "react-grid-layout": "^2.2.2", "react-quill-new": "^3.6.0", "react-resizable": "^3.0.5", "styled-components": "^6.1.19", 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..82fdda48 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/dashboard.stories.tsx @@ -0,0 +1,733 @@ +import React, { useCallback } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useArgs } from "storybook/preview-api"; +import { + LucideBriefcase, + LucideClipboard, + LucideMail, + LucideUserCheck, + LucideCalendar, + LucideTrendingUp, + LucideTrendingDown, + LucideMoreVertical, + LucidePlus, +} from "lucide-react"; + +import { Dashboard } from "./index"; +import { Button } from "../button"; +import { Progress } from "../progress"; +import { Badge } from "../badge"; +import { Avatar } from "../avatar"; +import { CircularProgressBar } from "../circularProgressBar"; +import { AxisChart, DonutChart } from "../charts"; +import type { DashboardLayout, Widget, WidgetSizes } from "./types"; + +const meta: Meta = { + title: "Components/Dashboard", + component: Dashboard, + parameters: { + docs: { source: { type: "dynamic" } }, + layout: "fullscreen", + }, + argTypes: { + layoutLock: { + control: "boolean", + description: "Lock the layout to prevent dragging", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + dragHandle: { + control: "boolean", + description: "Show drag handle on components (when false, drag anywhere)", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + dragHandleOnHover: { + control: "boolean", + description: + "Show drag handle only on hover (requires dragHandle to be true)", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + widgetSizes: { + control: "object", + description: + "An object defining the width and height for predefined widget sizes.", + }, + layoutFlow: { + control: "select", + options: ["row", "column"], + description: + "Defines the flow direction of the dashboard layout, either in rows or columns.", + }, + autoAdjustWidth: { + control: "boolean", + description: + "Automatically adjust the width of widgets based on their content.", + }, + widgets: { + control: "object", + description: "Array of widgets to be displayed in the dashboard.", + }, + initialLayout: { + control: "object", + description: "The initial layout configuration of the dashboard.", + }, + savedLayout: { + control: "object", + description: "The saved layout configuration of the dashboard.", + }, + onLayoutChange: { + action: "changed", + description: "Callback function that is called when the layout changes.", + }, + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const Content = () => ( +
+

Content

+
+); + +const Stats = () => ( +
+

Stats

+
+); + +const Activity = () => ( +
+

Activity

+
+); + +const simpleWidgets: Widget[] = [ + { + id: "content", + name: "Content", + component: Content, + supportedSizes: ["large"], + }, + { id: "stats", name: "Stats", component: Stats, supportedSizes: ["medium"] }, + { + id: "activity", + name: "Activity", + component: Activity, + supportedSizes: ["medium", "large"], + }, +]; + +const simpleLayout: DashboardLayout = [ + [{ widgetId: "content", size: "large" }], + [ + { widgetId: "stats", size: "medium" }, + { widgetId: "activity", size: "medium" }, + ], + [{ widgetId: "", size: "large" }], +]; + +const simpleWidgetSizes: WidgetSizes = { + small: { w: 100, h: 100 }, + medium: { w: 242, h: 128 }, + large: { w: 500, h: 128 }, +}; + +export const Default: Story = { + render: function Render(args) { + const [, updateArgs] = useArgs(); + + const handleLayoutChange = useCallback( + (newLayout: DashboardLayout) => { + updateArgs({ savedLayout: newLayout }); + }, + [updateArgs] + ); + + return ( +
+
+ +
+
+ ); + }, + args: { + widgets: simpleWidgets, + initialLayout: simpleLayout, + widgetSizes: simpleWidgetSizes, + layoutLock: false, + dragHandle: false, + dragHandleOnHover: false, + }, +}; + +export const LocalStorage: Story = { + render: function Render(args) { + const savedLayoutStr = localStorage.getItem("dashboard-saved-layout"); + const savedLayout: DashboardLayout | undefined = savedLayoutStr + ? JSON.parse(savedLayoutStr) + : undefined; + + const handleLayoutChange = (newLayout: DashboardLayout) => { + localStorage.setItem("dashboard-saved-layout", JSON.stringify(newLayout)); + }; + + return ( +
+
+ +
+
+ +
+
+ ); + }, + args: { + widgets: simpleWidgets, + initialLayout: simpleLayout, + layoutLock: false, + dragHandle: true, + dragHandleOnHover: true, + widgetSizes: simpleWidgetSizes, + }, +}; + +const EmployeeOverviewWidget = ({ + title, + value, + subtitle, + progress, + trend, + trendValue, +}: { + title: string; + value: string; + subtitle: string; + progress?: number; + trend?: "up" | "down"; + trendValue?: string; +}) => ( +
+
+
+

{title}

+
+ + {value} + + {trend && trendValue && ( + + {trend === "up" ? ( + + ) : ( + + )} + {trendValue} + + )} +
+

{subtitle}

+
+ {progress !== undefined && ( +
+ +
+ )} +
+
+); + +const SalaryStatisticsWidget = () => { + const salaryData = [ + { month: "Jan", salary: 45000, bonus: 5000 }, + { month: "Feb", salary: 47000, bonus: 3000 }, + { month: "Mar", salary: 46000, bonus: 8000 }, + { month: "Apr", salary: 48000, bonus: 4000 }, + { month: "May", salary: 52000, bonus: 6000 }, + { month: "Jun", salary: 51000, bonus: 7000 }, + ]; + + return ( +
+

Salary Statistics

+

Monthly overview

+ +
+ ); +}; + +const EmployeeSatisfactionWidget = () => { + const satisfactionData = [ + { category: "Very Satisfied", count: 45 }, + { category: "Satisfied", count: 20 }, + { category: "Neutral", count: 25 }, + { category: "Dissatisfied", count: 10 }, + ]; + + return ( +
+

Employee Satisfaction

+

Q4 2025 Survey Results

+ +
+ ); +}; + +const PerformanceStatsWidget = () => ( +
+
+

+ Performance Statistics +

+ +
+
+
+
+ Engineering + 92% +
+ +
+
+
+ Design + 87% +
+ +
+
+
+ Marketing + 78% +
+ +
+
+
+ Sales + 85% +
+ +
+
+
+ HR + 90% +
+ +
+
+
+); + +const NewEmployeesWidget = () => { + const newHiresData = [ + { month: "Jul", hires: 8 }, + { month: "Aug", hires: 12 }, + { month: "Sep", hires: 6 }, + { month: "Oct", hires: 15 }, + { month: "Nov", hires: 9 }, + { month: "Dec", hires: 11 }, + ]; + + return ( +
+

New Employees

+

Last 6 months hiring

+ +
+ ); +}; + +const UpcomingEventsWidget = () => { + const events = [ + { + id: 1, + title: "Team Meeting", + date: "Jan 21", + time: "10:00 AM", + type: "meeting", + }, + { + id: 2, + title: "Performance Review", + date: "Jan 22", + time: "2:00 PM", + type: "review", + }, + { + id: 3, + title: "New Hire Orientation", + date: "Jan 23", + time: "9:00 AM", + type: "onboarding", + }, + { + id: 4, + title: "Quarterly Planning", + date: "Jan 25", + time: "11:00 AM", + type: "planning", + }, + ]; + + return ( +
+
+

+ Upcoming Events +

+ +
+
+ {events.map((event) => ( +
+
+ +
+
+

+ {event.title} +

+

+ {event.date} • {event.time} +

+
+ + {event.type} + +
+ ))} +
+
+ ); +}; + +const RecentActivitiesWidget = () => { + const activities = [ + { + id: 1, + user: "John Doe", + action: "joined the Engineering team", + time: "2 hours ago", + avatar: "https://i.pravatar.cc/150?img=1", + }, + { + id: 2, + user: "Sarah Smith", + action: "completed onboarding", + time: "4 hours ago", + avatar: "https://i.pravatar.cc/150?img=5", + }, + { + id: 3, + user: "Mike Johnson", + action: "submitted leave request", + time: "Yesterday", + avatar: "https://i.pravatar.cc/150?img=3", + }, + { + id: 4, + user: "Emily Brown", + action: "updated profile", + time: "Yesterday", + avatar: "https://i.pravatar.cc/150?img=9", + }, + ]; + + return ( +
+
+

+ Recent Activities +

+ +
+
+ {activities.map((activity) => ( +
+ +
+

+ {activity.user}{" "} + {activity.action} +

+

{activity.time}

+
+
+ ))} +
+
+ ); +}; + +const QuickActionsWidget = () => ( +
+

+ Quick Actions +

+
+ + + + +
+
+); + +const hrWidgets: Widget[] = [ + { + id: "total-employees", + name: "Total Employees", + component: EmployeeOverviewWidget, + props: { + title: "Total Employees", + value: "1,234", + subtitle: "Active employees", + progress: 85, + trend: "up", + trendValue: "12%", + }, + supportedSizes: ["small"], + }, + { + id: "new-hires", + name: "New Hires", + component: EmployeeOverviewWidget, + props: { + title: "New Hires", + value: "48", + subtitle: "This month", + progress: 72, + trend: "up", + trendValue: "8%", + }, + supportedSizes: ["small"], + }, + { + id: "turnover", + name: "Turnover Rate", + component: EmployeeOverviewWidget, + props: { + title: "Turnover Rate", + value: "4.2%", + subtitle: "Last 12 months", + progress: 42, + trend: "down", + trendValue: "2%", + }, + supportedSizes: ["small"], + }, + { + id: "open-positions", + name: "Open Positions", + component: EmployeeOverviewWidget, + props: { + title: "Open Positions", + value: "23", + subtitle: "Across all departments", + progress: 65, + }, + supportedSizes: ["small"], + }, + { + id: "salary-stats", + name: "Salary Statistics", + component: SalaryStatisticsWidget, + supportedSizes: ["large"], + }, + { + id: "satisfaction", + name: "Employee Satisfaction", + component: EmployeeSatisfactionWidget, + supportedSizes: ["large"], + }, + { + id: "performance", + name: "Performance Stats", + component: PerformanceStatsWidget, + supportedSizes: ["medium"], + }, + { + id: "new-employees", + name: "New Employees", + component: NewEmployeesWidget, + supportedSizes: ["medium", "large"], + }, + { + id: "events", + name: "Upcoming Events", + component: UpcomingEventsWidget, + supportedSizes: ["medium", "large"], + }, + { + id: "activities", + name: "Recent Activities", + component: RecentActivitiesWidget, + supportedSizes: ["medium"], + }, + { + id: "quick-actions", + name: "Quick Actions", + component: QuickActionsWidget, + supportedSizes: ["medium"], + }, +]; + +const hrLayout: DashboardLayout = [ + [ + { widgetId: "total-employees", size: "small" }, + { widgetId: "new-hires", size: "small" }, + { widgetId: "turnover", size: "small" }, + { widgetId: "open-positions", size: "small" }, + ], + [ + { widgetId: "salary-stats", size: "large" }, + { widgetId: "satisfaction", size: "large" }, + ], + [ + { widgetId: "performance", size: "medium" }, + { widgetId: "new-employees", size: "medium" }, + { widgetId: "events", size: "medium" }, + ], + [ + { widgetId: "activities", size: "medium" }, + { widgetId: "quick-actions", size: "medium" }, + { widgetId: "", size: "medium" }, + ], +]; + +const hrWidgetSizes: WidgetSizes = { + small: { w: 300, h: 150 }, + medium: { w: 404, h: 350 }, + large: { w: 616, h: 350 }, +}; + +export const HRDashboardExample: Story = { + render: function Render(args) { + const [, updateArgs] = useArgs(); + + const handleLayoutChange = useCallback( + (newLayout: DashboardLayout) => { + updateArgs({ savedLayout: newLayout }); + }, + [updateArgs] + ); + + return ( +
+ +
+ ); + }, + args: { + widgets: hrWidgets, + initialLayout: hrLayout, + widgetSizes: hrWidgetSizes, + layoutLock: false, + dragHandle: true, + dragHandleOnHover: true, + autoAdjustWidth: true, + layoutFlow: "row", + }, +}; 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..1ac35b0e --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/dashboard.tsx @@ -0,0 +1,41 @@ +/** + * External dependencies. + */ +import React, { useEffect, useState } from "react"; + +/** + * Internal dependencies. + */ +import { LayoutContainer } from "./layoutContainer"; +import { validateSerializedLayout } from "./dashboardUtil"; +import type { DashboardProps, DashboardLayout } from "./types"; + +export const Dashboard: React.FC = ({ + savedLayout, + onLayoutChange, + initialLayout, + ...rest +}) => { + const [layout, setLayout] = useState(() => { + if (savedLayout && validateSerializedLayout(savedLayout)) { + return savedLayout; + } + return initialLayout; + }); + + useEffect(() => { + if (savedLayout && validateSerializedLayout(savedLayout)) { + setLayout(savedLayout); + } + }, [savedLayout]); + + useEffect(() => { + onLayoutChange?.(layout); + }, [layout, onLayoutChange]); + + return ; +}; + +Dashboard.displayName = "Dashboard"; + +export default Dashboard; diff --git a/packages/frappe-ui-react/src/components/dashboard/dashboardUtil.ts b/packages/frappe-ui-react/src/components/dashboard/dashboardUtil.ts new file mode 100644 index 00000000..824451a6 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/dashboardUtil.ts @@ -0,0 +1,26 @@ +import type { DashboardLayout } from "./types"; + +export const validateSerializedLayout = (layout: DashboardLayout): boolean => { + if (!Array.isArray(layout) || layout.length === 0) return false; + for (const row of layout) { + if (!row || !Array.isArray(row)) return false; + for (const layoutWidget of row) { + if (typeof layoutWidget?.widgetId !== "string") return false; + } + } + return true; +}; + +export const parseSlotIds = (activeId: string, overId: string) => { + const activeMatch = activeId.match(/layout-(\d+)-slot-(\d+)/); + const overMatch = overId.match(/layout-(\d+)-slot-(\d+)/); + + if (!activeMatch || !overMatch) return false; + + return { + sourceLayoutIndex: parseInt(activeMatch[1]), + sourceSlotIndex: parseInt(activeMatch[2]), + targetLayoutIndex: parseInt(overMatch[1]), + targetSlotIndex: parseInt(overMatch[2]), + }; +} 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..479b4237 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/index.ts @@ -0,0 +1,2 @@ +export { Dashboard, default } from './dashboard'; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/dashboard/layout.tsx b/packages/frappe-ui-react/src/components/dashboard/layout.tsx new file mode 100644 index 00000000..ea8069ae --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/layout.tsx @@ -0,0 +1,48 @@ +/** + * External dependencies. + */ +import React from "react"; + +/** + * Internal dependencies. + */ +import { clsx } from "clsx"; +import { Slot } from "./slot"; +import type { LayoutProps } from "./types"; + +export const Layout: React.FC = ({ + widgets, + items, + layoutIndex, + layoutFlow = "row", + parentLocked = false, + onAddWidget, + onRemoveWidget, +}) => { + return ( +
+ {items.map((layoutItem, slotIndex) => { + const slotId = `layout-${layoutIndex}-slot-${slotIndex}`; + return ( + + onAddWidget(layoutIndex, slotIndex, widgetId) + } + onRemoveWidget={() => onRemoveWidget(layoutIndex, slotIndex)} + /> + ); + })} +
+ ); +}; diff --git a/packages/frappe-ui-react/src/components/dashboard/layoutContainer.tsx b/packages/frappe-ui-react/src/components/dashboard/layoutContainer.tsx new file mode 100644 index 00000000..eadf6185 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/layoutContainer.tsx @@ -0,0 +1,213 @@ +/** + * External dependencies. + */ +import { useState, useMemo, useCallback } from "react"; +import clsx from "clsx"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + pointerWithin, + type DragStartEvent, + type DragEndEvent, + UniqueIdentifier, +} from "@dnd-kit/core"; + +/** + * Internal dependencies. + */ +import { Layout } from "./layout"; +import { LayoutContext } from "./layoutContext"; +import { LayoutContainerProps } from "./types"; +import { parseSlotIds } from "./dashboardUtil"; + +export const LayoutContainer: React.FC = ({ + widgets, + layout, + layoutFlow = "row", + setLayout, + layoutLock = false, + dragHandle = false, + dragHandleOnHover = false, + widgetSizes, + autoAdjustWidth = false, + className = "", +}) => { + const [activeId, setActiveId] = useState(null); + + const activeSlotId = useMemo(() => { + if (!activeId) return null; + return activeId as string; + }, [activeId]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: dragHandle + ? { distance: 0 } + : { + distance: 10, + delay: 100, + tolerance: 5, + }, + }), + useSensor(KeyboardSensor) + ); + + const checkSizeCompatibility = useCallback( + (activeId: string, overId: string) => { + const parsedSlotIds = parseSlotIds(activeId, overId); + if (!parsedSlotIds) return false; + + const { + sourceLayoutIndex, + sourceSlotIndex, + targetLayoutIndex, + targetSlotIndex, + } = parsedSlotIds; + + const sourceItem = layout[sourceLayoutIndex][sourceSlotIndex]; + const targetItem = layout[targetLayoutIndex][targetSlotIndex]; + + const sourceWidget = widgets.find((w) => w.id === sourceItem.widgetId); + const targetWidget = widgets.find((w) => w.id === targetItem.widgetId); + + if (!sourceWidget && !targetWidget) return true; + + if ( + (sourceWidget && + !sourceWidget.supportedSizes.includes(targetItem.size)) || + (targetWidget && !targetWidget.supportedSizes.includes(sourceItem.size)) + ) { + return false; + } + + return true; + }, + [layout, widgets] + ); + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + + if (!over || active.id === over.id) return; + + const activeIdStr = String(active.id); + const overIdStr = String(over.id); + + const parsedSlotIds = parseSlotIds(activeIdStr, overIdStr); + if (!parsedSlotIds) return false; + + const { + sourceLayoutIndex, + sourceSlotIndex, + targetLayoutIndex, + targetSlotIndex, + } = parsedSlotIds; + + if (!checkSizeCompatibility(activeIdStr, overIdStr)) return; + + // Swap the widgets in the layout + const newLayout = layout.map((items, layoutIdx) => + items.map((layoutItem, slotIndex) => { + const isSource = + layoutIdx === sourceLayoutIndex && slotIndex === sourceSlotIndex; + const isTarget = + layoutIdx === targetLayoutIndex && slotIndex === targetSlotIndex; + + if (!isSource && !isTarget) return layoutItem; + + const sourceWidgetId = + layout[sourceLayoutIndex][sourceSlotIndex].widgetId; + const targetWidgetId = + layout[targetLayoutIndex][targetSlotIndex].widgetId; + + return { + widgetId: isSource ? targetWidgetId : sourceWidgetId, + size: layoutItem.size, + }; + }) + ); + setLayout(newLayout); + }; + + const handleAddWidget = useCallback( + (layoutIndex: number, slotIndex: number, widgetId: string) => { + const newLayout = layout.map((items) => [...items]); + const slot = newLayout[layoutIndex][slotIndex]; + const supportedSizes = widgets.find( + (w) => w.id === widgetId + )?.supportedSizes; + if (!supportedSizes) return; + if (!supportedSizes.includes(slot.size)) return; + + newLayout[layoutIndex][slotIndex] = { + widgetId, + size: layout[layoutIndex][slotIndex].size, + }; + setLayout(newLayout); + }, + [layout, setLayout, widgets] + ); + + const handleRemoveWidget = useCallback( + (layoutIndex: number, slotIndex: number) => { + const newLayout = layout.map((items) => [...items]); + newLayout[layoutIndex][slotIndex] = { + widgetId: "", + size: newLayout[layoutIndex][slotIndex].size, + }; + setLayout(newLayout); + }, + [layout, setLayout] + ); + + return ( + + +
+ {layout.map((items, layoutIndex) => ( + + ))} +
+
+
+ ); +}; diff --git a/packages/frappe-ui-react/src/components/dashboard/layoutContext.tsx b/packages/frappe-ui-react/src/components/dashboard/layoutContext.tsx new file mode 100644 index 00000000..d00fc750 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/layoutContext.tsx @@ -0,0 +1,11 @@ +/** + * External dependencies. + */ +import { createContext } from "react"; + +/** + * Internal dependencies. + */ +import { LayoutContextValue } from "./types"; + +export const LayoutContext = createContext(null); diff --git a/packages/frappe-ui-react/src/components/dashboard/slot.tsx b/packages/frappe-ui-react/src/components/dashboard/slot.tsx new file mode 100644 index 00000000..bf35f230 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/slot.tsx @@ -0,0 +1,238 @@ +/** + * External dependencies. + */ +import { useDroppable, useDraggable } from "@dnd-kit/core"; +import { clsx } from "clsx"; +import { useContext, useState, useMemo } from "react"; +import { GripVertical, X, Plus } from "lucide-react"; + +/** + * Internal dependencies. + */ +import { Autocomplete } from "../autoComplete"; +import { Popover } from "../popover"; +import { LayoutContext } from "./layoutContext"; +import { Button } from "../button"; +import type { SlotProps } from "./types"; + +export const Slot: React.FC = ({ + widgets, + widgetId, + slotId, + size, + parentLocked = false, + onAddWidget, + onRemoveWidget, +}) => { + const context = useContext(LayoutContext); + const { + activeSlotId, + layoutLock, + dragHandle, + dragHandleOnHover, + checkSizeCompatibility, + widgetSizes = { + small: { w: "auto", h: "auto" }, + medium: { w: "auto", h: "auto" }, + large: { w: "auto", h: "auto" }, + }, + autoAdjustWidth, + } = context || { + activeSlotId: null, + layoutLock: false, + dragHandle: false, + dragHandleOnHover: false, + autoAdjustWidth: false, + }; + + const [isHovered, setIsHovered] = useState(false); + const isEmpty = widgetId === ""; + const isDisabled = layoutLock || parentLocked; + + const widget = useMemo(() => { + return widgets.find((w) => w.id === widgetId); + }, [widgets, widgetId]); + + const validSize = useMemo(() => { + return widget?.supportedSizes.includes(size); + }, [widget, size]); + + const availableWidgets = useMemo(() => { + return widgets + .filter((w) => w.supportedSizes.includes(size)) + .map((w) => ({ + label: w.name, + value: w.id, + })); + }, [widgets, size]); + + const { + setNodeRef: setDroppableRef, + isOver, + over, + active, + } = useDroppable({ + id: slotId, + disabled: slotId === activeSlotId || isDisabled, + }); + + const overValidSize = useMemo(() => { + if (!over || !active || !checkSizeCompatibility) return false; + return checkSizeCompatibility(String(active.id), String(over.id)); + }, [over, active, checkSizeCompatibility]); + + const { + attributes, + listeners, + setNodeRef: setDraggableRef, + setActivatorNodeRef, + transform, + isDragging, + } = useDraggable({ + id: slotId, + disabled: isEmpty || isDisabled, + }); + + const dragStyle = transform + ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + zIndex: isDragging ? 99 : "auto", + } + : {}; + + const showDragHandle = + dragHandle && !isDisabled && !isEmpty && (!dragHandleOnHover || isHovered); + const showRemoveButton = !isDisabled && !isEmpty && isHovered; + + const Component = widget?.component; + + if (isEmpty || !validSize || !Component) { + return ( +
+ {!isDisabled && ( + ( + + )} + body={({ close }) => ( +
+ { + if (selected && !Array.isArray(selected)) { + const widgetId = + typeof selected === "object" && + selected !== null && + "value" in selected + ? selected.value + : selected; + if (typeof widgetId === "string") { + onAddWidget(widgetId); + close(); + } + } + }} + /> +
+ )} + /> + )} +
+ ); + } + + return ( +
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {(showRemoveButton || showDragHandle) && ( +
+ {showRemoveButton && ( + + )} + {showDragHandle && ( + + )} +
+ )} + +
+
+ ); +}; 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..296e66f6 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboard/types.ts @@ -0,0 +1,76 @@ +export type WidgetSize = 'small' | 'medium' | 'large'; +export type WidgetSizes = Record; + +export interface Widget { + id: string; + name: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component: React.ComponentType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props?: Record; + supportedSizes: WidgetSize[]; +} + +export interface LayoutItem { + widgetId: string; + size: WidgetSize; +} + +export type DashboardLayout = LayoutItem[][]; +export interface DashboardProps { + widgets: Widget[]; + layoutFlow?: "row" | "column"; + initialLayout: DashboardLayout; + widgetSizes?: WidgetSizes; + autoAdjustWidth?: boolean; + layoutLock?: boolean; + dragHandle?: boolean; + dragHandleOnHover?: boolean; + savedLayout?: DashboardLayout; + onLayoutChange?: (layout: DashboardLayout) => void; + className?: string; +} + +export interface LayoutContainerProps { + widgets: Widget[]; + layout: DashboardLayout; + widgetSizes?: WidgetSizes; + autoAdjustWidth?: boolean; + layoutFlow?: "row" | "column"; + setLayout: (layout: DashboardLayout) => void; + layoutLock?: boolean; + dragHandle?: boolean; + dragHandleOnHover?: boolean; + className?: string; +} + +export interface LayoutProps { + widgets: Widget[]; + items: LayoutItem[]; + layoutIndex: number; + layoutFlow?: "row" | "column"; + parentLocked?: boolean; + onAddWidget: (layoutIndex: number, slotIndex: number, widgetId: string) => void; + onRemoveWidget: (layoutIndex: number, slotIndex: number) => void; +} + +export interface SlotProps { + widgets: Widget[]; + widgetId: string; + slotId: string; + size: WidgetSize; + parentLocked?: boolean; + onAddWidget: (widgetId: string) => void; + onRemoveWidget: () => void; +} + +export interface LayoutContextValue { + activeSlotId: string | null; + layoutLock: boolean; + dragHandle: boolean; + dragHandleOnHover: boolean; + layout: DashboardLayout; + widgetSizes?: WidgetSizes; + autoAdjustWidth: boolean; + checkSizeCompatibility: (activeId: string, overId: string) => boolean; +} \ No newline at end of file diff --git a/packages/frappe-ui-react/src/components/dashboardGrid/dashboard.css b/packages/frappe-ui-react/src/components/dashboardGrid/dashboard.css new file mode 100644 index 00000000..228957a9 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboardGrid/dashboard.css @@ -0,0 +1,18 @@ +/** + * Dashboard Grid Layout Styles + * + * Disable transitions on initial mount to prevent jarring animations + * when the grid compacts widgets into their final positions. + */ + +.react-grid-layout-no-transition .react-grid-item { + transition: none !important; +} + +.react-grid-layout-no-transition .react-grid-item.resizing { + transition: none !important; +} + +.react-grid-layout-no-transition .react-resizable-handle { + transition: none !important; +} diff --git a/packages/frappe-ui-react/src/components/dashboardGrid/dashboard.stories.tsx b/packages/frappe-ui-react/src/components/dashboardGrid/dashboard.stories.tsx new file mode 100644 index 00000000..600583c4 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboardGrid/dashboard.stories.tsx @@ -0,0 +1,880 @@ +import React, { useCallback } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useArgs } from "storybook/preview-api"; +import { + LucideBriefcase, + LucideClipboard, + LucideMail, + LucideUserCheck, + LucideCalendar, + LucideTrendingUp, + LucideTrendingDown, + LucideMoreVertical, + LucidePlus, + LucideX, +} from "lucide-react"; + +import { + DashboardGrid as Dashboard, + DashboardWidgetGallery, + DashboardProvider, +} from "./index"; +import { Button } from "../button"; +import { Progress } from "../progress"; +import { Badge } from "../badge"; +import { Avatar } from "../avatar"; +import { CircularProgressBar } from "../circularProgressBar"; +import { AxisChart, DonutChart } from "../charts"; +import type { + WidgetDefinition, + WidgetSizePresets, + DashboardLayouts, + WidgetLayouts, +} from "./types"; +import clsx from "clsx"; + +const meta: Meta = { + title: "Components/DashboardGrid", + component: Dashboard, + parameters: { + docs: { source: { type: "dynamic" } }, + layout: "fullscreen", + }, + argTypes: { + layoutLock: { + control: "boolean", + description: "Lock the layout to prevent dragging", + }, + dragHandle: { + control: "boolean", + description: "Show drag handle on components", + }, + dragHandleOnHover: { + control: "boolean", + description: "Show drag handle only on hover", + }, + compactType: { + control: "select", + options: ["vertical", "horizontal"], + description: + "How to compact the layout: 'vertical' (default) moves widgets up to fill gaps, 'horizontal' moves widgets left", + }, + isBounded: { + control: "boolean", + description: "Prevent widgets from being dragged outside the grid bounds", + }, + cols: { + control: "object", + description: "Column counts for each breakpoint", + }, + breakpoints: { + control: "object", + description: "Breakpoints for responsive layout", + }, + rowHeight: { + control: "number", + description: "Height of each row in pixels", + }, + margin: { + control: "object", + description: "Margin between widgets in pixels", + }, + widgets: { + control: "object", + description: "Array of widget definitions", + }, + initialLayouts: { + control: "object", + description: "Initial layout configuration with positions per breakpoint", + }, + savedLayout: { + control: "object", + description: "Saved layout from database/localStorage", + }, + onLayoutChange: { + action: "changed", + description: "Callback function that is called when the layout changes.", + }, + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const Content = () => ( +
+

Content

+
+); + +const Stats = () => ( +
+

Stats

+
+); + +const Activity = () => ( +
+

Activity

+
+); + +const simpleWidgets: WidgetDefinition[] = [ + { + id: "content-1", + name: "Content", + component: Content, + size: "large", + }, + { + id: "stats-1", + name: "Stats", + component: Stats, + size: "medium", + }, + { + id: "activity-1", + name: "Activity", + component: Activity, + size: "medium", + }, +]; + +const simpleSizePresets: WidgetSizePresets = { + small: { w: 3, h: 2, minW: 2, maxW: 4, minH: 2, maxH: 4 }, + medium: { w: 6, h: 2, minW: 4, maxW: 8, minH: 2, maxH: 6 }, + large: { w: 12, h: 2, minW: 8, maxW: 12, minH: 2, maxH: 8 }, +}; + +const simpleLayouts: WidgetLayouts = { + lg: [["content-1"], ["stats-1", "activity-1"]], +}; + +export const Default: Story = { + render: function Render(args) { + const [, updateArgs] = useArgs(); + + const handleLayoutChange = useCallback( + (newLayout: DashboardLayouts) => { + updateArgs({ savedLayout: newLayout }); + }, + [updateArgs] + ); + + return ( +
+
+ +
+
+ ); + }, + args: { + widgets: simpleWidgets, + initialLayouts: simpleLayouts, + sizes: simpleSizePresets, + rowHeight: 100, + layoutLock: false, + dragHandle: false, + dragHandleOnHover: false, + }, +}; + +export const LocalStorage: Story = { + render: function Render(args) { + const savedLayoutStr = localStorage.getItem("dashboard-saved-layout"); + const savedLayout: DashboardLayouts | undefined = savedLayoutStr + ? JSON.parse(savedLayoutStr) + : undefined; + + const handleLayoutChange = (newLayout: DashboardLayouts) => { + localStorage.setItem("dashboard-saved-layout", JSON.stringify(newLayout)); + }; + + return ( +
+
+ +
+
+ +
+
+ ); + }, + args: { + widgets: simpleWidgets, + initialLayouts: simpleLayouts, + sizes: simpleSizePresets, + rowHeight: 100, + layoutLock: false, + dragHandle: true, + dragHandleOnHover: true, + }, +}; + +const EmployeeOverviewWidget = ({ + title, + value, + subtitle, + progress, + trend, + trendValue, +}: { + title: string; + value: string; + subtitle: string; + progress?: number; + trend?: "up" | "down"; + trendValue?: string; +}) => ( +
+
+
+

{title}

+
+ + {value} + + {trend && trendValue && ( + + {trend === "up" ? ( + + ) : ( + + )} + {trendValue} + + )} +
+

{subtitle}

+
+ {progress !== undefined && ( +
+ +
+ )} +
+
+); + +const SalaryStatisticsWidget = () => { + const salaryData = [ + { month: "Jan", salary: 45000, bonus: 5000 }, + { month: "Feb", salary: 47000, bonus: 3000 }, + { month: "Mar", salary: 46000, bonus: 8000 }, + { month: "Apr", salary: 48000, bonus: 4000 }, + { month: "May", salary: 52000, bonus: 6000 }, + { month: "Jun", salary: 51000, bonus: 7000 }, + ]; + + return ( +
+

Salary Statistics

+

Monthly overview

+ +
+ ); +}; + +const EmployeeSatisfactionWidget = () => { + const satisfactionData = [ + { category: "Very Satisfied", count: 45 }, + { category: "Satisfied", count: 20 }, + { category: "Neutral", count: 25 }, + { category: "Dissatisfied", count: 10 }, + ]; + + return ( +
+

Employee Satisfaction

+

Q4 2025 Survey Results

+ +
+ ); +}; + +const PerformanceStatsWidget = () => ( +
+
+

+ Performance Statistics +

+ +
+
+
+
+ Engineering + 92% +
+ +
+
+
+ Design + 87% +
+ +
+
+
+ Marketing + 78% +
+ +
+
+
+ Sales + 85% +
+ +
+
+
+ HR + 90% +
+ +
+
+
+); + +const NewEmployeesWidget = () => { + const newHiresData = [ + { month: "Jul", hires: 8 }, + { month: "Aug", hires: 12 }, + { month: "Sep", hires: 6 }, + { month: "Oct", hires: 15 }, + { month: "Nov", hires: 9 }, + { month: "Dec", hires: 11 }, + ]; + + return ( +
+

New Employees

+

Last 6 months hiring

+ +
+ ); +}; + +const UpcomingEventsWidget = () => { + const events = [ + { + id: 1, + title: "Team Meeting", + date: "Jan 21", + time: "10:00 AM", + type: "meeting", + }, + { + id: 2, + title: "Performance Review", + date: "Jan 22", + time: "2:00 PM", + type: "review", + }, + { + id: 3, + title: "New Hire Orientation", + date: "Jan 23", + time: "9:00 AM", + type: "onboarding", + }, + { + id: 4, + title: "Quarterly Planning", + date: "Jan 25", + time: "11:00 AM", + type: "planning", + }, + ]; + + return ( +
+
+

+ Upcoming Events +

+ +
+
+ {events.map((event) => ( +
+
+ +
+
+

+ {event.title} +

+

+ {event.date} • {event.time} +

+
+ + {event.type} + +
+ ))} +
+
+ ); +}; + +const RecentActivitiesWidget = () => { + const activities = [ + { + id: 1, + user: "John Doe", + action: "joined the Engineering team", + time: "2 hours ago", + avatar: "https://i.pravatar.cc/150?img=1", + }, + { + id: 2, + user: "Sarah Smith", + action: "completed onboarding", + time: "4 hours ago", + avatar: "https://i.pravatar.cc/150?img=5", + }, + { + id: 3, + user: "Mike Johnson", + action: "submitted leave request", + time: "Yesterday", + avatar: "https://i.pravatar.cc/150?img=3", + }, + { + id: 4, + user: "Emily Brown", + action: "updated profile", + time: "Yesterday", + avatar: "https://i.pravatar.cc/150?img=9", + }, + ]; + + return ( +
+
+

+ Recent Activities +

+ +
+
+ {activities.map((activity) => ( +
+ +
+

+ {activity.user}{" "} + {activity.action} +

+

{activity.time}

+
+
+ ))} +
+
+ ); +}; + +const QuickActionsWidget = () => ( +
+

+ Quick Actions +

+
+ + + + +
+
+); + +const hrWidgets: WidgetDefinition[] = [ + { + id: "total-employees", + name: "Total Employees", + component: EmployeeOverviewWidget, + size: "small", + props: { + title: "Total Employees", + value: "1,234", + subtitle: "Active employees", + progress: 85, + trend: "up", + trendValue: "12%", + }, + preview: { + props: { + title: "Total Employees", + value: "1,234", + subtitle: "Active employees", + progress: 85, + trend: "up", + trendValue: "12%", + }, + description: "Total number of active employees", + }, + }, + { + id: "new-hires", + name: "New Hires", + component: EmployeeOverviewWidget, + size: "small", + props: { + title: "New Hires", + value: "48", + subtitle: "This month", + progress: 72, + trend: "up", + trendValue: "8%", + }, + preview: { + props: { + title: "New Hires", + value: "48", + subtitle: "This month", + progress: 72, + trend: "up", + trendValue: "8%", + }, + description: "New hires this month", + }, + }, + { + id: "turnover", + name: "Turnover Rate", + component: EmployeeOverviewWidget, + size: "small", + isResizable: false, + props: { + title: "Turnover Rate", + value: "4.2%", + subtitle: "Last 12 months", + progress: 42, + trend: "down", + trendValue: "2%", + }, + preview: { + props: { + title: "Turnover Rate", + value: "4.2%", + subtitle: "Last 12 months", + progress: 42, + trend: "down", + trendValue: "2%", + }, + description: "Employee turnover rate", + }, + }, + { + id: "open-positions", + name: "Open Positions", + component: EmployeeOverviewWidget, + size: "small", + isResizable: false, + props: { + title: "Open Positions", + value: "23", + subtitle: "Across all departments", + progress: 65, + }, + preview: { + props: { + title: "Open Positions", + value: "23", + subtitle: "Across all departments", + progress: 65, + }, + description: "Current open job positions", + }, + }, + { + id: "salary-stats", + name: "Salary Statistics", + component: SalaryStatisticsWidget, + size: "large", + preview: { + props: {}, + description: "Monthly salary overview with bonuses", + }, + }, + { + id: "satisfaction", + name: "Employee Satisfaction", + component: EmployeeSatisfactionWidget, + size: "large", + preview: { + props: {}, + description: "Employee satisfaction survey results", + }, + }, + { + id: "performance", + name: "Performance Stats", + component: PerformanceStatsWidget, + size: "medium", + preview: { + props: {}, + description: "Department performance overview", + }, + }, + { + id: "new-employees", + name: "New Employees", + component: NewEmployeesWidget, + size: "medium", + preview: { + props: {}, + description: "Hiring trends over last 6 months", + }, + }, + { + id: "events", + name: "Upcoming Events", + component: UpcomingEventsWidget, + size: "medium", + preview: { + props: {}, + description: "Calendar of upcoming HR events", + }, + }, + { + id: "activities", + name: "Recent Activities", + component: RecentActivitiesWidget, + size: "medium", + preview: { + props: {}, + description: "Latest employee activities", + }, + }, + { + id: "quick-actions", + name: "Quick Actions", + component: QuickActionsWidget, + size: "medium", + preview: { + props: {}, + description: "Common HR tasks and shortcuts", + }, + }, +]; + +const hrSizePresets: WidgetSizePresets = { + small: { w: 3, h: 2, minW: 3, maxW: 3, minH: 2, maxH: 2, isResizable: false }, + medium: { w: 4, h: 4, minW: 3, maxW: 6, minH: 3, maxH: 6, isResizable: true }, + large: { w: 6, h: 4, minW: 4, maxW: 12, minH: 3, maxH: 8, isResizable: true }, +}; + +const hrLayouts: WidgetLayouts = { + lg: [ + ["total-employees", "new-hires", "turnover", "open-positions"], + ["salary-stats", "satisfaction"], + ["performance", "new-employees", "events"], + ["activities", "quick-actions"], + ], + md: [ + ["total-employees", "new-hires", "turnover"], + ["open-positions"], + ["salary-stats"], + ["satisfaction"], + ["performance", "new-employees"], + ["events", "activities"], + ["quick-actions"], + ], + sm: [ + ["total-employees", "new-hires"], + ["turnover", "open-positions"], + ["salary-stats"], + ["satisfaction"], + ["performance"], + ["new-employees"], + ["events"], + ["activities"], + ["quick-actions"], + ], + xs: [ + ["total-employees"], + ["new-hires"], + ["turnover"], + ["open-positions"], + ["salary-stats"], + ["satisfaction"], + ["performance"], + ["new-employees"], + ["events"], + ["activities"], + ["quick-actions"], + ], +}; + +export const HRDashboardExample: Story = { + render: function Render(args) { + const [, updateArgs] = useArgs(); + + const handleLayoutChange = useCallback( + (newLayout: DashboardLayouts) => { + updateArgs({ savedLayout: newLayout }); + }, + [updateArgs] + ); + + // @ts-expect-error - isGalleryOpen is only this story specific + const isGalleryOpen = args.isGalleryOpen || false; + + return ( + +
+
+
+

Widget Gallery

+
+
+ updateArgs({ isGalleryOpen: false })} + /> +
+
+ +
+ + + +
+
+
+ ); + }, + args: { + widgets: hrWidgets, + initialLayouts: hrLayouts, + sizes: hrSizePresets, + breakpoints: { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }, + cols: { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }, + rowHeight: 80, + layoutLock: false, + dragHandle: true, + dragHandleOnHover: true, + isBounded: true, + // @ts-expect-error - isGalleryOpen is only this story specific + isGalleryOpen: false, + }, +}; diff --git a/packages/frappe-ui-react/src/components/dashboardGrid/dashboard.tsx b/packages/frappe-ui-react/src/components/dashboardGrid/dashboard.tsx new file mode 100644 index 00000000..8a6762b6 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboardGrid/dashboard.tsx @@ -0,0 +1,240 @@ +/** + * External dependencies. + */ +import React, { useCallback, useEffect, useState, useContext } from "react"; +import type { Layout as RGLLayout } from "react-grid-layout"; + +/** + * Internal dependencies. + */ +import { LayoutContainer } from "./layoutContainer"; +import { + resolveWidgetSize, + serializeLayouts, + normalizeLayouts, + deserializeLayouts, +} from "./dashboardUtil"; +import type { + DashboardProps, + WidgetLayout, + DashboardLayouts, + Breakpoint, +} from "./types"; +import { DashboardContext } from "./dashboardContext"; + +export const DashboardGrid: React.FC = ({ + widgets, + initialLayouts, + savedLayout, + onLayoutChange, + sizes, + ...rest +}) => { + const context = useContext(DashboardContext); + const [disableCompaction, setDisableCompaction] = useState(false); + + const [layouts, setLayouts] = useState(() => { + if (savedLayout) { + return deserializeLayouts(savedLayout); + } + if (initialLayouts) { + return normalizeLayouts(initialLayouts, widgets, sizes); + } + return {}; + }); + + useEffect(() => { + if (savedLayout) { + setLayouts(deserializeLayouts(savedLayout)); + } + }, [savedLayout]); + + const handleLayoutChange = useCallback( + (newLayouts: DashboardLayouts) => { + setLayouts(newLayouts); + onLayoutChange?.(serializeLayouts(newLayouts)); + }, + [onLayoutChange] + ); + + const addWidgetToLayout = useCallback( + ( + widgetId: string, + position: { x: number; y: number }, + isDropped = false + ) => { + const widgetDef = widgets.find((w) => w.id === widgetId); + if (!widgetDef) return; + + setLayouts((prevLayouts) => { + const newLayouts: DashboardLayouts = { ...prevLayouts }; + + for (const bp in prevLayouts) { + const breakpoint = bp as Breakpoint; + const currentLayout = prevLayouts[breakpoint] || []; + + const existingCount = currentLayout.filter( + (item) => item.id === widgetId + ).length; + + const newKey = + existingCount === 0 ? widgetId : `${widgetId}-${existingCount}`; + + const { w, h } = resolveWidgetSize(widgetDef, sizes); + + const newLayoutItem: WidgetLayout = { + id: widgetId, + key: newKey, + x: position.x, + y: position.y, + w, + h, + size: widgetDef.size, + isResizable: widgetDef.isResizable, + isDraggable: widgetDef.isDraggable, + static: isDropped ? true : widgetDef.static, + }; + + newLayouts[breakpoint] = [...currentLayout, newLayoutItem]; + } + + onLayoutChange?.(serializeLayouts(newLayouts)); + return newLayouts; + }); + + if (isDropped) { + // Execute after current task completes to resolve maximum call stack issue + queueMicrotask(() => { + setLayouts((prevLayouts) => { + const newLayouts: DashboardLayouts = { ...prevLayouts }; + + for (const bp in prevLayouts) { + const breakpoint = bp as Breakpoint; + const currentLayout = prevLayouts[breakpoint] || []; + + newLayouts[breakpoint] = currentLayout.map((item) => { + const widgetDef = widgets.find((w) => w.id === item.id); + + return { + ...item, + static: widgetDef?.static, + isResizable: widgetDef?.isResizable, + isDraggable: widgetDef?.isDraggable, + }; + }); + } + + return newLayouts; + }); + + setDisableCompaction(false); + }); + } + }, + [widgets, sizes, onLayoutChange] + ); + const handleDrop = useCallback( + ( + widgetId: string, + layoutData: { x: number; y: number }, + currentLayout?: RGLLayout[] + ) => { + // Disable compaction and freeze all widgets at their current preview positions to ensure the widget is placed exactly where it was dropped + setDisableCompaction(true); + setLayouts((prevLayouts) => { + const newLayouts: DashboardLayouts = { ...prevLayouts }; + + for (const bp in prevLayouts) { + const breakpoint = bp as Breakpoint; + const layout = prevLayouts[breakpoint] || []; + + newLayouts[breakpoint] = layout.map((item) => { + if (currentLayout) { + const rglItem = currentLayout.find( + (rgl) => rgl.i === (item.key || item.id) + ); + if (rglItem) { + return { + ...item, + x: rglItem.x, + y: rglItem.y, + static: true, + }; + } + } + return { + ...item, + static: true, + }; + }); + } + + return newLayouts; + }); + + addWidgetToLayout(widgetId, layoutData, true); + }, + [addWidgetToLayout] + ); + + const handleRemoveWidget = useCallback( + (widgetKey: string) => { + setLayouts((prevLayouts) => { + const newLayouts: DashboardLayouts = {}; + + for (const breakpoint in prevLayouts) { + const currentLayout = prevLayouts[breakpoint as Breakpoint] || []; + newLayouts[breakpoint as Breakpoint] = currentLayout.filter( + (w) => (w.key || w.id) !== widgetKey + ); + } + + onLayoutChange?.(serializeLayouts(newLayouts)); + return newLayouts; + }); + }, + [onLayoutChange] + ); + + const handleAddWidget = useCallback( + (widgetId: string) => { + // Find the bottom-most Y positions + let maxY = 0; + for (const bp in layouts) { + const currentLayout = layouts[bp as Breakpoint] || []; + const bottomY = currentLayout.reduce((max, item) => { + const itemBottom = (item.y || 0) + (item.h || 0); + return itemBottom > max ? itemBottom : max; + }, 0); + maxY = Math.max(maxY, bottomY); + } + + addWidgetToLayout(widgetId, { x: 0, y: maxY }); + }, + [addWidgetToLayout, layouts] + ); + + useEffect(() => { + if (context) { + context.setHandleAddWidget(() => handleAddWidget); + context.setWidgets(widgets); + } + }, [context, handleAddWidget, widgets]); + + return ( + + ); +}; + +DashboardGrid.displayName = "DashboardGrid"; + +export default DashboardGrid; diff --git a/packages/frappe-ui-react/src/components/dashboardGrid/dashboardContext.tsx b/packages/frappe-ui-react/src/components/dashboardGrid/dashboardContext.tsx new file mode 100644 index 00000000..05e1f61d --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboardGrid/dashboardContext.tsx @@ -0,0 +1,72 @@ +/** + * External dependencies. + */ +import { createContext, useState, useCallback } from "react"; +import React from "react"; + +/** + * Internal dependencies. + */ +import type { WidgetDefinition } from "./types"; + +export interface DraggingWidgetData { + widgetId: string; + w: number; + h: number; + widget?: WidgetDefinition; +} + +export interface DashboardContextValue { + draggingWidget: DraggingWidgetData | null; + setDraggingWidget: (widget: DraggingWidgetData | null) => void; + handleAddWidget?: (widgetId: string) => void; + setHandleAddWidget: ( + handler: ((widgetId: string) => void) | undefined + ) => void; + widgets: WidgetDefinition[]; + setWidgets: (widgets: WidgetDefinition[]) => void; +} + +const DashboardContext = createContext(null); + +export interface DashboardProviderProps { + children: React.ReactNode; +} + +/** + * Optional provider for sharing drag state between Dashboard and WidgetGallery. + */ +export const DashboardProvider: React.FC = ({ + children, +}) => { + const [draggingWidget, setDraggingWidget] = + useState(null); + const [handleAddWidget, setHandleAddWidget] = useState< + ((widgetId: string) => void) | undefined + >(); + const [widgets, setWidgets] = useState([]); + + const handleSetDraggingWidget = useCallback( + (widget: DraggingWidgetData | null) => { + setDraggingWidget(widget); + }, + [] + ); + + return ( + + {children} + + ); +}; + +export { DashboardContext }; diff --git a/packages/frappe-ui-react/src/components/dashboardGrid/dashboardUtil.ts b/packages/frappe-ui-react/src/components/dashboardGrid/dashboardUtil.ts new file mode 100644 index 00000000..c49c034e --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboardGrid/dashboardUtil.ts @@ -0,0 +1,191 @@ +import type { + WidgetLayout, + WidgetSizePresets, + WidgetDefinition, + DashboardLayout, + DashboardLayouts, + WidgetLayouts, + Breakpoint, +} from "./types"; + +/** + * Convert DashboardLayout to flat WidgetLayout array with positions + */ +export const normalizeLayout = ( + layout: DashboardLayout, + widgets: WidgetDefinition[], + sizePresets?: WidgetSizePresets +): WidgetLayout[] => { + if (!layout || layout.length === 0) return []; + + const result: WidgetLayout[] = []; + let currentY = 0; + + for (const row of layout) { + if (!Array.isArray(row)) continue; + + let currentX = 0; + let maxRowHeight = 1; + + for (const item of row) { + const widgetLayout: WidgetLayout = + typeof item === "string" ? { id: item } : { ...item }; + + const widgetDef = widgets.find((w) => w.id === widgetLayout.id); + const resolved = resolveWidgetSize(widgetLayout, sizePresets, widgetDef); + + widgetLayout.x = currentX; + widgetLayout.y = currentY; + widgetLayout.static = widgetDef?.static; + + result.push(widgetLayout); + + currentX += resolved.w; + maxRowHeight = Math.max(maxRowHeight, resolved.h); + } + + currentY += maxRowHeight; + } + + return ensureLayoutKeys(result); +}; + +/** + * Resolve widget dimensions from size preset or explicit values + */ +export const resolveWidgetSize = ( + layout: WidgetLayout, + sizePresets?: WidgetSizePresets, + widgetDef?: WidgetDefinition +): Required> & + Pick => { + const sizeName = layout.size ?? widgetDef?.size; + const preset = + sizeName && sizePresets?.[sizeName] ? sizePresets[sizeName] : null; + + return { + w: layout.w ?? preset?.w ?? 4, + h: layout.h ?? preset?.h ?? 4, + minW: layout.minW ?? preset?.minW, + maxW: layout.maxW ?? preset?.maxW, + minH: layout.minH ?? preset?.minH, + maxH: layout.maxH ?? preset?.maxH, + isResizable: + layout.isResizable ?? preset?.isResizable ?? widgetDef?.isResizable, + }; +}; + +/** + * Validate dashboard layout array + */ +export const validateLayout = (layout: WidgetLayout[]): boolean => { + if (!Array.isArray(layout)) return false; + if (layout.length === 0) return false; + + for (const widget of layout) { + if (!widget || typeof widget.id !== "string") return false; + if (typeof widget.x !== "number" || widget.x < 0) return false; + if (typeof widget.y !== "number" || widget.y < 0) return false; + if (widget.w !== undefined && widget.w <= 0) return false; + if (widget.h !== undefined && widget.h <= 0) return false; + } + + return true; +}; + +/** + * Ensure all layout items have unique keys for internal tracking. + */ +export const ensureLayoutKeys = (layouts: WidgetLayout[]): WidgetLayout[] => { + const keyCount = new Map(); + + return layouts.map((item) => { + if (item.key) return item; + + const count = keyCount.get(item.id) || 0; + keyCount.set(item.id, count + 1); + + return { + ...item, + key: count === 0 ? item.id : `${item.id}-${count}`, + }; + }); +}; + +/** + * Normalize DashboardLayouts to WidgetLayout per breakpoint + */ +export const normalizeLayouts = ( + layouts: WidgetLayouts, + widgets: WidgetDefinition[], + sizes?: WidgetSizePresets +): DashboardLayouts => { + const normalized: DashboardLayouts = {}; + + for (const breakpoint in layouts) { + const layout = layouts[breakpoint as Breakpoint]; + if (layout) { + normalized[breakpoint as Breakpoint] = ensureLayoutKeys( + normalizeLayout(layout, widgets, sizes) + ); + } + } + + // Fill missing breakpoints by copying nearest defined layout + const breakpointOrder: Breakpoint[] = ["lg", "md", "sm", "xs", "xxs"]; + + breakpointOrder.forEach((bp, i) => { + if (normalized[bp]) return; + const nearestLayout = + breakpointOrder + .slice(0, i) + .reverse() + .find((b) => normalized[b]) || + breakpointOrder.slice(i + 1).find((b) => normalized[b]); + + if (nearestLayout) { + normalized[bp] = normalized[nearestLayout]!.map((item) => ({ ...item })); + } + }); + + return normalized; +}; + +/** + * Serialize layouts for saving by removing internal tracking keys. + */ +export const serializeLayouts = ( + layouts: DashboardLayouts +): DashboardLayouts => { + const serialized: DashboardLayouts = {}; + + for (const breakpoint in layouts) { + const layout = layouts[breakpoint as Breakpoint]; + if (layout) { + serialized[breakpoint as Breakpoint] = layout.map( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ({ key, ...item }) => item + ); + } + } + + return serialized; +}; + +/** + * Deserialize saved layouts and restore with proper keys. + */ +export const deserializeLayouts = ( + layouts: DashboardLayouts +): DashboardLayouts => { + const deserialized: DashboardLayouts = {}; + + for (const breakpoint in layouts) { + const layout = layouts[breakpoint as Breakpoint]; + if (layout && validateLayout(layout)) { + deserialized[breakpoint as Breakpoint] = ensureLayoutKeys(layout); + } + } + + return deserialized; +}; diff --git a/packages/frappe-ui-react/src/components/dashboardGrid/dashboardWidgetGallery.tsx b/packages/frappe-ui-react/src/components/dashboardGrid/dashboardWidgetGallery.tsx new file mode 100644 index 00000000..6c5fc83b --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboardGrid/dashboardWidgetGallery.tsx @@ -0,0 +1,58 @@ +/** + * External dependencies. + */ +import React, { useContext } from "react"; +import clsx from "clsx"; + +/** + * Internal dependencies. + */ +import type { DashboardWidgetGalleryProps } from "./types"; +import { DashboardWidgetGalleryItem } from "./dashboardWidgetGalleryItem"; +import { DashboardContext } from "./dashboardContext"; + +export const DashboardWidgetGallery: React.FC = ({ + title, + description, + className, + view = "list", + mode = "both", + filterWidgets, + onWidgetAdd, + onWidgetDrop, +}) => { + const context = useContext(DashboardContext)!; + const widgets = filterWidgets + ? filterWidgets(context.widgets) + : context.widgets; + return ( +
+ {(title || description) && ( +
+ {title && ( +

{title}

+ )} + {description && ( +

{description}

+ )} +
+ )} +
+ {widgets.map((widget) => ( + + ))} +
+
+ ); +}; diff --git a/packages/frappe-ui-react/src/components/dashboardGrid/dashboardWidgetGalleryItem.tsx b/packages/frappe-ui-react/src/components/dashboardGrid/dashboardWidgetGalleryItem.tsx new file mode 100644 index 00000000..90d3c6fb --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboardGrid/dashboardWidgetGalleryItem.tsx @@ -0,0 +1,84 @@ +/** + * External dependencies. + */ +import React, { useContext } from "react"; +import clsx from "clsx"; + +/** + * Internal dependencies. + */ +import type { DashboardWidgetGalleryItemProps } from "./types"; +import { DashboardContext } from "./dashboardContext"; +import { resolveWidgetSize } from "./dashboardUtil"; + +export const DashboardWidgetGalleryItem: React.FC< + DashboardWidgetGalleryItemProps +> = ({ widget, view = "list", mode = "both", onWidgetAdd, onWidgetDrop }) => { + const context = useContext(DashboardContext); + + const handleDragStart = () => { + const { w, h } = resolveWidgetSize(widget); + + const widgetData = { + widgetId: widget.id, + w, + h, + }; + + if (context) { + context.setDraggingWidget({ ...widgetData, widget }); + } + }; + + const handleDragEnd = () => { + if (context) { + context.setDraggingWidget(null); + } + onWidgetDrop?.(widget.id); + }; + + const handleClick = () => { + if (mode === "click" || mode === "both") { + context?.handleAddWidget?.(widget.id); + onWidgetAdd?.(widget.id); + } + }; + + const isDraggable = mode === "drag" || mode === "both"; + + return ( +
+ {widget.preview && ( +
+
+
+ +
+
+
+
+ )} +
+
{widget.name}
+ {widget.preview?.description && ( +
+ {widget.preview.description} +
+ )} + {widget.size && ( +
{widget.size}
+ )} +
+
+ ); +}; diff --git a/packages/frappe-ui-react/src/components/dashboardGrid/index.ts b/packages/frappe-ui-react/src/components/dashboardGrid/index.ts new file mode 100644 index 00000000..e80b04d8 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboardGrid/index.ts @@ -0,0 +1,5 @@ +export { DashboardGrid, default } from './dashboard'; +export { WidgetWrapper } from './widgetWrapper'; +export { DashboardWidgetGallery } from './dashboardWidgetGallery'; +export { DashboardProvider } from './dashboardContext'; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/dashboardGrid/layoutContainer.tsx b/packages/frappe-ui-react/src/components/dashboardGrid/layoutContainer.tsx new file mode 100644 index 00000000..9f425ac1 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboardGrid/layoutContainer.tsx @@ -0,0 +1,259 @@ +/** + * External dependencies. + */ +import { useCallback, useMemo, useEffect, useState, useContext } from "react"; +import clsx from "clsx"; +import { + WidthProvider, + Responsive, + Layout as RGLLayout, +} from "react-grid-layout/legacy"; +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; +import "./dashboard.css"; + +/** + * Internal dependencies. + */ +import { WidgetWrapper } from "./widgetWrapper"; +import type { + LayoutContainerProps, + WidgetLayout, + Breakpoint, + DashboardLayouts, +} from "./types"; +import { DashboardContext } from "./dashboardContext"; +import { resolveWidgetSize } from "./dashboardUtil"; +import React from "react"; + +const ResponsiveGridLayout = WidthProvider(Responsive); + +export const LayoutContainer: React.FC = ({ + widgets, + layouts, + setLayouts, + onDrop, + onRemove, + sizes, + layoutLock = false, + dragHandle = false, + dragHandleOnHover = false, + breakpoints, + cols, + rowHeight = 100, + margin = [16, 16], + compactType = "vertical", + isBounded = true, + className = "", +}) => { + const context = useContext(DashboardContext); + const [isMounted, setIsMounted] = useState(false); + const [activeBreakpoint, setActiveBreakpoint] = useState("lg"); + + useEffect(() => { + const timer = setTimeout(() => setIsMounted(true), 100); + return () => clearTimeout(timer); + }, []); + + const defaultBreakpoints = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; + const defaultCols = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }; + + const actualBreakpoints = breakpoints || defaultBreakpoints; + const actualCols = cols || defaultCols; + + const widgetMap = useMemo( + () => new Map(widgets.map((w) => [w.id, w])), + [widgets] + ); + + const rglLayouts = useMemo(() => { + const result: { [key: string]: RGLLayout[] } = {}; + + for (const breakpoint in layouts) { + const layout = layouts[breakpoint as Breakpoint]; + if (layout) { + result[breakpoint] = layout.map((item: WidgetLayout) => { + const widgetDef = widgetMap.get(item.id); + const size = resolveWidgetSize(item, sizes, widgetDef); + return { + i: item.key || item.id, + x: item.x ?? 0, + y: item.y ?? 0, + w: size.w, + h: size.h, + minW: size.minW, + minH: size.minH, + maxW: size.maxW, + maxH: size.maxH, + static: item.static, + isDraggable: item.static + ? false + : item.isDraggable !== undefined + ? item.isDraggable + : widgetDef?.isDraggable ?? !layoutLock, + isResizable: item.static + ? false + : size.isResizable !== undefined + ? size.isResizable + : true, + }; + }); + } + } + + return result; + }, [layouts, sizes, layoutLock, widgetMap]); + + const handleLayoutChange = useCallback( + ( + _currentLayout: RGLLayout[], + allLayouts: { [key: string]: RGLLayout[] } + ) => { + if (layoutLock || !setLayouts) return; + + const newLayouts: DashboardLayouts = {}; + + for (const breakpoint in allLayouts) { + const rglLayout = allLayouts[breakpoint]; + const originalLayout = layouts[breakpoint as Breakpoint] || []; + + const layoutMap = new Map( + rglLayout.map((item: RGLLayout) => [item.i, item]) + ); + const updatedLayout: WidgetLayout[] = originalLayout.map( + (item: WidgetLayout) => { + const key = item.key || item.id; + const rglItem = layoutMap.get(key); + if (!rglItem) return item; + return { + ...item, + x: rglItem.x, + y: rglItem.y, + w: rglItem.w, + h: rglItem.h, + static: rglItem.static, + isDraggable: rglItem.isDraggable, + isResizable: rglItem.isResizable, + }; + } + ); + + newLayouts[breakpoint as Breakpoint] = updatedLayout; + } + + setLayouts(newLayouts); + }, + [layouts, setLayouts, layoutLock] + ); + + const handleBreakpointChange = useCallback((breakpoint: Breakpoint) => { + setActiveBreakpoint(breakpoint); + }, []); + + const handleDrop = useCallback( + (layoutItem: RGLLayout[], item: RGLLayout) => { + if (!onDrop) return; + + const widgetData = context?.draggingWidget; + + if (widgetData?.widgetId) { + onDrop( + widgetData.widgetId, + { + x: item.x, + y: item.y, + w: widgetData.w, + h: widgetData.h, + }, + layoutItem + ); + } + + if (context) { + context.setDraggingWidget(null); + } + }, + [onDrop, context] + ); + + const droppingItemSize = useMemo(() => { + if (!context?.draggingWidget?.widget) return { w: 4, h: 3 }; + + const widgetDef = context.draggingWidget.widget; + const dummyLayout: WidgetLayout = { id: widgetDef.id, x: 0, y: 0 }; + const resolved = resolveWidgetSize(dummyLayout, sizes, widgetDef); + + return { w: resolved.w, h: resolved.h }; + }, [context?.draggingWidget?.widget, sizes]); + + const commonProps = { + className: clsx( + "layout min-h-full", + !isMounted && "react-grid-layout-no-transition" + ), + rowHeight, + margin, + onLayoutChange: handleLayoutChange, + onBreakpointChange: handleBreakpointChange, + onDrop: onDrop ? handleDrop : undefined, + isDroppable: Boolean(onDrop), + droppingItem: onDrop + ? { + i: "__dropping-elem__", + w: droppingItemSize.w, + h: droppingItemSize.h, + } + : undefined, + isBounded, + isDraggable: !layoutLock, + isResizable: !layoutLock, + draggableHandle: dragHandle ? ".dashboard-drag-handle" : undefined, + draggableCancel: ".dashboard-drag-cancel", + compactType, + preventCollision: false, + allowOverlap: false, + useCSSTransforms: true, + }; + + const handleRemoveWidget = useCallback( + (widgetKey: string) => { + onRemove?.(widgetKey); + }, + [onRemove] + ); + + const currentLayout = layouts[activeBreakpoint] || []; + + return ( +
+ + {currentLayout.map((layoutItem: WidgetLayout) => { + const widgetDef = widgetMap.get(layoutItem.id); + if (!widgetDef) return null; + + const Component = widgetDef.component; + const key = layoutItem.key || layoutItem.id; + + return ( +
+ + + +
+ ); + })} +
+
+ ); +}; diff --git a/packages/frappe-ui-react/src/components/dashboardGrid/types.ts b/packages/frappe-ui-react/src/components/dashboardGrid/types.ts new file mode 100644 index 00000000..97798e77 --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboardGrid/types.ts @@ -0,0 +1,132 @@ +import type { Layout as RGLLayout } from "react-grid-layout"; + +export type Breakpoint = "lg" | "md" | "sm" | "xs" | "xxs"; + +export interface WidgetSize { + w: number; + h: number; + minW?: number; + maxW?: number; + minH?: number; + maxH?: number; + isResizable?: boolean; +} + +export type WidgetSizePresets = Record; + +export interface WidgetLayout { + id: string; + key?: string; + x?: number; + y?: number; + size?: string; + w?: number; + h?: number; + minW?: number; + maxW?: number; + minH?: number; + maxH?: number; + static?: boolean; + isDraggable?: boolean; + isResizable?: boolean; +} + +export type WidgetRow = Array; +export type DashboardLayout = WidgetRow[]; + +export type DashboardLayouts = { + [key in Breakpoint]?: WidgetLayout[]; +}; + +export type WidgetLayouts = { + [key in Breakpoint]?: DashboardLayout; +}; + +export interface WidgetDefinition { + id: string; + name: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component: React.ComponentType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props?: Record; + size?: string; + isResizable?: boolean; + isDraggable?: boolean; + static?: boolean; + preview?: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props?: Record; + description?: string; + }; +} + +export interface DashboardProps { + widgets: WidgetDefinition[]; + initialLayouts?: WidgetLayouts; + savedLayout?: DashboardLayouts; + onLayoutChange?: (layout: DashboardLayouts) => void; + sizes?: WidgetSizePresets; + breakpoints?: { [key in Breakpoint]?: number }; + cols?: { [key in Breakpoint]?: number }; + rowHeight?: number; + margin?: [number, number]; + layoutLock?: boolean; + dragHandle?: boolean; + dragHandleOnHover?: boolean; + compactType?: "vertical" | "horizontal" | null; + isBounded?: boolean; + className?: string; +} + +export interface LayoutContainerProps { + widgets: WidgetDefinition[]; + layouts: DashboardLayouts; + setLayouts?: (layouts: DashboardLayouts) => void; + onDrop?: ( + widgetId: string, + layout: { x: number; y: number; w: number; h: number }, + currentLayout?: RGLLayout[] + ) => void; + onRemove?: (widgetId: string) => void; + sizes?: WidgetSizePresets; + breakpoints?: { [key in Breakpoint]?: number }; + cols?: { [key in Breakpoint]?: number }; + rowHeight?: number; + margin?: [number, number]; + layoutLock?: boolean; + dragHandle?: boolean; + dragHandleOnHover?: boolean; + compactType?: "vertical" | "horizontal" | null; + isBounded?: boolean; + className?: string; +} + +export interface WidgetWrapperProps { + widgetId: string; + onRemove?: (widgetId: string) => void; + layoutLock?: boolean; + dragHandle?: boolean; + dragHandleOnHover?: boolean; + children: React.ReactNode; +} + +export interface DashboardWidgetGalleryProps { + title?: string; + description?: string; + className?: string; + view?: "list" | "grid"; + mode?: "drag" | "click" | "both"; + filterWidgets?: (widgets: WidgetDefinition[]) => WidgetDefinition[]; + onWidgetAdd?: (widgetId: string) => void; + onWidgetDrop?: (widgetId: string) => void; +} + +export interface DashboardWidgetGalleryItemProps { + widget: WidgetDefinition; + view?: "list" | "grid"; + mode?: "drag" | "click" | "both"; + onWidgetAdd?: (widgetId: string) => void; + onWidgetDrop?: (widgetId: string) => void; +} + +export type GridLayoutItem = RGLLayout; diff --git a/packages/frappe-ui-react/src/components/dashboardGrid/widgetWrapper.tsx b/packages/frappe-ui-react/src/components/dashboardGrid/widgetWrapper.tsx new file mode 100644 index 00000000..1fd5c25e --- /dev/null +++ b/packages/frappe-ui-react/src/components/dashboardGrid/widgetWrapper.tsx @@ -0,0 +1,52 @@ +/** + * External dependencies. + */ +import { useState } from "react"; +import { GripVertical, X } from "lucide-react"; + +/** + * Internal dependencies. + */ +import type { WidgetWrapperProps } from "./types"; +import { Button } from "../button"; + +export const WidgetWrapper: React.FC = ({ + widgetId, + onRemove, + layoutLock = false, + dragHandle = false, + dragHandleOnHover = false, + children, +}) => { + const [isHovered, setIsHovered] = useState(false); + + const showDragHandle = + dragHandle && !layoutLock && (!dragHandleOnHover || isHovered); + const showRemoveButton = !layoutLock && isHovered && onRemove; + + return ( +
setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + > + {(showRemoveButton || showDragHandle) && ( +
+ {showRemoveButton && ( +
+ )} +
{children}
+
+ ); +};