From c031d33211e17dbad1db2b28f5a6f0ce1cd46204 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 21 Oct 2025 08:08:32 -0400 Subject: [PATCH 1/6] feat: init widget dashboard --- ARCHITECTURE.md | 381 ++++++++++++++++++ MIGRATION.md | 256 ++++++++++++ README.md | 145 ++++++- packages/module/package.json | 11 +- .../design-guidelines/design-guidelines.md | 23 -- .../content/examples/Basic.tsx | 4 - .../content/examples/BasicExample.tsx | 110 +++++ .../content/examples/LockedLayoutExample.tsx | 87 ++++ .../content/examples/WithoutDrawerExample.tsx | 106 +++++ .../patternfly-docs/content/examples/basic.md | 75 +++- .../src/ExtendedButton/ExtendedButton.tsx | 30 -- .../__tests__/ExtendedButton.test.tsx | 72 ---- .../ExtendedButton.test.tsx.snap | 19 - packages/module/src/ExtendedButton/index.ts | 1 - .../module/src/WidgetLayout/GridLayout.tsx | 290 +++++++++++++ packages/module/src/WidgetLayout/GridTile.tsx | 230 +++++++++++ .../module/src/WidgetLayout/WidgetDrawer.tsx | 166 ++++++++ .../module/src/WidgetLayout/WidgetLayout.tsx | 131 ++++++ .../__tests__/WidgetLayout.test.tsx | 123 ++++++ .../__snapshots__/WidgetLayout.test.tsx.snap | 12 + .../src/WidgetLayout/__tests__/utils.test.ts | 114 ++++++ packages/module/src/WidgetLayout/images.d.ts | 5 + packages/module/src/WidgetLayout/index.ts | 23 ++ packages/module/src/WidgetLayout/styles.ts | 134 ++++++ packages/module/src/WidgetLayout/types.ts | 82 ++++ packages/module/src/WidgetLayout/utils.ts | 71 ++++ packages/module/src/index.ts | 13 +- yarn.lock | 186 ++++++++- 28 files changed, 2736 insertions(+), 164 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 MIGRATION.md delete mode 100644 packages/module/patternfly-docs/content/design-guidelines/design-guidelines.md delete mode 100644 packages/module/patternfly-docs/content/examples/Basic.tsx create mode 100644 packages/module/patternfly-docs/content/examples/BasicExample.tsx create mode 100644 packages/module/patternfly-docs/content/examples/LockedLayoutExample.tsx create mode 100644 packages/module/patternfly-docs/content/examples/WithoutDrawerExample.tsx delete mode 100644 packages/module/src/ExtendedButton/ExtendedButton.tsx delete mode 100644 packages/module/src/ExtendedButton/__tests__/ExtendedButton.test.tsx delete mode 100644 packages/module/src/ExtendedButton/__tests__/__snapshots__/ExtendedButton.test.tsx.snap delete mode 100644 packages/module/src/ExtendedButton/index.ts create mode 100644 packages/module/src/WidgetLayout/GridLayout.tsx create mode 100644 packages/module/src/WidgetLayout/GridTile.tsx create mode 100644 packages/module/src/WidgetLayout/WidgetDrawer.tsx create mode 100644 packages/module/src/WidgetLayout/WidgetLayout.tsx create mode 100644 packages/module/src/WidgetLayout/__tests__/WidgetLayout.test.tsx create mode 100644 packages/module/src/WidgetLayout/__tests__/__snapshots__/WidgetLayout.test.tsx.snap create mode 100644 packages/module/src/WidgetLayout/__tests__/utils.test.ts create mode 100644 packages/module/src/WidgetLayout/images.d.ts create mode 100644 packages/module/src/WidgetLayout/index.ts create mode 100644 packages/module/src/WidgetLayout/styles.ts create mode 100644 packages/module/src/WidgetLayout/types.ts create mode 100644 packages/module/src/WidgetLayout/utils.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..e6e4b3f --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,381 @@ +# Architecture Overview + +## Component Structure + +The Widgetized Dashboard is composed of several key components that work together to provide a complete dashboard experience: + +``` +WidgetLayout (main component) +├── WidgetDrawer (widget selection) +│ └── WidgetWrapper (draggable widget cards) +└── GridLayout (drag-and-drop grid) + └── GridTile (individual widget wrapper) + └── Widget Content (user-provided via renderWidget) +``` + +## Core Components + +### 1. WidgetLayout +The top-level component that orchestrates the entire dashboard experience. + +**Responsibilities:** +- Manages overall state (template, drawer open/close) +- Coordinates between GridLayout and WidgetDrawer +- Provides unified API for consumers + +**State:** +- `template`: Current layout configuration +- `drawerOpen`: Whether widget drawer is visible +- `currentlyUsedWidgets`: List of widget types in use + +### 2. GridLayout +The core layout engine powered by `react-grid-layout`. + +**Responsibilities:** +- Rendering the responsive grid +- Handling drag-and-drop operations +- Managing widget positions and sizes +- Responsive breakpoint handling +- Widget CRUD operations + +**Key Features:** +- 4 responsive breakpoints (xl, lg, md, sm) +- Drag-and-drop from drawer +- Resize with corner handles +- Lock/unlock individual widgets +- Empty state display + +**State:** +- `isDragging`: Drag operation in progress +- `isInitialRender`: Skip save on first render +- `layoutVariant`: Current breakpoint +- `layoutWidth`: Container width +- `currentDropInItem`: Widget being dragged + +### 3. GridTile +Wrapper component for individual widgets. + +**Responsibilities:** +- Render widget content +- Provide widget actions menu +- Display widget header with icon and title +- Handle widget-level interactions + +**Features:** +- Lock/unlock toggle +- Autosize to max height +- Minimize to min height +- Remove widget +- Optional header link +- Drag handle + +### 4. WidgetDrawer +Side panel for selecting widgets to add. + +**Responsibilities:** +- Display available widgets +- Filter out currently used widgets +- Provide drag source for new widgets +- Instructions for users + +## Data Flow + +### Template Management + +``` +┌─────────────┐ +│ Parent │ +│ Component │ +└──────┬──────┘ + │ + │ initialTemplate (prop) + ▼ +┌─────────────┐ +│ WidgetLayout│ +│ (state) │ +└──────┬──────┘ + │ + │ template (prop) + ▼ +┌─────────────┐ +│ GridLayout │ +│ (renders) │ +└──────┬──────┘ + │ + │ onChange + ▼ +┌─────────────┐ +│onTemplateChange│ +│ (callback) │ +└──────┬──────┘ + │ + │ save to API/storage + ▼ +┌─────────────┐ +│ Parent │ +│ Component │ +└─────────────┘ +``` + +### Widget Rendering + +``` +┌──────────────┐ +│WidgetMapping │ +│ (config) │ +└──────┬───────┘ + │ + │ widgetType → renderWidget + ▼ +┌──────────────┐ +│ GridTile │ +│ (wrapper) │ +└──────┬───────┘ + │ + │ renders children + ▼ +┌──────────────┐ +│Widget Content│ +│ (React node) │ +└──────────────┘ +``` + +## State Management + +The component uses **local React state** for all state management: + +- No external state libraries required (no Jotai, Redux, etc.) +- Parent controls template via `initialTemplate` and `onTemplateChange` +- Internal state synchronized with props +- Callbacks for notifications, analytics, etc. + +### State Flow + +```typescript +// Parent manages template +const [template, setTemplate] = useState(initialTemplate); + +// WidgetLayout receives and manages internally + { + setTemplate(newTemplate); + saveToAPI(newTemplate); + }} +/> + +// Internal state stays in sync +useEffect(() => { + setInternalTemplate(template); +}, [template]); +``` + +## Responsive Design + +### Breakpoints + +| Breakpoint | Width | Columns | Use Case | +|-----------|------------|---------|----------| +| xl | ≥1550px | 4 | Large desktop | +| lg | 1400-1549px| 3 | Desktop | +| md | 1100-1399px| 2 | Tablet landscape | +| sm | 800-1099px | 1 | Tablet portrait | + +### Grid System + +- **Row Height**: 56px (fixed) +- **Container**: 100% width, min-height 200px +- **Columns**: Responsive (4/3/2/1) +- **Gutter**: Managed by react-grid-layout + +### Responsive Behavior + +```typescript +// Auto-detect breakpoint on mount and resize +const observer = new ResizeObserver((entries) => { + const width = entries[0].contentRect.width; + const variant = getGridDimensions(width); + setLayoutVariant(variant); +}); +``` + +Each template must define layouts for all breakpoints: + +```typescript +const template: ExtendedTemplateConfig = { + xl: [...], // 4 columns + lg: [...], // 3 columns + md: [...], // 2 columns + sm: [...] // 1 column +}; +``` + +## Type System + +### Core Types + +```typescript +// Widget definition +type WidgetMapping = { + [widgetType: string]: { + defaults: WidgetDefaults; + config?: WidgetConfiguration; + renderWidget: (widgetId: string) => React.ReactNode; + }; +}; + +// Layout configuration +type ExtendedTemplateConfig = { + xl: ExtendedLayoutItem[]; + lg: ExtendedLayoutItem[]; + md: ExtendedLayoutItem[]; + sm: ExtendedLayoutItem[]; +}; + +// Individual widget placement +type ExtendedLayoutItem = Layout & { + widgetType: string; + title: string; + config?: WidgetConfiguration; + locked?: boolean; +}; +``` + +## Performance Considerations + +### Optimization Strategies + +1. **Memoization** + - `useMemo` for dropping item template + - `useMemo` for dropdown items + - `useMemo` for header links + +2. **Conditional Rendering** + - Empty state only when needed + - Drawer only when `showDrawer={true}` + - Resize handles only on hover + +3. **Event Handling** + - No inline function creation in render + - Callbacks defined at component level + - Event handlers properly memoized + +4. **Grid Reset** + - Key prop forces remount on breakpoint change + - Necessary for proper react-grid-layout behavior + - `key={'grid-' + layoutVariant}` + +## Integration Points + +### With PatternFly + +- Uses PatternFly Card for widget containers +- Uses PatternFly EmptyState for empty dashboard +- Uses PatternFly Dropdown for actions menu +- Uses PatternFly Icons throughout +- Follows PatternFly design tokens + +### With react-grid-layout + +- Wraps ReactGridLayout component +- Custom resize handles +- Custom drag handles +- Responsive breakpoints +- Layout persistence + +### With React Router + +- Optional Link component in GridTile +- Handles internal/external links +- Target blank for external links + +## Extensibility + +### Custom Empty State + +```typescript +} +/> +``` + +### Custom Analytics + +```typescript + { + myAnalytics.track(event, data); + }} +/> +``` + +### Custom Notifications + +```typescript + { + toast.show(notification.title, { + variant: notification.variant + }); + }} +/> +``` + +## File Structure + +``` +src/WidgetLayout/ +├── types.ts # TypeScript type definitions +├── utils.ts # Utility functions +├── GridLayout.tsx # Main grid component +├── GridLayout.scss # Grid styles +├── GridTile.tsx # Widget wrapper component +├── GridTile.scss # Tile styles +├── WidgetDrawer.tsx # Widget selection drawer +├── WidgetDrawer.scss # Drawer styles +├── WidgetLayout.tsx # Main component +├── index.ts # Public exports +├── resize-handle.svg # Resize handle icon +└── __tests__/ # Test files + ├── WidgetLayout.test.tsx + ├── utils.test.ts + └── __snapshots__/ +``` + +## Dependencies + +### Runtime Dependencies + +- `react` & `react-dom` (peer) +- `react-router-dom` (peer) +- `@patternfly/react-core` +- `@patternfly/react-icons` +- `react-grid-layout` +- `clsx` + +### Development Dependencies + +- `@types/react-grid-layout` +- TypeScript +- Jest & Testing Library + +## Browser Support + +Modern browsers with ES6 support: +- Chrome/Edge (Chromium) +- Firefox +- Safari +- Mobile browsers + +## Future Enhancements + +Potential areas for extension: + +1. **Widget Templates** - Pre-defined layout templates +2. **Export/Import** - JSON export/import of layouts +3. **Undo/Redo** - Layout change history +4. **Widget Groups** - Grouping related widgets +5. **Themes** - Custom color schemes +6. **Animations** - Smooth transitions +7. **Touch Support** - Better mobile experience + diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..cc37c16 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,256 @@ +# Migration Guide from widget-layout + +This document provides guidance for users migrating from the RedHatInsights/widget-layout repository to this generic PatternFly component. + +## What Changed + +### Removed Dependencies + +The following console-specific dependencies have been removed: + +1. **Scalprum** (`@scalprum/react-core`) - Federated module loading +2. **Chrome Services APIs** - API calls for template persistence +3. **useCurrentUser** - Console-specific authentication +4. **useChrome** - Console-specific hooks and analytics +5. **Jotai atoms** - External state management +6. **Console icons and branding** + +### New Approach + +#### Widget Definition + +**Before (widget-layout):** +```typescript +// Widget loaded via Scalprum federated modules +const widgetMapping = { + 'my-widget': { + scope: 'myApp', + module: './MyWidget', + importName: 'MyWidget', + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { title: 'My Widget' } + } +}; +``` + +**After (widgetized-dashboard):** +```typescript +// Widget rendered directly via React component +const widgetMapping: WidgetMapping = { + 'my-widget': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'My Widget', + icon: + }, + renderWidget: (id) => + } +}; +``` + +#### Template Management + +**Before (widget-layout):** +```typescript +// Templates loaded/saved automatically via Chrome Services API +import { getDashboardTemplates, patchDashboardTemplate } from './api/dashboard-templates'; + +// Component handles API calls internally + +``` + +**After (widgetized-dashboard):** +```typescript +// Templates managed via props (bring your own state management) +const [template, setTemplate] = useState(loadTemplate()); + +const handleTemplateChange = async (newTemplate) => { + setTemplate(newTemplate); + await saveTemplateToAPI(newTemplate); // Your own API +}; + + +``` + +#### State Management + +**Before (widget-layout):** +```typescript +// State managed via Jotai atoms +import { useAtom } from 'jotai'; +import { templateAtom } from './state/templateAtom'; + +const [template, setTemplate] = useAtom(templateAtom); +``` + +**After (widgetized-dashboard):** +```typescript +// State managed via React state or your preferred solution +const [template, setTemplate] = useState(initialTemplate); + +// Or use Redux, MobX, Zustand, etc. +``` + +#### Analytics + +**Before (widget-layout):** +```typescript +// Chrome analytics used internally +import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; + +const { analytics } = useChrome(); +analytics.track('widget-layout.widget-add', { data }); +``` + +**After (widgetized-dashboard):** +```typescript +// Optional analytics via callback + { + yourAnalytics.track(event, data); + }} +/> +``` + +## Step-by-Step Migration + +### 1. Install Dependencies + +```bash +yarn add @patternfly/widgetized-dashboard +``` + +### 2. Update Widget Definitions + +Convert your Scalprum widget definitions to render functions: + +```typescript +// Old +{ + 'insights-widget': { + scope: '@redhat-cloud-services/insights', + module: './InsightsWidget', + importName: 'default', + defaults: { w: 2, h: 3, maxH: 6, minH: 2 } + } +} + +// New +{ + 'insights-widget': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Insights', + icon: + }, + renderWidget: (id) => + } +} +``` + +### 3. Implement Template Persistence + +Replace Chrome Services API calls with your own persistence layer: + +```typescript +// Load from your backend +const loadTemplate = async () => { + const response = await fetch('/api/dashboard/template'); + return response.json(); +}; + +// Save to your backend +const saveTemplate = async (template) => { + await fetch('/api/dashboard/template', { + method: 'POST', + body: JSON.stringify(template) + }); +}; + +// Use in component +const MyDashboard = () => { + const [template, setTemplate] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadTemplate().then(t => { + setTemplate(t); + setLoading(false); + }); + }, []); + + if (loading) return ; + + return ( + + ); +}; +``` + +### 4. Update Imports + +```typescript +// Old +import GridLayout from './Components/DnDLayout/GridLayout'; +import { getDashboardTemplates } from './api/dashboard-templates'; + +// New +import { WidgetLayout, GridLayout, ExtendedTemplateConfig } from '@patternfly/widgetized-dashboard'; +``` + +### 5. Remove State Atoms + +If you were using the Jotai atoms, replace them with your own state management: + +```typescript +// Old +import { useAtom } from 'jotai'; +import { templateAtom } from './state/templateAtom'; + +// New - use React state, Redux, or your preferred solution +import { useState } from 'react'; +const [template, setTemplate] = useState(initialTemplate); +``` + +## API Mapping + +| widget-layout | widgetized-dashboard | Notes | +|--------------|---------------------|-------| +| `getDashboardTemplates()` | `loadTemplate()` | Implement your own | +| `patchDashboardTemplate()` | `saveTemplate()` | Implement your own | +| `useCurrentUser()` | Your auth solution | Use your app's auth | +| `useChrome()` | Props/callbacks | Pass as props | +| Jotai atoms | React state/your store | Bring your own state | +| Scalprum loading | `renderWidget` prop | Direct rendering | + +## Breaking Changes + +1. **No automatic persistence** - You must implement template loading/saving +2. **No federated modules** - Use direct React components instead +3. **No built-in auth** - Handle authentication in your app layer +4. **State management** - Component is self-contained, no external atoms +5. **Analytics** - Optional callback instead of automatic tracking + +## Benefits of Migration + +1. **No console dependencies** - Works in any PatternFly application +2. **Simpler mental model** - Props in, callbacks out +3. **Flexible persistence** - Use any backend or storage solution +4. **Better TypeScript support** - Full type definitions +5. **Lighter bundle** - Fewer dependencies +6. **More control** - Explicit state management + +## Need Help? + +- [Full Documentation](./README.md) +- [Examples](./packages/module/patternfly-docs/content/examples/) +- [Design Guidelines](./packages/module/patternfly-docs/content/design-guidelines/) + diff --git a/README.md b/README.md index 9cf774e..efe2ddd 100644 --- a/README.md +++ b/README.md @@ -1 +1,144 @@ -# Widgetized dashboard +# Widgetized Dashboard + +A generic, reusable PatternFly component library providing a customizable widget-based dashboard with drag-and-drop functionality. This library was created by lifting and adapting code from the [RedHatInsights/widget-layout](https://github.com/RedHatInsights/widget-layout) repository, removing console-specific dependencies to make it suitable for any PatternFly application. + +## Features + +- **Drag-and-Drop Grid Layout**: Powered by `react-grid-layout` with responsive breakpoints +- **Widget Drawer**: Easy widget selection and management +- **Lock/Unlock Widgets**: Prevent accidental changes to widget positions and sizes +- **Resize Widgets**: Adjust widget dimensions with corner handles +- **Responsive Design**: Automatic layout adjustments for xl, lg, md, and sm breakpoints +- **Customizable**: Fully configurable widgets with custom icons, titles, and content +- **TypeScript Support**: Full type definitions included +- **No External Dependencies**: Self-contained state management (no Jotai, Redux, or other state libraries required) + +## Installation + +```bash +yarn add @patternfly/widgetized-dashboard +``` + +Or with npm: + +```bash +npm install @patternfly/widgetized-dashboard +``` + +### Peer Dependencies + +Make sure you have the required peer dependencies installed: + +```bash +yarn add react react-dom react-router-dom @patternfly/react-core @patternfly/react-icons +``` + +## Quick Start + +```tsx +import React from 'react'; +import { WidgetLayout, WidgetMapping, ExtendedTemplateConfig } from '@patternfly/widgetized-dashboard'; +import { CubeIcon } from '@patternfly/react-icons'; + +// Define your widgets +const widgetMapping: WidgetMapping = { + 'example-widget': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Example Widget', + icon: + }, + renderWidget: (id) =>
Widget content goes here
+ } +}; + +// Define initial layout (or load from API/localStorage) +const initialTemplate: ExtendedTemplateConfig = { + xl: [ + { i: 'example-widget#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget', title: 'Example Widget' } + ], + lg: [ + { i: 'example-widget#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget', title: 'Example Widget' } + ], + md: [ + { i: 'example-widget#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget', title: 'Example Widget' } + ], + sm: [ + { i: 'example-widget#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'example-widget', title: 'Example Widget' } + ] +}; + +function App() { + return ( + { + // Save template to API or localStorage + console.log('Template changed:', template); + }} + /> + ); +} +``` + +## Documentation + +- [Getting Started Guide](./packages/module/patternfly-docs/content/examples/basic.md) +- [API Reference](./packages/module/patternfly-docs/content/design-guidelines/design-guidelines.md) + +## Key Components + +### WidgetLayout + +The main component that provides the complete dashboard experience with grid layout and widget drawer. + +### GridLayout + +The core layout engine with drag-and-drop functionality (can be used standalone). + +### WidgetDrawer + +The widget selection drawer (can be used standalone with GridLayout). + +### GridTile + +Individual widget tile wrapper with actions menu (used internally by GridLayout). + +## Differences from widget-layout + +This library is based on [RedHatInsights/widget-layout](https://github.com/RedHatInsights/widget-layout) but has been adapted to be a generic, reusable PatternFly component: + +### Removed +- ❌ Scalprum federated module loading +- ❌ Chrome Services API calls for template persistence +- ❌ Console-specific authentication (useCurrentUser) +- ❌ Console-specific analytics (useChrome) +- ❌ Jotai state management atoms +- ❌ Console-specific icons and branding + +### Added +- ✅ Generic widget rendering via `renderWidget` prop +- ✅ Prop-based template management (bring your own state management) +- ✅ Optional analytics callback +- ✅ Standalone component usage (no external state required) +- ✅ Full TypeScript support +- ✅ Simplified API without console dependencies + +## Browser Support + +Modern browsers (Chrome, Firefox, Safari, Edge) with ES6 support. + +## License + +MIT + +## Contributing + +### AI-assisted development guidelines + +Please reference [PatternFly's AI-assisted development guidelines](https://github.com/patternfly/.github/blob/main/CONTRIBUTING.md) if you'd like to contribute code generated using AI. + +## Credits + +This library is based on the [RedHatInsights/widget-layout](https://github.com/RedHatInsights/widget-layout) repository, adapted to be a generic PatternFly component. diff --git a/packages/module/package.json b/packages/module/package.json index 2be194f..96e801e 100644 --- a/packages/module/package.json +++ b/packages/module/package.json @@ -30,11 +30,15 @@ "access": "public" }, "dependencies": { - "@patternfly/react-core": "^6.3.1" + "@patternfly/react-core": "^6.3.1", + "@patternfly/react-icons": "^6.3.1", + "clsx": "^2.1.0", + "react-grid-layout": "^1.5.1" }, "peerDependencies": { "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "react-router-dom": "^6" }, "devDependencies": { "@babel/plugin-proposal-class-properties": "^7.18.6", @@ -46,8 +50,11 @@ "@patternfly/patternfly-a11y": "^5.1.0", "@patternfly/react-code-editor": "^6.3.1", "@patternfly/react-table": "^6.3.1", + "@types/react-grid-layout": "^1.3.5", "monaco-editor": "^0.53.0", + "nodemon": "^3.0.0", "react-monaco-editor": "^0.59.0", + "react-router-dom": "^6.24.0", "rimraf": "^6.0.1" } } diff --git a/packages/module/patternfly-docs/content/design-guidelines/design-guidelines.md b/packages/module/patternfly-docs/content/design-guidelines/design-guidelines.md deleted file mode 100644 index 4417c78..0000000 --- a/packages/module/patternfly-docs/content/design-guidelines/design-guidelines.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -# Sidenav top-level section -# should be the same for all markdown files for each extension -section: extensions -# Sidenav secondary level section -# should be the same for all markdown files for each extension -id: Widgetized dashboard -# Tab (react | react-demos | html | html-demos | design-guidelines | accessibility) -source: design-guidelines ---- - -Design guidelines intro - -## Header - -### Sub-header - -Guidelines: - -1. A -1. list -1. using -1. markdown \ No newline at end of file diff --git a/packages/module/patternfly-docs/content/examples/Basic.tsx b/packages/module/patternfly-docs/content/examples/Basic.tsx deleted file mode 100644 index ad7eec8..0000000 --- a/packages/module/patternfly-docs/content/examples/Basic.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import React from 'react'; -import { ExtendedButton } from '@patternfly/widgetized-dashboard'; - -export const BasicExample: React.FunctionComponent = () => My custom extension button; diff --git a/packages/module/patternfly-docs/content/examples/BasicExample.tsx b/packages/module/patternfly-docs/content/examples/BasicExample.tsx new file mode 100644 index 0000000..5bb3a7a --- /dev/null +++ b/packages/module/patternfly-docs/content/examples/BasicExample.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import WidgetLayout from '@patternfly/widgetized-dashboard/dist/esm/WidgetLayout/WidgetLayout'; +import { WidgetMapping, ExtendedTemplateConfig } from '@patternfly/widgetized-dashboard/dist/esm/WidgetLayout/types'; +import { CubeIcon, ChartLineIcon, BellIcon } from '@patternfly/react-icons'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; + +// Example widget content components +const ExampleWidget1 = () => ( + + + Example Widget 1 +

