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..185e56c 100644 --- a/packages/module/package.json +++ b/packages/module/package.json @@ -30,7 +30,10 @@ "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", @@ -46,7 +49,9 @@ "@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", "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..7181069 --- /dev/null +++ b/packages/module/patternfly-docs/content/examples/BasicExample.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import WidgetLayout from '../../../src/WidgetLayout/WidgetLayout'; +import { WidgetMapping, ExtendedTemplateConfig } from '../../../src/WidgetLayout/types'; +import { CubeIcon, ChartLineIcon, BellIcon, ExternalLinkAltIcon, ArrowRightIcon } from '@patternfly/react-icons'; +import { Card, CardBody, CardFooter, Content, Icon } from '@patternfly/react-core'; + +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 = () => ( + +); + +const ExampleWidget3 = () => ( + +); + +// 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: 'Chart Widget', + icon: + }, + renderWidget: () => + }, + 'notifications-widget': { + defaults: { w: 1, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Notification Widget', + 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: '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: '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: '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: 'Chart Widget' } + ] +}; + +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..1df063d --- /dev/null +++ b/packages/module/patternfly-docs/content/examples/LockedLayoutExample.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import WidgetLayout from '../../../src/WidgetLayout/WidgetLayout'; +import { WidgetMapping, ExtendedTemplateConfig } from '../../../src/WidgetLayout/types'; +import { CubeIcon, ChartLineIcon, ExternalLinkAltIcon, ArrowRightIcon } from '@patternfly/react-icons'; +import { Card, CardBody, CardFooter, Content, Icon } from '@patternfly/react-core'; + +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 = () => ( + +); + +// 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: 'Chart Widget', + icon: + }, + renderWidget: () => + } +}; + +// 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', 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', 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', 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', 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 = () => ( +
+ +
+); + 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..a6d96b4 --- /dev/null +++ b/packages/module/patternfly-docs/content/examples/WithoutDrawerExample.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import WidgetLayout from '../../../src/WidgetLayout/WidgetLayout'; +import { WidgetMapping, ExtendedTemplateConfig } from '../../../src/WidgetLayout/types'; +import { CubeIcon, ChartLineIcon, BellIcon, ExternalLinkAltIcon, ArrowRightIcon } from '@patternfly/react-icons'; +import { Card, CardBody, CardFooter, Content, Icon } from '@patternfly/react-core'; + +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 = () => ( + +); + +const ExampleWidget3 = () => ( + +); + +// 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: 'Chart Widget', + icon: + }, + renderWidget: () => + }, + 'notifications-widget': { + defaults: { w: 1, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Notification Widget', + 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: '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: '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: '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: 'Chart Widget' } + ] +}; + +export const WithoutDrawerExample: 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..4adff32 100644 --- a/packages/module/patternfly-docs/content/examples/basic.md +++ b/packages/module/patternfly-docs/content/examples/basic.md @@ -9,21 +9,80 @@ 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 { FunctionComponent, useState } from 'react'; +import { ExternalLinkAltIcon, ArrowRightIcon, CubeIcon, ChartLineIcon, BellIcon } from '@patternfly/react-icons'; +import { Card, CardBody, CardFooter, Content, Icon } from '@patternfly/react-core'; +import { WidgetLayout, GridLayout, WidgetDrawer } from '@patternfly/widgetized-dashboard'; + +### Basic usage + +The WidgetLayout component provides a complete drag-and-drop dashboard experience with a widget drawer for adding and removing widgets. + +### Interactive example -import { ExtendedButton } from "@patternfly/widgetized-dashboard"; +```js file="./BasicExample.tsx" -## Basic usage +``` + +### Locked layout -### Example +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..d3c89ea --- /dev/null +++ b/packages/module/src/WidgetLayout/GridLayout.tsx @@ -0,0 +1,289 @@ +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 + .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..3e2d3b2 --- /dev/null +++ b/packages/module/src/WidgetLayout/GridTile.tsx @@ -0,0 +1,231 @@ +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 { 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..c97eb03 --- /dev/null +++ b/packages/module/src/WidgetLayout/WidgetDrawer.tsx @@ -0,0 +1,172 @@ +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, PlusCircleIcon } 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> + ) + )} + </> + )} + + + + + + {isOpen &&
{panelContent}
} + {children} + + ); +}; + +export default WidgetDrawer; + diff --git a/packages/module/src/WidgetLayout/WidgetLayout.tsx b/packages/module/src/WidgetLayout/WidgetLayout.tsx new file mode 100644 index 0000000..b2069c3 --- /dev/null +++ b/packages/module/src/WidgetLayout/WidgetLayout.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import GridLayout from './GridLayout'; +import WidgetDrawer from './WidgetDrawer'; +import { ExtendedTemplateConfig, WidgetMapping, AnalyticsTracker } from './types'; + +export interface WidgetLayoutProps { + /** Widget mapping definition */ + widgetMapping: WidgetMapping; + /** Initial template configuration */ + initialTemplate: 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 widget drawer */ + showDrawer?: boolean; + /** Whether to show the empty state when no widgets exist */ + showEmptyState?: boolean; + /** Custom instruction text for the drawer */ + drawerInstructionText?: string; + /** Whether the drawer is initially open */ + initialDrawerOpen?: boolean; +}; + +const WidgetLayout = ({ + widgetMapping, + initialTemplate, + onTemplateChange, + isLayoutLocked = false, + emptyStateComponent, + documentationLink, + analytics, + showDrawer = true, + showEmptyState = true, + drawerInstructionText, + initialDrawerOpen = false, +}: WidgetLayoutProps) => { + const [template, setTemplate] = useState(initialTemplate); + const [drawerOpen, setDrawerOpen] = useState(initialDrawerOpen); + const [currentlyUsedWidgets, setCurrentlyUsedWidgets] = useState([]); + + const handleTemplateChange = (newTemplate: ExtendedTemplateConfig) => { + setTemplate(newTemplate); + onTemplateChange?.(newTemplate); + }; + + const handleDrawerExpandChange = (expanded: boolean) => { + setDrawerOpen(expanded); + }; + + const handleActiveWidgetsChange = (widgetTypes: string[]) => { + setCurrentlyUsedWidgets(widgetTypes); + }; + + const gridLayout = ( + + ); + + if (!showDrawer) { + return gridLayout; + } + + return ( + + {gridLayout} + + ); +}; + +export default WidgetLayout; + diff --git a/packages/module/src/WidgetLayout/__tests__/WidgetLayout.test.tsx b/packages/module/src/WidgetLayout/__tests__/WidgetLayout.test.tsx new file mode 100644 index 0000000..5adb835 --- /dev/null +++ b/packages/module/src/WidgetLayout/__tests__/WidgetLayout.test.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { WidgetLayout } from '../WidgetLayout'; +import { WidgetMapping, ExtendedTemplateConfig } from '../types'; +import { CubeIcon } from '@patternfly/react-icons'; +import { BrowserRouter } from 'react-router-dom'; + +const mockWidgetMapping: WidgetMapping = { + 'test-widget': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Test Widget', + icon: + }, + renderWidget: (id) =>
Test Widget Content
+ } +}; + +const mockTemplate: ExtendedTemplateConfig = { + xl: [ + { i: 'test-widget#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'test-widget', title: 'Test Widget' } + ], + lg: [ + { i: 'test-widget#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'test-widget', title: 'Test Widget' } + ], + md: [ + { i: 'test-widget#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'test-widget', title: 'Test Widget' } + ], + sm: [ + { i: 'test-widget#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'test-widget', title: 'Test Widget' } + ] +}; + +const emptyTemplate: ExtendedTemplateConfig = { + xl: [], + lg: [], + md: [], + sm: [] +}; + +describe('WidgetLayout', () => { + it('renders without crashing', () => { + render( + + + + ); + expect(screen.getByTestId('widget-test-widget#1')).toBeInTheDocument(); + }); + + it('shows empty state when no widgets are present', () => { + render( + + + + ); + expect(screen.getByText(/No dashboard content/i)).toBeInTheDocument(); + }); + + it('hides empty state when showEmptyState is false', () => { + render( + + + + ); + expect(screen.queryByText(/No dashboard content/i)).not.toBeInTheDocument(); + }); + + it('hides drawer when showDrawer is false', () => { + render( + + + + ); + // Drawer instructions should not be present + expect(screen.queryByText(/Add new and previously removed widgets/i)).not.toBeInTheDocument(); + }); + + it('renders widget with custom title', () => { + render( + + + + ); + expect(screen.getByText('Test Widget')).toBeInTheDocument(); + }); + + it('calls onTemplateChange when template changes', () => { + const handleChange = jest.fn(); + render( + + + + ); + // Note: Testing actual drag-and-drop interactions would require more complex testing setup + // This test just verifies the callback prop is accepted + expect(handleChange).not.toHaveBeenCalled(); + }); +}); + diff --git a/packages/module/src/WidgetLayout/__tests__/__snapshots__/WidgetLayout.test.tsx.snap b/packages/module/src/WidgetLayout/__tests__/__snapshots__/WidgetLayout.test.tsx.snap new file mode 100644 index 0000000..006e639 --- /dev/null +++ b/packages/module/src/WidgetLayout/__tests__/__snapshots__/WidgetLayout.test.tsx.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WidgetLayout renders without crashing 1`] = ` +
+
+ Test Widget Content +
+
+`; + diff --git a/packages/module/src/WidgetLayout/__tests__/utils.test.ts b/packages/module/src/WidgetLayout/__tests__/utils.test.ts new file mode 100644 index 0000000..605a48a --- /dev/null +++ b/packages/module/src/WidgetLayout/__tests__/utils.test.ts @@ -0,0 +1,114 @@ +import { + getWidgetIdentifier, + mapWidgetDefaults, + getGridDimensions, + isWidgetType, + extendLayout, + mapTemplateConfigToExtendedTemplateConfig, +} from '../utils'; +import { ExtendedTemplateConfig, TemplateConfig } from '../types'; + +describe('utils', () => { + describe('getWidgetIdentifier', () => { + it('creates identifier with widgetType and uuid', () => { + const result = getWidgetIdentifier('my-widget', 'test-uuid'); + expect(result).toBe('my-widget#test-uuid'); + }); + + it('generates random uuid when not provided', () => { + const result1 = getWidgetIdentifier('my-widget'); + const result2 = getWidgetIdentifier('my-widget'); + expect(result1).toMatch(/^my-widget#/); + expect(result2).toMatch(/^my-widget#/); + expect(result1).not.toBe(result2); + }); + }); + + describe('mapWidgetDefaults', () => { + it('splits identifier into widgetType and uuid', () => { + const [widgetType, uuid] = mapWidgetDefaults('my-widget#test-uuid'); + expect(widgetType).toBe('my-widget'); + expect(uuid).toBe('test-uuid'); + }); + }); + + describe('getGridDimensions', () => { + it('returns xl for width >= 1550', () => { + expect(getGridDimensions(1550)).toBe('xl'); + expect(getGridDimensions(2000)).toBe('xl'); + }); + + it('returns lg for width 1400-1549', () => { + expect(getGridDimensions(1400)).toBe('lg'); + expect(getGridDimensions(1549)).toBe('lg'); + }); + + it('returns md for width 1100-1399', () => { + expect(getGridDimensions(1100)).toBe('md'); + expect(getGridDimensions(1399)).toBe('md'); + }); + + it('returns sm for width < 1100', () => { + expect(getGridDimensions(800)).toBe('sm'); + expect(getGridDimensions(1099)).toBe('sm'); + }); + }); + + describe('isWidgetType', () => { + it('returns true if type exists in mapping', () => { + const mapping = { 'widget-1': {}, 'widget-2': {} }; + expect(isWidgetType(mapping, 'widget-1')).toBe(true); + }); + + it('returns false if type does not exist in mapping', () => { + const mapping = { 'widget-1': {} }; + expect(isWidgetType(mapping, 'widget-2')).toBe(false); + }); + }); + + describe('extendLayout', () => { + it('filters out dropping element', () => { + const config: ExtendedTemplateConfig = { + xl: [ + { i: 'widget-1#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'widget-1', title: 'Widget 1' }, + { i: '__dropping-elem__', x: 2, y: 0, w: 2, h: 3, widgetType: 'widget-2', title: 'Dropping' } + ], + lg: [], + md: [], + sm: [] + }; + + const result = extendLayout(config); + expect(result.xl).toHaveLength(1); + expect(result.xl[0].i).toBe('widget-1#1'); + }); + + it('adds widgetType from identifier', () => { + const config: ExtendedTemplateConfig = { + xl: [{ i: 'my-widget#123', x: 0, y: 0, w: 2, h: 3, widgetType: '', title: 'Widget' }], + lg: [], + md: [], + sm: [] + }; + + const result = extendLayout(config); + expect(result.xl[0].widgetType).toBe('my-widget'); + }); + }); + + describe('mapTemplateConfigToExtendedTemplateConfig', () => { + it('converts TemplateConfig to ExtendedTemplateConfig', () => { + const config: TemplateConfig = { + xl: [{ i: 'widget-1#1', x: 0, y: 0, w: 2, h: 3, title: 'Widget 1' }], + lg: [], + md: [], + sm: [] + }; + + const result = mapTemplateConfigToExtendedTemplateConfig(config); + expect(result.xl[0].widgetType).toBe('widget-1'); + expect(result.xl[0].i).toBe('widget-1#1'); + }); + }); +}); + diff --git a/packages/module/src/WidgetLayout/index.ts b/packages/module/src/WidgetLayout/index.ts new file mode 100644 index 0000000..0f7b92d --- /dev/null +++ b/packages/module/src/WidgetLayout/index.ts @@ -0,0 +1,23 @@ +export { default as WidgetLayout } from './WidgetLayout'; +export { default as GridLayout } from './GridLayout'; +export { default as GridTile } from './GridTile'; +export { default as WidgetDrawer } from './WidgetDrawer'; + +export * from './types'; +export { + columns, + breakpoints, + droppingElemId, + getWidgetIdentifier, + mapWidgetDefaults, + getGridDimensions, + isWidgetType, + extendLayout, + mapTemplateConfigToExtendedTemplateConfig, +} from './utils'; + +export type { WidgetLayoutProps } from './WidgetLayout'; +export type { GridLayoutProps } from './GridLayout'; +export type { GridTileProps, SetWidgetAttribute } from './GridTile'; +export type { WidgetDrawerProps } from './WidgetDrawer'; + diff --git a/packages/module/src/WidgetLayout/styles.ts b/packages/module/src/WidgetLayout/styles.ts new file mode 100644 index 0000000..5695ed5 --- /dev/null +++ b/packages/module/src/WidgetLayout/styles.ts @@ -0,0 +1,134 @@ +/** + * Styles for WidgetLayout components using PatternFly tokens + * This replaces the SCSS files with CSS-in-JS approach + */ + +export const gridLayoutStyles = ` + .react-grid-item .react-resizable-handle-nw, + .react-grid-item .react-resizable-handle-sw, + .react-grid-item .react-resizable-handle-se { + display: none; + } + + .react-grid-item .react-resizable-handle-nw, + .react-grid-item .react-resizable-handle-se { + cursor: nwse-resize; + } + + .react-grid-item .react-resizable-handle-ne, + .react-grid-item .react-resizable-handle-sw { + cursor: nesw-resize; + } + + .react-grid-item:hover:not(.static) .react-resizable-handle-nw, + .react-grid-item:hover:not(.static) .react-resizable-handle-sw, + .react-grid-item:hover:not(.static) .react-resizable-handle-se, + .react-grid-item:active:not(.static) .react-resizable-handle-nw, + .react-grid-item:active:not(.static) .react-resizable-handle-sw, + .react-grid-item:active:not(.static) .react-resizable-handle-se { + display: inherit; + } + + .react-grid-item.react-grid-placeholder { + background-color: var(--pf-t--color--gray--60); + border-radius: 12px; + } + + .react-grid-item .react-resizable-handle::after { + display: none; + } + + .react-grid-item .react-resizable-handle img { + padding: 3px; + } + + #widget-layout-container { + width: 100%; + min-height: 200px; + } + + .grid-tile::before { + z-index: 1; + } + + .grid-tile { + background: var(--pf-t--global--background--color--100); + position: relative; + height: 100%; + overflow: hidden; + } + + .grid-tile .drag-handle { + cursor: grab; + } + + .grid-tile .drag-handle.dragging { + cursor: grabbing; + } + + .grid-tile.static .drag-handle, + .grid-tile.static .drag-handle.dragging { + cursor: not-allowed; + } + + .grid-tile .pf-v6-c-card__header { + padding: var(--pf-t--global--spacer--md); + padding-bottom: var(--pf-t--global--spacer--sm); + background: var(--pf-t--global--background--color--200); + } + + .grid-tile .widg-c-icon--header .service-icon { + height: 28px; + width: 28px; + } + + .grid-tile .widg-c-icon--header .pf-v6-svg { + color: var(--pf-t--color--blue--50); + height: 22px; + width: 22px; + margin-bottom: var(--pf-t--global--spacer--sm); + } + + .grid-tile .widg-card-header-text { + gap: 0; + line-height: 0; + padding-top: 2px; + } + + .grid-tile .pf-v6-c-card__header .pf-v6-c-menu-toggle { + padding-left: 0; + padding-right: 0; + } + + .grid-tile .pf-v6-c-card__actions { + padding-left: var(--pf-t--global--spacer--xs); + } + + .grid-tile .pf-v6-c-card__body { + background: var(--pf-t--global--background--color--100); + } + + .widg-c-drawer__header { + height: 100%; + } + + .widg-c-drawer__drag-handle { + cursor: grab; + } + + .widg-l-gallery { + --pf-v6-l-gallery--m-gutter--GridGap: 8px; + } +`; + +// Inject styles into document head +if (typeof document !== 'undefined') { + const styleId = 'widget-layout-styles'; + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.textContent = gridLayoutStyles; + document.head.appendChild(style); + } +} + diff --git a/packages/module/src/WidgetLayout/types.ts b/packages/module/src/WidgetLayout/types.ts new file mode 100644 index 0000000..741741b --- /dev/null +++ b/packages/module/src/WidgetLayout/types.ts @@ -0,0 +1,82 @@ +import { Layout } from 'react-grid-layout'; + +export const widgetIdSeparator = '#'; + +export type Variants = 'sm' | 'md' | 'lg' | 'xl'; + +export type LayoutWithTitle = Layout & { title: string }; + +export type TemplateConfig = { + [k in Variants]: LayoutWithTitle[]; +}; + +export type PartialTemplateConfig = Partial; + +// Extended type the UI tracks +export type ExtendedLayoutItem = LayoutWithTitle & { + widgetType: string; + config?: WidgetConfiguration; + locked?: boolean; +}; + +// Extended type the UI tracks +export type ExtendedTemplateConfig = { + [k in Variants]: ExtendedLayoutItem[]; +}; + +// Extended type the UI tracks +export type PartialExtendedTemplateConfig = Partial; + +export interface WidgetDefaults { + w: number; + h: number; + maxH: number; + minH: number; +} + +export interface WidgetHeaderLink { + title?: string; + href?: string; +} + +export interface WidgetConfiguration { + icon?: React.ReactNode; + headerLink?: WidgetHeaderLink; + title?: string; +} + +/** + * Widget definition with rendering function + */ +export interface WidgetDefinition { + /** Unique widget type identifier */ + widgetType: string; + /** Default dimensions for the widget */ + defaults: WidgetDefaults; + /** Widget configuration (title, icon, header link) */ + config?: WidgetConfiguration; + /** Function that renders the widget content */ + renderWidget: (widgetId: string) => React.ReactNode; +} + +/** + * Widget mapping keyed by widget type + */ +export interface WidgetMapping { + [widgetType: string]: Omit; +} + +/** + * Notification type for user feedback + */ +export interface Notification { + variant: 'success' | 'danger' | 'warning' | 'info' | 'default'; + title: string; + description?: string; +} + +/** + * Analytics tracking function (optional) + */ +export type AnalyticsTracker = (event: string, data?: Record) => void; + diff --git a/packages/module/src/WidgetLayout/utils.ts b/packages/module/src/WidgetLayout/utils.ts new file mode 100644 index 0000000..cd0f57a --- /dev/null +++ b/packages/module/src/WidgetLayout/utils.ts @@ -0,0 +1,71 @@ +import { ExtendedTemplateConfig, TemplateConfig, Variants, widgetIdSeparator } from './types'; + +export const droppingElemId = '__dropping-elem__'; + +export const columns: Record = { xl: 4, lg: 3, md: 2, sm: 1 }; + +export const breakpoints: Record = { xl: 1550, lg: 1400, md: 1100, sm: 800 }; + +/** + * Generate a unique widget identifier + */ +export const getWidgetIdentifier = (widgetType: string, uniqueId: string = crypto.randomUUID()): string => `${widgetType}${widgetIdSeparator}${uniqueId}`; + +/** + * Parse widget identifier into type and unique ID + */ +export const mapWidgetDefaults = (id: string): [string, string] => { + const [widgetType, i] = id.split(widgetIdSeparator); + return [widgetType ?? '', i ?? '']; +}; + +/** + * Map template config to extended template config with widget types + */ +export const mapTemplateConfigToExtendedTemplateConfig = (templateConfig: TemplateConfig): ExtendedTemplateConfig => { + const result: ExtendedTemplateConfig = { sm: [], md: [], lg: [], xl: [] }; + (Object.keys(templateConfig) as Variants[]).forEach((key) => { + result[key] = templateConfig[key].map((layoutWithTitle) => ({ + ...layoutWithTitle, + widgetType: mapWidgetDefaults(layoutWithTitle.i)[0], + })); + }); + return result; +}; + +/** + * Extend layout by filtering out dropping elements and adding widget types + */ +export const extendLayout = (extendedTemplateConfig: ExtendedTemplateConfig): ExtendedTemplateConfig => { + const result: ExtendedTemplateConfig = { sm: [], md: [], lg: [], xl: [] }; + (Object.keys(extendedTemplateConfig) as Variants[]).forEach((key) => { + result[key] = extendedTemplateConfig[key] + .filter(({ i }) => i !== droppingElemId) + .map((item) => ({ + ...item, + widgetType: mapWidgetDefaults(item.i)[0], + })); + }); + return result; +}; + +/** + * Get grid dimensions based on container width + */ +export function getGridDimensions(currentWidth: number): Variants { + let variant: Variants = 'xl'; + Object.entries(breakpoints).forEach(([breakpoint, value]) => { + if (value >= currentWidth) { + variant = breakpoint as Variants; + } + }); + return variant; +} + +/** + * Check if a type exists in the widget mapping + */ +export function isWidgetType(widgetMapping: Record, type: string): boolean { + return Object.keys(widgetMapping).includes(type); +} + diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index 03c57e3..a0c5e97 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -1 +1,12 @@ -export * from './ExtendedButton'; +export { default as WidgetLayout } from './WidgetLayout/WidgetLayout'; +export { default as GridLayout } from './WidgetLayout/GridLayout'; +export { default as GridTile } from './WidgetLayout/GridTile'; +export { default as WidgetDrawer } from './WidgetLayout/WidgetDrawer'; + +export * from './WidgetLayout/types'; +export * from './WidgetLayout/utils'; + +export type { WidgetLayoutProps } from './WidgetLayout/WidgetLayout'; +export type { GridLayoutProps } from './WidgetLayout/GridLayout'; +export type { GridTileProps, SetWidgetAttribute } from './WidgetLayout/GridTile'; +export type { WidgetDrawerProps } from './WidgetLayout/WidgetDrawer'; diff --git a/yarn.lock b/yarn.lock index 580c4b0..4483811 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3319,13 +3319,20 @@ __metadata: "@patternfly/patternfly-a11y": "npm:^5.1.0" "@patternfly/react-code-editor": "npm:^6.3.1" "@patternfly/react-core": "npm:^6.3.1" + "@patternfly/react-icons": "npm:^6.3.1" "@patternfly/react-table": "npm:^6.3.1" + "@types/react-grid-layout": "npm:^1.3.5" + clsx: "npm:^2.1.0" monaco-editor: "npm:^0.53.0" + nodemon: "npm:^3.0.0" + react-grid-layout: "npm:^1.5.1" react-monaco-editor: "npm:^0.59.0" + react-router-dom: "npm:^6.24.0" rimraf: "npm:^6.0.1" peerDependencies: react: ^18 react-dom: ^18 + react-router-dom: ^6 languageName: unknown linkType: soft @@ -3399,6 +3406,13 @@ __metadata: languageName: node linkType: hard +"@remix-run/router@npm:1.23.0": + version: 1.23.0 + resolution: "@remix-run/router@npm:1.23.0" + checksum: 10c0/eaef5cb46a1e413f7d1019a75990808307e08e53a39d4cf69c339432ddc03143d725decef3d6b9b5071b898da07f72a4a57c4e73f787005fcf10162973d8d7d7 + languageName: node + linkType: hard + "@rtsao/scc@npm:^1.1.0": version: 1.1.0 resolution: "@rtsao/scc@npm:1.1.0" @@ -3836,6 +3850,24 @@ __metadata: languageName: node linkType: hard +"@types/react-grid-layout@npm:^1.3.5": + version: 1.3.5 + resolution: "@types/react-grid-layout@npm:1.3.5" + dependencies: + "@types/react": "npm:*" + checksum: 10c0/abd2a1dda9625c753ff2571a10b69740b2fb9ed1d3141755d54d5814cc12a9701c7c5cd78e8797e945486b441303b82543be71043a32d6a988b57a14237f93c6 + languageName: node + linkType: hard + +"@types/react@npm:*": + version: 19.2.2 + resolution: "@types/react@npm:19.2.2" + dependencies: + csstype: "npm:^3.0.2" + checksum: 10c0/f830b1204aca4634ce3c6cb3477b5d3d066b80a4dd832a4ee0069acb504b6debd2416548a43a11c1407c12bc60e2dc6cf362934a18fe75fe06a69c0a98cba8ab + languageName: node + linkType: hard + "@types/react@npm:^19.2.0": version: 19.2.1 resolution: "@types/react@npm:19.2.1" @@ -5928,7 +5960,7 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.6.0": +"chokidar@npm:^3.5.2, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" dependencies: @@ -6120,6 +6152,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^2.1.0, clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -6841,7 +6880,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": +"debug@npm:^4, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -8226,6 +8265,13 @@ __metadata: languageName: node linkType: hard +"fast-equals@npm:^4.0.3": + version: 4.0.3 + resolution: "fast-equals@npm:4.0.3" + checksum: 10c0/87fd2609c945ee61e9ed4d041eb2a8f92723fc02884115f67e429dd858d880279e962334894f116b3e9b223f387d246e3db5424ae779287849015ddadbf5ff27 + languageName: node + linkType: hard + "fast-fifo@npm:^1.2.0, fast-fifo@npm:^1.3.2": version: 1.3.2 resolution: "fast-fifo@npm:1.3.2" @@ -9642,6 +9688,13 @@ __metadata: languageName: node linkType: hard +"ignore-by-default@npm:^1.0.1": + version: 1.0.1 + resolution: "ignore-by-default@npm:1.0.1" + checksum: 10c0/9ab6e70e80f7cc12735def7ecb5527cfa56ab4e1152cd64d294522827f2dcf1f6d85531241537dc3713544e88dd888f65cb3c49c7b2cddb9009087c75274e533 + languageName: node + linkType: hard + "ignore@npm:^5.2.0, ignore@npm:^5.3.2": version: 5.3.2 resolution: "ignore@npm:5.3.2" @@ -12343,6 +12396,26 @@ __metadata: languageName: node linkType: hard +"nodemon@npm:^3.0.0": + version: 3.1.10 + resolution: "nodemon@npm:3.1.10" + dependencies: + chokidar: "npm:^3.5.2" + debug: "npm:^4" + ignore-by-default: "npm:^1.0.1" + minimatch: "npm:^3.1.2" + pstree.remy: "npm:^1.1.8" + semver: "npm:^7.5.3" + simple-update-notifier: "npm:^2.0.0" + supports-color: "npm:^5.5.0" + touch: "npm:^3.1.0" + undefsafe: "npm:^2.0.5" + bin: + nodemon: bin/nodemon.js + checksum: 10c0/95b64d647f2c22e85e375b250517b0a4b32c2d2392ad898444e331f70d6b1ab43b17f53a8a1d68d5879ab8401fc6cd6e26f0d2a8736240984f6b5a8435b407c0 + languageName: node + linkType: hard + "nopt@npm:^8.0.0": version: 8.1.0 resolution: "nopt@npm:8.1.0" @@ -13411,7 +13484,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.6.1, prop-types@npm:^15.8.1": +"prop-types@npm:15.x, prop-types@npm:^15.6.1, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -13480,6 +13553,13 @@ __metadata: languageName: node linkType: hard +"pstree.remy@npm:^1.1.8": + version: 1.1.8 + resolution: "pstree.remy@npm:1.1.8" + checksum: 10c0/30f78c88ce6393cb3f7834216cb6e282eb83c92ccb227430d4590298ab2811bc4a4745f850a27c5178e79a8f3e316591de0fec87abc19da648c2b3c6eb766d14 + languageName: node + linkType: hard + "pump@npm:^1.0.0": version: 1.0.3 resolution: "pump@npm:1.0.3" @@ -13718,6 +13798,19 @@ __metadata: languageName: node linkType: hard +"react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.6": + version: 4.5.0 + resolution: "react-draggable@npm:4.5.0" + dependencies: + clsx: "npm:^2.1.1" + prop-types: "npm:^15.8.1" + peerDependencies: + react: ">= 16.3.0" + react-dom: ">= 16.3.0" + checksum: 10c0/6f7591fe450555218bf0d9e31984be02451bf3f678fb121f51ac0a0a645d01a1b5ea8248ef9afddcd24239028911fd88032194b9c00b30ad5ece76ea13397fc3 + languageName: node + linkType: hard + "react-dropzone@npm:14.3.5": version: 14.3.5 resolution: "react-dropzone@npm:14.3.5" @@ -13744,6 +13837,23 @@ __metadata: languageName: node linkType: hard +"react-grid-layout@npm:^1.5.1": + version: 1.5.2 + resolution: "react-grid-layout@npm:1.5.2" + dependencies: + clsx: "npm:^2.1.1" + fast-equals: "npm:^4.0.3" + prop-types: "npm:^15.8.1" + react-draggable: "npm:^4.4.6" + react-resizable: "npm:^3.0.5" + resize-observer-polyfill: "npm:^1.5.1" + peerDependencies: + react: ">= 16.3.0" + react-dom: ">= 16.3.0" + checksum: 10c0/b6605d1435fe116c3720d168100a5a08da924c6905686fe8a486c33b82abbde8ccacbb59e5c6243fa52f5e808ad393a7bdf0c09a3446ebf76efe43f29d9f13ee + languageName: node + linkType: hard + "react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -13783,6 +13893,42 @@ __metadata: languageName: node linkType: hard +"react-resizable@npm:^3.0.5": + version: 3.0.5 + resolution: "react-resizable@npm:3.0.5" + dependencies: + prop-types: "npm:15.x" + react-draggable: "npm:^4.0.3" + peerDependencies: + react: ">= 16.3" + checksum: 10c0/cfe50aa6efb79e0aa09bd681a5beab2fcd1186737c4952eb4c3974ed9395d5d263ccd1130961d06b8f5e24c8f544dd2967b5c740ce68719962d1771de7bdb350 + languageName: node + linkType: hard + +"react-router-dom@npm:^6.24.0": + version: 6.30.1 + resolution: "react-router-dom@npm:6.30.1" + dependencies: + "@remix-run/router": "npm:1.23.0" + react-router: "npm:6.30.1" + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: 10c0/e9e1297236b0faa864424ad7d51c392fc6e118595d4dad4cd542fd217c479a81601a81c6266d5801f04f9e154de02d3b094fc22ccb544e755c2eb448fab4ec6b + languageName: node + linkType: hard + +"react-router@npm:6.30.1": + version: 6.30.1 + resolution: "react-router@npm:6.30.1" + dependencies: + "@remix-run/router": "npm:1.23.0" + peerDependencies: + react: ">=16.8" + checksum: 10c0/0414326f2d8e0c107fb4603cf4066dacba6a1f6f025c6e273f003e177b2f18888aca3de06d9b5522908f0e41de93be1754c37e82aa97b3a269c4742c08e82539 + languageName: node + linkType: hard + "react-ssr-prepass@npm:1.5.0": version: 1.5.0 resolution: "react-ssr-prepass@npm:1.5.0" @@ -14185,6 +14331,13 @@ __metadata: languageName: node linkType: hard +"resize-observer-polyfill@npm:^1.5.1": + version: 1.5.1 + resolution: "resize-observer-polyfill@npm:1.5.1" + checksum: 10c0/5e882475067f0b97dc07e0f37c3e335ac5bc3520d463f777cec7e894bb273eddbfecb857ae668e6fb6881fd6f6bb7148246967172139302da50fa12ea3a15d95 + languageName: node + linkType: hard + "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -15017,6 +15170,15 @@ __metadata: languageName: node linkType: hard +"simple-update-notifier@npm:^2.0.0": + version: 2.0.0 + resolution: "simple-update-notifier@npm:2.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/2a00bd03bfbcbf8a737c47ab230d7920f8bfb92d1159d421bdd194479f6d01ebc995d13fbe13d45dace23066a78a3dc6642999b4e3b38b847e6664191575b20c + languageName: node + linkType: hard + "sirv@npm:^2.0.3": version: 2.0.4 resolution: "sirv@npm:2.0.4" @@ -15602,7 +15764,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^5.3.0": +"supports-color@npm:^5.3.0, supports-color@npm:^5.5.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" dependencies: @@ -15961,6 +16123,15 @@ __metadata: languageName: node linkType: hard +"touch@npm:^3.1.0": + version: 3.1.1 + resolution: "touch@npm:3.1.1" + bin: + nodetouch: bin/nodetouch.js + checksum: 10c0/d2e4d269a42c846a22a29065b9af0b263de58effc85a1764bb7a2e8fc4b47700e9e2fcbd7eb1f5bffbb7c73d860f93600cef282b93ddac8f0b62321cb498b36e + languageName: node + linkType: hard + "tough-cookie@npm:^5.1.1": version: 5.1.2 resolution: "tough-cookie@npm:5.1.2" @@ -16344,6 +16515,13 @@ __metadata: languageName: node linkType: hard +"undefsafe@npm:^2.0.5": + version: 2.0.5 + resolution: "undefsafe@npm:2.0.5" + checksum: 10c0/96c0466a5fbf395917974a921d5d4eee67bca4b30d3a31ce7e621e0228c479cf893e783a109af6e14329b52fe2f0cb4108665fad2b87b0018c0df6ac771261d5 + languageName: node + linkType: hard + "undici-types@npm:~5.26.4": version: 5.26.5 resolution: "undici-types@npm:5.26.5"