diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index dd46784..d8a22a1 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -9,7 +9,7 @@ jobs: steps: - uses: actions/checkout@v4 - run: | - if [[ ! -z "${GH_PR_NUM}" ]]; then + if [[ ! -z "${GH_PR_NUM}" ]]; then echo "Checking out PR" git fetch origin pull/$GH_PR_NUM/head:tmp git checkout tmp @@ -22,14 +22,16 @@ jobs: run: corepack enable - uses: actions/cache@v4 id: yarn-cache - name: Cache npm deps + name: Load Yarn cache with: path: | - node_modules - **/node_modules - key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} - - run: yarn install --immutable - if: steps.yarn-cache.outputs.cache-hit != 'true' + .yarn/cache + key: ${{ runner.os }}-yarn-cache-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-cache- + - name: Install dependencies + shell: bash + run: yarn install --immutable --network-timeout 100000 - uses: actions/cache@v4 id: dist name: Cache dist @@ -48,7 +50,7 @@ jobs: steps: - uses: actions/checkout@v4 - run: | - if [[ ! -z "${GH_PR_NUM}" ]]; then + if [[ ! -z "${GH_PR_NUM}" ]]; then echo "Checking out PR" git fetch origin pull/$GH_PR_NUM/head:tmp git checkout tmp @@ -61,23 +63,22 @@ jobs: run: corepack enable - uses: actions/cache@v4 id: yarn-cache - name: Cache npm deps + name: Load Yarn cache with: path: | - node_modules - **/node_modules - key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} - - run: yarn install --immutable - if: steps.yarn-cache.outputs.cache-hit != 'true' + .yarn/cache + key: ${{ runner.os }}-yarn-cache-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-cache- + - name: Install dependencies + shell: bash + run: yarn install --immutable --network-timeout 100000 - uses: actions/cache@v4 id: lint-cache name: Load lint cache with: path: '.eslintcache' key: ${{ runner.os }}-lint-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} - - name: Enable Corepack - shell: bash - run: corepack enable - name: ESLint run: yarn lint:js - name: MDLint @@ -91,7 +92,7 @@ jobs: - uses: actions/checkout@v4 # Yes, we really want to checkout the PR - run: | - if [[ ! -z "${GH_PR_NUM}" ]]; then + if [[ ! -z "${GH_PR_NUM}" ]]; then echo "Checking out PR" git fetch origin pull/$GH_PR_NUM/head:tmp git checkout tmp @@ -104,23 +105,22 @@ jobs: run: corepack enable - uses: actions/cache@v4 id: yarn-cache - name: Cache npm deps + name: Load Yarn cache with: path: | - node_modules - **/node_modules - ~/.cache/Cypress - ~/.cache/puppeteer - key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} - - run: yarn install --immutable - if: steps.yarn-cache.outputs.cache-hit != 'true' + .yarn/cache + key: ${{ runner.os }}-yarn-cache-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-cache- + - name: Install dependencies + shell: bash + run: yarn install --immutable --network-timeout 100000 - uses: actions/cache@v4 id: dist name: Cache dist with: path: | packages/*/dist - packages/react-styles/css key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} - name: Build dist run: yarn build @@ -136,7 +136,7 @@ jobs: - uses: actions/checkout@v4 # Yes, we really want to checkout the PR - run: | - if [[ ! -z "${GH_PR_NUM}" ]]; then + if [[ ! -z "${GH_PR_NUM}" ]]; then echo "Checking out PR" git fetch origin pull/$GH_PR_NUM/head:tmp git checkout tmp @@ -149,23 +149,23 @@ jobs: run: corepack enable - uses: actions/cache@v4 id: yarn-cache - name: Cache npm deps + name: Load Yarn cache with: path: | - node_modules - **/node_modules - ~/.cache/Cypress - ~/.cache/puppeteer - key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} - - run: yarn install --immutable - if: steps.yarn-cache.outputs.cache-hit != 'true' + .yarn/cache + ~/.cache/puppeteer + key: ${{ runner.os }}-yarn-cache-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-cache- + - name: Install dependencies + shell: bash + run: yarn install --immutable --network-timeout 100000 - uses: actions/cache@v4 id: dist name: Cache dist with: path: | packages/*/dist - packages/react-styles/css key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} - name: Build dist run: yarn build diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index d283a72..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: build -on: - workflow_call: -jobs: - build: - runs-on: ubuntu-latest - env: - GH_PR_NUM: ${{ github.event.number }} - steps: - - uses: actions/checkout@v4 - - run: | - if [[ ! -z "${GH_PR_NUM}" ]]; then - echo "Checking out PR" - git fetch origin pull/$GH_PR_NUM/head:tmp - git checkout tmp - fi - - uses: actions/cache@v4 - id: setup-cache - name: Cache setup - with: - path: | - README.md - package.json - .tmplr.yml - packages/*/package.json - packages/*/patternfly-docs/content/** - packages/*/patternfly-docs/generated/** - key: ${{ runner.os }}-setup-15-${{ secrets.CACHE_VERSION }}-${{ hashFiles('package.json', 'packages/module/package.json') }} - - name: Run build script - run: ./devSetup.sh - shell: bash - if: steps.setup-cache.outputs.cache-hit != 'true' - - uses: actions/setup-node@v4 - with: - node-version: '20' - - name: Enable Corepack - shell: bash - run: corepack enable - - uses: actions/cache@v4 - id: yarn-cache - name: Cache npm deps - with: - path: | - node_modules - **/node_modules - key: ${{ runner.os }}-yarn-15-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} - - run: yarn install --immutable - if: steps.yarn-cache.outputs.cache-hit != 'true' - - uses: actions/cache@v4 - id: dist - name: Cache dist - with: - path: | - packages/*/dist - key: ${{ runner.os }}-dist-15-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} - - name: Build dist - run: yarn build - if: steps.dist.outputs.cache-hit != 'true' \ No newline at end of file diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 46e956c..48e4e63 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -1,5 +1,3 @@ - -### WARNING -- this file was generated by generate-workflows name: pr-preview on: pull_request_target jobs: @@ -23,20 +21,25 @@ jobs: git rev-parse origin/main..HEAD git log origin/main..HEAD --format="%b" - # Yes, we really want to checkout the PR - # Injected by generate-workflows.js - uses: actions/setup-node@v4 with: node-version: '20' + - name: Enable Corepack + shell: bash + run: corepack enable - uses: actions/cache@v4 - id: npm-cache - name: Load npm deps from cache + id: yarn-cache + name: Load Yarn cache with: - path: '**/node_modules' - key: ${{ runner.os }}-npm-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('package-lock.json') }} - - run: npm install --frozen-lockfile --legacy-peer-deps - if: steps.yarn-cache.outputs.cache-hit != 'true' - - run: npm run build + path: | + .yarn/cache + key: ${{ runner.os }}-yarn-cache-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-cache- + - name: Install dependencies + shell: bash + run: yarn install --immutable --network-timeout 100000 + - run: yarn build name: Build component groups - uses: actions/cache@v4 id: docs-cache @@ -44,15 +47,13 @@ jobs: with: path: '.cache' key: ${{ runner.os }}-v4-${{ hashFiles('yarn.lock') }} - - run: npm run build:docs + - run: yarn build:docs name: Build docs - - run: node .github/upload-preview.js packages/module/public - name: Upload docs - if: always() - - run: npx puppeteer browsers install chrome - name: Install Chrome for Puppeteer - - run: npm run serve:docs & npm run test:a11y - name: a11y tests - - run: node .github/upload-preview.js packages/module/coverage - name: Upload a11y report - if: always() + - name: Deploy preview to surge + if: env.SURGE_LOGIN != '' && env.SURGE_TOKEN != '' + run: | + npx surge packages/module/public --domain pr-${{ github.event.number }}-widgetized-dashboard.surge.sh + - name: Install Chrome for Puppeteer + run: npx puppeteer browsers install chrome + - name: a11y tests + run: yarn serve:docs & yarn test:a11y diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9498a2..7844ecd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,23 +22,22 @@ jobs: run: corepack enable - uses: actions/cache@v4 id: yarn-cache - name: Cache npm deps + name: Load Yarn cache with: path: | - node_modules - **/node_modules - ~/.cache/Cypress - ~/.cache/puppeteer - key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} - - run: yarn install --immutable - if: steps.yarn-cache.outputs.cache-hit != 'true' + .yarn/cache + key: ${{ runner.os }}-yarn-cache-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-cache- + - name: Install dependencies + shell: bash + run: yarn install --immutable --network-timeout 100000 - uses: actions/cache@v4 id: dist name: Cache dist with: path: | packages/*/dist - packages/react-styles/css key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} - name: Build dist run: yarn build diff --git a/README.md b/README.md index 5572d1d..e425093 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,23 @@ This library requires React 18+ and React DOM 18+ as peer dependencies. Make sur yarn add react@^18 react-dom@^18 ``` +## Styles + +Import the required stylesheet in your application entry point: + +```ts +import '@patternfly/widgetized-dashboard/dist/esm/styles.css'; +``` + +This stylesheet is required for the dashboard layout, drag-and-drop, and widget tile styling. You will also need PatternFly's base styles — see the [PatternFly getting started guide](https://www.patternfly.org/get-started/develop/) for details. + ## Quick Start ```tsx import React from 'react'; import { WidgetLayout, WidgetMapping, ExtendedTemplateConfig } from '@patternfly/widgetized-dashboard'; -import { CubeIcon } from '@patternfly/react-icons'; +import '@patternfly/widgetized-dashboard/dist/esm/styles.css'; +import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon'; // Define your widgets const widgetMapping: WidgetMapping = { @@ -86,7 +97,7 @@ function App() { - [Basic Example](./packages/module/patternfly-docs/content/examples/BasicExample.tsx) - Complete dashboard with drawer - [Locked Layout Example](./packages/module/patternfly-docs/content/examples/LockedLayoutExample.tsx) - Dashboard with locked widgets -- [Without Drawer Example](./packages/module/patternfly-docs/content/examples/WithoutDrawerExample.tsx) - Grid layout without widget drawer +- [Custom Toolbar Example](./packages/module/patternfly-docs/content/examples/CustomToolbarExample.tsx) - Dashboard with custom toolbar controls ## Key Components diff --git a/babel.config.js b/babel.config.js index 0fdfb56..9938542 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,7 +2,6 @@ module.exports = { presets: [ ['@babel/preset-env', { targets: { node: 'current' } }], ['@babel/preset-react', { runtime: 'automatic' }], - '@babel/preset-flow', '@babel/preset-typescript' ] }; \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 708e296..2082387 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,6 +10,9 @@ module.exports = { transform: { '^.+\\.[jt]sx?$': 'babel-jest' }, + transformIgnorePatterns: [ + 'node_modules/(?!@patternfly)' + ], moduleNameMapper: { '\\.(css|less)$': '/styleMock.js' }, diff --git a/packages/module/package.json b/packages/module/package.json index 185e56c..038acc9 100644 --- a/packages/module/package.json +++ b/packages/module/package.json @@ -5,8 +5,8 @@ "main": "dist/esm/index.js", "module": "dist/esm/index.js", "scripts": { - "build": "tsc --build --verbose ./tsconfig.json", - "build:watch": "tsc --build --verbose --watch ./tsconfig.json", + "build": "tsc --build --verbose ./tsconfig.json && cp src/styles.css dist/esm/styles.css", + "build:watch": "tsc --build --verbose --watch ./tsconfig.json & nodemon --watch src/styles.css --exec 'cp src/styles.css dist/esm/styles.css'", "build:fed:packages": "node generate-fed-package-json.js", "clean": "rimraf dist", "docs:develop": "pf-docs-framework start", @@ -33,11 +33,11 @@ "@patternfly/react-core": "^6.3.1", "@patternfly/react-icons": "^6.3.1", "clsx": "^2.1.0", - "react-grid-layout": "^1.5.1" + "react-grid-layout": "^2.2.2" }, "peerDependencies": { - "react": "^18", - "react-dom": "^18" + "react": "^18 || ^19", + "react-dom": "^18 || ^19" }, "devDependencies": { "@babel/plugin-proposal-class-properties": "^7.18.6", @@ -45,11 +45,10 @@ "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@patternfly/documentation-framework": "^6.24.2", - "@patternfly/patternfly": "^6.3.1", + "@patternfly/patternfly": "^6.4.0", "@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", diff --git a/packages/module/patternfly-docs/content/examples/BasicExample.tsx b/packages/module/patternfly-docs/content/examples/BasicExample.tsx index dec63a1..a0b3022 100644 --- a/packages/module/patternfly-docs/content/examples/BasicExample.tsx +++ b/packages/module/patternfly-docs/content/examples/BasicExample.tsx @@ -1,129 +1,158 @@ 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'; +import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon'; +import ChartLineIcon from '@patternfly/react-icons/dist/esm/icons/chart-line-icon'; +import BellIcon from '@patternfly/react-icons/dist/esm/icons/bell-icon'; +import ExternalLinkAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon'; +import ArrowRightIcon from '@patternfly/react-icons/dist/esm/icons/arrow-right-icon'; +import { + Card, + CardBody, + CardFooter, + Content, + ContentVariants, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Icon, + List, + ListItem, +} from '@patternfly/react-core'; -interface SimpleWidgetProps { - id: number; - body: string; - linkTitle: string; - url: string; - isExternal?: boolean; -} - -const CardExample: React.FunctionComponent = (props) => ( - - - - - {props.body} - - +// A simple overview widget with key stats +const OverviewWidget = () => ( + + + + + Clusters + 12 + + + Running + 10 + + + Degraded + 1 + + + Stopped + 1 + + - - {props.isExternal ? ( - - {props.linkTitle} - - - - - ) : ( - - {props.linkTitle} - - - - - )} + + + View all clusters + + + + ); -// Example widget content components -const ExampleWidget1 = () => ( - -); - -const ExampleWidget2 = () => ( - +// A widget showing recent activity +const ActivityWidget = () => ( + + + Recent deployments and changes across your infrastructure. + + Production deploy completed — 2 min ago + Config update applied — 15 min ago + New node added to cluster-03 — 1 hr ago + Certificate renewed — 3 hr ago + Scaling policy triggered — 5 hr ago + + + + + View full report + + + + + + ); -const ExampleWidget3 = () => ( - +// A notifications widget +const NotificationsWidget = () => ( + + + + 3 alerts require attention + Maintenance window scheduled for Saturday + 2 patches available + + + + + View all notifications + + + + + + ); // Define widget mapping const widgetMapping: WidgetMapping = { - 'example-widget-1': { + 'overview-widget': { defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, config: { - title: 'Example Widget', + title: 'Cluster Overview', icon: , headerLink: { title: 'View details', href: '#' } }, - renderWidget: () => + renderWidget: () => }, - 'chart-widget': { + 'activity-widget': { defaults: { w: 2, h: 4, maxH: 8, minH: 3 }, config: { - title: 'Chart Widget', - icon: + title: 'Recent Activity', + icon: , + headerLink: { + title: 'View full report', + href: '#' + } }, - renderWidget: () => + renderWidget: () => }, 'notifications-widget': { defaults: { w: 1, h: 3, maxH: 6, minH: 2 }, config: { - title: 'Notification Widget', + title: 'Notifications', icon: }, - renderWidget: () => + 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' } + { i: 'overview-widget#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'overview-widget', title: 'Cluster Overview' }, + { i: 'activity-widget#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'activity-widget', title: 'Recent Activity' }, ], 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' } + { i: 'overview-widget#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'overview-widget', title: 'Cluster Overview' }, + { i: 'activity-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'activity-widget', title: 'Recent Activity' }, ], 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' } + { i: 'overview-widget#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'overview-widget', title: 'Cluster Overview' }, + { i: 'activity-widget#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'activity-widget', title: 'Recent Activity' }, ], 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' } + { i: 'overview-widget#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'overview-widget', title: 'Cluster Overview' }, + { i: 'activity-widget#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'activity-widget', title: 'Recent Activity' }, ] }; @@ -131,7 +160,7 @@ export const BasicExample: React.FunctionComponent = () => { const [template, setTemplate] = React.useState(initialTemplate); return ( -
+
( + + + + + Clusters + 12 + + + Running + 10 + + + Degraded + 1 + + + Stopped + 1 + + + + + + View all clusters + + + + +); + +const RecentActivityWidget = () => ( + + + Recent deployments and changes across your infrastructure. + + Production deploy completed — 2 min ago + Config update applied — 15 min ago + New node added to cluster-03 — 1 hr ago + Certificate renewed — 3 hr ago + + + + + View full report + + + + +); + +const NotificationsWidget = () => ( + + + + 3 alerts require attention + Maintenance window scheduled for Saturday + 2 patches available + + + + + View all notifications + + + + +); + +const ComplianceWidget = () => ( + + + + + Compliant + 87% + + + Policies + 24 active + + + Violations + 3 open + + + + + + View compliance report + + + + +); + +// Widget mapping with four widgets +const widgetMapping: WidgetMapping = { + 'cluster-overview': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Cluster Overview', + icon: , + headerLink: { title: 'View details', href: '#' }, + }, + renderWidget: () => , + }, + 'recent-activity': { + defaults: { w: 2, h: 4, maxH: 8, minH: 3 }, + config: { + title: 'Recent Activity', + icon: , + headerLink: { title: 'View full report', href: '#' }, + }, + renderWidget: () => , + }, + 'notifications': { + defaults: { w: 1, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Notifications', + icon: , + }, + renderWidget: () => , + }, + 'compliance': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Compliance', + icon: , + headerLink: { title: 'View report', href: '#' }, + }, + renderWidget: () => , + }, +}; + +// Initial template — only 2 of 4 widgets placed, leaving 2 for the drawer +const initialTemplate: ExtendedTemplateConfig = { + xl: [ + { i: 'cluster-overview#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'cluster-overview', title: 'Cluster Overview' }, + { i: 'recent-activity#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'recent-activity', title: 'Recent Activity' }, + ], + lg: [ + { i: 'cluster-overview#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'cluster-overview', title: 'Cluster Overview' }, + { i: 'recent-activity#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'recent-activity', title: 'Recent Activity' }, + ], + md: [ + { i: 'cluster-overview#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'cluster-overview', title: 'Cluster Overview' }, + { i: 'recent-activity#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'recent-activity', title: 'Recent Activity' }, + ], + sm: [ + { i: 'cluster-overview#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'cluster-overview', title: 'Cluster Overview' }, + { i: 'recent-activity#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'recent-activity', title: 'Recent Activity' }, + ], +}; + +export const CustomToolbarExample: React.FunctionComponent = () => { + const [template, setTemplate] = useState(initialTemplate); + const [drawerOpen, setDrawerOpen] = useState(false); + const [isLocked, setIsLocked] = useState(false); + const [currentlyUsedWidgets, setCurrentlyUsedWidgets] = useState([]); + + const handleReset = () => { + setTemplate(initialTemplate); + setDrawerOpen(false); + }; + + return ( +
+ + + + + +
+ ); +}; diff --git a/packages/module/patternfly-docs/content/examples/LockedLayoutExample.tsx b/packages/module/patternfly-docs/content/examples/LockedLayoutExample.tsx index 1df063d..600a7ea 100644 --- a/packages/module/patternfly-docs/content/examples/LockedLayoutExample.tsx +++ b/packages/module/patternfly-docs/content/examples/LockedLayoutExample.tsx @@ -1,116 +1,125 @@ 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'; +import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon'; +import ChartLineIcon from '@patternfly/react-icons/dist/esm/icons/chart-line-icon'; +import ArrowRightIcon from '@patternfly/react-icons/dist/esm/icons/arrow-right-icon'; +import { + Card, + CardBody, + CardFooter, + Content, + ContentVariants, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Icon, + List, + ListItem, +} from '@patternfly/react-core'; -interface SimpleWidgetProps { - id: number; - body: string; - linkTitle: string; - url: string; - isExternal?: boolean; -} - -const CardExample: React.FunctionComponent = (props) => ( - - - - - {props.body} - - +const ClusterOverviewWidget = () => ( + + + + + Clusters + 12 + + + Running + 10 + + + Degraded + 1 + + + Stopped + 1 + + - - {props.isExternal ? ( - - {props.linkTitle} - - - - - ) : ( - - {props.linkTitle} - - - - - )} + + + View all clusters + + ); -// Example widget content components -const ExampleWidget1 = () => ( - -); - -const ExampleWidget2 = () => ( - +const RecentActivityWidget = () => ( + + + Recent deployments and changes across your infrastructure. + + Production deploy completed — 2 min ago + Config update applied — 15 min ago + New node added to cluster-03 — 1 hr ago + Certificate renewed — 3 hr ago + + + + + View full report + + + + ); // Define widget mapping const widgetMapping: WidgetMapping = { - 'example-widget-1': { + 'cluster-overview': { defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, config: { - title: 'Example Widget', + title: 'Cluster Overview', icon: , headerLink: { title: 'View details', href: '#' } }, - renderWidget: () => + renderWidget: () => }, - 'chart-widget': { + 'recent-activity': { defaults: { w: 2, h: 4, maxH: 8, minH: 3 }, config: { - title: 'Chart Widget', - icon: + title: 'Recent Activity', + icon: , + headerLink: { + title: 'View full report', + href: '#' + } }, - renderWidget: () => + 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 } + { i: 'cluster-overview#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'cluster-overview', title: 'Cluster Overview', static: true }, + { i: 'recent-activity#1', x: 2, y: 0, w: 2, h: 4, widgetType: 'recent-activity', title: 'Recent Activity', 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 } + { i: 'cluster-overview#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'cluster-overview', title: 'Cluster Overview', static: true }, + { i: 'recent-activity#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'recent-activity', title: 'Recent Activity', 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 } + { i: 'cluster-overview#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'cluster-overview', title: 'Cluster Overview', static: true }, + { i: 'recent-activity#1', x: 0, y: 3, w: 2, h: 4, widgetType: 'recent-activity', title: 'Recent Activity', 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 } + { i: 'cluster-overview#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'cluster-overview', title: 'Cluster Overview', static: true }, + { i: 'recent-activity#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'recent-activity', title: 'Recent Activity', 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 deleted file mode 100644 index a6d96b4..0000000 --- a/packages/module/patternfly-docs/content/examples/WithoutDrawerExample.tsx +++ /dev/null @@ -1,142 +0,0 @@ -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 75eaf5b..8673bb6 100644 --- a/packages/module/patternfly-docs/content/examples/basic.md +++ b/packages/module/patternfly-docs/content/examples/basic.md @@ -9,15 +9,42 @@ 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: ['WidgetLayout', 'GridLayout', 'WidgetDrawer'] +propComponents: ['WidgetLayout', 'GridLayout', 'WidgetDrawer', 'AddWidgetsButton'] 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'; +import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon'; +import ChartLineIcon from '@patternfly/react-icons/dist/esm/icons/chart-line-icon'; +import BellIcon from '@patternfly/react-icons/dist/esm/icons/bell-icon'; +import ShieldAltIcon from '@patternfly/react-icons/dist/esm/icons/shield-alt-icon'; +import LockIcon from '@patternfly/react-icons/dist/esm/icons/lock-icon'; +import LockOpenIcon from '@patternfly/react-icons/dist/esm/icons/lock-open-icon'; +import UndoIcon from '@patternfly/react-icons/dist/esm/icons/undo-icon'; +import ArrowRightIcon from '@patternfly/react-icons/dist/esm/icons/arrow-right-icon'; +import ExternalLinkAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon'; +import { + Button, + Card, + CardBody, + CardFooter, + Content, + ContentVariants, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Icon, + List, + ListItem, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + Tooltip, +} from '@patternfly/react-core'; +import { WidgetLayout, GridLayout, WidgetDrawer, AddWidgetsButton } from '@patternfly/widgetized-dashboard'; ### Basic usage @@ -37,11 +64,11 @@ Use `isLayoutLocked` to prevent users from modifying the layout. ``` -### Without drawer +### Custom toolbar -You can hide the widget drawer by setting `showDrawer={false}`. +Use `GridLayout`, `WidgetDrawer`, and `AddWidgetsButton` directly to build a custom toolbar with lock/unlock, reset, and other controls. -```js file="./WithoutDrawerExample.tsx" +```js file="./CustomToolbarExample.tsx" isFullscreen ``` @@ -63,13 +90,30 @@ const widgetMapping: WidgetMapping = { defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, config: { title: 'My Widget', - icon: + icon: , + headerLink: { + title: 'View details', + href: '/details' + } }, renderWidget: (id) => } }; ``` +### Widget configuration options + +| Property | Type | Description | +|----------|------|-------------| +| `defaults.w` | `number` | Default width in grid columns | +| `defaults.h` | `number` | Default height in grid rows | +| `defaults.maxH` | `number` | Maximum height the widget can be resized to | +| `defaults.minH` | `number` | Minimum height the widget can be resized to | +| `config.title` | `string` | Widget title displayed in the header | +| `config.icon` | `ReactNode` | Icon displayed next to the title | +| `config.headerLink` | `{ title: string, href: string }` | Optional link displayed in the widget header | +| `renderWidget` | `(id: string) => ReactNode` | Function that renders the widget content | + ## Template configuration Define your initial layout using the `ExtendedTemplateConfig` type: @@ -86,3 +130,29 @@ const initialTemplate: ExtendedTemplateConfig = { ``` Each breakpoint (xl, lg, md, sm) should have its own layout configuration to ensure proper responsive behavior. + +### Layout item properties + +#### Required properties + +| Property | Type | Description | +|----------|------|-------------| +| `i` | `string` | Unique identifier in format `widgetType#uuid` (e.g., `'my-widget#1'`) | +| `x` | `number` | X position in grid columns (0-indexed from left) | +| `y` | `number` | Y position in grid rows (0-indexed from top) | +| `w` | `number` | Width in grid columns | +| `h` | `number` | Height in grid rows | +| `widgetType` | `string` | Must match a key in `widgetMapping` | +| `title` | `string` | Display title for this widget instance | + +#### Optional properties + +| Property | Type | Description | +|----------|------|-------------| +| `minW` | `number` | Minimum width during resize | +| `maxW` | `number` | Maximum width during resize | +| `minH` | `number` | Minimum height during resize | +| `maxH` | `number` | Maximum height during resize | +| `static` | `boolean` | If `true`, widget cannot be moved or resized | +| `locked` | `boolean` | If `true`, widget is locked in place | +| `config` | `WidgetConfiguration` | Override the widget's default config for this instance | diff --git a/packages/module/patternfly-docs/patternfly-docs.css.js b/packages/module/patternfly-docs/patternfly-docs.css.js index 240ac3b..c715f81 100644 --- a/packages/module/patternfly-docs/patternfly-docs.css.js +++ b/packages/module/patternfly-docs/patternfly-docs.css.js @@ -6,3 +6,4 @@ import '@patternfly/patternfly/patternfly-addons.css'; import '@patternfly/documentation-framework/global.css'; // Add your extension CSS below +import '../src/styles.css'; diff --git a/packages/module/src/WidgetLayout/AddWidgetsButton.tsx b/packages/module/src/WidgetLayout/AddWidgetsButton.tsx new file mode 100644 index 0000000..c10f4dc --- /dev/null +++ b/packages/module/src/WidgetLayout/AddWidgetsButton.tsx @@ -0,0 +1,27 @@ +import { Button, ButtonProps } from '@patternfly/react-core'; +import PlusCircleIcon from '@patternfly/react-icons/dist/esm/icons/plus-circle-icon'; +import React from 'react'; + +export interface AddWidgetsButtonProps extends Omit { + /** Callback when the button is clicked */ + onClick: () => void; +} + +const AddWidgetsButton: React.FunctionComponent = ({ + onClick, + children = 'Add widgets', + variant = 'secondary', + icon = , + ...rest +}) => ( + +); + +export default AddWidgetsButton; diff --git a/packages/module/src/WidgetLayout/GridLayout.tsx b/packages/module/src/WidgetLayout/GridLayout.tsx index d8991b4..a78d802 100644 --- a/packages/module/src/WidgetLayout/GridLayout.tsx +++ b/packages/module/src/WidgetLayout/GridLayout.tsx @@ -1,8 +1,7 @@ import 'react-grid-layout/css/styles.css'; -import './styles'; -import ReactGridLayout, { Layout, ReactGridLayoutProps } from 'react-grid-layout'; +import ReactGridLayout, { useContainerWidth, LayoutItem } from 'react-grid-layout'; import GridTile, { SetWidgetAttribute } from './GridTile'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { isWidgetType } from './utils'; import React from 'react'; import { @@ -14,25 +13,26 @@ import { WidgetConfiguration, } from './types'; import { Button, EmptyState, EmptyStateActions, EmptyStateBody, EmptyStateVariant, PageSection } from '@patternfly/react-core'; -import { ExternalLinkAltIcon, GripVerticalIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import ExternalLinkAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon'; +import GripVerticalIcon from '@patternfly/react-icons/dist/esm/icons/grip-vertical-icon'; +import PlusCircleIcon from '@patternfly/react-icons/dist/esm/icons/plus-circle-icon'; import { columns, breakpoints, droppingElemId, getWidgetIdentifier, extendLayout, getGridDimensions } from './utils'; export const defaultBreakpoints = breakpoints; const createSerializableConfig = (config?: WidgetConfiguration) => { - if (!config) {return undefined;} + if (!config) { return undefined; } return { ...(config.title && { title: config.title }), ...(config.headerLink && { headerLink: config.headerLink }) }; }; -// 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 +const getResizeHandle = (resizeHandleAxis: string, ref: React.Ref) => ( +
} className={`react-resizable-handle react-resizable-handle-${resizeHandleAxis}`}> +
); @@ -57,6 +57,8 @@ export interface GridLayoutProps { onDrawerExpandChange?: (expanded: boolean) => void; /** Currently active widgets (for tracking) */ onActiveWidgetsChange?: (widgetTypes: string[]) => void; + /** Widget type currently being dragged from drawer */ + droppingWidgetType?: string; } const LayoutEmptyState = ({ @@ -71,15 +73,15 @@ const LayoutEmptyState = ({ }, [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 && ( - @@ -100,14 +102,15 @@ const GridLayout = ({ showEmptyState = true, onDrawerExpandChange, onActiveWidgetsChange, + droppingWidgetType, }: 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(); + // Use v2 hook for container width measurement + const { width: layoutWidth, containerRef, mounted } = useContainerWidth(); + const [internalTemplate, setInternalTemplate] = useState(template); // Sync external template changes to internal state @@ -115,20 +118,19 @@ const GridLayout = ({ setInternalTemplate(template); }, [template]); - const droppingItemTemplate: ReactGridLayoutProps['droppingItem'] = useMemo(() => { - if (currentDropInItem && isWidgetType(widgetMapping, currentDropInItem)) { - const widget = widgetMapping[currentDropInItem]; + const droppingItemTemplate = useMemo(() => { + if (droppingWidgetType && isWidgetType(widgetMapping, droppingWidgetType)) { + const widget = widgetMapping[droppingWidgetType]; if (!widget) {return undefined;} return { ...widget.defaults, i: droppingElemId, - widgetType: currentDropInItem, - title: 'New title', - config: createSerializableConfig(widget.config) + x: 0, + y: 0, }; } return undefined; - }, [currentDropInItem, widgetMapping]); + }, [droppingWidgetType, widgetMapping]); const setWidgetAttribute: SetWidgetAttribute = (id, attributeName, value) => { const newTemplate = Object.entries(internalTemplate).reduce( @@ -154,10 +156,11 @@ const GridLayout = ({ onTemplateChange?.(newTemplate); }; - const onDrop: ReactGridLayoutProps['onDrop'] = (_layout: ExtendedLayoutItem[], layoutItem: ExtendedLayoutItem, event: DragEvent) => { - const data = event.dataTransfer?.getData('text') || ''; + const onDrop = (_layout: readonly LayoutItem[], layoutItem: LayoutItem | undefined, event: Event) => { + if (!layoutItem) { return; } + const dragEvent = event as DragEvent; + const data = dragEvent.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]) => { @@ -188,98 +191,98 @@ const GridLayout = ({ ), }; }, {} as ExtendedTemplateConfig); - + setInternalTemplate(newTemplate); onTemplateChange?.(newTemplate); analytics?.('widget-layout.widget-add', { data }); } - event.preventDefault(); + dragEvent.preventDefault(); }; - const onLayoutChange = (currentLayout: Layout[]) => { + const onLayoutChange = (currentLayout: readonly LayoutItem[]) => { if (isInitialRender) { setIsInitialRender(false); const activeWidgets = activeLayout.map((item) => item.widgetType); onActiveWidgetsChange?.(activeWidgets); return; } - if (isLayoutLocked || currentDropInItem) { + if (isLayoutLocked || droppingWidgetType) { return; } - const newTemplate = extendLayout({ ...internalTemplate, [layoutVariant]: currentLayout }); + // Create mutable copy of readonly layout for extendLayout + const newTemplate = extendLayout({ ...internalTemplate, [layoutVariant]: [...currentLayout] }); const activeWidgets = activeLayout.map((item) => item.widgetType); onActiveWidgetsChange?.(activeWidgets); - + setInternalTemplate(newTemplate); onTemplateChange?.(newTemplate); }; + // Update layout variant when container width changes 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); + if (mounted && layoutWidth > 0) { + const variant: Variants = getGridDimensions(layoutWidth); setLayoutVariant(variant); - setLayoutWidth(currentWidth); - }); - - if (layoutRef.current) { - observer.observe(layoutRef.current); } - - return () => { - observer.disconnect(); - }; - }, []); + }, [layoutWidth, mounted]); const activeLayout = internalTemplate[layoutVariant] || []; - + + // Use default width before mount, actual width after + const effectiveWidth = mounted && layoutWidth > 0 ? layoutWidth : 1200; + return ( -
- {activeLayout.length === 0 && !currentDropInItem && showEmptyState && ( +
+ {activeLayout.length === 0 && !droppingWidgetType && showEmptyState && ( emptyStateComponent || )} - setCurrentDropInItem(undefined)} - useCSSTransforms - verticalCompact + onDragStart={() => {}} onLayoutChange={onLayoutChange} > {activeLayout - .map((layoutItem, index) => { + .map((layoutItem) => { const { widgetType } = layoutItem; const widget = widgetMapping[widgetType]; if (!widget) { return null; } - const config = widgetMapping[widgetType]?.config; + const config = widget.config; return ( -
+
layoutItem !== null)} - + }
); }; export default GridLayout; - diff --git a/packages/module/src/WidgetLayout/GridTile.tsx b/packages/module/src/WidgetLayout/GridTile.tsx index 54866c1..99a35d5 100644 --- a/packages/module/src/WidgetLayout/GridTile.tsx +++ b/packages/module/src/WidgetLayout/GridTile.tsx @@ -4,24 +4,27 @@ import { 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 CompressIcon from '@patternfly/react-icons/dist/esm/icons/compress-icon'; +import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; +import ExpandIcon from '@patternfly/react-icons/dist/esm/icons/expand-icon'; +import GripVerticalIcon from '@patternfly/react-icons/dist/esm/icons/grip-vertical-icon'; +import LockIcon from '@patternfly/react-icons/dist/esm/icons/lock-icon'; +import MinusCircleIcon from '@patternfly/react-icons/dist/esm/icons/minus-circle-icon'; +import UnlockIcon from '@patternfly/react-icons/dist/esm/icons/unlock-icon'; import React, { useMemo, useState } from 'react'; import clsx from 'clsx'; -import { Layout } from 'react-grid-layout'; +import type { LayoutItem } from 'react-grid-layout'; import { ExtendedLayoutItem, WidgetConfiguration, AnalyticsTracker } from './types'; export type SetWidgetAttribute = (id: string, attributeName: keyof ExtendedLayoutItem, value: T) => void; @@ -32,7 +35,7 @@ export type GridTileProps = React.PropsWithChildren<{ setIsDragging: (isDragging: boolean) => void; isDragging: boolean; setWidgetAttribute: SetWidgetAttribute; - widgetConfig: Layout & { + widgetConfig: LayoutItem & { colWidth: number; locked?: boolean; config?: WidgetConfiguration; @@ -87,7 +90,7 @@ const GridTile = ({ {widgetConfig.static ? 'Unlock location and size' : 'Lock location and size'} { setWidgetAttribute(widgetConfig.i, 'h', widgetConfig.maxH ?? widgetConfig.h); @@ -95,7 +98,7 @@ const GridTile = ({ }} icon={} > - Autosize height to content + Maximize height + } isDisabled={widgetConfig.static} + description="All 'removed' widgets can be added back by clicking the 'Add widgets' button." > Remove - - - {"All 'removed' widgets can be added back by clicking the 'Add widgets' button."} - - ); @@ -134,8 +133,7 @@ const GridTile = ({ const headerActions = ( <> - Actions

}> - ) => ( setIsOpen((prev) => !prev)} variant="plain" - aria-label="widget actions menu toggle" + aria-label="Widget actions" > @@ -158,19 +157,18 @@ const GridTile = ({ > {dropdownItems} -
- {widgetConfig.static ? 'Widget locked' : 'Move'}

}> + {widgetConfig.static ? 'Widget locked' : 'Move'}

}> { setIsDragging(true); analytics?.('widget-layout.widget-move', { widgetType }); }} onMouseUp={() => setIsDragging(false)} - className={clsx('drag-handle', { - dragging: isDragging, + className={clsx({ + 'pf-v6-widget-drag-handle--dragging': isDragging, })} > - +
@@ -180,25 +178,24 @@ const GridTile = ({ - + - + {widgetConfig?.config?.icon && ( -
+ {isLoaded ? widgetConfig.config.icon : } -
+ )} - + {isLoaded ? ( {widgetConfig?.config?.title || widgetType} @@ -208,11 +205,12 @@ const GridTile = ({ {hasHeader && isLoaded && ( @@ -222,11 +220,9 @@ const GridTile = ({
- - {children} + {children}
); }; export default GridTile; - diff --git a/packages/module/src/WidgetLayout/WidgetDrawer.tsx b/packages/module/src/WidgetLayout/WidgetDrawer.tsx index c97eb03..ace746b 100644 --- a/packages/module/src/WidgetLayout/WidgetDrawer.tsx +++ b/packages/module/src/WidgetLayout/WidgetDrawer.tsx @@ -14,7 +14,8 @@ import { Tooltip, } from '@patternfly/react-core'; import React, { useState } from 'react'; -import { CloseIcon, GripVerticalIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import CloseIcon from '@patternfly/react-icons/dist/esm/icons/close-icon'; +import GripVerticalIcon from '@patternfly/react-icons/dist/esm/icons/grip-vertical-icon'; import { WidgetMapping, WidgetConfiguration } from './types'; export type WidgetDrawerProps = React.PropsWithChildren<{ @@ -28,6 +29,10 @@ export type WidgetDrawerProps = React.PropsWithChildren<{ onOpenChange?: (isOpen: boolean) => void; /** Custom instruction text */ instructionText?: string; + /** Callback when widget drag starts from drawer */ + onWidgetDragStart?: (widgetType: string) => void; + /** Callback when widget drag ends */ + onWidgetDragEnd?: () => void; }>; const WidgetWrapper = ({ widgetType, config, onDragStart, onDragEnd }: { @@ -38,12 +43,12 @@ const WidgetWrapper = ({ widgetType, config, onDragStart, onDragEnd }: { }) => { const headerActions = ( Drag to add widget

}> - - + +
); - + return ( { @@ -64,20 +69,20 @@ const WidgetWrapper = ({ widgetType, config, onDragStart, onDragEnd }: { onDragStart(widgetType); }} onDragEnd={onDragEnd} - + unselectable="on" draggable={true} - className="grid-tile" + className="pf-v6-widget-grid-tile" ouiaId={`add-widget-card-${config?.title || widgetType}`} > - - + + {config?.icon && ( -
+ {config.icon} -
+ )} - {config?.title || widgetType} + {config?.title || widgetType}
@@ -91,9 +96,11 @@ const WidgetDrawer = ({ isOpen: controlledIsOpen, onOpenChange, instructionText, + onWidgetDragStart, + onWidgetDragEnd, }: 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; @@ -105,16 +112,16 @@ const WidgetDrawer = ({ const panelContent = ( - + - + <Title headingLevel="h2" size="md" className="pf-v6-widget-drawer__title"> {instructionText || ( <> - {defaultInstructionText.split('icon').map((part, i) => - i === 0 ? part : ( - <React.Fragment key={i}> + {defaultInstructionText.split('icon').map((part, index) => + index === 0 ? part : ( + <React.Fragment key={`icon-${index}`}> <GripVerticalIcon /> {part} </React.Fragment> @@ -127,7 +134,7 @@ const WidgetDrawer = ({ <SplitItem> <Button variant="plain" - className="pf-v6-u-pt-0 pf-v6-u-pr-0" + className="pf-v6-widget-drawer__close-button" onClick={() => { setIsOpen(!isOpen); }} @@ -136,32 +143,23 @@ const WidgetDrawer = ({ /> </SplitItem> </Split> - <Gallery className="widg-l-gallery pf-v6-u-pt-sm pf-v6-u-justify-content-center" hasGutter> - {filteredWidgetMapping.map(([type, widget], i) => ( - <GalleryItem key={i}> + <Gallery className="pf-v6-widget-gallery" hasGutter> + {filteredWidgetMapping.map(([type, widget]) => ( + <GalleryItem key={type}> <WidgetWrapper widgetType={type} config={widget.config} - onDragStart={() => {}} - onDragEnd={() => {}} + onDragStart={(widgetType) => onWidgetDragStart?.(widgetType)} + onDragEnd={() => onWidgetDragEnd?.()} /> </GalleryItem> ))} </Gallery> </PageSection> ); - + return ( <> - <div style={{ position: 'sticky', top: 0, zIndex: 100, display: 'flex', justifyContent: 'flex-end', padding: 'var(--pf-t--global--spacer--md)' }}> - <Button - variant='secondary' - onClick={() => setIsOpen(!isOpen)} - icon={<PlusCircleIcon />} - > - Add widgets - </Button> - </div> {isOpen && <div>{panelContent}</div>} {children} </> @@ -169,4 +167,3 @@ const WidgetDrawer = ({ }; export default WidgetDrawer; - diff --git a/packages/module/src/WidgetLayout/WidgetLayout.tsx b/packages/module/src/WidgetLayout/WidgetLayout.tsx index b2069c3..d2152fd 100644 --- a/packages/module/src/WidgetLayout/WidgetLayout.tsx +++ b/packages/module/src/WidgetLayout/WidgetLayout.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; +import { Toolbar, ToolbarContent, ToolbarItem } from '@patternfly/react-core'; import GridLayout from './GridLayout'; import WidgetDrawer from './WidgetDrawer'; +import AddWidgetsButton from './AddWidgetsButton'; import { ExtendedTemplateConfig, WidgetMapping, AnalyticsTracker } from './types'; export interface WidgetLayoutProps { @@ -44,6 +46,7 @@ const WidgetLayout = ({ const [template, setTemplate] = useState<ExtendedTemplateConfig>(initialTemplate); const [drawerOpen, setDrawerOpen] = useState(initialDrawerOpen); const [currentlyUsedWidgets, setCurrentlyUsedWidgets] = useState<string[]>([]); + const [droppingWidgetType, setDroppingWidgetType] = useState<string | undefined>(); const handleTemplateChange = (newTemplate: ExtendedTemplateConfig) => { setTemplate(newTemplate); @@ -70,6 +73,7 @@ const WidgetLayout = ({ showEmptyState={showEmptyState} onDrawerExpandChange={handleDrawerExpandChange} onActiveWidgetsChange={handleActiveWidgetsChange} + droppingWidgetType={droppingWidgetType} /> ); @@ -78,15 +82,26 @@ const WidgetLayout = ({ } return ( - <WidgetDrawer - widgetMapping={widgetMapping} - currentlyUsedWidgets={currentlyUsedWidgets} - isOpen={drawerOpen} - onOpenChange={setDrawerOpen} - instructionText={drawerInstructionText} - > - {gridLayout} - </WidgetDrawer> + <> + <Toolbar isSticky> + <ToolbarContent> + <ToolbarItem align={{ default: 'alignEnd' }}> + <AddWidgetsButton onClick={() => setDrawerOpen(!drawerOpen)} /> + </ToolbarItem> + </ToolbarContent> + </Toolbar> + <WidgetDrawer + widgetMapping={widgetMapping} + currentlyUsedWidgets={currentlyUsedWidgets} + isOpen={drawerOpen} + onOpenChange={setDrawerOpen} + instructionText={drawerInstructionText} + onWidgetDragStart={setDroppingWidgetType} + onWidgetDragEnd={() => setDroppingWidgetType(undefined)} + > + {gridLayout} + </WidgetDrawer> + </> ); }; diff --git a/packages/module/src/WidgetLayout/__tests__/AddWidgetsButton.test.tsx b/packages/module/src/WidgetLayout/__tests__/AddWidgetsButton.test.tsx new file mode 100644 index 0000000..4a70370 --- /dev/null +++ b/packages/module/src/WidgetLayout/__tests__/AddWidgetsButton.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import AddWidgetsButton from '../AddWidgetsButton'; + +describe('AddWidgetsButton', () => { + it('renders with default text', () => { + render(<AddWidgetsButton onClick={jest.fn()} />); + expect(screen.getByRole('button', { name: /add widgets/i })).toBeInTheDocument(); + }); + + it('renders with custom children', () => { + render(<AddWidgetsButton onClick={jest.fn()}>Custom Label</AddWidgetsButton>); + expect(screen.getByRole('button', { name: /custom label/i })).toBeInTheDocument(); + }); + + it('calls onClick when clicked', async () => { + const handleClick = jest.fn(); + render(<AddWidgetsButton onClick={handleClick} />); + await userEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('can be disabled', () => { + render(<AddWidgetsButton onClick={jest.fn()} isDisabled />); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('passes additional props to the underlying Button', () => { + render(<AddWidgetsButton onClick={jest.fn()} aria-label="Custom aria" />); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Custom aria'); + }); +}); diff --git a/packages/module/src/WidgetLayout/__tests__/GridLayout.test.tsx b/packages/module/src/WidgetLayout/__tests__/GridLayout.test.tsx new file mode 100644 index 0000000..30cdf5c --- /dev/null +++ b/packages/module/src/WidgetLayout/__tests__/GridLayout.test.tsx @@ -0,0 +1,263 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import GridLayout from '../GridLayout'; +import { WidgetMapping, ExtendedTemplateConfig } from '../types'; +import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon'; + +// Mock react-grid-layout v2 — ReactGridLayout can't render in jsdom +// so we replace it with a pass-through that renders children and triggers onLayoutChange +jest.mock('react-grid-layout', () => { + const React = require('react'); + const MockGridLayout = ({ children, onLayoutChange, layout }: { + children: React.ReactNode; + onLayoutChange?: (layout: unknown[]) => void; + layout?: unknown[]; + [key: string]: unknown; + }) => { + const calledRef = React.useRef(false); + React.useEffect(() => { + if (!calledRef.current) { + calledRef.current = true; + onLayoutChange?.(layout || []); + } + }); + return <div data-testid="mock-grid-layout">{children}</div>; + }; + return { + __esModule: true, + default: MockGridLayout, + useContainerWidth: () => ({ + width: 1600, + containerRef: React.createRef(), + mounted: true, + }), + }; +}); + +const widgetMapping: WidgetMapping = { + 'widget-a': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { + title: 'Widget A', + icon: <CubeIcon />, + }, + renderWidget: (id) => <div data-testid={`content-${id}`}>Content A</div>, + }, + 'widget-b': { + defaults: { w: 1, h: 4, maxH: 8, minH: 3 }, + config: { + title: 'Widget B', + }, + renderWidget: (id) => <div data-testid={`content-${id}`}>Content B</div>, + }, +}; + +const template: ExtendedTemplateConfig = { + xl: [ + { i: 'widget-a#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'widget-a', title: 'Widget A' }, + { i: 'widget-b#1', x: 2, y: 0, w: 1, h: 4, widgetType: 'widget-b', title: 'Widget B' }, + ], + lg: [ + { i: 'widget-a#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'widget-a', title: 'Widget A' }, + { i: 'widget-b#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'widget-b', title: 'Widget B' }, + ], + md: [ + { i: 'widget-a#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'widget-a', title: 'Widget A' }, + { i: 'widget-b#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'widget-b', title: 'Widget B' }, + ], + sm: [ + { i: 'widget-a#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'widget-a', title: 'Widget A' }, + { i: 'widget-b#1', x: 0, y: 3, w: 1, h: 4, widgetType: 'widget-b', title: 'Widget B' }, + ], +}; + +const emptyTemplate: ExtendedTemplateConfig = { + xl: [], + lg: [], + md: [], + sm: [], +}; + +describe('GridLayout', () => { + describe('rendering', () => { + it('renders all widgets from the template', () => { + render( + <GridLayout + widgetMapping={widgetMapping} + template={template} + /> + ); + expect(screen.getByText('Widget A')).toBeInTheDocument(); + expect(screen.getByText('Widget B')).toBeInTheDocument(); + }); + + it('renders widget content via renderWidget', () => { + render( + <GridLayout + widgetMapping={widgetMapping} + template={template} + /> + ); + expect(screen.getByTestId('content-widget-a#1')).toBeInTheDocument(); + expect(screen.getByTestId('content-widget-b#1')).toBeInTheDocument(); + }); + + it('skips widgets with unknown widgetType', () => { + const badTemplate: ExtendedTemplateConfig = { + xl: [ + { i: 'unknown#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'nonexistent', title: 'Ghost' }, + { i: 'widget-a#1', x: 2, y: 0, w: 2, h: 3, widgetType: 'widget-a', title: 'Widget A' }, + ], + lg: [], md: [], sm: [], + }; + render( + <GridLayout + widgetMapping={widgetMapping} + template={badTemplate} + /> + ); + expect(screen.queryByText('Ghost')).not.toBeInTheDocument(); + expect(screen.getByText('Widget A')).toBeInTheDocument(); + }); + }); + + describe('empty state', () => { + it('shows empty state when template has no widgets', () => { + render( + <GridLayout + widgetMapping={widgetMapping} + template={emptyTemplate} + /> + ); + expect(screen.getByText(/No dashboard content/i)).toBeInTheDocument(); + }); + + it('hides empty state when showEmptyState is false', () => { + render( + <GridLayout + widgetMapping={widgetMapping} + template={emptyTemplate} + showEmptyState={false} + /> + ); + expect(screen.queryByText(/No dashboard content/i)).not.toBeInTheDocument(); + }); + + it('renders custom empty state component', () => { + render( + <GridLayout + widgetMapping={widgetMapping} + template={emptyTemplate} + emptyStateComponent={<div data-testid="custom-empty">No widgets here</div>} + /> + ); + expect(screen.getByTestId('custom-empty')).toBeInTheDocument(); + expect(screen.queryByText(/No dashboard content/i)).not.toBeInTheDocument(); + }); + + it('renders documentation link in empty state', () => { + render( + <GridLayout + widgetMapping={widgetMapping} + template={emptyTemplate} + documentationLink="https://example.com/docs" + /> + ); + const link = screen.getByText(/Learn more about widget dashboard/i); + expect(link).toBeInTheDocument(); + expect(link.closest('a')).toHaveAttribute('href', 'https://example.com/docs'); + expect(link.closest('a')).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('calls onDrawerExpandChange(true) when empty state renders', () => { + const onDrawerExpandChange = jest.fn(); + render( + <GridLayout + widgetMapping={widgetMapping} + template={emptyTemplate} + onDrawerExpandChange={onDrawerExpandChange} + /> + ); + expect(onDrawerExpandChange).toHaveBeenCalledWith(true); + }); + }); + + describe('locked layout', () => { + it('renders widgets when layout is locked', () => { + render( + <GridLayout + widgetMapping={widgetMapping} + template={template} + isLayoutLocked + /> + ); + expect(screen.getByText('Widget A')).toBeInTheDocument(); + expect(screen.getByText('Widget B')).toBeInTheDocument(); + }); + }); + + describe('callbacks', () => { + it('calls onActiveWidgetsChange on initial render', () => { + const onActiveWidgetsChange = jest.fn(); + render( + <GridLayout + widgetMapping={widgetMapping} + template={template} + onActiveWidgetsChange={onActiveWidgetsChange} + /> + ); + expect(onActiveWidgetsChange).toHaveBeenCalledWith(['widget-a', 'widget-b']); + }); + }); + + describe('droppingWidgetType', () => { + it('hides empty state when a widget is being dropped', () => { + render( + <GridLayout + widgetMapping={widgetMapping} + template={emptyTemplate} + droppingWidgetType="widget-a" + /> + ); + expect(screen.queryByText(/No dashboard content/i)).not.toBeInTheDocument(); + }); + }); + + describe('template sync', () => { + it('updates when template prop changes', () => { + const { rerender } = render( + <GridLayout + widgetMapping={widgetMapping} + template={template} + /> + ); + expect(screen.getByText('Widget A')).toBeInTheDocument(); + expect(screen.getByText('Widget B')).toBeInTheDocument(); + + // Re-render with only widget-a + const singleWidgetTemplate: ExtendedTemplateConfig = { + xl: [ + { i: 'widget-a#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'widget-a', title: 'Widget A' }, + ], + lg: [ + { i: 'widget-a#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'widget-a', title: 'Widget A' }, + ], + md: [ + { i: 'widget-a#1', x: 0, y: 0, w: 2, h: 3, widgetType: 'widget-a', title: 'Widget A' }, + ], + sm: [ + { i: 'widget-a#1', x: 0, y: 0, w: 1, h: 3, widgetType: 'widget-a', title: 'Widget A' }, + ], + }; + rerender( + <GridLayout + widgetMapping={widgetMapping} + template={singleWidgetTemplate} + /> + ); + expect(screen.getByText('Widget A')).toBeInTheDocument(); + expect(screen.queryByText('Widget B')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/module/src/WidgetLayout/__tests__/GridTile.test.tsx b/packages/module/src/WidgetLayout/__tests__/GridTile.test.tsx index 0308b28..772c976 100644 --- a/packages/module/src/WidgetLayout/__tests__/GridTile.test.tsx +++ b/packages/module/src/WidgetLayout/__tests__/GridTile.test.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import GridTile, { GridTileProps } from '../GridTile'; -const defaultProps: GridTileProps = { +const createProps = (overrides?: Partial<GridTileProps>): GridTileProps => ({ widgetType: 'test-widget', isDragging: false, setIsDragging: jest.fn(), @@ -14,12 +15,17 @@ const defaultProps: GridTileProps = { y: 0, w: 2, h: 3, + maxH: 6, + minH: 2, colWidth: 100, }, removeWidget: jest.fn(), children: <div data-testid="widget-content">Widget Content</div>, isLoaded: true, -}; + ...overrides, +}); + +const defaultProps = createProps(); describe('GridTile - wrapperProps and cardBodyProps', () => { describe('wrapperProps', () => { @@ -65,7 +71,7 @@ describe('GridTile - wrapperProps and cardBodyProps', () => { ); const cardWrapper = screen.getByTestId('card-with-custom-class'); - expect(cardWrapper).toHaveClass('grid-tile'); + expect(cardWrapper).toHaveClass('pf-v6-widget-grid-tile'); expect(cardWrapper).toHaveClass('custom-wrapper-class'); }); }); @@ -113,7 +119,7 @@ describe('GridTile - wrapperProps and cardBodyProps', () => { ); const cardBody = screen.getByTestId('body-with-custom-class'); - expect(cardBody).toHaveClass('pf-v6-u-p-0'); + expect(cardBody).toHaveClass('pf-v6-widget-grid-tile__body'); expect(cardBody).toHaveClass('custom-body-class'); }); }); @@ -144,12 +150,179 @@ describe('GridTile - wrapperProps and cardBodyProps', () => { ); const cardWrapper = screen.getByTestId('combined-wrapper'); - expect(cardWrapper).toHaveClass('grid-tile'); + expect(cardWrapper).toHaveClass('pf-v6-widget-grid-tile'); expect(cardWrapper).toHaveClass('custom-wrapper'); const cardBody = screen.getByTestId('combined-body'); - expect(cardBody).toHaveClass('pf-v6-u-p-0'); + expect(cardBody).toHaveClass('pf-v6-widget-grid-tile__body'); expect(cardBody).toHaveClass('custom-body'); }); }); }); + +describe('GridTile - widget actions', () => { + const openActionsMenu = async () => { + const toggle = screen.getByRole('button', { name: /widget actions/i }); + await userEvent.click(toggle); + }; + + it('renders lock action and calls setWidgetAttribute on click', async () => { + const setWidgetAttribute = jest.fn(); + render(<GridTile {...createProps({ setWidgetAttribute })} />); + + await openActionsMenu(); + await userEvent.click(screen.getByText('Lock location and size')); + + expect(setWidgetAttribute).toHaveBeenCalledWith('test-widget-1', 'static', true); + }); + + it('renders unlock action when widget is static', async () => { + const setWidgetAttribute = jest.fn(); + render( + <GridTile + {...createProps({ + setWidgetAttribute, + widgetConfig: { ...createProps().widgetConfig, static: true }, + })} + /> + ); + + await openActionsMenu(); + await userEvent.click(screen.getByText('Unlock location and size')); + + expect(setWidgetAttribute).toHaveBeenCalledWith('test-widget-1', 'static', false); + }); + + it('calls setWidgetAttribute with maxH on maximize', async () => { + const setWidgetAttribute = jest.fn(); + render(<GridTile {...createProps({ setWidgetAttribute })} />); + + await openActionsMenu(); + await userEvent.click(screen.getByText('Maximize height')); + + expect(setWidgetAttribute).toHaveBeenCalledWith('test-widget-1', 'h', 6); + }); + + it('calls setWidgetAttribute with minH on minimize', async () => { + const setWidgetAttribute = jest.fn(); + render(<GridTile {...createProps({ setWidgetAttribute })} />); + + await openActionsMenu(); + await userEvent.click(screen.getByText('Minimize height')); + + expect(setWidgetAttribute).toHaveBeenCalledWith('test-widget-1', 'h', 2); + }); + + it('disables maximize when already at maxH', async () => { + render( + <GridTile + {...createProps({ + widgetConfig: { ...createProps().widgetConfig, h: 6, maxH: 6 }, + })} + /> + ); + + await openActionsMenu(); + const maximizeItem = screen.getByText('Maximize height').closest('button'); + expect(maximizeItem).toBeDisabled(); + }); + + it('disables minimize when already at minH', async () => { + render( + <GridTile + {...createProps({ + widgetConfig: { ...createProps().widgetConfig, h: 2, minH: 2 }, + })} + /> + ); + + await openActionsMenu(); + const minimizeItem = screen.getByText('Minimize height').closest('button'); + expect(minimizeItem).toBeDisabled(); + }); + + it('calls removeWidget on remove action', async () => { + const removeWidget = jest.fn(); + render(<GridTile {...createProps({ removeWidget })} />); + + await openActionsMenu(); + await userEvent.click(screen.getByText('Remove')); + + expect(removeWidget).toHaveBeenCalledWith('test-widget-1'); + }); + + it('disables actions when widget is static', async () => { + render( + <GridTile + {...createProps({ + widgetConfig: { ...createProps().widgetConfig, static: true }, + })} + /> + ); + + await openActionsMenu(); + + const maximizeItem = screen.getByText('Maximize height').closest('button'); + const minimizeItem = screen.getByText('Minimize height').closest('button'); + const removeItem = screen.getByText('Remove').closest('button'); + + expect(maximizeItem).toBeDisabled(); + expect(minimizeItem).toBeDisabled(); + expect(removeItem).toBeDisabled(); + }); +}); + +describe('GridTile - header link', () => { + it('renders header link when configured', () => { + render( + <GridTile + {...createProps({ + widgetConfig: { + ...createProps().widgetConfig, + config: { + title: 'Test Widget', + headerLink: { title: 'View details', href: '#' }, + }, + }, + })} + /> + ); + + expect(screen.getByText('View details')).toBeInTheDocument(); + }); + + it('does not render header link when not configured', () => { + render( + <GridTile + {...createProps({ + widgetConfig: { + ...createProps().widgetConfig, + config: { title: 'Test Widget' }, + }, + })} + /> + ); + + expect(screen.queryByText('View details')).not.toBeInTheDocument(); + }); + + it('renders widget title from config', () => { + render( + <GridTile + {...createProps({ + widgetConfig: { + ...createProps().widgetConfig, + config: { title: 'My Custom Title' }, + }, + })} + /> + ); + + expect(screen.getByText('My Custom Title')).toBeInTheDocument(); + }); + + it('falls back to widgetType when no title configured', () => { + render(<GridTile {...createProps()} />); + expect(screen.getByText('test-widget')).toBeInTheDocument(); + }); +}); diff --git a/packages/module/src/WidgetLayout/__tests__/WidgetDrawer.test.tsx b/packages/module/src/WidgetLayout/__tests__/WidgetDrawer.test.tsx new file mode 100644 index 0000000..f55a09c --- /dev/null +++ b/packages/module/src/WidgetLayout/__tests__/WidgetDrawer.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import WidgetDrawer from '../WidgetDrawer'; +import { WidgetMapping } from '../types'; + +const widgetMapping: WidgetMapping = { + 'widget-a': { + defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, + config: { title: 'Widget A' }, + renderWidget: () => <div>A</div>, + }, + 'widget-b': { + defaults: { w: 1, h: 3, maxH: 6, minH: 2 }, + config: { title: 'Widget B' }, + renderWidget: () => <div>B</div>, + }, + 'widget-c': { + defaults: { w: 2, h: 4, maxH: 8, minH: 3 }, + config: { title: 'Widget C' }, + renderWidget: () => <div>C</div>, + }, +}; + +describe('WidgetDrawer', () => { + it('renders children when closed', () => { + render( + <WidgetDrawer widgetMapping={widgetMapping} isOpen={false}> + <div data-testid="child-content">Dashboard</div> + </WidgetDrawer> + ); + expect(screen.getByTestId('child-content')).toBeInTheDocument(); + }); + + it('does not render drawer panel when closed', () => { + render( + <WidgetDrawer widgetMapping={widgetMapping} isOpen={false}> + <div>Dashboard</div> + </WidgetDrawer> + ); + expect(screen.queryByText('Widget A')).not.toBeInTheDocument(); + }); + + it('renders all widgets in drawer when open with none in use', () => { + render( + <WidgetDrawer widgetMapping={widgetMapping} isOpen={true}> + <div>Dashboard</div> + </WidgetDrawer> + ); + expect(screen.getByText('Widget A')).toBeInTheDocument(); + expect(screen.getByText('Widget B')).toBeInTheDocument(); + expect(screen.getByText('Widget C')).toBeInTheDocument(); + }); + + it('filters out currently used widgets', () => { + render( + <WidgetDrawer + widgetMapping={widgetMapping} + currentlyUsedWidgets={['widget-a', 'widget-c']} + isOpen={true} + > + <div>Dashboard</div> + </WidgetDrawer> + ); + expect(screen.queryByText('Widget A')).not.toBeInTheDocument(); + expect(screen.getByText('Widget B')).toBeInTheDocument(); + expect(screen.queryByText('Widget C')).not.toBeInTheDocument(); + }); + + it('shows no widget cards when all widgets are in use', () => { + render( + <WidgetDrawer + widgetMapping={widgetMapping} + currentlyUsedWidgets={['widget-a', 'widget-b', 'widget-c']} + isOpen={true} + > + <div>Dashboard</div> + </WidgetDrawer> + ); + // Drawer panel is rendered but no widget cards inside + expect(screen.queryByText('Widget A')).not.toBeInTheDocument(); + expect(screen.queryByText('Widget B')).not.toBeInTheDocument(); + expect(screen.queryByText('Widget C')).not.toBeInTheDocument(); + }); + + it('renders custom instruction text', () => { + render( + <WidgetDrawer + widgetMapping={widgetMapping} + isOpen={true} + instructionText="Drag widgets below to add them." + > + <div>Dashboard</div> + </WidgetDrawer> + ); + expect(screen.getByText('Drag widgets below to add them.')).toBeInTheDocument(); + }); + + it('renders default instruction text when none provided', () => { + render( + <WidgetDrawer widgetMapping={widgetMapping} isOpen={true}> + <div>Dashboard</div> + </WidgetDrawer> + ); + expect(screen.getByText(/Add new and previously removed widgets/)).toBeInTheDocument(); + }); +}); diff --git a/packages/module/src/WidgetLayout/__tests__/WidgetLayout.test.tsx b/packages/module/src/WidgetLayout/__tests__/WidgetLayout.test.tsx index 7f925d2..cdf93ab 100644 --- a/packages/module/src/WidgetLayout/__tests__/WidgetLayout.test.tsx +++ b/packages/module/src/WidgetLayout/__tests__/WidgetLayout.test.tsx @@ -3,7 +3,7 @@ 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 CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon'; const mockWidgetMapping: WidgetMapping = { 'test-widget': { @@ -93,7 +93,7 @@ describe('WidgetLayout', () => { expect(screen.getByText('Test Widget')).toBeInTheDocument(); }); - it('accepts onTemplateChange callback', () => { + it('passes onTemplateChange callback to GridLayout', () => { const handleChange = jest.fn(); render( <WidgetLayout @@ -102,9 +102,8 @@ describe('WidgetLayout', () => { onTemplateChange={handleChange} /> ); - // Note: The callback may be called during initial layout setup - // This test just verifies the callback prop is accepted without errors - expect(handleChange).toBeDefined(); + // onTemplateChange is invoked by GridLayout during initial layout setup + expect(handleChange).toHaveBeenCalled(); }); }); diff --git a/packages/module/src/WidgetLayout/styles.ts b/packages/module/src/WidgetLayout/styles.ts deleted file mode 100644 index 1ae4530..0000000 --- a/packages/module/src/WidgetLayout/styles.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * 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: var(--pf-t--global--border--radius--medium); - } - - .react-grid-item .react-resizable-handle::after { - display: none; - } - - .react-grid-item .react-resizable-handle img { - padding: var(--pf-t--global--spacer--xs); - } - - #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: var(--pf-t--global--icon--size--lg); - width: var(--pf-t--global--icon--size--lg); - } - - .grid-tile .widg-c-icon--header .pf-v6-svg { - color: var(--pf-t--color--blue--50); - height: var(--pf-t--global--icon--size--md); - width: var(--pf-t--global--icon--size--md); - margin-bottom: var(--pf-t--global--spacer--sm); - } - - .grid-tile .widg-card-header-text { - gap: 0; - line-height: 0; - padding-top: var(--pf-t--global--spacer--xs); - } - - .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: var(--pf-t--global--spacer--sm); - } -`; - -// 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 index 0e01e84..912d6c3 100644 --- a/packages/module/src/WidgetLayout/types.ts +++ b/packages/module/src/WidgetLayout/types.ts @@ -1,11 +1,11 @@ -import { Layout } from 'react-grid-layout'; +import type { LayoutItem } from 'react-grid-layout'; import { CardProps, CardBodyProps } from '@patternfly/react-core'; export const widgetIdSeparator = '#'; export type Variants = 'sm' | 'md' | 'lg' | 'xl'; -export type LayoutWithTitle = Layout & { title: string }; +export type LayoutWithTitle = LayoutItem & { title: string }; export type TemplateConfig = { [k in Variants]: LayoutWithTitle[]; diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index a0c5e97..5cdec75 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -2,6 +2,7 @@ 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 { default as AddWidgetsButton } from './WidgetLayout/AddWidgetsButton'; export * from './WidgetLayout/types'; export * from './WidgetLayout/utils'; @@ -10,3 +11,4 @@ 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'; +export type { AddWidgetsButtonProps } from './WidgetLayout/AddWidgetsButton'; diff --git a/packages/module/src/styles.css b/packages/module/src/styles.css new file mode 100644 index 0000000..6d82307 --- /dev/null +++ b/packages/module/src/styles.css @@ -0,0 +1,131 @@ +.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; +} + +/* Doubled class for specificity to override react-grid-layout defaults */ +.react-grid-item.react-grid-placeholder.react-grid-placeholder { + background: var(--pf-t--global--color--nonstatus--blue--default); + border-radius: var(--pf-t--global--border--radius--medium); +} + +.react-grid-item .react-resizable-handle::after { + display: none; +} + +.react-grid-item .react-resizable-handle svg { + width: 100%; + height: 100%; + display: block; +} + +.pf-v6-widget-layout-container { + position: relative; + width: 100%; + min-height: 200px; +} + +.pf-v6-widget-grid-tile::before { + z-index: 1; +} + +.pf-v6-widget-grid-tile { + height: 100%; +} + +.pf-v6-widget-grid-tile .pf-v6-widget-drag-handle { + cursor: grab; +} + +.pf-v6-widget-grid-tile .pf-v6-widget-drag-handle.pf-v6-widget-drag-handle--dragging { + cursor: grabbing; +} + +.pf-v6-widget-grid-tile.pf-v6-widget-grid-tile--static .pf-v6-widget-drag-handle, +.pf-v6-widget-grid-tile.pf-v6-widget-grid-tile--static .pf-v6-widget-drag-handle.pf-v6-widget-drag-handle--dragging { + cursor: not-allowed; +} + +.pf-v6-widget-grid-tile .pf-v6-widget-grid-tile__header { + padding-block-start: var(--pf-t--global--spacer--md); + padding-block-end: var(--pf-t--global--spacer--sm); + background: var(--pf-t--global--background--color--secondary--default); +} + +.pf-v6-widget-grid-tile .pf-v6-widget-header-layout { + flex-wrap: nowrap; +} + +.pf-v6-widget-grid-tile .pf-v6-widget-grid-tile__title--dragging { + user-select: none; +} + +.pf-v6-widget-grid-tile .pf-v6-widget-card-header-text { + gap: 0; +} + +.pf-v6-widget-grid-tile .pf-v6-widget-grid-tile__body { + padding: 0; + overflow: auto; + min-height: 0; +} + +.pf-v6-widget-drawer__header { + height: 100%; + padding-block-start: var(--pf-t--global--spacer--md) !important; + padding-block-end: var(--pf-t--global--spacer--md); + background: var(--pf-t--global--background--color--secondary--default); +} + +.pf-v6-widget-drawer__drag-handle { + cursor: grab; +} + +.pf-v6-widget-page__main-section--drawer { + padding: var(--pf-t--global--spacer--md); + background-color: var(--pf-t--global--background--color--secondary--default); +} + +@media (min-width: 576px) { + .pf-v6-widget-page__main-section--drawer { + padding: var(--pf-t--global--spacer--lg); + } +} + +.pf-v6-widget-drawer__title { + padding-block-end: var(--pf-t--global--spacer--sm); +} + +.pf-v6-widget-gallery { + --pf-v6-l-gallery--m-gutter--GridGap: var(--pf-t--global--spacer--sm); + padding-block-start: var(--pf-t--global--spacer--sm); + justify-content: center; +} + +.pf-v6-widget-empty-layout { + padding: 0; +} + +.pf-v6-widget-empty-state { + padding: var(--pf-t--global--spacer--sm); +} diff --git a/packages/module/tsconfig.json b/packages/module/tsconfig.json index 169eb13..cdd105d 100644 --- a/packages/module/tsconfig.json +++ b/packages/module/tsconfig.json @@ -9,7 +9,7 @@ "lib": ["es2015", "dom"], /* Specify library files to be included in the compilation. */ // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ - "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */, + "jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */, "declaration": true /* Generates corresponding '.d.ts' file. */, "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ "sourceMap": true, /* Generates corresponding '.map' file. */ @@ -45,7 +45,7 @@ "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ /* Module Resolution Options */ - "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, + "moduleResolution": "bundler" /* Updated to 'bundler' for react-grid-layout v2 ES module subpath exports */, // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ diff --git a/yarn.lock b/yarn.lock index 107cf51..b401b2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3258,10 +3258,10 @@ __metadata: languageName: node linkType: hard -"@patternfly/patternfly@npm:^6.3.1": - version: 6.3.1 - resolution: "@patternfly/patternfly@npm:6.3.1" - checksum: 10c0/57f77e3f3a5280961c2528a36e16d85483a866cd015b4ce32e856f7d7eef96c3380dbb203f909975a51c1bfac316fc5d51d237715d6d578c9237dd07667fe780 +"@patternfly/patternfly@npm:^6.4.0": + version: 6.4.0 + resolution: "@patternfly/patternfly@npm:6.4.0" + checksum: 10c0/255c22e04a7649c9373790dbc66f97e2f01a121a843d3bc27627596f35296cc7f2228447d5866f63f17bccd36bae24ec68c45e1572778285dea617086d309737 languageName: node linkType: hard @@ -3394,22 +3394,21 @@ __metadata: "@babel/plugin-proposal-private-methods": "npm:^7.18.6" "@babel/plugin-proposal-private-property-in-object": "npm:^7.21.11" "@patternfly/documentation-framework": "npm:^6.24.2" - "@patternfly/patternfly": "npm:^6.3.1" + "@patternfly/patternfly": "npm:^6.4.0" "@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-grid-layout: "npm:^2.2.2" react-monaco-editor: "npm:^0.59.0" rimraf: "npm:^6.0.1" peerDependencies: - react: ^18 - react-dom: ^18 + react: ^18 || ^19 + react-dom: ^18 || ^19 languageName: unknown linkType: soft @@ -3920,24 +3919,6 @@ __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" @@ -13929,9 +13910,9 @@ __metadata: languageName: node linkType: hard -"react-grid-layout@npm:^1.5.1": - version: 1.5.2 - resolution: "react-grid-layout@npm:1.5.2" +"react-grid-layout@npm:^2.2.2": + version: 2.2.2 + resolution: "react-grid-layout@npm:2.2.2" dependencies: clsx: "npm:^2.1.1" fast-equals: "npm:^4.0.3" @@ -13942,7 +13923,7 @@ __metadata: peerDependencies: react: ">= 16.3.0" react-dom: ">= 16.3.0" - checksum: 10c0/b6605d1435fe116c3720d168100a5a08da924c6905686fe8a486c33b82abbde8ccacbb59e5c6243fa52f5e808ad393a7bdf0c09a3446ebf76efe43f29d9f13ee + checksum: 10c0/4b38496948136b3d63689c141bcdfaece5e6ce9d228bffad89f751d3b510f6ab1589609982c6fa340a805bda7e0533b212bdff0185b8a52293e031a2e57ebdb9 languageName: node linkType: hard