This is the content of the first example widget.

+

You can put any React content here!

+
+
+); + +const ExampleWidget2 = () => ( + + + Chart Widget +
+

Chart content would go here

+
+
+
+); + +const ExampleWidget3 = () => ( + + + Notifications +
    +
  • Notification 1
  • +
  • Notification 2
  • +
  • Notification 3
  • +
+
+
+); + +// Define widget mapping +const widgetMapping: WidgetMapping = { + 'example-widget-1': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Example Widget', + icon: , + headerLink: { + title: 'View details', + href: '#' + } + }, + renderWidget: () => + }, + 'chart-widget': { + defaults: { w: 2, h: 4, maxH: 8, minH: 3 }, + config: { + title: 'Performance Chart', + icon: + }, + renderWidget: () => + }, + 'notifications-widget': { + defaults: { w: 1, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Recent Notifications', + icon: + }, + renderWidget: () => + } +}; + +// Define initial template +const initialTemplate: ExtendedTemplateConfig = { + xl: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ], + lg: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ], + md: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ], + sm: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ] +}; + +export const BasicExample: React.FunctionComponent = () => { + const [template, setTemplate] = React.useState(initialTemplate); + + return ( +
+ { + setTemplate(newTemplate); + }} + documentationLink="https://www.patternfly.org" + initialDrawerOpen={true} + /> +
+ ); +}; diff --git a/packages/module/patternfly-docs/content/examples/LockedLayoutExample.tsx b/packages/module/patternfly-docs/content/examples/LockedLayoutExample.tsx new file mode 100644 index 0000000..dac0dcc --- /dev/null +++ b/packages/module/patternfly-docs/content/examples/LockedLayoutExample.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import WidgetLayout from '@patternfly/widgetized-dashboard/dist/esm/WidgetLayout/WidgetLayout'; +import { WidgetMapping, ExtendedTemplateConfig } from '../../../src/WidgetLayout/types'; +import { CubeIcon, ChartLineIcon } from '@patternfly/react-icons'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; + +// Example widget content components +const ExampleWidget1 = () => ( + + + Example Widget 1 +

This is the content of the first example widget.

+

You can put any React content here!

+
+
+); + +const ExampleWidget2 = () => ( + + + Chart Widget +
+

Chart content would go here

+
+
+
+); + +// Define widget mapping +const widgetMapping: WidgetMapping = { + 'example-widget-1': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Example Widget', + icon: , + headerLink: { + title: 'View details', + href: '#' + } + }, + renderWidget: () => + }, + 'chart-widget': { + defaults: { w: 2, h: 4, maxH: 8, minH: 3 }, + config: { + title: 'Performance Chart', + icon: + }, + renderWidget: () => + } +}; + +// Define initial template +const initialTemplate: ExtendedTemplateConfig = { + xl: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ], + lg: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ], + md: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ], + sm: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ] +}; + +export const BasicExample: React.FunctionComponent = () => ( +
+ +
+); + diff --git a/packages/module/patternfly-docs/content/examples/WithoutDrawerExample.tsx b/packages/module/patternfly-docs/content/examples/WithoutDrawerExample.tsx new file mode 100644 index 0000000..6f1782c --- /dev/null +++ b/packages/module/patternfly-docs/content/examples/WithoutDrawerExample.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import WidgetLayout from '@patternfly/widgetized-dashboard/dist/esm/WidgetLayout/WidgetLayout'; +import { WidgetMapping, ExtendedTemplateConfig } from '@patternfly/widgetized-dashboard/dist/esm/WidgetLayout/types'; +import { CubeIcon, ChartLineIcon, BellIcon } from '@patternfly/react-icons'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; + +// Example widget content components +const ExampleWidget1 = () => ( + + + Example Widget 1 +

This is the content of the first example widget.

+

You can put any React content here!

+
+
+); + +const ExampleWidget2 = () => ( + + + Chart Widget +
+

Chart content would go here

+
+
+
+); + +const ExampleWidget3 = () => ( + + + Notifications +
    +
  • Notification 1
  • +
  • Notification 2
  • +
  • Notification 3
  • +
+
+
+); + +// Define widget mapping +const widgetMapping: WidgetMapping = { + 'example-widget-1': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Example Widget', + icon: , + headerLink: { + title: 'View details', + href: '#' + } + }, + renderWidget: () => + }, + 'chart-widget': { + defaults: { w: 2, h: 4, maxH: 8, minH: 3 }, + config: { + title: 'Performance Chart', + icon: + }, + renderWidget: () => + }, + 'notifications-widget': { + defaults: { w: 1, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Recent Notifications', + icon: + }, + renderWidget: () => + } +}; + +// Define initial template +const initialTemplate: ExtendedTemplateConfig = { + xl: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ], + lg: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ], + md: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ], + sm: [ + { i: 'example-widget-1#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, + { i: 'chart-widget#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + ] +}; + +export const BasicExample: React.FunctionComponent = () => ( +
+ { + // Template changes can be saved here + }} + /> +
+); + diff --git a/packages/module/patternfly-docs/content/examples/basic.md b/packages/module/patternfly-docs/content/examples/basic.md index 34a3ab6..0119db6 100644 --- a/packages/module/patternfly-docs/content/examples/basic.md +++ b/packages/module/patternfly-docs/content/examples/basic.md @@ -9,21 +9,82 @@ id: Widgetized dashboard source: react # If you use typescript, the name of the interface to display props for # These are found through the sourceProps function provided in patternfly-docs.source.js -propComponents: ['ExtendedButton'] ---- +propComponents: ['WidgetLayout', 'GridLayout', 'WidgetDrawer'] +sortValue: 1 +sourceLink: https://github.com/patternfly/widgetized-dashboard +--- -import { ExtendedButton } from "@patternfly/widgetized-dashboard"; +import { FunctionComponent, useState } from 'react'; +import { CubeIcon, ChartLineIcon, BellIcon } from '@patternfly/react-icons'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import WidgetLayout from '@patternfly/widgetized-dashboard/dist/esm/WidgetLayout/WidgetLayout'; +import GridLayout from '@patternfly/widgetized-dashboard/dist/esm/WidgetLayout/GridLayout'; +import WidgetDrawer from '@patternfly/widgetized-dashboard/dist/esm/WidgetLayout/WidgetDrawer'; ## Basic usage -### Example +The WidgetLayout component provides a complete drag-and-drop dashboard experience with a widget drawer for adding and removing widgets. + +### Interactive example + +```js file="./BasicExample.tsx" + +``` + +## Locked layout + +Use `isLayoutLocked` to prevent users from modifying the layout. -```js file="./Basic.tsx" +```js file="./LockedLayoutExample.tsx" ``` -### Fullscreen example +## Without drawer + +You can hide the widget drawer by setting `showDrawer={false}`. -```js file="./Basic.tsx" isFullscreen +```js file="./WithoutDrawerExample.tsx" ``` + +## Key features + +- **Drag and drop**: Drag widgets from the drawer to add them to the dashboard +- **Resize**: Drag corner handles to resize widgets +- **Lock/unlock**: Lock widgets to prevent accidental changes +- **Responsive**: Automatically adjusts layout for different screen sizes (xl, lg, md, sm) +- **Persistent**: Save and restore layouts using the `onTemplateChange` callback + +## Widget mapping + +Define your widgets using the `WidgetMapping` type: + +```typescript +const widgetMapping: WidgetMapping = { + 'my-widget': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'My Widget', + icon: + }, + renderWidget: (id) => + } +}; +``` + +## Template configuration + +Define your initial layout using the `ExtendedTemplateConfig` type: + +```typescript +const initialTemplate: ExtendedTemplateConfig = { + xl: [ + { i: 'my-widget#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'my-widget', title: 'My Widget' } + ], + lg: [...], + md: [...], + sm: [...] +}; +``` + +Each breakpoint (xl, lg, md, sm) should have its own layout configuration to ensure proper responsive behavior. diff --git a/packages/module/src/ExtendedButton/ExtendedButton.tsx b/packages/module/src/ExtendedButton/ExtendedButton.tsx deleted file mode 100644 index b8119e9..0000000 --- a/packages/module/src/ExtendedButton/ExtendedButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { Button, ButtonProps } from '@patternfly/react-core'; - -export interface ExtendedButtonProps extends ButtonProps { - /** Content to render inside the extended button component */ - children?: React.ReactNode; -} - -export const ExtendedButton: React.FunctionComponent = ({ - children, - ...props -}: ExtendedButtonProps) => { - const [currentVariantIndex, setCurrentVariantIndex] = React.useState(0); - - const buttonVariants: ButtonProps['variant'][] = [ - 'primary', - 'secondary', - 'tertiary' - ]; - - const handleClick = () => { - setCurrentVariantIndex((previousVariantIndex) => (previousVariantIndex + 1) % buttonVariants.length); - }; - - return ( - - ); -}; diff --git a/packages/module/src/ExtendedButton/__tests__/ExtendedButton.test.tsx b/packages/module/src/ExtendedButton/__tests__/ExtendedButton.test.tsx deleted file mode 100644 index 3a8b357..0000000 --- a/packages/module/src/ExtendedButton/__tests__/ExtendedButton.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import userEvent from '@testing-library/user-event'; -import { ExtendedButton } from '../ExtendedButton'; - -test('Renders without children', () => { - render( -
- -
- ); - - expect(screen.getByTestId('container').firstChild).toBeVisible(); -}); - -test('Renders children', () => { - render(Test); - - expect(screen.getByRole('button', { name: 'Test' })).toBeVisible(); -}); - -test('Passes inherited props to the returned component', () => { - render(Test); - - expect(screen.getByRole('button')).toHaveAccessibleName('Test label'); -}); - -test('Renders as a primary button initially', () => { - render(Test); - - expect(screen.getByRole('button')).toHaveClass('pf-v6-c-button pf-m-primary', { exact: true }); -}); - -test('Renders as a secondary button once it has been clicked once', () => { - render(Test); - const button = screen.getByRole('button'); - userEvent.click(screen.getByRole('button')); - - waitFor(() => { - expect(button).toHaveClass('pf-v6-c-button pf-m-secondary', { exact: true }); - }); -}); - -test('Renders as a tertiary button once it has been clicked twice', () => { - render(Test); - - const button = screen.getByRole('button'); - userEvent.click(button); - userEvent.click(button); - - waitFor(() => { - expect(button).toHaveClass('pf-v6-c-button pf-m-tertiary', { exact: true }); - }); -}); - -test('Loops back to rendering a primary button again after being clicked three times', () => { - render(Test); - - const button = screen.getByRole('button'); - userEvent.click(button); - userEvent.click(button); - userEvent.click(button); - - expect(button).toHaveClass('pf-v6-c-button pf-m-primary', { exact: true }); -}); - -test('Matches expected default snapshot', () => { - const { asFragment } = render(Test); - - expect(asFragment()).toMatchSnapshot(); -}); diff --git a/packages/module/src/ExtendedButton/__tests__/__snapshots__/ExtendedButton.test.tsx.snap b/packages/module/src/ExtendedButton/__tests__/__snapshots__/ExtendedButton.test.tsx.snap deleted file mode 100644 index 9911eeb..0000000 --- a/packages/module/src/ExtendedButton/__tests__/__snapshots__/ExtendedButton.test.tsx.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`Matches expected default snapshot 1`] = ` - - - -`; diff --git a/packages/module/src/ExtendedButton/index.ts b/packages/module/src/ExtendedButton/index.ts deleted file mode 100644 index 03c57e3..0000000 --- a/packages/module/src/ExtendedButton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ExtendedButton'; diff --git a/packages/module/src/WidgetLayout/GridLayout.tsx b/packages/module/src/WidgetLayout/GridLayout.tsx new file mode 100644 index 0000000..a94bc5d --- /dev/null +++ b/packages/module/src/WidgetLayout/GridLayout.tsx @@ -0,0 +1,290 @@ +import 'react-grid-layout/css/styles.css'; +import './styles'; +import ReactGridLayout, { Layout, ReactGridLayoutProps } from 'react-grid-layout'; +import GridTile, { SetWidgetAttribute } from './GridTile'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { isWidgetType } from './utils'; +import React from 'react'; +import { + ExtendedLayoutItem, + Variants, + WidgetMapping, + ExtendedTemplateConfig, + AnalyticsTracker, +} from './types'; +import { Button, EmptyState, EmptyStateActions, EmptyStateBody, EmptyStateVariant, PageSection } from '@patternfly/react-core'; +import { ExternalLinkAltIcon, GripVerticalIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import { columns, breakpoints, droppingElemId, getWidgetIdentifier, extendLayout, getGridDimensions } from './utils'; + +export const defaultBreakpoints = breakpoints; + +// SVG resize handle as inline data URI +const resizeHandleSvg = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE2IDEuMTQyODZMMTQuODU3MSAwTDAgMTQuODU3MVYxNkgxLjE0Mjg2TDE2IDEuMTQyODZaIiBmaWxsPSIjRDJEMkQyIi8+Cjwvc3ZnPgo='; + +const getResizeHandle = (resizeHandleAxis: string, ref: React.Ref) => ( +
+ Resize handle +
+ ); + +export interface GridLayoutProps { + /** Widget mapping definition */ + widgetMapping: WidgetMapping; + /** Current template configuration */ + template: ExtendedTemplateConfig; + /** Callback when template changes */ + onTemplateChange?: (template: ExtendedTemplateConfig) => void; + /** Whether the layout is locked (disables drag, drop, resize) */ + isLayoutLocked?: boolean; + /** Custom empty state component */ + emptyStateComponent?: React.ReactNode; + /** Documentation link for empty state */ + documentationLink?: string; + /** Analytics tracker (optional) */ + analytics?: AnalyticsTracker; + /** Whether to show the empty state when no widgets exist */ + showEmptyState?: boolean; + /** Callback when drawer should be expanded */ + onDrawerExpandChange?: (expanded: boolean) => void; + /** Currently active widgets (for tracking) */ + onActiveWidgetsChange?: (widgetTypes: string[]) => void; +} + +const LayoutEmptyState = ({ + onDrawerExpandChange, + documentationLink, +}: { + onDrawerExpandChange?: (expanded: boolean) => void; + documentationLink?: string; +}) => { + useEffect(() => { + onDrawerExpandChange?.(true); + }, [onDrawerExpandChange]); + + return ( + + + + You don't have any widgets on your dashboard. To populate your dashboard, drag items from the widget drawer to this + dashboard. + + {documentationLink && ( + + + + )} + + + ); +}; + +const GridLayout = ({ + widgetMapping, + template, + onTemplateChange, + isLayoutLocked = false, + emptyStateComponent, + documentationLink, + analytics, + showEmptyState = true, + onDrawerExpandChange, + onActiveWidgetsChange, +}: GridLayoutProps) => { + const [isDragging, setIsDragging] = useState(false); + const [isInitialRender, setIsInitialRender] = useState(true); + const [layoutVariant, setLayoutVariant] = useState('xl'); + const [layoutWidth, setLayoutWidth] = useState(1200); + const layoutRef = useRef(null); + + const [currentDropInItem, setCurrentDropInItem] = useState(); + const [internalTemplate, setInternalTemplate] = useState(template); + + // Sync external template changes to internal state + useEffect(() => { + setInternalTemplate(template); + }, [template]); + + const droppingItemTemplate: ReactGridLayoutProps['droppingItem'] = useMemo(() => { + if (currentDropInItem && isWidgetType(widgetMapping, currentDropInItem)) { + const widget = widgetMapping[currentDropInItem]; + if (!widget) {return undefined;} + return { + ...widget.defaults, + i: droppingElemId, + widgetType: currentDropInItem, + title: 'New title', + config: widget.config, + }; + } + return undefined; + }, [currentDropInItem, widgetMapping]); + + const setWidgetAttribute: SetWidgetAttribute = (id, attributeName, value) => { + const newTemplate = Object.entries(internalTemplate).reduce( + (acc, [size, layout]) => ({ + ...acc, + [size]: layout.map((widget) => (widget.i === id ? { ...widget, [attributeName]: value } : widget)), + }), + {} as ExtendedTemplateConfig + ); + setInternalTemplate(newTemplate); + onTemplateChange?.(newTemplate); + }; + + const removeWidget = (id: string) => { + const newTemplate = Object.entries(internalTemplate).reduce( + (acc, [size, layout]) => ({ + ...acc, + [size]: layout.filter((widget) => widget.i !== id), + }), + {} as ExtendedTemplateConfig + ); + setInternalTemplate(newTemplate); + onTemplateChange?.(newTemplate); + }; + + const onDrop: ReactGridLayoutProps['onDrop'] = (_layout: ExtendedLayoutItem[], layoutItem: ExtendedLayoutItem, event: DragEvent) => { + const data = event.dataTransfer?.getData('text') || ''; + if (isWidgetType(widgetMapping, data)) { + setCurrentDropInItem(undefined); + const widget = widgetMapping[data]; + if (!widget) {return;} + const newTemplate = Object.entries(internalTemplate).reduce((acc, [size, layout]) => { + const newWidget = { + ...layoutItem, + ...widget.defaults, + // make sure the configuration is valid for all layout sizes + w: size === layoutVariant ? layoutItem.w : Math.min(widget.defaults.w, columns[size as Variants]), + x: size === layoutVariant ? layoutItem.x : Math.min(layoutItem.x, columns[size as Variants]), + widgetType: data, + i: getWidgetIdentifier(data), + title: 'New title', + config: widget.config, + }; + return { + ...acc, + [size]: layout.reduce( + (acc, curr) => { + if (curr.x + curr.w > newWidget.x && curr.y + curr.h <= newWidget.y) { + acc.push(curr); + } else { + // push the current items down on the Y axis if they are supposed to be below the new widget + acc.push({ ...curr, y: curr.y + curr.h }); + } + return acc; + }, + [newWidget] + ), + }; + }, {} as ExtendedTemplateConfig); + + setInternalTemplate(newTemplate); + onTemplateChange?.(newTemplate); + analytics?.('widget-layout.widget-add', { data }); + } + event.preventDefault(); + }; + + const onLayoutChange = (currentLayout: Layout[]) => { + if (isInitialRender) { + setIsInitialRender(false); + const activeWidgets = activeLayout.map((item) => item.widgetType); + onActiveWidgetsChange?.(activeWidgets); + return; + } + if (isLayoutLocked || currentDropInItem) { + return; + } + + const newTemplate = extendLayout({ ...internalTemplate, [layoutVariant]: currentLayout }); + const activeWidgets = activeLayout.map((item) => item.widgetType); + onActiveWidgetsChange?.(activeWidgets); + + setInternalTemplate(newTemplate); + onTemplateChange?.(newTemplate); + }; + + useEffect(() => { + const currentWidth = layoutRef.current?.getBoundingClientRect().width ?? 1200; + const variant: Variants = getGridDimensions(currentWidth); + setLayoutVariant(variant); + setLayoutWidth(currentWidth); + + const observer = new ResizeObserver((entries) => { + if (!entries[0]) {return;} + + const currentWidth = entries[0].contentRect.width; + const variant: Variants = getGridDimensions(currentWidth); + setLayoutVariant(variant); + setLayoutWidth(currentWidth); + }); + + if (layoutRef.current) { + observer.observe(layoutRef.current); + } + + return () => { + observer.disconnect(); + }; + }, []); + + const activeLayout = internalTemplate[layoutVariant] || []; + + return ( +
+ {activeLayout.length === 0 && !currentDropInItem && showEmptyState && ( + emptyStateComponent || + )} + setCurrentDropInItem(undefined)} + useCSSTransforms + verticalCompact + onLayoutChange={onLayoutChange} + > + {activeLayout + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .map(({ widgetType, title, ...rest }, index) => { + const widget = widgetMapping[widgetType]; + if (!widget) { + return null; + } + const config = widgetMapping[widgetType]?.config; + return ( +
+ + {widget.renderWidget(rest.i)} + +
+ ); + }) + .filter((layoutItem) => layoutItem !== null)} +
+
+ ); +}; + +export default GridLayout; + diff --git a/packages/module/src/WidgetLayout/GridTile.tsx b/packages/module/src/WidgetLayout/GridTile.tsx new file mode 100644 index 0000000..073b300 --- /dev/null +++ b/packages/module/src/WidgetLayout/GridTile.tsx @@ -0,0 +1,230 @@ +import { + Button, + Card, + CardBody, + CardHeader, + CardTitle, + Divider, + Dropdown, + DropdownItem, + DropdownList, + Flex, + FlexItem, + HelperText, + HelperTextItem, + Icon, + MenuToggle, + MenuToggleElement, + Skeleton, + Tooltip, +} from '@patternfly/react-core'; +import { CompressIcon, EllipsisVIcon, ExpandIcon, GripVerticalIcon, LockIcon, MinusCircleIcon, UnlockIcon } from '@patternfly/react-icons'; +import React, { useMemo, useState } from 'react'; +import clsx from 'clsx'; +import { Link } from 'react-router-dom'; +import { Layout } from 'react-grid-layout'; +import { ExtendedLayoutItem, WidgetConfiguration, AnalyticsTracker } from './types'; + +export type SetWidgetAttribute = (id: string, attributeName: keyof ExtendedLayoutItem, value: T) => void; + +export type GridTileProps = React.PropsWithChildren<{ + widgetType: string; + icon?: React.ReactNode; + setIsDragging: (isDragging: boolean) => void; + isDragging: boolean; + setWidgetAttribute: SetWidgetAttribute; + widgetConfig: Layout & { + colWidth: number; + locked?: boolean; + config?: WidgetConfiguration; + }; + removeWidget: (id: string) => void; + children: React.ReactNode; + isLoaded?: boolean; + analytics?: AnalyticsTracker; +}>; + +const GridTile = ({ + widgetType, + isDragging, + setIsDragging, + setWidgetAttribute, + widgetConfig, + removeWidget, + children, + isLoaded = true, + analytics, +}: GridTileProps) => { + const [isOpen, setIsOpen] = useState(false); + const { headerLink } = widgetConfig.config || {}; + const hasHeader = headerLink && headerLink.href && headerLink.title; + + const [linkHref, linkTarget] = useMemo(() => { + if (!headerLink?.href) { + return []; + } + + try { + const url = new URL(headerLink.href); + return url.origin === window.location.origin ? [url.pathname, undefined] : [headerLink.href, '_blank']; + } catch { + return [headerLink.href]; + } + }, [headerLink?.href]); + + const dropdownItems = useMemo(() => { + const isMaximized = widgetConfig.h === widgetConfig.maxH; + const isMinimized = widgetConfig.h === widgetConfig.minH; + return ( + <> + { + setIsOpen(false); + setWidgetAttribute(widgetConfig.i, 'static', !widgetConfig.static); + }} + icon={widgetConfig.static ? : } + > + {widgetConfig.static ? 'Unlock location and size' : 'Lock location and size'} + + { + setWidgetAttribute(widgetConfig.i, 'h', widgetConfig.maxH ?? widgetConfig.h); + setIsOpen(false); + }} + icon={} + > + Autosize height to content + + { + setWidgetAttribute(widgetConfig.i, 'h', widgetConfig.minH ?? widgetConfig.h); + setIsOpen(false); + }} + isDisabled={isMinimized || widgetConfig.static} + icon={} + > + Minimize height + + { + removeWidget(widgetConfig.i); + analytics?.('widget-layout.widget-remove', { widgetType }); + }} + icon={ + + + + } + isDisabled={widgetConfig.static} + > + Remove + + + {"All 'removed' widgets can be added back by clicking the 'Add widgets' button."} + + + + + ); + }, [widgetConfig.h, widgetConfig.maxH, widgetConfig.minH, widgetConfig.static, widgetConfig.i, setWidgetAttribute, removeWidget, analytics, widgetType]); + + const headerActions = ( + <> + Actions

}> + ) => ( + setIsOpen((prev) => !prev)} + variant="plain" + aria-label="widget actions menu toggle" + > + + )} + isOpen={isOpen} + onOpenChange={(isOpen: boolean) => setIsOpen(isOpen)} + > + {dropdownItems} + +
+ {widgetConfig.static ? 'Widget locked' : 'Move'}

}> + { + setIsDragging(true); + analytics?.('widget-layout.widget-move', { widgetType }); + }} + onMouseUp={() => setIsDragging(false)} + className={clsx('drag-handle', { + dragging: isDragging, + })} + > + + +
+ + ); + + return ( + + + + + {widgetConfig?.config?.icon && ( +
+ {isLoaded ? widgetConfig.config.icon : } +
+ )} + + {isLoaded ? ( + + {widgetConfig?.config?.title || widgetType} + + ) : ( + + )} + {hasHeader && isLoaded && ( + + + + )} + +
+
+
+ + {children} +
+ ); +}; + +export default GridTile; + diff --git a/packages/module/src/WidgetLayout/WidgetDrawer.tsx b/packages/module/src/WidgetLayout/WidgetDrawer.tsx new file mode 100644 index 0000000..2c35d16 --- /dev/null +++ b/packages/module/src/WidgetLayout/WidgetDrawer.tsx @@ -0,0 +1,166 @@ +import { + Button, + Card, + CardHeader, + CardTitle, + Flex, + Gallery, + GalleryItem, + Icon, + PageSection, + Split, + SplitItem, + Title, + Tooltip, +} from '@patternfly/react-core'; +import React, { useState } from 'react'; +import { CloseIcon, GripVerticalIcon } from '@patternfly/react-icons'; +import { WidgetMapping, WidgetConfiguration } from './types'; + +export type WidgetDrawerProps = React.PropsWithChildren<{ + /** Widget mapping definition */ + widgetMapping: WidgetMapping; + /** List of currently used widget types (to filter out from drawer) */ + currentlyUsedWidgets?: string[]; + /** Whether the drawer is expanded */ + isOpen?: boolean; + /** Callback when drawer open state changes */ + onOpenChange?: (isOpen: boolean) => void; + /** Custom instruction text */ + instructionText?: string; +}>; + +const WidgetWrapper = ({ widgetType, config, onDragStart, onDragEnd }: { + widgetType: string; + config?: WidgetConfiguration; + onDragStart: (widgetType: string) => void; + onDragEnd: () => void; +}) => { + const headerActions = ( + Drag to add widget

}> + + + +
+ ); + + return ( + { + const nodeRect = (e.target as HTMLElement).getBoundingClientRect(); + + if (e.dataTransfer) { + e.dataTransfer.setDragImage( + e.target as HTMLDivElement, + e.clientX - nodeRect.left, + e.clientY - nodeRect.top + ); + } + + if (e.dataTransfer) { + e.dataTransfer.setData('text', widgetType); + } + + onDragStart(widgetType); + }} + onDragEnd={onDragEnd} + + unselectable="on" + draggable={true} + className="grid-tile" + ouiaId={`add-widget-card-${config?.title || widgetType}`} + > + + + {config?.icon && ( +
+ {config.icon} +
+ )} + {config?.title || widgetType} +
+
+
+ ); +}; + +const WidgetDrawer = ({ + children, + widgetMapping, + currentlyUsedWidgets = [], + isOpen: controlledIsOpen, + onOpenChange, + instructionText, +}: WidgetDrawerProps) => { + const [internalIsOpen, setInternalIsOpen] = useState(false); + + // Use controlled state if provided, otherwise use internal state + const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen; + const setIsOpen = onOpenChange || setInternalIsOpen; + + const filteredWidgetMapping = Object.entries(widgetMapping).filter(([type]) => !currentlyUsedWidgets.includes(type)); + + const defaultInstructionText = `Add new and previously removed widgets by clicking the icon, then drag and drop to a new location. Drag the corners of the cards to resize widgets.`; + + const panelContent = ( + + + + + {instructionText || ( + <> + {defaultInstructionText.split('icon').map((part, i) => + i === 0 ? part : ( + <React.Fragment key={i}> + <GripVerticalIcon /> + {part} + </React.Fragment> + ) + )} + </> + )} + + + + diff --git a/packages/module/src/WidgetLayout/types.ts b/packages/module/src/WidgetLayout/types.ts index 5b45383..741741b 100644 --- a/packages/module/src/WidgetLayout/types.ts +++ b/packages/module/src/WidgetLayout/types.ts @@ -27,28 +27,28 @@ export type ExtendedTemplateConfig = { // Extended type the UI tracks export type PartialExtendedTemplateConfig = Partial; -export type WidgetDefaults = { +export interface WidgetDefaults { w: number; h: number; maxH: number; minH: number; -}; +} -export type WidgetHeaderLink = { +export interface WidgetHeaderLink { title?: string; href?: string; -}; +} -export type WidgetConfiguration = { +export interface WidgetConfiguration { icon?: React.ReactNode; headerLink?: WidgetHeaderLink; title?: string; -}; +} /** * Widget definition with rendering function */ -export type WidgetDefinition = { +export interface WidgetDefinition { /** Unique widget type identifier */ widgetType: string; /** Default dimensions for the widget */ @@ -57,23 +57,23 @@ export type WidgetDefinition = { config?: WidgetConfiguration; /** Function that renders the widget content */ renderWidget: (widgetId: string) => React.ReactNode; -}; +} /** * Widget mapping keyed by widget type */ -export type WidgetMapping = { +export interface WidgetMapping { [widgetType: string]: Omit; -}; +} /** * Notification type for user feedback */ -export type Notification = { +export interface Notification { variant: 'success' | 'danger' | 'warning' | 'info' | 'default'; title: string; description?: string; -}; +} /** * Analytics tracking function (optional) From 21359fbbac239b246a4247d630bbb0a4793733dc Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 21 Oct 2025 09:15:03 -0400 Subject: [PATCH 3/6] fix: be sure locked tooltip is showing in locked layout example --- .../content/examples/BasicExample.tsx | 104 ++++++++++++------ .../content/examples/LockedLayoutExample.tsx | 93 +++++++++++----- .../content/examples/WithoutDrawerExample.tsx | 102 +++++++++++------ .../patternfly-docs/content/examples/basic.md | 4 +- 4 files changed, 207 insertions(+), 96 deletions(-) diff --git a/packages/module/patternfly-docs/content/examples/BasicExample.tsx b/packages/module/patternfly-docs/content/examples/BasicExample.tsx index ac8ec54..7181069 100644 --- a/packages/module/patternfly-docs/content/examples/BasicExample.tsx +++ b/packages/module/patternfly-docs/content/examples/BasicExample.tsx @@ -1,42 +1,78 @@ import React from 'react'; import WidgetLayout from '../../../src/WidgetLayout/WidgetLayout'; import { WidgetMapping, ExtendedTemplateConfig } from '../../../src/WidgetLayout/types'; -import { CubeIcon, ChartLineIcon, BellIcon } from '@patternfly/react-icons'; -import { Card, CardBody, CardTitle } from '@patternfly/react-core'; +import { CubeIcon, ChartLineIcon, BellIcon, ExternalLinkAltIcon, ArrowRightIcon } from '@patternfly/react-icons'; +import { Card, CardBody, CardFooter, Content, Icon } from '@patternfly/react-core'; -// Example widget content components -const ExampleWidget1 = () => ( - - - Example Widget 1 -

This is the content of the first example widget.

-

You can put any React content here!

+interface SimpleWidgetProps { + id: number; + body: string; + linkTitle: string; + url: string; + isExternal?: boolean; +} + +const CardExample: React.FunctionComponent = (props) => ( + + + + + {props.body} + + + + {props.isExternal ? ( + + {props.linkTitle} + + + + + ) : ( + + {props.linkTitle} + + + + + )} + ); +// Example widget content components +const ExampleWidget1 = () => ( + +); + const ExampleWidget2 = () => ( - - - Chart Widget -
-

Chart content would go here

-
-
-
+ ); const ExampleWidget3 = () => ( - - - Notifications -
    -
  • Notification 1
  • -
  • Notification 2
  • -
  • Notification 3
  • -
-
-
+ ); // Define widget mapping @@ -56,7 +92,7 @@ const widgetMapping: WidgetMapping = { 'chart-widget': { defaults: { w: 2, h: 4, maxH: 8, minH: 3 }, config: { - title: 'Performance Chart', + title: 'Chart Widget', icon: }, renderWidget: () => @@ -64,7 +100,7 @@ const widgetMapping: WidgetMapping = { 'notifications-widget': { defaults: { w: 1, h: 3, maxH: 6, minH: 2 }, config: { - title: 'Recent Notifications', + title: 'Notification Widget', icon: }, renderWidget: () => @@ -75,19 +111,19 @@ const widgetMapping: WidgetMapping = { const initialTemplate: ExtendedTemplateConfig = { xl: [ { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'chart-widget#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'chart-widget', title: 'Chart Widget' } ], lg: [ { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Chart Widget' } ], md: [ { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Chart Widget' } ], sm: [ { i: 'example-widget-1#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'chart-widget#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'chart-widget', title: 'Chart Widget' } ] }; @@ -95,7 +131,7 @@ export const BasicExample: React.FunctionComponent = () => { const [template, setTemplate] = React.useState(initialTemplate); return ( -
+
( +interface SimpleWidgetProps { + id: number; + body: string; + linkTitle: string; + url: string; + isExternal?: boolean; +} + +const CardExample: React.FunctionComponent = (props) => ( - - Example Widget 1 -

This is the content of the first example widget.

-

You can put any React content here!

+ + + + {props.body} + + + + {props.isExternal ? ( + + {props.linkTitle} + + + + + ) : ( + + {props.linkTitle} + + + + + )} +
); +// Example widget content components +const ExampleWidget1 = () => ( + +); + const ExampleWidget2 = () => ( - - - Chart Widget -
-

Chart content would go here

-
-
-
+ ); // Define widget mapping @@ -43,35 +82,35 @@ const widgetMapping: WidgetMapping = { 'chart-widget': { defaults: { w: 2, h: 4, maxH: 8, minH: 3 }, config: { - title: 'Performance Chart', + title: 'Chart Widget', icon: }, renderWidget: () => } }; -// Define initial template +// Define initial template with locked widgets const initialTemplate: ExtendedTemplateConfig = { xl: [ - { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget', static: true }, + { i: 'chart-widget#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'chart-widget', title: 'Chart Widget', static: true } ], lg: [ - { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget', static: true }, + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Chart Widget', static: true } ], md: [ - { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget', static: true }, + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Chart Widget', static: true } ], sm: [ - { i: 'example-widget-1#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'example-widget-1#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'example-widget-1', title: 'Example Widget', static: true }, + { i: 'chart-widget#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'chart-widget', title: 'Chart Widget', static: true } ] }; export const LockedLayoutExample: React.FunctionComponent = () => ( -
+
( +interface SimpleWidgetProps { + id: number; + body: string; + linkTitle: string; + url: string; + isExternal?: boolean; +} + +const CardExample: React.FunctionComponent = (props) => ( - - Example Widget 1 -

This is the content of the first example widget.

-

You can put any React content here!

+ + + + {props.body} + + + + {props.isExternal ? ( + + {props.linkTitle} + + + + + ) : ( + + {props.linkTitle} + + + + + )} +
); +// Example widget content components +const ExampleWidget1 = () => ( + +); + const ExampleWidget2 = () => ( - - - Chart Widget -
-

Chart content would go here

-
-
-
+ ); const ExampleWidget3 = () => ( - - - Notifications -
    -
  • Notification 1
  • -
  • Notification 2
  • -
  • Notification 3
  • -
-
-
+ ); // Define widget mapping @@ -56,7 +92,7 @@ const widgetMapping: WidgetMapping = { 'chart-widget': { defaults: { w: 2, h: 4, maxH: 8, minH: 3 }, config: { - title: 'Performance Chart', + title: 'Chart Widget', icon: }, renderWidget: () => @@ -64,7 +100,7 @@ const widgetMapping: WidgetMapping = { 'notifications-widget': { defaults: { w: 1, h: 3, maxH: 6, minH: 2 }, config: { - title: 'Recent Notifications', + title: 'Notification Widget', icon: }, renderWidget: () => @@ -75,24 +111,24 @@ const widgetMapping: WidgetMapping = { const initialTemplate: ExtendedTemplateConfig = { xl: [ { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'chart-widget#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'chart-widget', title: 'Chart Widget' } ], lg: [ { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Chart Widget' } ], md: [ { i: 'example-widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'chart-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'chart-widget', title: 'Chart Widget' } ], sm: [ { i: 'example-widget-1#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'example-widget-1', title: 'Example Widget' }, - { i: 'chart-widget#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'chart-widget', title: 'Performance Chart' } + { i: 'chart-widget#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'chart-widget', title: 'Chart Widget' } ] }; export const WithoutDrawerExample: React.FunctionComponent = () => ( -
+
Date: Tue, 21 Oct 2025 10:15:19 -0400 Subject: [PATCH 4/6] chore: add button to open drawer --- .../module/src/WidgetLayout/WidgetDrawer.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/module/src/WidgetLayout/WidgetDrawer.tsx b/packages/module/src/WidgetLayout/WidgetDrawer.tsx index 2c35d16..c97eb03 100644 --- a/packages/module/src/WidgetLayout/WidgetDrawer.tsx +++ b/packages/module/src/WidgetLayout/WidgetDrawer.tsx @@ -14,7 +14,7 @@ import { Tooltip, } from '@patternfly/react-core'; import React, { useState } from 'react'; -import { CloseIcon, GripVerticalIcon } from '@patternfly/react-icons'; +import { CloseIcon, GripVerticalIcon, PlusCircleIcon } from '@patternfly/react-icons'; import { WidgetMapping, WidgetConfiguration } from './types'; export type WidgetDrawerProps = React.PropsWithChildren<{ @@ -105,10 +105,7 @@ const WidgetDrawer = ({ const panelContent = ( @@ -139,7 +136,7 @@ const WidgetDrawer = ({ /> - + {filteredWidgetMapping.map(([type, widget], i) => ( - {isOpen ?
{panelContent}
: null} +
+ +
+ {isOpen &&
{panelContent}
} {children} ); From 110ae57b0f631721228d48036e77e28d547b8cd2 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 21 Oct 2025 10:22:39 -0400 Subject: [PATCH 5/6] fix:remove commented code --- .../module/src/WidgetLayout/WidgetLayout.tsx | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/packages/module/src/WidgetLayout/WidgetLayout.tsx b/packages/module/src/WidgetLayout/WidgetLayout.tsx index 9ce839a..b2069c3 100644 --- a/packages/module/src/WidgetLayout/WidgetLayout.tsx +++ b/packages/module/src/WidgetLayout/WidgetLayout.tsx @@ -28,43 +28,6 @@ export interface WidgetLayoutProps { initialDrawerOpen?: boolean; }; -/** - * WidgetLayout - A complete drag-and-drop dashboard layout component - * - * This component provides a full-featured dashboard experience with: - * - Responsive grid layout with drag-and-drop - * - Widget drawer for adding/removing widgets - * - Lock/unlock widgets - * - Resize widgets - * - Persistent layout configuration - * - * @example - * ```tsx - * const widgetMapping = { - * 'my-widget': { - * defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, - * config: { - * title: 'My Widget', - * icon: - * }, - * renderWidget: (id) => - * } - * }; - * - * const template = { - * xl: [{ i: 'my-widget#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'my-widget', title: 'My Widget' }], - * lg: [...], - * md: [...], - * sm: [...] - * }; - * - * saveTemplate(newTemplate)} - * /> - * ``` - */ const WidgetLayout = ({ widgetMapping, initialTemplate, From 65b74bffec7c8c3f3f70f9110bf6591b34c22da1 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 23 Oct 2025 08:39:31 -0400 Subject: [PATCH 6/6] remove files/text --- ARCHITECTURE.md | 381 ------------------- MIGRATION.md | 256 ------------- packages/module/src/WidgetLayout/images.d.ts | 5 - 3 files changed, 642 deletions(-) delete mode 100644 ARCHITECTURE.md delete mode 100644 MIGRATION.md delete mode 100644 packages/module/src/WidgetLayout/images.d.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index e6e4b3f..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,381 +0,0 @@ -# Architecture Overview - -## Component Structure - -The Widgetized Dashboard is composed of several key components that work together to provide a complete dashboard experience: - -``` -WidgetLayout (main component) -├── WidgetDrawer (widget selection) -│ └── WidgetWrapper (draggable widget cards) -└── GridLayout (drag-and-drop grid) - └── GridTile (individual widget wrapper) - └── Widget Content (user-provided via renderWidget) -``` - -## Core Components - -### 1. WidgetLayout -The top-level component that orchestrates the entire dashboard experience. - -**Responsibilities:** -- Manages overall state (template, drawer open/close) -- Coordinates between GridLayout and WidgetDrawer -- Provides unified API for consumers - -**State:** -- `template`: Current layout configuration -- `drawerOpen`: Whether widget drawer is visible -- `currentlyUsedWidgets`: List of widget types in use - -### 2. GridLayout -The core layout engine powered by `react-grid-layout`. - -**Responsibilities:** -- Rendering the responsive grid -- Handling drag-and-drop operations -- Managing widget positions and sizes -- Responsive breakpoint handling -- Widget CRUD operations - -**Key Features:** -- 4 responsive breakpoints (xl, lg, md, sm) -- Drag-and-drop from drawer -- Resize with corner handles -- Lock/unlock individual widgets -- Empty state display - -**State:** -- `isDragging`: Drag operation in progress -- `isInitialRender`: Skip save on first render -- `layoutVariant`: Current breakpoint -- `layoutWidth`: Container width -- `currentDropInItem`: Widget being dragged - -### 3. GridTile -Wrapper component for individual widgets. - -**Responsibilities:** -- Render widget content -- Provide widget actions menu -- Display widget header with icon and title -- Handle widget-level interactions - -**Features:** -- Lock/unlock toggle -- Autosize to max height -- Minimize to min height -- Remove widget -- Optional header link -- Drag handle - -### 4. WidgetDrawer -Side panel for selecting widgets to add. - -**Responsibilities:** -- Display available widgets -- Filter out currently used widgets -- Provide drag source for new widgets -- Instructions for users - -## Data Flow - -### Template Management - -``` -┌─────────────┐ -│ Parent │ -│ Component │ -└──────┬──────┘ - │ - │ initialTemplate (prop) - ▼ -┌─────────────┐ -│ WidgetLayout│ -│ (state) │ -└──────┬──────┘ - │ - │ template (prop) - ▼ -┌─────────────┐ -│ GridLayout │ -│ (renders) │ -└──────┬──────┘ - │ - │ onChange - ▼ -┌─────────────┐ -│onTemplateChange│ -│ (callback) │ -└──────┬──────┘ - │ - │ save to API/storage - ▼ -┌─────────────┐ -│ Parent │ -│ Component │ -└─────────────┘ -``` - -### Widget Rendering - -``` -┌──────────────┐ -│WidgetMapping │ -│ (config) │ -└──────┬───────┘ - │ - │ widgetType → renderWidget - ▼ -┌──────────────┐ -│ GridTile │ -│ (wrapper) │ -└──────┬───────┘ - │ - │ renders children - ▼ -┌──────────────┐ -│Widget Content│ -│ (React node) │ -└──────────────┘ -``` - -## State Management - -The component uses **local React state** for all state management: - -- No external state libraries required (no Jotai, Redux, etc.) -- Parent controls template via `initialTemplate` and `onTemplateChange` -- Internal state synchronized with props -- Callbacks for notifications, analytics, etc. - -### State Flow - -```typescript -// Parent manages template -const [template, setTemplate] = useState(initialTemplate); - -// WidgetLayout receives and manages internally - { - setTemplate(newTemplate); - saveToAPI(newTemplate); - }} -/> - -// Internal state stays in sync -useEffect(() => { - setInternalTemplate(template); -}, [template]); -``` - -## Responsive Design - -### Breakpoints - -| Breakpoint | Width | Columns | Use Case | -|-----------|------------|---------|----------| -| xl | ≥1550px | 4 | Large desktop | -| lg | 1400-1549px| 3 | Desktop | -| md | 1100-1399px| 2 | Tablet landscape | -| sm | 800-1099px | 1 | Tablet portrait | - -### Grid System - -- **Row Height**: 56px (fixed) -- **Container**: 100% width, min-height 200px -- **Columns**: Responsive (4/3/2/1) -- **Gutter**: Managed by react-grid-layout - -### Responsive Behavior - -```typescript -// Auto-detect breakpoint on mount and resize -const observer = new ResizeObserver((entries) => { - const width = entries[0].contentRect.width; - const variant = getGridDimensions(width); - setLayoutVariant(variant); -}); -``` - -Each template must define layouts for all breakpoints: - -```typescript -const template: ExtendedTemplateConfig = { - xl: [...], // 4 columns - lg: [...], // 3 columns - md: [...], // 2 columns - sm: [...] // 1 column -}; -``` - -## Type System - -### Core Types - -```typescript -// Widget definition -type WidgetMapping = { - [widgetType: string]: { - defaults: WidgetDefaults; - config?: WidgetConfiguration; - renderWidget: (widgetId: string) => React.ReactNode; - }; -}; - -// Layout configuration -type ExtendedTemplateConfig = { - xl: ExtendedLayoutItem[]; - lg: ExtendedLayoutItem[]; - md: ExtendedLayoutItem[]; - sm: ExtendedLayoutItem[]; -}; - -// Individual widget placement -type ExtendedLayoutItem = Layout & { - widgetType: string; - title: string; - config?: WidgetConfiguration; - locked?: boolean; -}; -``` - -## Performance Considerations - -### Optimization Strategies - -1. **Memoization** - - `useMemo` for dropping item template - - `useMemo` for dropdown items - - `useMemo` for header links - -2. **Conditional Rendering** - - Empty state only when needed - - Drawer only when `showDrawer={true}` - - Resize handles only on hover - -3. **Event Handling** - - No inline function creation in render - - Callbacks defined at component level - - Event handlers properly memoized - -4. **Grid Reset** - - Key prop forces remount on breakpoint change - - Necessary for proper react-grid-layout behavior - - `key={'grid-' + layoutVariant}` - -## Integration Points - -### With PatternFly - -- Uses PatternFly Card for widget containers -- Uses PatternFly EmptyState for empty dashboard -- Uses PatternFly Dropdown for actions menu -- Uses PatternFly Icons throughout -- Follows PatternFly design tokens - -### With react-grid-layout - -- Wraps ReactGridLayout component -- Custom resize handles -- Custom drag handles -- Responsive breakpoints -- Layout persistence - -### With React Router - -- Optional Link component in GridTile -- Handles internal/external links -- Target blank for external links - -## Extensibility - -### Custom Empty State - -```typescript -} -/> -``` - -### Custom Analytics - -```typescript - { - myAnalytics.track(event, data); - }} -/> -``` - -### Custom Notifications - -```typescript - { - toast.show(notification.title, { - variant: notification.variant - }); - }} -/> -``` - -## File Structure - -``` -src/WidgetLayout/ -├── types.ts # TypeScript type definitions -├── utils.ts # Utility functions -├── GridLayout.tsx # Main grid component -├── GridLayout.scss # Grid styles -├── GridTile.tsx # Widget wrapper component -├── GridTile.scss # Tile styles -├── WidgetDrawer.tsx # Widget selection drawer -├── WidgetDrawer.scss # Drawer styles -├── WidgetLayout.tsx # Main component -├── index.ts # Public exports -├── resize-handle.svg # Resize handle icon -└── __tests__/ # Test files - ├── WidgetLayout.test.tsx - ├── utils.test.ts - └── __snapshots__/ -``` - -## Dependencies - -### Runtime Dependencies - -- `react` & `react-dom` (peer) -- `react-router-dom` (peer) -- `@patternfly/react-core` -- `@patternfly/react-icons` -- `react-grid-layout` -- `clsx` - -### Development Dependencies - -- `@types/react-grid-layout` -- TypeScript -- Jest & Testing Library - -## Browser Support - -Modern browsers with ES6 support: -- Chrome/Edge (Chromium) -- Firefox -- Safari -- Mobile browsers - -## Future Enhancements - -Potential areas for extension: - -1. **Widget Templates** - Pre-defined layout templates -2. **Export/Import** - JSON export/import of layouts -3. **Undo/Redo** - Layout change history -4. **Widget Groups** - Grouping related widgets -5. **Themes** - Custom color schemes -6. **Animations** - Smooth transitions -7. **Touch Support** - Better mobile experience - diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index cc37c16..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,256 +0,0 @@ -# Migration Guide from widget-layout - -This document provides guidance for users migrating from the RedHatInsights/widget-layout repository to this generic PatternFly component. - -## What Changed - -### Removed Dependencies - -The following console-specific dependencies have been removed: - -1. **Scalprum** (`@scalprum/react-core`) - Federated module loading -2. **Chrome Services APIs** - API calls for template persistence -3. **useCurrentUser** - Console-specific authentication -4. **useChrome** - Console-specific hooks and analytics -5. **Jotai atoms** - External state management -6. **Console icons and branding** - -### New Approach - -#### Widget Definition - -**Before (widget-layout):** -```typescript -// Widget loaded via Scalprum federated modules -const widgetMapping = { - 'my-widget': { - scope: 'myApp', - module: './MyWidget', - importName: 'MyWidget', - defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, - config: { title: 'My Widget' } - } -}; -``` - -**After (widgetized-dashboard):** -```typescript -// Widget rendered directly via React component -const widgetMapping: WidgetMapping = { - 'my-widget': { - defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, - config: { - title: 'My Widget', - icon: - }, - renderWidget: (id) => - } -}; -``` - -#### Template Management - -**Before (widget-layout):** -```typescript -// Templates loaded/saved automatically via Chrome Services API -import { getDashboardTemplates, patchDashboardTemplate } from './api/dashboard-templates'; - -// Component handles API calls internally - -``` - -**After (widgetized-dashboard):** -```typescript -// Templates managed via props (bring your own state management) -const [template, setTemplate] = useState(loadTemplate()); - -const handleTemplateChange = async (newTemplate) => { - setTemplate(newTemplate); - await saveTemplateToAPI(newTemplate); // Your own API -}; - - -``` - -#### State Management - -**Before (widget-layout):** -```typescript -// State managed via Jotai atoms -import { useAtom } from 'jotai'; -import { templateAtom } from './state/templateAtom'; - -const [template, setTemplate] = useAtom(templateAtom); -``` - -**After (widgetized-dashboard):** -```typescript -// State managed via React state or your preferred solution -const [template, setTemplate] = useState(initialTemplate); - -// Or use Redux, MobX, Zustand, etc. -``` - -#### Analytics - -**Before (widget-layout):** -```typescript -// Chrome analytics used internally -import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; - -const { analytics } = useChrome(); -analytics.track('widget-layout.widget-add', { data }); -``` - -**After (widgetized-dashboard):** -```typescript -// Optional analytics via callback - { - yourAnalytics.track(event, data); - }} -/> -``` - -## Step-by-Step Migration - -### 1. Install Dependencies - -```bash -yarn add @patternfly/widgetized-dashboard -``` - -### 2. Update Widget Definitions - -Convert your Scalprum widget definitions to render functions: - -```typescript -// Old -{ - 'insights-widget': { - scope: '@redhat-cloud-services/insights', - module: './InsightsWidget', - importName: 'default', - defaults: { w: 2, h: 3, maxH: 6, minH: 2 } - } -} - -// New -{ - 'insights-widget': { - defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, - config: { - title: 'Insights', - icon: - }, - renderWidget: (id) => - } -} -``` - -### 3. Implement Template Persistence - -Replace Chrome Services API calls with your own persistence layer: - -```typescript -// Load from your backend -const loadTemplate = async () => { - const response = await fetch('/api/dashboard/template'); - return response.json(); -}; - -// Save to your backend -const saveTemplate = async (template) => { - await fetch('/api/dashboard/template', { - method: 'POST', - body: JSON.stringify(template) - }); -}; - -// Use in component -const MyDashboard = () => { - const [template, setTemplate] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - loadTemplate().then(t => { - setTemplate(t); - setLoading(false); - }); - }, []); - - if (loading) return ; - - return ( - - ); -}; -``` - -### 4. Update Imports - -```typescript -// Old -import GridLayout from './Components/DnDLayout/GridLayout'; -import { getDashboardTemplates } from './api/dashboard-templates'; - -// New -import { WidgetLayout, GridLayout, ExtendedTemplateConfig } from '@patternfly/widgetized-dashboard'; -``` - -### 5. Remove State Atoms - -If you were using the Jotai atoms, replace them with your own state management: - -```typescript -// Old -import { useAtom } from 'jotai'; -import { templateAtom } from './state/templateAtom'; - -// New - use React state, Redux, or your preferred solution -import { useState } from 'react'; -const [template, setTemplate] = useState(initialTemplate); -``` - -## API Mapping - -| widget-layout | widgetized-dashboard | Notes | -|--------------|---------------------|-------| -| `getDashboardTemplates()` | `loadTemplate()` | Implement your own | -| `patchDashboardTemplate()` | `saveTemplate()` | Implement your own | -| `useCurrentUser()` | Your auth solution | Use your app's auth | -| `useChrome()` | Props/callbacks | Pass as props | -| Jotai atoms | React state/your store | Bring your own state | -| Scalprum loading | `renderWidget` prop | Direct rendering | - -## Breaking Changes - -1. **No automatic persistence** - You must implement template loading/saving -2. **No federated modules** - Use direct React components instead -3. **No built-in auth** - Handle authentication in your app layer -4. **State management** - Component is self-contained, no external atoms -5. **Analytics** - Optional callback instead of automatic tracking - -## Benefits of Migration - -1. **No console dependencies** - Works in any PatternFly application -2. **Simpler mental model** - Props in, callbacks out -3. **Flexible persistence** - Use any backend or storage solution -4. **Better TypeScript support** - Full type definitions -5. **Lighter bundle** - Fewer dependencies -6. **More control** - Explicit state management - -## Need Help? - -- [Full Documentation](./README.md) -- [Examples](./packages/module/patternfly-docs/content/examples/) -- [Design Guidelines](./packages/module/patternfly-docs/content/design-guidelines/) - diff --git a/packages/module/src/WidgetLayout/images.d.ts b/packages/module/src/WidgetLayout/images.d.ts deleted file mode 100644 index 6bebf14..0000000 --- a/packages/module/src/WidgetLayout/images.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module '*.svg' { - const content: string; - export default content; -} -