diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index abf3919..bc15629 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -44,6 +44,33 @@ jobs: - name: Unit Tests run: npm run test + native_tests: + name: Native browser contracts (Chromium) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + - name: Install Packages + run: npm ci + - name: Install Chromium + run: npx playwright install --with-deps chromium + - name: Native browser contracts + run: npm run test:browser -- --project=chromium + - name: Upload failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: native-browser-failures + path: | + playwright-report/ + test-results/ + retention-days: 7 + # Validate the lower edge of the React peer range. Runs only on # release-please PRs so regular PR CI stays fast; failure here blocks # the release before it can be tagged and published. diff --git a/.gitignore b/.gitignore index 297e589..8a8366d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ node_modules # Generated content build storybook-static + +# Playwright +playwright-report/ +test-results/ diff --git a/.storybook/main.ts b/.storybook/main.ts index e41851a..4ee0d21 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -5,6 +5,13 @@ const config: StorybookConfig = { name: '@storybook/react-webpack5', options: {fastRefresh: true, legacyRootApi: false}, }, + babel: async (options) => ({ + ...options, + plugins: [ + ['@babel/plugin-transform-typescript', {allowDeclareFields: true}], + ...(options.plugins || []), + ], + }), stories: ['../src/**/*.stories.@(ts|tsx)'], docs: { autodocs: false, diff --git a/README.md b/README.md index ccf7fbb..435f2d8 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A dashboard grid rendering library. +The separate [`DashKitGridStack` entry](docs/gridstack.md) provides GridStack layout with the same plugin and parameter APIs, including optional nested containers. Existing root imports keep the legacy layout. + ## Installation ```bash @@ -77,7 +79,7 @@ interface DashKitProps { - **onItemRender**: Called when item render complete - **defaultGlobalParams**, **globalParams**: [Parameters](#Params) that affect all widgets. In DataLens, `defaultGlobalParams` are global parameters set in the dashboard settings. `globalParams` are globals parameters that can be set in the url. - **itemsStateAndParams**: [itemsStateAndParams](#itemsStateAndParams). -- **settings**: DashKit settings. +- **settings**: DashKit settings. Optional `isolateItemUpdates: true` suppresses renderer updates caused only by other widgets' layout changes. Own data, state, params, geometry, settings, context and callbacks still update; the default keeps full-layout renderer updates. - **context**: Object that will be propped up on all widgets. - **overlayControls**: Object that overrides widget controls at the time of editing. If not transmitted, basic controls will be displayed. If `null` passed only close button or custom menu will be displayed. - **overlayMenuItems**: Custom dropdown menu items diff --git a/docs/gridstack.md b/docs/gridstack.md new file mode 100644 index 0000000..0eb3781 --- /dev/null +++ b/docs/gridstack.md @@ -0,0 +1,98 @@ +# GridStack layout + +`DashKitGridStack` uses GridStack 12.6.0 for layout and native dragging and resizing. It shares DashKit's plugin registry, parameter processing, controls, events and readiness callbacks. The existing `DashKit` entry continues to use the legacy layout. + +```tsx +import {DashKitGridStack} from '@gravity-ui/dashkit/gridstack'; +import {DashKitDnDWrapper, pluginText} from '@gravity-ui/dashkit'; + +DashKitGridStack.registerPlugins(pluginText); + + + +; +``` + +The separate entry includes its required styles. Keep importing shared components, built-in plugins and public types from the root, and helpers from `@gravity-ui/dashkit/helpers`. Registration and `setSettings` are shared across both components in one module instance. Do not register the same type twice; `reloadPlugins` replaces a registration. + +## Existing API + +The component accepts the existing DashKit props, including groups, parameters, item state, plugin context, overlays, focus callbacks, copy callbacks and drag/resize callbacks. Its ref supports `on('change', handler)` (returns an unsubscribe function), `getItemsMeta()` (an array of promises) and `reloadItems({targetIds, force})`. Existing registration, settings and item helpers remain available as statics. `reflowLayout` additionally accepts `containers` to resolve child-grid settings and treat sibling containers as fixed collision footprints. It returns widget layout only and leaves descriptors unchanged. New-item priority is independent of canonical saved order. `setItem` rejects insertion into a full horizontal-nowrap row. Direct `reflowLayout` retains the legacy transformation behavior for existing flat inputs, including packing/wrapping an explicitly over-budget layout; interactive admission still enforces the nowrap row budget. + +`config` remains controlled. Replace it to apply undo, redo or an external document; this does not emit a change event. Geometry is reconciled by widget ID, preserving a widget's React instance and ref when it changes zone or container. Reordering `config.items` changes desktop stacking within a grid; mobile `orderId` retains its existing meaning. + +The synchronous `change` event precedes `onChange`. Calling `event.preventDefault()` suppresses `onChange` and still advances the internal complete geometry baseline. A later parameter, copy or delete operation therefore uses the latest layout. Change events can include optional `containers` and `previousContainers`; an existing widget's membership patch can include `containerId`. + +`onItemMountChange` and `onItemRender` describe plugin readiness. A layout event does not mean asynchronous content has finished rendering. Automatic widget height remains a visual adjustment; the original saved height is retained until an explicit resize or reset follows the existing DashKit contract. GridStack does not add lazy mount suppression to React plugins. + +## Grid settings + +`groups` remain runtime zones, with the existing `render` and `gridProperties` functions. The implementation supports vertical, horizontal, horizontal-nowrap and disabled compaction, overlap, collision prevention, size constraints, bounds, resize directions and custom resize handles. The legacy `verticalCompact: false` disables compaction. + +Static `setItem` and `reflowLayout` resolve the same effective grid settings as the mounted layout. A `gridProperties` callback replaces the supplied settings; omitted geometry uses the legacy renderer defaults of 12 columns, row height 150 and margin/padding 10. Return `{...defaults, ...overrides}` to retain the supplied settings instead. + +Widths, margins and container padding use the existing DashKit grid units. For a grid of width `W`, `C` columns, horizontal padding `P` and gap `G`, the column step is `(W - 2P + G) / C`; a widget of width `w` occupies `w * step - G` CSS pixels. Vertical step is `rowHeight + gapY`. + +For pixel reports, use `cols` equal to the unscaled canvas width, `rowHeight: 1`, `margin: [0, 0]`, `compactType: null` and `allowOverlap: true`. Set `maxRows` for the vertical upper bound. A fixed canvas also needs a definite containing height and `autoSize: false`; `maxRows` alone does not make an auto-sized zone tall. Use `transformScale` or the existing mutable `transformScaleRef` for scaled canvases. All configured resize corners operate in unscaled coordinates. + +Ordinary auto-sized zones derive height from their content, gap and padding, including empty zones. They do not gain a minimum number of rows. A fluid grid's inline size is contained so the native grid's trailing gap cannot enlarge an ancestor's intrinsic width. + +## Nested document format + +Flat configurations continue to work without migration. Nesting adds fields without changing widget IDs, data or existing layout coordinates: + +```ts +const nested = { + ...config, + layoutVersion: 2, + containers: [ + { + id: 'panel', + parent: 'slide', + x: 20, + y: 20, + w: 400, + h: 300, + padding: [12, 20, 24, 16], // top, right, bottom, left; CSS pixels + gap: [8, 6], // horizontal and vertical CSS pixels + grid: { + cols: 12, + rowHeight: 18, + compactType: null, + allowOverlap: false, + maxRows: 10, + }, + }, + ], + layout: config.layout.map((item) => + item.i === 'chart' + ? {...item, parent: 'slide', containerId: 'panel', x: 0, y: 0, w: 6, h: 4} + : item, + ), +}; +``` + +`parent` always identifies the root runtime zone. `containerId` identifies the immediate containing grid; x/y/w/h are local to that grid. A container can itself have `containerId`. Container padding has four independent sides and is separate from the child gap. Child grids inherit interaction flags, scale and resize handles from their root zone; their persisted grid settings determine sizing, compaction, overlap and optional row bounds. Zone callbacks are not copied onto every nested grid. Native child admission is bounded by the container’s usable height after padding and gap conversion; an optional `maxRows` can tighten that bound. The inferred bound is never saved, and valid fractional geometry is retained. Invalid native proposals are restored before a document transaction is emitted. Plugin autoheight is visual: nested height and its height constraints are capped to the remaining usable rows without changing the saved height or growing the parent; resetting restores the saved height. + +The component validates duplicate/reserved IDs, geometry, padding, gaps, missing membership, cycles and inconsistent root zones before applying a document. Invalid input throws an explanatory error. The measured geometry and snapshot projection also reject nonpositive usable content width/height or children exceeding their container bounds; padding does not silently enlarge a saved container. Remove or move a container's children explicitly before deleting it; removing a nonempty container does not silently delete its subtree. Pass a nested configuration to `DashKitGridStack`; the legacy component explicitly rejects it. + +Moving between grids preserves the widget instance and converts its visible unscaled size and constraints into the destination's units, with destination-grid quantization. A container subtree can move between grids with its descendants and local coordinates intact; a destination that cannot fit its projected size rejects the drop. Releasing a cross-grid drag outside an accepted destination restores the previous document. Resizing a container respects the extents of its children. Mobile mode traverses the hierarchy and renders each widget once using the existing mobile presentation. + +For snapshots, `getGridStackLayoutRects(config, rootGrids)` returns widget rectangles in unscaled CSS pixels, accumulating container origins and padding. Supply each root zone's width and effective `gridProperties`; use `__default` for the default zone. It does not mount a grid, mutate a configuration or wait for plugin content. + +## Packaging and local checks + +The entry has ESM, CommonJS and TypeScript declarations. GridStack's published native modules use extensionless ESM imports; the CommonJS build includes a transpiled copy of the pinned runtime and its license. Run the library build when changing the runtime or dependency version. Both module formats import CSS and require an application bundler or an appropriate CSS loader for Node rendering. + +The React peer range is unchanged. The new layout uses portals and the existing lifecycle shell; it does not require `createRoot` in consumer applications. Browser support follows modern GridStack requirements, including CSS variables and inline-size containment. ResizeObserver uses the existing polyfill when unavailable. A JavaScript build does not polyfill CSS features. + +The public stories include report, nested, cross-zone, dashboard, preview/overlap and legacy comparison fixtures. Native tests use Storybook's normal bundler and server; Playwright starts it automatically: + +```sh +npx playwright install chromium +npm run test:browser -- --project=chromium +# All installed engines (Chromium, Firefox, WebKit): +npm run test:browser +``` + +Playwright writes traces and failure screenshots to `test-results/` and its HTML report to `playwright-report/` (both ignored). `npm test` runs engine-independent helpers once, the same mounted flat contract for legacy and GridStack, and separate engine-specific suites. Native specs retain gesture, scale, nested containment, cancellation, bounds, overlap and StrictMode checks, plus a common flat gesture contract for both renderers. Only the native CDP touch check is Chromium-specific. diff --git a/gulpfile.js b/gulpfile.js index d4ffb3e..615658d 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -72,6 +72,36 @@ task('compile-to-cjs', () => { return compileTs(); }); +// GridStack publishes extensionless ESM. Transpile its native modules for our CJS entry. +task('gridstack-cjs', (done) => { + const fs = require('fs'); + const ts = require('typescript'); + const source = path.dirname(require.resolve('gridstack')); + const target = path.join(BUILD_DIR, 'cjs', 'gridstack-runtime'); + fs.mkdirSync(target, {recursive: true}); + for (const name of fs.readdirSync(source)) { + if (!name.endsWith('.js') || name.includes('-all')) { + continue; + } + const result = ts.transpileModule(fs.readFileSync(path.join(source, name), 'utf8'), { + compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020}, + }); + fs.writeFileSync(path.join(target, name), result.outputText); + } + fs.copyFileSync(path.join(source, '..', 'LICENSE'), path.join(target, 'LICENSE')); + const entry = path.join(BUILD_DIR, 'cjs', 'components', 'GridStack', 'runtime.js'); + fs.writeFileSync( + entry, + fs + .readFileSync(entry, 'utf8') + .replace( + /require\(["']gridstack["']\)/g, + 'require("../../gridstack-runtime/gridstack")', + ), + ); + done(); +}); + task('copy-js-declarations', () => { return src(['src/**/*.d.ts', '!src/**/__stories__/**/*.d.ts']) .pipe(dest(path.resolve(BUILD_DIR, 'esm'))) @@ -98,7 +128,15 @@ task('copy-docs', (done) => { buildDocs({ rootDir: __dirname, outDir: path.join(__dirname, 'build', 'docs'), - sources: [], + sources: [ + { + title: 'Guides', + kind: 'markdown', + baseDir: 'docs', + outPrefix: 'guides', + nameFromTitle: true, + }, + ], }); done(); }); @@ -110,6 +148,7 @@ task( parallel(['compile-to-esm', 'compile-to-cjs']), parallel(['copy-js-declarations', 'copy-i18n']), 'styles-components', + 'gridstack-cjs', 'copy-docs', ]), ); @@ -118,12 +157,15 @@ task('default', series(['build'])); task( 'rebuild', - parallel([ - 'compile-to-esm', - 'compile-to-cjs', - 'copy-js-declarations', - 'copy-i18n', - 'styles-components', + series([ + parallel([ + 'compile-to-esm', + 'compile-to-cjs', + 'copy-js-declarations', + 'copy-i18n', + 'styles-components', + ]), + 'gridstack-cjs', ]), ); diff --git a/jest.config.js b/jest.config.js index d895beb..099dfac 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,7 +5,7 @@ module.exports = { transform: { '^.+\\.(t|j)sx?$': ['ts-jest', {tsconfig: './tsconfig.json'}], }, - transformIgnorePatterns: ['node_modules/(?!(@gravity-ui)/)'], + transformIgnorePatterns: ['node_modules/(?!(@gravity-ui|gridstack)/)'], moduleNameMapper: { '\\.(css|scss)$': '/jest/styleMock.js', }, diff --git a/package-lock.json b/package-lock.json index cee041a..44b1250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@bem-react/classname": "^1.6.0", + "gridstack": "12.6.0", "hashids": "^2.2.8", "immutability-helper": "^3.1.1", "prop-types": "^15.8.1", @@ -18,6 +19,7 @@ "resize-observer-polyfill": "^1.5.1" }, "devDependencies": { + "@babel/plugin-transform-typescript": "7.29.7", "@commitlint/cli": "^19.6.1", "@commitlint/config-conventional": "^19.6.0", "@gravity-ui/eslint-config": "^3.2.0", @@ -28,6 +30,7 @@ "@gravity-ui/stylelint-config": "^4.0.1", "@gravity-ui/tsconfig": "^1.0.0", "@gravity-ui/uikit": "^7.2.0", + "@playwright/test": "1.48.2", "@storybook/addon-essentials": "^7.6.15", "@storybook/addon-knobs": "^7.0.2", "@storybook/cli": "^7.6.15", @@ -4946,6 +4949,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.2.tgz", + "integrity": "sha512-54w1xCWfXuax7dz4W2M9uw0gDyh+ti/0K/MxcCUxChFh37kkdxPdfZDw5QBbuPUJHr1CiHJ1hXgSs+GgeQc5Zw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.48.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz", @@ -16263,6 +16282,22 @@ "dev": true, "license": "MIT" }, + "node_modules/gridstack": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/gridstack/-/gridstack-12.6.0.tgz", + "integrity": "sha512-dUrqsormSybFn/2P4Dz8AgprftKD5e/IiV7UmC0XLQU+G+/WtkAeFiCSNLoAGhPDXoJ/O61Xtj3gljY/Ds83yQ==", + "funding": [ + { + "type": "paypal", + "url": "https://www.paypal.me/alaind831" + }, + { + "type": "venmo", + "url": "https://www.venmo.com/adumesny" + } + ], + "license": "MIT" + }, "node_modules/gulp": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/gulp/-/gulp-5.0.1.tgz", @@ -23577,6 +23612,53 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.2.tgz", + "integrity": "sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.48.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz", + "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plugin-error": { "version": "1.0.1", "dev": true, diff --git a/package.json b/package.json index f2b3b85..66ab7d1 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,16 @@ "types": "./build/cjs/helpers.d.ts", "default": "./build/cjs/helpers.js" } + }, + "./gridstack": { + "import": { + "types": "./build/esm/gridstack.d.ts", + "default": "./build/esm/gridstack.js" + }, + "require": { + "types": "./build/cjs/gridstack.d.ts", + "default": "./build/cjs/gridstack.js" + } } }, "files": [ @@ -49,13 +59,16 @@ ], "helpers": [ "./build/esm/helpers.d.ts" + ], + "gridstack": [ + "./build/esm/gridstack.d.ts" ] } }, "scripts": { "lint:prettier": "prettier --check --log-level=error '**/*.{js,jsx,ts,tsx,scss}'", "lint:prettier:fix": "prettier --write '**/*.md'", - "lint:js": "eslint src --ext .js,.jsx,.ts,.tsx", + "lint:js": "eslint src tests playwright.config.ts .storybook/main.ts gulpfile.js --ext .js,.jsx,.ts,.tsx --no-ignore", "lint:js:fix": "npm run lint:js -- --fix", "lint:styles": "stylelint 'src/**/*.scss'", "lint:styles:fix": "npm run lint:styles -- --fix", @@ -66,13 +79,15 @@ "build:docs": "gulp copy-docs", "build:watch": "gulp watch", "start": "storybook dev -p 7120", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc -p tests/browser/tsconfig.json", "build-storybook": "sb build -c .storybook -o storybook-static", "prepare": "husky install", - "prepublishOnly": "npm run lint && npm run test && npm run build" + "prepublishOnly": "npm run lint && npm run test && npm run build", + "test:browser": "playwright test" }, "dependencies": { "@bem-react/classname": "^1.6.0", + "gridstack": "12.6.0", "hashids": "^2.2.8", "immutability-helper": "^3.1.1", "prop-types": "^15.8.1", @@ -87,6 +102,7 @@ "react-dom": "^16.8.0 || ^17 || ^18 || ^19" }, "devDependencies": { + "@babel/plugin-transform-typescript": "7.29.7", "@commitlint/cli": "^19.6.1", "@commitlint/config-conventional": "^19.6.0", "@gravity-ui/eslint-config": "^3.2.0", @@ -97,6 +113,7 @@ "@gravity-ui/stylelint-config": "^4.0.1", "@gravity-ui/tsconfig": "^1.0.0", "@gravity-ui/uikit": "^7.2.0", + "@playwright/test": "1.48.2", "@storybook/addon-essentials": "^7.6.15", "@storybook/addon-knobs": "^7.0.2", "@storybook/cli": "^7.6.15", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a0e6fb5 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,29 @@ +import {defineConfig, devices} from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/browser', + timeout: 180_000, + expect: {timeout: 10_000}, + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: 0, + workers: process.env.CI ? 2 : undefined, + reporter: [['list'], ['html', {open: 'never'}]], + use: { + baseURL: 'http://127.0.0.1:7137', + viewport: {width: 1000, height: 1000}, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + actionTimeout: 10_000, + }, + projects: [ + {name: 'chromium', use: {...devices['Desktop Chrome']}}, + {name: 'firefox', use: {...devices['Desktop Firefox']}}, + {name: 'webkit', use: {...devices['Desktop Safari']}}, + ], + webServer: { + command: 'npm run start -- --port 7137 --ci --host 127.0.0.1 --no-open', + url: 'http://127.0.0.1:7137/iframe.html', + timeout: 180_000, + }, +}); diff --git a/src/components/DashKit/DashKit.tsx b/src/components/DashKit/DashKit.tsx index dfca6d1..79fecb1 100644 --- a/src/components/DashKit/DashKit.tsx +++ b/src/components/DashKit/DashKit.tsx @@ -45,6 +45,16 @@ const getReflowGroupsConfig = (groups: DashKitGroup[] = []) => { }; }; +function assertLegacyLayout(config: Pick) { + if ( + config.layoutVersion === 2 || + config.containers?.length || + config.layout.some((item) => item.containerId) + ) { + throw new Error('Nested configuration requires @gravity-ui/dashkit/gridstack'); + } +} + export class DashKit extends DashKitBase { static setItem({ item: setItem, @@ -59,6 +69,10 @@ export class DashKit extends DashKitBase { options?: Omit; groups?: DashKitGroup[]; }): Config { + assertLegacyLayout({ + ...config, + layout: [...config.layout, ...(options.updateLayout || [])], + }); if (setItem.id) { return UpdateManager.editItem({ item: setItem, @@ -77,6 +91,7 @@ export class DashKit extends DashKitBase { const copyItem = {...item}; if (copyItem.layout) { + assertLegacyLayout({layout: [{...copyItem.layout, i: 'incoming item'}]}); Object.assign(layout, copyItem.layout); delete copyItem.layout; } @@ -100,6 +115,7 @@ export class DashKit extends DashKitBase { config: Config; itemsStateAndParams: ItemsStateAndParams; }): {config: Config; itemsStateAndParams: ItemsStateAndParams} { + assertLegacyLayout(config); return UpdateManager.removeItem({id, config, itemsStateAndParams}); } @@ -112,6 +128,7 @@ export class DashKit extends DashKitBase { layout: ConfigLayout[]; groups?: DashKitGroup[]; }) { + assertLegacyLayout({layout: newLayoutItem ? [...layout, newLayoutItem] : layout}); return reflowLayout({ newLayoutItem, layout, @@ -120,6 +137,7 @@ export class DashKit extends DashKitBase { } render() { + assertLegacyLayout(this.props.config); const content = ( { expect(onChange).toHaveBeenCalledTimes(1); }); - it('preserves layout additions, removals and order changes', () => { + it('preserves container-only changes and layout additions, removals and order changes', () => { + const container = { + id: 'box', + x: 0, + y: 0, + w: 12, + h: 10, + padding: [0, 0, 0, 0] as [number, number, number, number], + gap: [0, 0] as [number, number], + grid: {cols: 12, rowHeight: 18, compactType: null}, + }; const second = {...TEST_CONFIG.layout[0], i: 'item2', x: 4}; for (const [previous, next] of [ + [TEST_CONFIG, {...TEST_CONFIG, containers: [container]}], + [ + {...TEST_CONFIG, containers: [container]}, + {...TEST_CONFIG, containers: [{...container, x: 1}]}, + ], [TEST_CONFIG, {...TEST_CONFIG, layout: [...TEST_CONFIG.layout, second]}], [TEST_CONFIG, {...TEST_CONFIG, layout: []}], [ diff --git a/src/components/DashKit/__tests__/flat-contract.test.tsx b/src/components/DashKit/__tests__/flat-contract.test.tsx index 8e4b20b..75ceb65 100644 --- a/src/components/DashKit/__tests__/flat-contract.test.tsx +++ b/src/components/DashKit/__tests__/flat-contract.test.tsx @@ -19,6 +19,7 @@ import {act, fireEvent, render} from '@testing-library/react'; import {DashKitContext} from '../../../context'; import type {Config} from '../../../shared'; import type {DashKitEventMap, DashKitGroup, PluginWidgetProps} from '../../../typings'; +import {DashKitGridStack} from '../../GridStack/DashKitGridStack'; import {DashKit} from '../DashKit'; const type = 'flat-contract-widget'; @@ -114,7 +115,10 @@ const rects = (layout: Config['layout']) => layout.map(({i, x, y, w, h}) => ({i, // Critical: yes. Saved edits, parameter identity and restore must survive either renderer. // Only UI chrome is mocked; layout components, context, Item and plugin refs are real. -const LAYOUT_ENGINES = [['legacy', DashKit]] as const; +const LAYOUT_ENGINES = [ + ['legacy', DashKit], + ['gridstack', DashKitGridStack], +] as const; describe.each(LAYOUT_ENGINES)('[%s] common flat DashKit contract', (_engine, Component) => { beforeAll(() => { Component.reloadPlugins({ @@ -150,8 +154,15 @@ describe.each(LAYOUT_ENGINES)('[%s] common flat DashKit contract', (_engine, Com this.style.position === 'fixed' ? parseFloat(this.style.top) || 0 : Number(entry?.dataset.dashkitGridY || 0) * 20; - const width = entry ? Number(entry.dataset.dashkitGridW) * 50 : 600; - const height = entry ? Number(entry.dataset.dashkitGridH) * 20 : 400; + let width = entry ? Number(entry.dataset.dashkitGridW) * 50 : 600; + let height = entry ? Number(entry.dataset.dashkitGridH) * 20 : 400; + // GridStack measures this native 1px reference to calculate CSS scale. + if (this.style.width === '1px') { + width = 1; + } + if (this.style.height === '1px') { + height = 1; + } return { x, y, @@ -311,7 +322,7 @@ describe.each(LAYOUT_ENGINES)('[%s] common flat DashKit contract', (_engine, Com }); test('external add, data update, reorder and removal retain widget identity and live refs', async () => { - let instance: DashKit | null = null; + let instance: DashKit | DashKitGridStack | null = null; const current = () => { assert(instance); return instance; @@ -446,7 +457,7 @@ describe.each(LAYOUT_ENGINES)('[%s] common flat DashKit contract', (_engine, Com }); test('view to edit transition retains widget identity and one metadata ref per item without echo', async () => { - let instance: DashKit | null = null; + let instance: DashKit | DashKitGridStack | null = null; const current = () => { assert(instance); return instance; diff --git a/src/components/DashKitView/DashKitView.tsx b/src/components/DashKitView/DashKitView.tsx index 0e1561a..d6ea67a 100644 --- a/src/components/DashKitView/DashKitView.tsx +++ b/src/components/DashKitView/DashKitView.tsx @@ -18,7 +18,7 @@ function DashKitView() { const {registerManager, forwardedMetaRef} = context; return ( -
+
{registerManager.settings.isMobile ? ( ) : ( diff --git a/src/components/GridItem/GridItem.tsx b/src/components/GridItem/GridItem.tsx index b06cc4a..fe6166d 100644 --- a/src/components/GridItem/GridItem.tsx +++ b/src/components/GridItem/GridItem.tsx @@ -71,6 +71,8 @@ type GridItemProps = { forwardedRef?: React.Ref; forwardedPluginRef?: (pluginRef: PluginRef) => void; isPlaceholder?: boolean; + layoutTransform?: string; + fillParent?: boolean; onItemMountChange?: DashKitProps['onItemMountChange']; onItemRender?: DashKitProps['onItemRender']; @@ -223,7 +225,7 @@ export class GridItem extends React.PureComponent style?.width === undefined ? undefined : Number.parseInt(String(style.width), 10); const height = style?.height === undefined ? undefined : Number.parseInt(String(style.height), 10); - const transform = style?.transform; + const transform = this.props.layoutTransform ?? style?.transform; const preparedClassName = (editMode ? className @@ -272,7 +274,7 @@ export class GridItem extends React.PureComponent data-dashkit-grid-w={this.props['data-dashkit-grid-w']} data-dashkit-grid-x={this.props['data-dashkit-grid-x']} data-dashkit-grid-y={this.props['data-dashkit-grid-y']} - style={style} + style={this.props.fillParent ? {...style, width: '100%', height: '100%'} : style} ref={this.props.forwardedRef} {...reactGridLayoutProps} {...reactFocusProps} diff --git a/src/components/GridStack/DashKitGridStack.tsx b/src/components/GridStack/DashKitGridStack.tsx new file mode 100644 index 0000000..3a8e1ed --- /dev/null +++ b/src/components/GridStack/DashKitGridStack.tsx @@ -0,0 +1,246 @@ +import React from 'react'; + +import {DEFAULT_NAMESPACE} from '../../constants/common'; +import {withContext} from '../../hocs/withContext'; +import type {Config, GridStackContainer} from '../../shared'; +import type {AddConfigItem, AddNewItemOptions, DashKitGroup, SetConfigItem} from '../../typings'; +import {UpdateManager} from '../../utils/update-manager'; +import {DashKitBase, _emitSymbol} from '../DashKit/DashKitBase'; +import {registerManager} from '../DashKit/registration'; +import {DashKitDnDWrapper} from '../DashKitDnDWrapper/DashKitDnDWrapper'; + +import {GridStackLayout} from './GridStackLayout'; +import {configIds, getGridKey, validateConfig, validateLayout} from './document'; +import {DashKitEngine} from './engine'; +import {getGridProperties} from './grid-properties'; +import {requireValue} from './invariants'; +import {gridStackLayoutAdapter} from './layout-adapter'; +import type {DashKitGridStackProps} from './types'; + +const GridStackView = withContext(GridStackLayout, gridStackLayoutAdapter); + +export class DashKitGridStack extends DashKitBase { + static reflowLayout({ + newLayoutItem, + layout, + groups, + containers, + }: { + newLayoutItem?: Config['layout'][number]; + layout: Config['layout']; + groups?: DashKitGroup[]; + containers?: GridStackContainer[]; + }) { + const result = [...layout, ...(newLayoutItem ? [newLayoutItem] : [])].map((entry) => ({ + ...entry, + })); + if (containers?.length || result.some((entry) => entry.containerId !== undefined)) { + validateLayout(result, containers, groups); + } + const keys = new Set(result.map(getGridKey)); + keys.forEach((key) => { + const props = getGridProperties(key, registerManager.gridLayout, groups, containers); + const engine = new DashKitEngine({ + column: props.cols || 36, + maxRow: props.maxRows, + float: true, + }); + engine.mode = { + compactType: + props.verticalCompact === false ? null : props.compactType ?? 'vertical', + allowOverlap: props.allowOverlap, + }; + if (props.compactType === null) { + engine.mode.compactType = null; + } + const entries = result.filter((entry) => getGridKey(entry) === key); + this.validateNestedLayout(entries, groups, containers); + engine.batchUpdate(); + // Reflow returns widgets only, so descriptors are immovable collision footprints. + containers + ?.filter((entry) => getGridKey(entry) === key) + .forEach(({id, x, y, w, h}) => engine.addNode({id, x, y, w, h, locked: true})); + const prioritized = newLayoutItem + ? [ + ...entries.filter(({i}) => i === newLayoutItem.i), + ...entries.filter(({i}) => i !== newLayoutItem.i), + ] + : entries; + prioritized.forEach((entry) => engine.addNode({id: entry.i, ...entry})); + engine.batchUpdate(false); + entries.forEach((entry) => { + const node = requireValue( + engine.nodes.find((candidate) => candidate.id === entry.i), + 'reflowed widget node', + ); + Object.assign(entry, {x: node.x, y: node.y, w: node.w, h: node.h}); + }); + }); + this.validateNestedLayout(result, groups, containers); + return result; + } + + static setItem({ + item, + namespace = DEFAULT_NAMESPACE, + config, + options = {}, + groups, + }: { + item: SetConfigItem; + namespace?: string; + config: Config; + options?: Omit; + groups?: DashKitGroup[]; + }): Config { + validateConfig(config, groups); + if (item.id) { + const result = UpdateManager.editItem({item, namespace, config, options}); + this.validateNestedLayout(result.layout, groups, result.containers); + return result; + } + const addition = item as AddConfigItem; + const plugin = registerManager.getItem(item.type); + const layout = {...plugin.defaultLayout, ...addition.layout}; + const props = getGridProperties( + getGridKey(layout), + registerManager.gridLayout, + groups, + config.containers, + ); + const needsReflow = + Boolean(options.updateLayout) || + Number.isFinite(layout.y) || + props.compactType === 'horizontal-nowrap'; + layout.w = Number.isFinite(layout.w) ? layout.w : props.cols || 36; + layout.y = Number.isFinite(layout.y) + ? layout.y + : [...config.layout, ...(config.containers || [])] + .filter((entry) => getGridKey(entry) === getGridKey(layout)) + .reduce((bottom, entry) => Math.max(bottom, entry.y + entry.h), 0); + if (props.compactType === 'horizontal-nowrap') { + const available = + (props.cols || 36) - + [...config.layout, ...(config.containers || [])] + .filter((entry) => getGridKey(entry) === getGridKey(layout)) + .reduce((sum, entry) => sum + entry.w, 0); + if (available < (layout.minW || 1)) { + throw new Error('DashKitGridStack: no space in horizontal-nowrap grid'); + } + layout.w = Math.min(layout.w, available); + layout.y = 0; + } + const {layout: _layout, ...newItem} = addition; + const next = UpdateManager.addItem({ + item: newItem, + namespace, + config, + layout, + options: { + ...options, + reflowLayoutOptions: undefined, + excludeIds: [...(options.excludeIds || []), ...configIds(config)], + }, + }); + const result = needsReflow + ? { + ...next, + layout: this.reflowLayout({ + layout: next.layout.filter((entry) => + config.layout.some(({i}) => i === entry.i), + ), + newLayoutItem: next.layout.find( + (entry) => !config.layout.some(({i}) => i === entry.i), + ), + groups, + containers: config.containers, + }), + } + : next; + validateConfig(result, groups); + this.validateNestedLayout(result.layout, groups, result.containers); + return result; + } + + static removeItem(options: { + id: string; + config: Config; + itemsStateAndParams: NonNullable; + }) { + validateConfig(options.config); + const container = options.config.containers?.find((entry) => entry.id === options.id); + if (container) { + if ( + options.config.layout.some((entry) => entry.containerId === options.id) || + options.config.containers?.some((entry) => entry.containerId === options.id) + ) { + throw new Error( + `DashKitGridStack: move or remove children before removing container "${options.id}"`, + ); + } + return { + config: { + ...options.config, + containers: options.config.containers?.filter( + (entry) => entry.id !== options.id, + ), + }, + itemsStateAndParams: options.itemsStateAndParams, + }; + } + if ( + ![...options.config.items, ...(options.config.globalItems || [])].some( + (entry) => entry.id === options.id, + ) + ) { + throw new Error(`DashKitGridStack: unknown item "${options.id}"`); + } + return UpdateManager.removeItem(options); + } + + private static validateNestedLayout( + layout: Config['layout'], + groups?: DashKitGroup[], + containers?: GridStackContainer[], + ) { + [...layout, ...(containers || []).map(({id, ...entry}) => ({i: id, ...entry}))] + .filter((entry) => entry.containerId) + .forEach((entry) => { + const props = getGridProperties( + getGridKey(entry), + registerManager.gridLayout, + groups, + containers, + ); + if ( + entry.y + entry.h > requireValue(props.maxRows, 'props.maxRows') + 1e-7 || + entry.x + entry.w > requireValue(props.cols, 'props.cols') + ) { + throw new Error( + `DashKitGridStack: child "${entry.i}" exceeds its container content bounds`, + ); + } + }); + } + + declare props: DashKitGridStackProps & typeof DashKitBase.defaultProps; + + render() { + validateConfig(this.props.config, this.props.groups); + const content = ( + + ); + return !this.context && this.props.groups ? ( + {content} + ) : ( + content + ); + } +} + +export type {DashKitGridStackProps, GridStackConfig, GridStackContainer} from './types'; diff --git a/src/components/GridStack/GridStack.scss b/src/components/GridStack/GridStack.scss new file mode 100644 index 0000000..120e92e --- /dev/null +++ b/src/components/GridStack/GridStack.scss @@ -0,0 +1,48 @@ +@import 'gridstack/dist/gridstack'; + +.dashkit-gridstack { + min-width: 0; + // The gap-inclusive native grid must not increase an ancestor's intrinsic width. + contain: inline-size; + + .grid-stack { + transition: none; + } + &__zone { + // Group renderers can portal zones outside the contained DashKit root. + contain: inline-size; + box-sizing: border-box; + width: 100%; + min-width: 0; + position: relative; + } + + &__host > .grid-stack-item-content { + overflow: visible; + } + + &__host > .ui-resizable-handle { + background-image: none; + transform: none; + } + + &__container { + box-sizing: border-box; + overflow: hidden; + } + + .dashkit-grid-item { + width: 100%; + height: 100%; + } + + .grid-stack-placeholder { + pointer-events: none; + } + + .grid-stack-placeholder > .placeholder-content { + background: var(--dashkit-placeholder-color, var(--g-color-base-brand)); + opacity: var(--dashkit-placeholder-opacity, 0.1); + border-radius: var(--dashkit-grid-item-border-radius, 3px); + } +} diff --git a/src/components/GridStack/GridStackLayout.tsx b/src/components/GridStack/GridStackLayout.tsx new file mode 100644 index 0000000..dcc900a --- /dev/null +++ b/src/components/GridStack/GridStackLayout.tsx @@ -0,0 +1,1422 @@ +import React from 'react'; + +import isEqual from 'lodash/isEqual'; +import {createPortal} from 'react-dom'; +import ResizeObserverPolyfill from 'resize-observer-polyfill'; + +import { + DEFAULT_GROUP, + DRAGGABLE_CANCEL_CLASS_NAME, + TEMPORARY_ITEM_ID, +} from '../../constants/common'; +import {DashKitContext} from '../../context'; +import type {DashKitCtxShape} from '../../context'; +import type {Config, ConfigItem, ConfigLayout} from '../../shared'; +import type { + ItemManipulationCallback, + PluginRef, + ReactGridLayoutProps, + WidgetLayout, +} from '../../typings'; +import GridItem from '../GridItem/GridItem'; +import MobileLayout from '../MobileLayout/MobileLayout'; + +import { + getContainerDescendants, + getGridKey, + projectLayoutDocument, + validateConfig, + validateGrid, +} from './document'; +import {DashKitEngine} from './engine'; +import {getGridGeometry, getGridStackLayoutRects, visibleGeometry} from './geometry'; +import {getGridProperties} from './grid-properties'; +import {requireValue} from './invariants'; +import {DashKitNativeGrid} from './native-grid'; +import {GridStack} from './runtime'; +import type {GridItemHTMLElement, GridStackNode, GridStackOptions} from './runtime'; + +import './GridStack.scss'; + +interface Host { + el: GridItemHTMLElement; + content: HTMLElement; + key: string; + container?: boolean; + lastRecord?: GridRecord; + dragObserver?: MutationObserver; +} +interface GridRecord { + grid: DashKitNativeGrid; + wrapper: HTMLElement; + root: HTMLElement; + properties: ReactGridLayoutProps; + geometry: ReturnType; + parent?: string; + containerId?: string; +} +interface Gesture { + id: string; + resize: boolean; + oldItem: WidgetLayout; + event: Event; + element: HTMLElement; + sourceKey: string; + width: number; + height: number; + cancelled?: boolean; +} +const position = (id: string, node: GridStackNode): ConfigLayout => ({ + i: id, + x: requireValue(node.x, 'node.x'), + y: requireValue(node.y, 'node.y'), + w: requireValue(node.w, 'node.w'), + h: requireValue(node.h, 'node.h'), +}); + +const gridDepth = ({grid}: GridRecord) => { + let depth = 0; + for (let parent = grid.parentGridNode; parent; parent = parent.grid?.parentGridNode) { + depth++; + } + return depth; +}; + +class GridStackCanvas extends React.PureComponent { + static contextType = DashKitContext; + declare context: DashKitCtxShape; + private roots = new Map(); + private grids = new Map(); + private hosts = new Map(); + private plugins = new Map(); + private autoHeightItems = new Set(); + private pluginCallbacks = new Map void>(); + private observer?: ResizeObserver; + private syncing = false; + private appliedContext?: DashKitCtxShape; + private active?: Gesture; + private preview?: ConfigItem; + private finishQueued = false; + private timer?: ReturnType; + private lastReload = 0; + private mounted = false; + private frame?: number; + + componentDidMount() { + this.mounted = true; + const Observer = + typeof ResizeObserver === 'function' ? ResizeObserver : ResizeObserverPolyfill; + this.observer = new Observer(() => { + if (!this.active && !this.preview) { + this.sync(); + } + }); + this.sync(); + this.reloadItems(); + document.addEventListener('visibilitychange', this.onVisibilityChange); + window.addEventListener('dragend', this.clearPreview); + window.addEventListener('mousemove', this.refreshGestureScale, true); + window.addEventListener('touchmove', this.refreshGestureScale, { + capture: true, + passive: true, + }); + } + componentDidUpdate() { + if ( + this.active && + this.appliedContext?.externalLayoutRevision !== this.context.externalLayoutRevision + ) { + this.active.cancelled = true; + // Finish native pointer cleanup before applying the authoritative document. + this.syncing = true; + try { + const el = this.active.element as GridItemHTMLElement; + el.gridstackNode?.grid?.prepareDragDrop(el, true); + } finally { + this.syncing = false; + } + const host = this.hosts.get(this.active.id); + if (host?.el.gridstackNode) { + this.membership(host); + } + this.sync(); + } + if (!this.active && this.appliedContext !== this.context) { + this.clearPreview(); + this.sync(); + } + clearTimeout(this.timer); + this.reloadItems(); + } + componentWillUnmount() { + this.mounted = false; + this.clearPreview(); + clearTimeout(this.timer); + if (this.frame !== undefined) { + cancelAnimationFrame(this.frame); + } + document.removeEventListener('visibilitychange', this.onVisibilityChange); + window.removeEventListener('dragend', this.clearPreview); + window.removeEventListener('mousemove', this.refreshGestureScale, true); + window.removeEventListener('touchmove', this.refreshGestureScale, true); + this.observer?.disconnect(); + this.hosts.forEach((host) => host.dragObserver?.disconnect()); + [...this.grids.values()] + .sort((a, b) => gridDepth(b) - gridDepth(a)) + .forEach(({grid, root}) => { + root.removeEventListener('dragover', this.paletteOver); + root.removeEventListener('drop', this.paletteDrop); + root.removeEventListener('dragleave', this.paletteLeave); + grid.destroy(false); + }); + this.grids.clear(); + this.plugins.clear(); + } + render() { + const { + groups = [{id: DEFAULT_GROUP}], + config, + configItems, + editMode, + context, + } = this.context; + const portals = configItems.map((item) => this.renderItem(item)); + const customHandles = [...this.hosts].flatMap(([id, host]) => { + const properties = this.grids.get(host.key)?.properties; + if (!properties?.resizeHandle) { + return []; + } + return (properties.resizeHandles || ['se']).map((direction) => { + const target = host.el.querySelector(`.ui-resizable-${direction}`); + const handle = + typeof properties.resizeHandle === 'function' + ? properties.resizeHandle(direction) + : properties.resizeHandle; + return target ? createPortal(handle, target, `${id}:${direction}`) : null; + }); + }); + const temporary = this.context.temporaryLayout; + const preview = + this.preview || + (temporary + ? { + id: TEMPORARY_ITEM_ID, + type: temporary.dragProps.type, + namespace: 'default', + data: {}, + } + : undefined); + return ( +
+ this.getProperties(`zone:${group.id || DEFAULT_GROUP}`).autoSize === + false, + ) + ? {height: '100%'} + : undefined + } + data-dashkit-engine="gridstack" + data-dashkit-engine-version="12.6.0" + > + {groups.map((group) => { + const id = group.id || DEFAULT_GROUP; + const key = `zone:${id}`; + const properties = this.getProperties(key); + const children = ( +
{ + if (el) { + this.roots.set(key, el); + } else { + this.roots.delete(key); + } + }} + > +
+
+ ); + return group.render + ? group.render(id, children, { + config, + editMode, + isMobile: false, + items: configItems.filter((item) => + this.context.layout.some( + (entry) => + entry.i === item.id && + (entry.parent || DEFAULT_GROUP) === id, + ), + ), + layout: this.context.layout.filter( + (entry) => (entry.parent || DEFAULT_GROUP) === id, + ), + context, + }) + : children; + })} + {portals} + {customHandles} + {preview ? this.renderItem(preview, true) : null} +
+ ); + } + getItemsMeta = () => this.context.getItemsMeta([...this.plugins.values()]); + reloadItems = ({targetIds, force}: {targetIds?: string[]; force?: boolean} = {}) => { + const {settings, editMode} = this.context; + const interval = Number(settings.autoupdateInterval) * 1000; + const remaining = interval - (Date.now() - this.lastReload); + if (force || (interval && !document.hidden && !editMode && remaining <= 0)) { + this.lastReload = Date.now(); + this.context.reloadItems( + [...this.plugins.entries()] + .filter(([id]) => !targetIds || targetIds.includes(id)) + .map(([, plugin]) => plugin), + {silentLoading: Boolean(settings.silentLoading), noVeil: true}, + ); + } + clearTimeout(this.timer); + if (interval) { + this.timer = setTimeout( + () => this.reloadItems(), + Math.max(1, remaining <= 0 ? interval : remaining), + ); + } + }; + // Refresh before native document handlers calculate helper/UI geometry for this motion. + private refreshGestureScale = () => { + const el = this.active?.element as GridItemHTMLElement | undefined; + const grid = el?.gridstackNode?.grid; + if (el && grid instanceof DashKitNativeGrid) { + grid.refreshScale(el); + } + }; + private onVisibilityChange = () => this.reloadItems(); + private getProperties(key: string) { + return getGridProperties( + key, + this.context.registerManager.gridLayout, + this.context.groups, + this.context.containers, + ); + } + private createGrid(key: string, wrapper: HTMLElement, root: HTMLElement) { + const properties = this.getProperties(key); + validateGrid(properties); + const options: GridStackOptions = { + column: properties.cols || 36, + cellHeight: (properties.rowHeight ?? 18) + (properties.margin?.[1] ?? 2), + margin: 0, + float: true, + animate: false, + auto: false, + engineClass: DashKitEngine, + alwaysShowResizeHandle: true, + maxRow: Number.isFinite(properties.maxRows) ? properties.maxRows : undefined, + acceptWidgets: (el) => this.accept(key, el as GridItemHTMLElement), + subGridDynamic: false, + draggable: { + handle: this.context.draggableHandleClassName + ? `.${this.context.draggableHandleClassName}` + : '.grid-stack-item-content', + cancel: `.${DRAGGABLE_CANCEL_CLASS_NAME}`, + scroll: !properties.isBounded, + }, + resizable: {handles: (properties.resizeHandles || ['se']).join(',')}, + }; + const grid = new DashKitNativeGrid(root, options); + grid.onCancel = () => { + if (this.active) { + this.active.cancelled = true; + } + }; + const record: GridRecord = { + grid, + wrapper, + root, + properties, + geometry: getGridGeometry(0, properties), + }; + // Teardown still needs the applied settings after controlled descriptors disappear. + grid.properties = () => (this.syncing ? record.properties : this.getProperties(key)); + if (key.startsWith('container:')) { + record.containerId = key.slice(10); + record.parent = this.context.containers?.find( + ({id}) => id === record.containerId, + )?.parent; + } else { + record.parent = key.slice(5) === DEFAULT_GROUP ? undefined : key.slice(5); + } + this.grids.set(key, record); + grid.on('dragstart resizestart', (event: Event, el: GridItemHTMLElement) => + this.start(event, el), + ); + grid.on('drag resize', (event: Event, el: GridItemHTMLElement) => this.move(event, el)); + grid.on('dragstop resizestop', (event: Event, el: GridItemHTMLElement) => + this.finish(event, el), + ); + grid.on('dropped', (event, _previous, node) => + this.finish(event, requireValue(node.el, 'native node element')), + ); + this.observer?.observe(wrapper); + root.addEventListener('dragover', this.paletteOver); + root.addEventListener('drop', this.paletteDrop); + root.addEventListener('dragleave', this.paletteLeave); + return record; + } + private syncDragHandle(id: string) { + const host = this.hosts.get(id); + if (!host || !this.mounted || host.container || this.active || this.preview) { + return; + } + const custom = this.context.draggableHandleClassName; + if (!custom && !host.dragObserver) { + return; + } + const grid = this.grids.get(host.key)?.grid; + if (!grid || host.el.gridstackNode?.grid !== grid) { + return; + } + if (custom && !host.dragObserver) { + // Portal content and async plugin handles can appear after makeWidget initializes DD. + host.dragObserver = new MutationObserver(() => this.syncDragHandle(id)); + host.dragObserver.observe(host.content, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class'], + }); + } + const selector = custom ? `.${custom}, .${custom} *` : '.grid-stack-item-content'; + const elements = [...host.el.querySelectorAll(selector)].filter( + (element) => + element.closest('.grid-stack-item') === host.el && + !element.closest('input, textarea, select, option'), + ); + grid.setDragHandles(host.el, selector, elements); + if (custom && !elements.length) { + GridStack.getDD().draggable(host.el, 'disable'); + } + if (!custom) { + host.dragObserver?.disconnect(); + delete host.dragObserver; + } + } + private createHost(id: string, key: string, container = false): Host { + const el: GridItemHTMLElement = document.createElement('div'); + el.className = 'grid-stack-item react-grid-item dashkit-gridstack__host'; + el.dataset.dashkitId = id; + const content = document.createElement('div'); + content.className = 'grid-stack-item-content'; + el.appendChild(content); + const host = {el, content, key, container}; + this.hosts.set(id, host); + return host; + } + private attach(id: string, entry: Omit, container = false) { + const key = getGridKey(entry); + const record = this.grids.get(key); + const previous = this.hosts.get(id); + if (!record) { + if (previous) { + this.grids.get(previous.key)?.grid.removeWidget(previous.el, false, false); + previous.el.remove(); + previous.key = key; + } + return undefined; + } + const host = previous || this.createHost(id, key, container); + host.lastRecord = record; + if (host.key !== key) { + this.grids.get(host.key)?.grid.removeWidget(host.el, false, false); + host.key = key; + } + const constraints = entry as Partial; + const inheritedConstraints = + entry.containerId && !container + ? this.convertConstraints( + constraints, + this.grids.get(`zone:${entry.parent || DEFAULT_GROUP}`) || record, + record, + ) + : constraints; + const node = { + id, + x: entry.x, + y: entry.y, + w: entry.w, + h: entry.h, + minW: inheritedConstraints.minW, + // Autoheight constraints are already expressed in the containing grid units. + minH: this.autoHeightItems.has(id) ? constraints.minH : inheritedConstraints.minH, + maxW: inheritedConstraints.maxW, + maxH: this.autoHeightItems.has(id) ? constraints.maxH : inheritedConstraints.maxH, + }; + if (host.el.parentElement !== record.root || host.el.gridstackNode?.grid !== record.grid) { + record.root.appendChild(host.el); + record.grid.makeWidget(host.el, node); + } + const current = host.el.gridstackNode; + if ( + Object.entries(node).some( + ([field, value]) => current?.[field as keyof typeof node] !== value, + ) + ) { + record.grid.update(host.el, node); + } + return host; + } + private validateGeometry(config: Config) { + const rootGrids = Object.fromEntries( + [...this.roots] + .map(([key, wrapper]) => { + const gridProperties = this.getProperties(key); + const previous = this.grids.get(key)?.geometry; + return [ + key.slice(5), + { + width: + gridProperties.width ?? + (wrapper.clientWidth || + (previous + ? previous.stepX * previous.cols + + 2 * previous.paddingX - + previous.gapX + : 0)), + gridProperties, + }, + ] as const; + }) + .filter(([, {width}]) => width > 0), + ); + if (config.containers?.length && Object.keys(rootGrids).length) { + getGridStackLayoutRects( + { + ...config, + layout: config.layout.filter( + (entry) => rootGrids[entry.parent || DEFAULT_GROUP], + ), + containers: config.containers.filter( + (entry) => rootGrids[entry.parent || DEFAULT_GROUP], + ), + }, + rootGrids, + ); + } + } + private sync = () => { + if (this.syncing || !this.mounted) { + return; + } + // Validate new saved input before clipping its temporary visual layout. + if (this.autoHeightItems.size && this.context.config !== this.appliedContext?.config) { + this.validateGeometry(this.context.config); + } + for (const widgetId of this.autoHeightItems) { + const entry: WidgetLayout | undefined = this.context.layout.find( + ({i}) => i === widgetId, + ); + if (entry?.containerId) { + const remaining = + requireValue( + this.getProperties(getGridKey(entry)).maxRows, + 'containing grid row capacity', + ) - entry.y; + if (Math.max(entry.h, entry.minH ?? 0, entry.maxH ?? 0) > remaining) { + this.adjustWidgetLayout({widgetId, adjustedWidgetLayout: entry}); + return; + } + } + } + this.validateGeometry({ + ...this.context.config, + layout: this.context.layout, + containers: this.context.containers, + }); + this.syncing = true; + const {configItems, layout, containers = [], temporaryLayout} = this.context; + const before = this.renderKey(); + try { + const replaced = [...this.grids] + .filter( + ([key, record]) => + !record.containerId && + (this.roots.get(key) !== record.wrapper || + record.wrapper.firstElementChild !== record.root), + ) + .map(([, record]) => record.root); + [...this.grids] + .sort(([, a], [, b]) => gridDepth(b) - gridDepth(a)) + .forEach(([key, record]) => { + if ( + replaced.some( + (root) => root === record.root || root.contains(record.wrapper), + ) + ) { + record.root.removeEventListener('dragover', this.paletteOver); + record.root.removeEventListener('drop', this.paletteDrop); + record.root.removeEventListener('dragleave', this.paletteLeave); + this.observer?.unobserve(record.wrapper); + record.grid.destroy(false); + if (record.containerId) { + record.wrapper.remove(); + } + this.grids.delete(key); + } + }); + this.roots.forEach((wrapper, key) => { + if (!this.grids.has(key)) { + this.createGrid(key, wrapper, wrapper.firstElementChild as HTMLElement); + } + this.updateGrid(requireValue(this.grids.get(key), 'grid record'), key); + }); + this.grids.forEach(({grid: gridInput}) => { + const grid = gridInput; + (grid.engine as DashKitEngine).restoring = true; + }); + const pending = [...containers]; + for (let pass = 0; pending.length && pass <= containers.length; pass++) { + for (let index = pending.length - 1; index >= 0; index--) { + const container = pending[index]; + const host = this.attach(container.id, container, true); + if (!host) { + continue; + } + host.content.classList.add('dashkit-gridstack__container'); + const key = `container:${container.id}`; + if (!this.grids.has(key)) { + const wrapper = document.createElement('div'); + wrapper.className = 'dashkit-gridstack__zone'; + const root = document.createElement('div'); + root.className = 'grid-stack react-grid-layout'; + wrapper.appendChild(root); + host.content.appendChild(wrapper); + const child = this.createGrid(key, wrapper, root); + this.updateGrid(child, key); + (child.grid.engine as DashKitEngine).restoring = true; + } + const child = requireValue(this.grids.get(key), 'grid record'); + child.grid.parentGridNode = host.el.gridstackNode; + requireValue(host.el.gridstackNode, 'host native node').subGrid = child.grid; + host.content.style.padding = container.padding + .map((value) => `${value}px`) + .join(' '); + pending.splice(index, 1); + } + } + const activeLayout = temporaryLayout?.data || layout; + configItems.forEach((item) => { + const entry = activeLayout.find(({i}) => i === item.id); + if (entry) { + this.attach(item.id, entry); + } + }); + // Reordering saved items changes overlap stacking without replacing portal targets. + this.grids.forEach((record, key) => { + let next: HTMLElement | null = null; + [...configItems].reverse().forEach(({id}) => { + const host = this.hosts.get(id); + if (host?.key === key) { + if (host.el.nextElementSibling !== next) { + record.root.insertBefore(host.el, next); + } + next = host.el; + } + }); + }); + const retained = new Set([ + ...configItems.map(({id}) => id), + ...containers.map(({id}) => id), + ]); + if (temporaryLayout || this.preview) { + retained.add(TEMPORARY_ITEM_ID); + } + this.hosts.forEach((host, id) => { + if (!retained.has(id)) { + host.dragObserver?.disconnect(); + this.grids.get(host.key)?.grid.removeWidget(host.el, false, false); + host.el.remove(); + this.hosts.delete(id); + this.autoHeightItems.delete(id); + this.plugins.delete(id); + this.pluginCallbacks.delete(id); + } + }); + [...this.grids] + .sort(([, a], [, b]) => gridDepth(b) - gridDepth(a)) + .forEach(([key, record]) => { + if ( + record.containerId && + !containers.some(({id}) => id === record.containerId) + ) { + record.grid.destroy(false); + this.observer?.unobserve(record.wrapper); + this.grids.delete(key); + return; + } + this.updateGrid(record, key); + }); + this.hosts.forEach((_host, id) => this.syncDragHandle(id)); + } finally { + this.grids.forEach(({grid: gridInput}) => { + const grid = gridInput; + (grid.engine as DashKitEngine).restoring = false; + }); + this.syncing = false; + this.appliedContext = this.context; + } + if (before !== this.renderKey()) { + this.forceUpdate(); + } + }; + private updateGrid(recordInput: GridRecord, key: string) { + const record = recordInput; + const properties = this.getProperties(key); + validateGrid(properties); + record.properties = properties; + if (record.containerId) { + record.parent = this.context.containers?.find( + ({id}) => id === record.containerId, + )?.parent; + } + if (properties.autoSize === false && !properties.style?.height) { + record.wrapper.style.height = '100%'; + } + const width = properties.width ?? record.wrapper.clientWidth; + // Hidden measurement is not a new geometry baseline for already mounted content. + if (properties.width === undefined && width === 0 && record.root.style.width) { + return; + } + record.geometry = getGridGeometry(width, properties); + const {paddingX, paddingY, gapX, gapY, stepY} = record.geometry; + record.root.style.left = `${paddingX}px`; + record.root.style.top = `${paddingY}px`; + record.root.style.width = `${Math.max(0, width - 2 * paddingX + gapX)}px`; + const engine = record.grid.engine as DashKitEngine; + const compactType = + properties.compactType === undefined ? 'vertical' : properties.compactType; + engine.mode = { + compactType: properties.verticalCompact === false ? null : compactType, + allowOverlap: properties.allowOverlap, + preventCollision: properties.preventCollision, + }; + engine.column = properties.cols || 36; + engine.maxRow = Number.isFinite(properties.maxRows) + ? requireValue(properties.maxRows, 'properties.maxRows') + : 0; + record.grid.opts.column = engine.column; + record.grid.opts.maxRow = engine.maxRow; + record.root.style.setProperty('--gs-column-width', `${100 / engine.column}%`); + record.grid.cellHeight(stepY); + record.grid.margin(`0 ${gapX}px ${gapY}px 0`); + const handles = (properties.resizeHandles || ['se']).join(','); + record.grid.opts.resizable = {...record.grid.opts.resizable, handles}; + const canMove = properties.isDraggable ?? this.context.editMode; + const canResize = + (properties.isResizable ?? this.context.editMode) && + properties.resizeHandles?.length !== 0; + if (Boolean(record.grid.opts.disableDrag) !== !canMove) { + record.grid.enableMove(canMove, false); + } + if (Boolean(record.grid.opts.disableResize) !== !canResize) { + record.grid.enableResize(canResize, false); + } + record.grid.engine.nodes.forEach((node) => { + // The runtime accepts handles via option; its published DDKey union omits it. + if (GridStack.getDD().isResizable(requireValue(node.el, 'native node element'))) { + GridStack.getDD().resizable( + requireValue(node.el, 'native node element'), + 'option', + 'handles' as Parameters['resizable']>[2], + handles, + ); + } + node.el + ?.querySelectorAll('.ui-resizable-handle') + .forEach((handleInput) => { + const handle = handleInput; + const direction = [...handle.classList] + .find((name) => /^ui-resizable-(n|s|e|w|nw|ne|sw|se)$/.test(name)) + ?.slice(13); + if (direction) { + handle.dataset.qa = `dashkit-resize-${direction}`; + handle.classList.add( + 'react-resizable-handle', + `react-resizable-handle-${direction}`, + ); + } + }); + const min = this.minimumContainer(node.id, record); + if (min) { + record.grid.update(requireValue(node.el, 'native node element'), min); + } + }); + record.grid.refreshPositions(); + } + private minimumContainer(id: string | undefined, parent: GridRecord) { + const container = this.context.containers?.find((entry) => entry.id === id); + const child = this.grids.get(`container:${id}`); + if (!container || !child) { + return undefined; + } + const entries = [ + ...this.context.layout.filter((entry) => entry.containerId === id), + ...(this.context.containers || []).filter((entry) => entry.containerId === id), + ]; + const right = entries.reduce((max, entry) => Math.max(max, entry.x + entry.w), 0); + const bottom = entries.reduce((max, entry) => Math.max(max, entry.y + entry.h), 0); + const [top, paddingRight, paddingBottom, left] = container.padding; + const minWidth = right * child.geometry.stepX - child.geometry.gapX + left + paddingRight; + const minHeight = bottom * child.geometry.stepY - child.geometry.gapY + top + paddingBottom; + return { + minW: Math.max(1, Math.ceil((minWidth + parent.geometry.gapX) / parent.geometry.stepX)), + minH: Math.max( + 1, + Math.ceil((minHeight + parent.geometry.gapY) / parent.geometry.stepY), + ), + }; + } + private renderKey() { + return JSON.stringify( + [...this.hosts].map(([id, host]) => { + const record = this.grids.get(host.key); + const node = host.el.gridstackNode; + return [id, host.key, node?.x, node?.y, node?.w, node?.h, record?.geometry]; + }), + ); + } + private convertConstraints( + entry: Partial, + source: GridRecord, + target: GridRecord, + ) { + const result: Partial = {}; + for (const key of ['minW', 'maxW', 'minH', 'maxH'] as const) { + const value = entry[key]; + if (value === undefined) { + continue; + } + const horizontal = key.endsWith('W'); + const pixels = + value * (horizontal ? source.geometry.stepX : source.geometry.stepY) - + (horizontal ? source.geometry.gapX : source.geometry.gapY); + const units = + (pixels + (horizontal ? target.geometry.gapX : target.geometry.gapY)) / + (horizontal ? target.geometry.stepX : target.geometry.stepY); + result[key] = Math.max(1, key.startsWith('min') ? Math.ceil(units) : Math.floor(units)); + } + return result; + } + + private accept(key: string, el: GridItemHTMLElement) { + if (!this.context.editMode) { + return false; + } + const id = el.dataset.dashkitId; + const {containers = []} = this.context; + const container = containers.find((entry) => entry.id === id); + if ( + container && + [...getContainerDescendants(container.id, containers)].some( + (descendant) => `container:${descendant}` === key, + ) + ) { + return false; + } + const record = this.grids.get(key); + const item = this.context.configItems.find((entry) => entry.id === id); + const node = el.gridstackNode; + if (!record || !node || (!item && !container)) { + return false; + } + const source = this.grids.get( + this.active?.sourceKey || this.hosts.get(requireValue(id, 'widget id'))?.key || '', + ); + let converted; + if (source && source !== record) { + const visible = + this.active || + visibleGeometry(position(requireValue(id, 'widget id'), node), source.geometry); + converted = { + w: Math.min( + container ? Infinity : record.geometry.cols, + Math.max( + 1, + Math.round((visible.width + record.geometry.gapX) / record.geometry.stepX), + ), + ), + h: Math.max( + 1, + Math.round((visible.height + record.geometry.gapY) / record.geometry.stepY), + ), + ...this.convertConstraints(this.active?.oldItem || node, source, record), + }; + } else { + converted = {w: requireValue(node.w, 'node.w'), h: requireValue(node.h, 'node.h')}; + } + const engine = record.grid.engine as DashKitEngine; + engine.dropSize = undefined; + if (container) { + const {minW = 1, minH = 1} = this.minimumContainer(container.id, record) || {}; + converted.w = Math.max(converted.w, minW); + converted.h = Math.max(converted.h, minH); + if (!engine.willItFit({...converted})) { + return false; + } + engine.dropSize = converted; + return true; + } + const {layout, containers: liveContainers} = this.readDocument(); + const accepted = + this.context.onDropDragOver( + {} as Parameters[0], + record.parent, + record.properties, + [ + ...layout, + ...(liveContainers || []).map(({id: containerId, ...entry}) => ({ + i: containerId, + ...entry, + })), + ].filter((entry) => getGridKey(entry) === key && entry.i !== TEMPORARY_ITEM_ID), + { + type: requireValue(item, 'widget config').type, + i: requireValue(item, 'widget config').id, + ...converted, + }, + ) !== false; + engine.dropSize = accepted ? converted : undefined; + return accepted; + } + private start(event: Event, el: GridItemHTMLElement) { + this.clearPreview(); + const id = requireValue(el.dataset.dashkitId, 'gesture widget id'); + const node = requireValue(el.gridstackNode, 'gesture native node'); + const host = requireValue(this.hosts.get(id), 'widget host'); + const record = requireValue(this.grids.get(host.key), 'host grid record'); + const visible = visibleGeometry(position(id, node), record.geometry); + this.active = { + id, + resize: event.type === 'resizestart', + oldItem: { + ...this.context.layout.find((entry) => entry.i === id), + ...position(id, node), + minW: node.minW, + minH: node.minH, + maxW: node.maxW, + maxH: node.maxH, + }, + event, + element: el, + sourceKey: host.key, + width: visible.width, + height: visible.height, + }; + if (this.context.focusable) { + host.content + .querySelector('.dashkit-grid-item') + ?.focus({preventScroll: true}); + } + this.callback( + this.active.resize ? this.context.onResizeStart : this.context.onDragStart, + event, + el, + ); + this.forceUpdate(); + } + private move(event: Event, el: GridItemHTMLElement) { + this.callback(this.active?.resize ? this.context.onResize : this.context.onDrag, event, el); + if (this.frame === undefined) { + this.frame = requestAnimationFrame(() => { + this.frame = undefined; + if (this.mounted) { + this.forceUpdate(); + } + }); + } + } + private callback( + callback: ItemManipulationCallback | undefined, + event: Event, + el: GridItemHTMLElement, + ) { + if (!this.active || !callback || !el.gridstackNode) { + return; + } + const node = el.gridstackNode; + const destination = [...this.grids.values()].find(({grid}) => grid === node.grid); + const item = { + ...this.active.oldItem, + ...position(this.active.id, node), + parent: destination?.parent, + containerId: destination?.containerId, + minW: node.minW, + minH: node.minH, + maxW: node.maxW, + maxH: node.maxH, + }; + const key = this.active.sourceKey; + const record = this.grids.get(key); + const placeholder = + event.type === 'drag' || event.type === 'resize' + ? {...position(this.active.id, node), placeholder: true} + : null; + const data = { + group: key.startsWith('zone:') ? key.slice(5) : record?.parent || DEFAULT_GROUP, + layout: this.readDocument() + .layout.filter((entry) => getGridKey(entry) === key) + .map((entry) => ({ + ...this.context.layout.find(({i}) => i === entry.i), + ...entry, + })), + oldItem: this.active.oldItem, + newItem: item, + // Legacy runtime sends null at start/stop although its public callback type omits null. + placeholder: placeholder as Parameters[0]['placeholder'], + e: event as MouseEvent, + element: el, + }; + callback(data); + } + private finish(event: Event, el: GridItemHTMLElement) { + if (!this.active || this.finishQueued) { + return; + } + this.finishQueued = true; + Promise.resolve().then(() => { + this.finishQueued = false; + if (!this.mounted || !this.active) { + return; + } + try { + this.finishGesture(event, el); + } finally { + this.active = undefined; + this.forceUpdate(); + } + }); + } + private finishGesture(event: Event, el: GridItemHTMLElement) { + const active = requireValue(this.active, 'active gesture'); + const host = this.hosts.get(active.id); + if (host?.el.gridstackNode) { + this.membership(host); + } + if (active.cancelled) { + this.sync(); + this.callback( + active.resize ? this.context.onResizeStop : this.context.onDragStop, + event, + el, + ); + return; + } + if (host) { + const record = requireValue(this.grids.get(host.key), 'host grid record'); + const pointer = event as MouseEvent; + const rect = record.root.getBoundingClientRect(); + if ( + !active.resize && + this.grids.size > 1 && + Number.isFinite(pointer.clientX) && + (pointer.clientX < rect.left || + pointer.clientX >= rect.right || + pointer.clientY < rect.top || + pointer.clientY >= rect.bottom) + ) { + // A rejected/outside cross-grid release restores the whole pre-gesture document. + this.sync(); + this.callback(this.context.onDragStop, event, el); + return; + } + if (host.key !== active.sourceKey && host.el.gridstackNode) { + const source = requireValue( + this.grids.get(active.sourceKey), + 'gesture source grid', + ); + record.grid.update( + host.el, + this.convertConstraints(active.oldItem, source, record), + ); + record.grid.engine.moveNodeCheck(host.el.gridstackNode, { + w: Math.max( + 1, + Math.round((active.width + record.geometry.gapX) / record.geometry.stepX), + ), + h: Math.max( + 1, + Math.round((active.height + record.geometry.gapY) / record.geometry.stepY), + ), + resizing: true, + }); + } + } + let document: ReturnType; + try { + document = this.readDocument(); + const {layout, containers} = document; + const config = { + ...this.context.config, + layout, + ...(containers ? {containers, layoutVersion: 2 as const} : {}), + }; + validateConfig(config, this.context.groups); + this.validateGeometry(config); + } catch { + // A native proposal is rejectable; controlled input still throws before applying it. + this.sync(); + this.callback( + active.resize ? this.context.onResizeStop : this.context.onDragStop, + event, + el, + ); + return; + } + const {layout, containers} = document; + this.context.layoutChange(layout, containers); + this.callback( + active.resize ? this.context.onResizeStop : this.context.onDragStop, + event, + el, + ); + const affected = new Set([active.sourceKey, host?.key]); + affected.forEach((key) => { + if (key) { + this.grids + .get(key) + ?.properties.onLayoutChange?.( + layout.filter((entry) => getGridKey(entry) === key), + ); + } + }); + } + private membership(hostInput: Host) { + const host = hostInput; + const key = + [...this.grids].find(([, record]) => record.root === host.el.parentElement)?.[0] || + host.key; + host.key = key; + const record = requireValue(this.grids.get(key), 'grid record'); + return {parent: record.parent, containerId: record.containerId}; + } + private readDocument() { + return projectLayoutDocument(this.context.layout, this.context.containers, (id) => { + const host = this.hosts.get(id); + const node = host?.el.gridstackNode; + if (!host || !node) { + return undefined; + } + const {i: _i, ...geometry} = position(id, node); + return {...geometry, ...this.membership(host)}; + }); + } + private paletteOver = (event: DragEvent) => { + const record = [...this.grids.values()].find(({root}) => root === event.currentTarget); + if (!record || !this.context.dragOverPlugin || !this.context.editMode) { + this.clearPreview(); + return; + } + event.preventDefault(); + event.stopPropagation(); + const source = this.grids.get(`zone:${record.parent || DEFAULT_GROUP}`) || record; + const raw = { + ...this.context.dragOverPlugin.defaultLayout, + ...this.context.dragProps?.layout, + }; + const defaults = record.containerId + ? { + w: Math.max( + 1, + Math.round( + ((Number.isFinite(raw.w) ? raw.w : source.geometry.cols) * + source.geometry.stepX - + source.geometry.gapX + + record.geometry.gapX) / + record.geometry.stepX, + ), + ), + h: Math.max( + 1, + Math.round( + (raw.h * source.geometry.stepY - + source.geometry.gapY + + record.geometry.gapY) / + record.geometry.stepY, + ), + ), + ...this.convertConstraints(raw, source, record), + } + : undefined; + const {layout, containers} = this.readDocument(); + const sizes = this.context.onDropDragOver( + event as unknown as Parameters[0], + record.parent, + record.properties, + [...layout, ...(containers || []).map(({id, ...entry}) => ({i: id, ...entry}))].filter( + (entry) => + getGridKey(entry) === + (record.containerId + ? `container:${record.containerId}` + : `zone:${record.parent || DEFAULT_GROUP}`) && + entry.i !== TEMPORARY_ITEM_ID, + ), + undefined, + defaults, + ); + if (sizes === false) { + this.clearPreview(); + return; + } + const rect = record.root.getBoundingClientRect(); + const scale = record.root.clientWidth ? rect.width / record.root.clientWidth : 1; + const entry: ConfigLayout = { + i: TEMPORARY_ITEM_ID, + x: Math.max(0, Math.round((event.clientX - rect.left) / scale / record.geometry.stepX)), + y: Math.max(0, Math.round((event.clientY - rect.top) / scale / record.geometry.stepY)), + w: sizes?.w || 3, + h: sizes?.h || 3, + ...(record.parent ? {parent: record.parent} : {}), + ...(record.containerId ? {containerId: record.containerId} : {}), + }; + if (this.hosts.get(TEMPORARY_ITEM_ID)?.key !== getGridKey(entry)) { + this.clearPreview(); + } + if (!record.grid.engine.willItFit({id: entry.i, ...entry})) { + this.clearPreview(); + return; + } + this.preview = { + id: TEMPORARY_ITEM_ID, + type: this.context.dragOverPlugin.type, + namespace: 'default', + data: {}, + }; + const host = this.attach(TEMPORARY_ITEM_ID, entry); + if (host) { + record.grid.engine.moveNodeCheck( + requireValue(host.el.gridstackNode, 'host native node'), + entry, + ); + } + this.forceUpdate(); + }; + private paletteDrop = (event: DragEvent) => { + if (!this.preview) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const host = requireValue(this.hosts.get(TEMPORARY_ITEM_ID), 'palette preview host'); + const record = requireValue(this.grids.get(host.key), 'host grid record'); + const node = requireValue(host.el.gridstackNode, 'host native node'); + if ( + (record.grid.engine.maxRow && + requireValue(node.y, 'node.y') + requireValue(node.h, 'node.h') > + record.grid.engine.maxRow) || + requireValue(node.x, 'node.x') + requireValue(node.w, 'node.w') > + record.grid.engine.column + ) { + this.clearPreview(); + return; + } + const item = { + ...position( + record.properties.droppingItem?.i || TEMPORARY_ITEM_ID, + requireValue(host.el.gridstackNode, 'host native node'), + ), + ...this.membership(host), + }; + try { + this.context.onDrop(this.readDocument().layout, item, event as unknown as MouseEvent); + } catch (error) { + this.clearPreview(); + throw error; + } + this.preview = undefined; + }; + private paletteLeave = (event: DragEvent) => { + const root = event.currentTarget as HTMLElement; + const rect = root.getBoundingClientRect(); + // WebKit can emit a null relatedTarget when the preview appears beneath the pointer. + const inside = + event.clientX >= rect.left && + event.clientX < rect.right && + event.clientY >= rect.top && + event.clientY < rect.bottom; + if (!inside && !root.contains(event.relatedTarget as Node | null)) { + this.clearPreview(); + } + }; + private clearPreview = () => { + if (!this.preview) { + return; + } + const host = this.hosts.get(TEMPORARY_ITEM_ID); + if (host) { + const grid = this.grids.get(host.key)?.grid; + if (grid) { + const engine = grid.engine as DashKitEngine; + const restoring = engine.restoring; + engine.restoring = true; + try { + grid.removeWidget(host.el, false, false); + } finally { + engine.restoring = restoring; + } + } + host.el.remove(); + this.hosts.delete(TEMPORARY_ITEM_ID); + } + this.preview = undefined; + // Reconcile against current saved/temporary edits, never the collision-shifted preview. + this.sync(); + if (this.mounted) { + this.forceUpdate(); + } + }; + private adjustWidgetLayout = ({ + widgetId, + needSetDefault, + adjustedWidgetLayout, + }: { + widgetId: string; + needSetDefault?: boolean; + adjustedWidgetLayout?: WidgetLayout; + }) => { + if (needSetDefault) { + this.autoHeightItems.delete(widgetId); + this.context.revertToOriginalLayout(widgetId); + } else if (adjustedWidgetLayout) { + const entry = this.context.layout.find(({i}) => i === widgetId); + if (entry) { + const adjusted = {...adjustedWidgetLayout}; + if (entry.containerId) { + const remaining = + requireValue( + this.getProperties(getGridKey(entry)).maxRows, + 'containing grid row capacity', + ) - entry.y; + adjusted.h = Math.min(adjusted.h, remaining); + if (adjusted.minH !== undefined) { + adjusted.minH = Math.min(adjusted.minH, adjusted.h); + } + if (adjusted.maxH !== undefined) { + adjusted.maxH = Math.min(adjusted.maxH, remaining); + } + } + this.autoHeightItems.add(widgetId); + if (!isEqual(entry, adjusted)) { + this.context.memorizeOriginalLayout(widgetId, {...entry}, adjusted); + } + } + } + }; + private renderItem(item: ConfigItem, placeholder = false) { + const host = this.hosts.get(item.id); + const node = host?.el.gridstackNode || this.context.layout.find(({i}) => i === item.id); + const record = host && (this.grids.get(host.key) || host.lastRecord); + if (!host || !node || !record) { + return null; + } + const measured = visibleGeometry(position(item.id, node), record.geometry); + const active = this.active?.id === item.id ? this.active : undefined; + const {width, height} = active && !active.resize ? active : measured; + const props = record.properties; + if (!this.pluginCallbacks.has(item.id)) { + this.pluginCallbacks.set(item.id, (value) => { + if (value) { + this.plugins.set(item.id, value); + } else { + this.plugins.delete(item.id); + } + }); + } + return createPortal( + enabled) + .map(([name]) => name) + .join(' ')} + item={item} + style={{width, height}} + fillParent + layoutTransform={ + props.useCSSTransforms === false + ? undefined + : `translate(${measured.left}px, ${measured.top}px)` + } + layout={this.context.layout} + gridLayout={props} + isDragging={Boolean(active)} + isPlaceholder={placeholder} + forwardedPluginRef={this.pluginCallbacks.get(item.id)} + adjustWidgetLayout={this.adjustWidgetLayout} + noOverlay={props.noOverlay ?? this.context.noOverlay} + focusable={this.context.focusable} + withCustomHandle={Boolean(this.context.draggableHandleClassName)} + onItemFocus={(focusedItem) => { + host.el.classList.add('dashkit-grid-item_is-focused'); + this.context.onItemFocus?.(focusedItem); + }} + onItemBlur={(blurredItem) => { + host.el.classList.remove('dashkit-grid-item_is-focused'); + this.context.onItemBlur?.(blurredItem); + }} + onItemMountChange={this.context.onItemMountChange} + onItemRender={this.context.onItemRender} + data-dashkit-grid-cols={String(props.cols || 36)} + data-dashkit-grid-x={String(node.x)} + data-dashkit-grid-y={String(node.y)} + data-dashkit-grid-w={String(node.w)} + data-dashkit-grid-h={String(node.h)} + data-dashkit-grid-row-height={String(props.rowHeight ?? 18)} + data-dashkit-grid-margin-x={String(record.geometry.gapX)} + data-dashkit-grid-margin-y={String(record.geometry.gapY)} + data-dashkit-grid-padding-x={String(record.geometry.paddingX)} + data-dashkit-grid-padding-y={String(record.geometry.paddingY)} + data-dashkit-grid-use-css-transforms={String(props.useCSSTransforms !== false)} + />, + host.content, + item.id, + ); + } +} + +export function GridStackLayout() { + const context = React.useContext(DashKitContext); + const canvas = React.useRef(null); + const mobile = React.useRef(null); + React.useImperativeHandle( + context.forwardedMetaRef, + () => ({ + getItemsMeta: () => + canvas.current?.getItemsMeta() || + context.getItemsMeta(mobile.current?.pluginsRefs || []), + reloadItems: (options?: {targetIds?: string[]; force?: boolean}) => + canvas.current?.reloadItems(options), + }), + [context], + ); + return context.registerManager.settings.isMobile ? ( + + ) : ( + + ); +} diff --git a/src/components/GridStack/__stories__/GridStack.stories.tsx b/src/components/GridStack/__stories__/GridStack.stories.tsx new file mode 100644 index 0000000..4137595 --- /dev/null +++ b/src/components/GridStack/__stories__/GridStack.stories.tsx @@ -0,0 +1,967 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; +import {createPortal} from 'react-dom'; + +import {DashKitGridStack} from '../../../gridstack'; +import type {GridStackConfig} from '../../../gridstack'; +import type {DashKitGroup, PluginWidgetProps} from '../../../typings'; +import {ActionPanel} from '../../ActionPanel/ActionPanel'; +import {DashKit} from '../../DashKit/DashKit'; +import {DashKitDnDWrapper} from '../../DashKitDnDWrapper/DashKitDnDWrapper'; +import {requireValue} from '../invariants'; + +import {PreviewOverlap as PreviewOverlapFixture} from './PreviewOverlap'; + +const type = 'gridstack-contract-widget'; +class Widget extends React.Component { + private identity = Math.random().toString(36).slice(2); + private renders = 0; + render() { + return ( +
+ {this.props.id}{' '} + + {this.props.width} × {this.props.height} + + + + +
+ ); + } + getMeta = () => Promise.resolve({id: this.props.id, identity: this.identity}); + reload = () => undefined; +} +DashKitGridStack.reloadPlugins({ + type, + defaultLayout: {w: 100, h: 80, minW: 20, minH: 20}, + renderer: (props, ref) => , +}); + +DashKitGridStack.reloadPlugins({ + type: 'gridstack-budget-widget', + defaultLayout: {w: 4, h: 2, minW: 1, minH: 1}, + renderer: (props, ref) => , +}); + +const initial: GridStackConfig = { + salt: 'public-gridstack-contract', + counter: 1, + aliases: {}, + connections: [], + items: ['a', 'b'].map((id) => ({id, type, namespace: 'default', data: {}})), + layout: [ + {i: 'a', x: 40, y: 40, w: 160, h: 100}, + {i: 'b', x: 120, y: 80, w: 180, h: 120}, + ], +}; + +function Contract({ + nested = false, + zones = false, + dashboard = false, + mode, + legacy = false, +}: { + legacy?: boolean; + nested?: boolean; + zones?: boolean; + dashboard?: boolean; + mode?: 'collision' | 'nowrap' | 'custom' | 'edges' | 'scalar' | 'budget' | 'horizontal'; +}) { + const Component = legacy ? DashKit : DashKitGridStack; + const canvasWidth = zones ? 320 : 640; + const [config, setConfig] = React.useState(() => { + if (mode === 'budget' || mode === 'horizontal') { + return { + ...initial, + items: initial.items.map((item) => ({...item, type: 'gridstack-budget-widget'})), + layout: [ + {i: 'a', x: 0, y: 0, w: 8, h: 2}, + { + i: 'b', + x: mode === 'budget' ? 8 : 0, + y: 0, + w: mode === 'budget' ? 4 : 6, + h: 2, + }, + ], + }; + } + if (mode === 'collision' || mode === 'nowrap') { + return { + ...initial, + layout: + mode === 'collision' + ? [ + {i: 'a', x: 40, y: 40, w: 160, h: 100}, + {i: 'b', x: 300, y: 40, w: 180, h: 120}, + ] + : [ + {i: 'a', x: 0, y: 0, w: 160, h: 100}, + {i: 'b', x: 160, y: 0, w: 180, h: 120}, + ], + }; + } + if (nested) { + return { + ...initial, + layoutVersion: 2, + containers: [ + { + id: 'panel', + x: 340, + y: 20, + w: 280, + h: 260, + padding: [12, 20, 24, 16], + gap: [8, 6], + grid: {cols: 12, rowHeight: 18, compactType: null}, + }, + ], + }; + } + if (zones) { + return { + ...initial, + layout: initial.layout.map((entry) => ({...entry, parent: 'left'})), + }; + } + if (dashboard) { + return { + ...initial, + layout: [ + {i: 'a', x: 0, y: 0, w: 12, h: 4}, + {i: 'b', x: 12, y: 0, w: 12, h: 4}, + ], + }; + } + return initial; + }); + const [scale, setScale] = React.useState(1); + const [editing, setEditing] = React.useState(!dashboard); + const [allowPalette, setAllowPalette] = React.useState(true); + const [silent, setSilent] = React.useState(false); + const [focused, setFocused] = React.useState(null); + const [events, setEvents] = React.useState(0); + const callbacks = React.useRef< + Array<{ + phase: string; + group?: string; + ids: string[]; + old?: unknown; + next?: unknown; + placeholder?: unknown; + }> + >([]); + const [states, setStates] = React.useState({}); + const [meta, setMeta] = React.useState([]); + const [drops, setDrops] = React.useState(0); + const ref = React.useRef(null); + const lastConfig = React.useRef(config); + const previous = React.useRef(config); + const scaleRef = React.useRef(scale); + const canvas = React.useRef(null); + React.useLayoutEffect(() => { + scaleRef.current = scale; + }, [scale]); + React.useEffect( + () => + ref.current?.on('change', (event) => { + setEvents((count) => count + 1); + lastConfig.current = { + ...lastConfig.current, + layout: event.layout, + ...(event.containers ? {containers: event.containers, layoutVersion: 2} : {}), + }; + if (silent) { + event.preventDefault(); + } + }), + [silent], + ); + const groups = React.useMemo( + () => + (zones ? ['left', 'right'] : ['__default']).map((id) => ({ + id, + render: zones + ? (groupId, children) => ( +
+ {children} +
+ ) + : undefined, + gridProperties: () => { + if (mode === 'budget' || mode === 'horizontal') { + return { + cols: 12, + rowHeight: 18, + margin: [8, 6], + containerPadding: [0, 0], + compactType: mode === 'budget' ? 'horizontal-nowrap' : 'horizontal', + maxRows: mode === 'budget' ? 4 : 10, + autoSize: false, + style: {height: 234}, + resizeHandles: ['se'], + }; + } + if (dashboard) { + return {cols: 36, rowHeight: 18, margin: [8, 8], containerPadding: [0, 0]}; + } + return { + cols: zones ? 320 : 640, + rowHeight: 1, + margin: [0, 0] as [number, number], + containerPadding: [0, 0] as [number, number], + compactType: mode === 'nowrap' ? 'horizontal-nowrap' : null, + autoSize: false, + allowOverlap: mode !== 'collision' && mode !== 'nowrap', + preventCollision: mode === 'collision', + maxRows: 400, + width: zones ? 320 : 640, + ...(zones ? {style: {height: 400}} : {}), + resizeHandles: (mode === 'edges' + ? ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'] + : ['nw', 'ne', 'sw', 'se']) as ( + | 'n' + | 's' + | 'e' + | 'w' + | 'nw' + | 'ne' + | 'sw' + | 'se' + )[], + transformScaleRef: mode === 'scalar' ? undefined : scaleRef, + transformScale: mode === 'scalar' ? scale : undefined, + ...(mode === 'custom' + ? { + resizeHandle: (direction: string) => ( + + ), + } + : {}), + onLayoutChange: (layout) => + callbacks.current.push({ + phase: 'grid', + group: id, + ids: layout.map(({i}) => i), + }), + }; + }, + })), + [zones, dashboard, mode, scale], + ); + return ( + allowPalette}> +
+
+ + + + + + + + + + {(mode === 'budget' || mode === 'horizontal') && ( + <> + + +
+                                {JSON.stringify(
+                                    DashKit.reflowLayout({
+                                        layout: config.layout.map((entry) => ({...entry})),
+                                        groups,
+                                    }),
+                                )}
+                            
+ + )} + + Events: {events} + +
+
+ setFocused(item.id)} + onItemBlur={() => setFocused(null)} + groups={groups} + itemsStateAndParams={states} + onChange={({config: next, itemsStateAndParams}) => { + previous.current = config; + lastConfig.current = next; + setConfig(next); + setStates(itemsStateAndParams); + }} + onDragStart={(event) => + callbacks.current.push({ + phase: 'start', + group: event.group, + ids: event.layout.map(({i}) => i), + old: event.oldItem, + next: event.newItem, + placeholder: event.placeholder, + }) + } + onDrag={(event) => + callbacks.current.push({ + phase: 'move', + group: event.group, + ids: event.layout.map(({i}) => i), + old: event.oldItem, + next: event.newItem, + placeholder: event.placeholder, + }) + } + onDragStop={(event) => + callbacks.current.push({ + phase: 'stop', + group: event.group, + ids: event.layout.map(({i}) => i), + old: event.oldItem, + next: event.newItem, + placeholder: event.placeholder, + }) + } + onDrop={({itemLayout, newLayout, commit, dragProps}) => { + setDrops((count) => count + 1); + const next = DashKitGridStack.setItem({ + config: lastConfig.current, + item: {type: dragProps.type, data: {}, layout: itemLayout}, + options: {updateLayout: newLayout}, + groups, + }); + setConfig(next); + lastConfig.current = next; + commit(); + }} + /> +
+ {focused || ''} +
{JSON.stringify(callbacks.current)}
+
{JSON.stringify(lastConfig.current)}
+
{JSON.stringify(meta)}
+ {drops} +
+
+ ); +} +export default {title: 'Components/GridStack', component: Contract} as Meta; +export const ReportCanvas: StoryFn = () => ; +export const NestedContainers: StoryFn = () => ; + +export const CrossZones: StoryFn = () => ; + +export const Dashboard: StoryFn = () => ; +export const Collision: StoryFn = () => ; +export const HorizontalNowrap: StoryFn = () => ; +export const CustomHandles: StoryFn = () => ; +export const AllHandles: StoryFn = () => ; +export const FiniteNowrap: StoryFn = () => ; +export const HorizontalPacking: StoryFn = () => ; + +export const ScalarZoom: StoryFn = () => ; + +export const HeightCompatibility: StoryFn = () => { + const document: GridStackConfig = { + ...initial, + items: [initial.items[0]], + layout: [{i: 'a', parent: 'short', x: 0, y: 0, w: 3, h: 1}], + }; + const groups: DashKitGroup[] = ['short', 'empty'].map((id) => ({ + id, + gridProperties: () => ({ + cols: 12, + rowHeight: 18, + maxRows: 100, + compactType: null, + margin: [8, 6], + containerPadding: [16, 12], + width: 640, + }), + render: (key, children) => ( +
+ {children} +
+ ), + })); + return ( +
+
+ +
+
+ +
+
+ ); +}; + +function PortalZone({explicit = false, fixed = false}: {explicit?: boolean; fixed?: boolean}) { + const [target, setTarget] = React.useState(null); + const [pinned, setPinned] = React.useState(false); + const [events, setEvents] = React.useState(0); + const parent = pinned ? 'fixed' : 'main'; + const config = React.useMemo( + () => ({ + ...initial, + layoutVersion: 2, + layout: [ + {i: 'a', parent, x: 0, y: 0, w: 3, h: 2, minW: 1, minH: 1}, + {i: 'b', parent, containerId: 'panel', x: 0, y: 0, w: 6, h: 2, minW: 1, minH: 1}, + ], + containers: [ + { + id: 'panel', + parent, + x: 4, + y: 0, + w: 6, + h: 6, + padding: [3, 7, 5, 11], + gap: [4, 2], + grid: {cols: 12, rowHeight: 18, compactType: null}, + }, + ], + }), + [parent], + ); + const groups = React.useMemo( + () => + ['main', ...(pinned ? ['fixed'] : [])].map((id) => ({ + id, + gridProperties: () => ({ + cols: 12, + rowHeight: 18, + compactType: null, + margin: [8, 6], + containerPadding: [0, 0], + autoSize: !fixed, + ...(explicit ? {width: 384} : {}), + style: {height: fixed ? 180 : undefined, ...(explicit ? {width: 384} : {})}, + }), + render: (key, children) => + id === 'fixed' && target ? ( + createPortal(children, target, key) + ) : ( +
+ {children} +
+ ), + })), + [explicit, fixed, pinned, target], + ); + return ( +
+ + {events} +
+ +
+
+
+
+ setEvents((value) => value + 1)} + /> +
+ ); +} + +export const PortalFlexZones: StoryFn = () => ( + <> + + + + + +); + +export const BoundedSubtrees: StoryFn = () => { + const [config, setConfig] = React.useState({ + ...initial, + items: initial.items.map((item) => ({...item, type: 'gridstack-budget-widget'})), + layoutVersion: 2, + layout: [ + { + i: 'a', + parent: 'left', + containerId: 'small', + x: 0, + y: 0, + w: 6, + h: 2, + }, + {i: 'b', parent: 'left', containerId: 'twig', x: 0, y: 0, w: 3, h: 2}, + ], + containers: [ + { + id: 'target', + parent: 'right', + x: 0, + y: 0, + w: 12, + h: 10, + padding: [0, 0, 0, 0], + gap: [0, 0], + grid: {cols: 12, rowHeight: 18, compactType: null, maxRows: 10}, + }, + { + id: 'small', + parent: 'left', + x: 20, + y: 20, + w: 80, + h: 80, + padding: [4, 6, 8, 10], + gap: [4, 2], + grid: {cols: 12, rowHeight: 18, compactType: null}, + }, + { + id: 'twig', + parent: 'left', + containerId: 'small', + x: 6, + y: 0, + w: 6, + h: 2, + padding: [0, 0, 0, 0], + gap: [0, 0], + grid: {cols: 6, rowHeight: 10, compactType: null}, + }, + { + id: 'large', + parent: 'left', + x: 10, + y: 180, + w: 300, + h: 190, + padding: [0, 0, 0, 0], + gap: [0, 0], + grid: {cols: 12, rowHeight: 18, compactType: null}, + }, + ], + }); + const [allowPalette, setAllowPalette] = React.useState(true); + const [drops, setDrops] = React.useState(0); + const previous = React.useRef(config); + const [events, setEvents] = React.useState(0); + const [meta, setMeta] = React.useState([]); + const ref = React.useRef(null); + const groups = React.useMemo( + () => + ['left', 'right'].map((id) => ({ + id, + render: (key, children) => ( +
+ {children} +
+ ), + gridProperties: () => ({ + cols: id === 'left' ? 320 : 12, + rowHeight: id === 'left' ? 1 : 18, + margin: [0, 0], + containerPadding: [0, 0], + compactType: null, + autoSize: false, + maxRows: id === 'left' ? 400 : 10, + isBounded: true, + style: {height: id === 'left' ? 400 : 180}, + resizeHandles: ['nw', 'ne', 'sw', 'se'], + }), + })), + [], + ); + return ( + allowPalette}> +
+ + + + + Events: {events} + { + const next = DashKitGridStack.setItem({ + config, + item: {type: dragProps.type, data: {}, layout: itemLayout}, + options: {updateLayout: newLayout}, + groups, + }); + previous.current = config; + setConfig(next); + setDrops((count) => count + 1); + commit(); + }} + onChange={({config: next}) => { + previous.current = config; + setConfig(next); + setEvents((count) => count + 1); + }} + /> +
{JSON.stringify(config)}
+
{JSON.stringify(meta)}
+ {drops} +
+
+ ); +}; + +export const StrictLifecycle: StoryFn = () => { + const [mounted, setMounted] = React.useState(true); + return ( + <> + + {mounted ? : null} + + ); +}; + +export const LegacyScaleContinuation: StoryFn = () => ; + +export const NestedBounds: StoryFn = () => { + const [config, setConfig] = React.useState({ + ...initial, + items: [{...initial.items[1], type: 'gridstack-budget-widget'}], + layout: [{i: 'b', x: 2, y: 2, w: 4, h: 3, containerId: 'panel'}], + layoutVersion: 2, + containers: [ + { + id: 'panel', + x: 100, + y: 20, + w: 280, + h: 260, + padding: [12, 20, 24, 16], + gap: [8, 6], + grid: {cols: 12, rowHeight: 18, compactType: null, allowOverlap: true}, + }, + ], + }); + const previous = React.useRef(config); + const [events, setEvents] = React.useState(0); + const [drops, setDrops] = React.useState(0); + const [hidden, setHidden] = React.useState(false); + const groups = React.useMemo( + () => [ + { + gridProperties: () => ({ + cols: 640, + rowHeight: 1, + margin: [0, 0], + containerPadding: [0, 0], + compactType: null, + allowOverlap: true, + autoSize: false, + maxRows: 400, + isBounded: true, + style: {height: 400}, + resizeHandles: ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'], + }), + }, + ], + [], + ); + return ( + +
+ {[6, 100].map((maxRows) => ( + + ))} + + {[6, 18].map((height) => ( + + ))} + + + Events: {events} + {drops} + + +
{JSON.stringify(config)}
+
+
+ ); +}; + +export const PreviewOverlap: StoryFn = (args) => ( + +); +PreviewOverlap.args = {nested: false, allowOverlap: false, compactType: null}; + +export const FlatContract: StoryFn = (args) => ; +FlatContract.args = {legacy: false}; diff --git a/src/components/GridStack/__stories__/PreviewOverlap.tsx b/src/components/GridStack/__stories__/PreviewOverlap.tsx new file mode 100644 index 0000000..328e72d --- /dev/null +++ b/src/components/GridStack/__stories__/PreviewOverlap.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import type {Config, GridStackContainer} from '../../../shared'; +import type {CompactType} from '../../../typings'; +import {ActionPanel} from '../../ActionPanel/ActionPanel'; +import {DashKitDnDWrapper} from '../../DashKitDnDWrapper/DashKitDnDWrapper'; +import {DashKitGridStack} from '../DashKitGridStack'; + +const type = 'preview-overlap-contract'; +DashKitGridStack.reloadPlugins({ + type, + defaultLayout: {w: 3, h: 2, minW: 1, minH: 1}, + renderer: ({id}) => ( +
+ {id} +
+ ), +}); + +export function PreviewOverlap({ + nested = false, + allowOverlap = false, + compactType = null, +}: { + nested?: boolean; + allowOverlap?: boolean; + compactType?: CompactType; +}) { + const grid = {cols: 12, rowHeight: 40, compactType, allowOverlap, maxRows: 10}; + const container: GridStackContainer = { + id: 'box', + x: 0, + y: 0, + w: 12, + h: 10, + padding: [0, 0, 0, 0], + gap: [0, 0], + grid, + }; + const [config, setConfig] = React.useState({ + salt: 'preview-overlap', + counter: 1, + aliases: {}, + connections: [], + items: ['a', 'b'].map((id) => ({id, type, namespace: 'default', data: {}})), + layout: [ + {i: 'a', x: 0, y: 0, w: 4, h: 2, ...(nested ? {containerId: 'box'} : {})}, + {i: 'b', x: 8, y: 0, w: 3, h: 2, ...(nested ? {containerId: 'box'} : {})}, + ], + ...(nested ? {containers: [container], layoutVersion: 2} : {}), + }); + const [drops, setDrops] = React.useState(0); + return ( + +
+ +
+ ({ + ...grid, + compactType: nested ? null : compactType, + margin: [0, 0], + containerPadding: [0, 0], + width: 600, + autoSize: false, + style: {height: 400}, + }), + }, + ]} + onChange={({config: next}) => setConfig(next)} + onDrop={({newLayout, itemLayout, commit}) => { + const id = 'added'; + setConfig({ + ...config, + items: [ + ...config.items, + {id, type, namespace: 'default', data: {}}, + ], + layout: [...newLayout, {...itemLayout, i: id}], + }); + setDrops((count) => count + 1); + commit(); + }} + /> +
+
{JSON.stringify(config)}
+ {drops} +
+
+ ); +} diff --git a/src/components/GridStack/__tests__/component.test.tsx b/src/components/GridStack/__tests__/component.test.tsx new file mode 100644 index 0000000..0566900 --- /dev/null +++ b/src/components/GridStack/__tests__/component.test.tsx @@ -0,0 +1,2236 @@ +/** @jest-environment jsdom */ +jest.mock('@gravity-ui/icons', () => ({Ellipsis: 'ellipsis', Gear: 'gear', Xmark: 'xmark'})); +jest.mock('@gravity-ui/uikit', () => { + const ReactMock = require('react'); + return { + Button: ({children}: {children?: unknown}) => + ReactMock.createElement('button', null, children), + DropdownMenu: ({items}: {items: Array<{text: string; action: () => void; qa?: string}>}) => + ReactMock.createElement( + 'div', + null, + items.map((item, index) => + ReactMock.createElement( + 'button', + {key: index, onClick: item.action, 'data-qa': item.qa}, + item.text, + ), + ), + ), + Icon: () => null, + }; +}); + +import React from 'react'; + +import {act, fireEvent, render} from '@testing-library/react'; + +import {DEFAULT_GROUP} from '../../../constants'; +import {DashKitContext} from '../../../context'; +import type {DashKitCtxShape} from '../../../context'; +import {withContext} from '../../../hocs/withContext'; +import type {Config, GridStackContainer} from '../../../shared'; +import type {DashKitGroup, PluginWidgetProps} from '../../../typings'; +import * as legacyGrid from '../../../utils/grid-layout'; +import {ActionPanel} from '../../ActionPanel/ActionPanel'; +import {DashKit, _emitSymbol} from '../../DashKit/DashKit'; +import {registerManager} from '../../DashKit/registration'; +import {DashKitDnDWrapper} from '../../DashKitDnDWrapper/DashKitDnDWrapper'; +import {DashKitGridStack} from '../DashKitGridStack'; +import {getGridStackLayoutRects} from '../geometry'; +import {requireValue} from '../invariants'; +import {gridStackLayoutAdapter} from '../layout-adapter'; +import {GridStack} from '../runtime'; +import type {GridItemHTMLElement} from '../runtime'; + +const type = 'gridstack-mounted-contract'; +const box: GridStackContainer = { + id: 'box', + x: 0, + y: 0, + w: 12, + h: 10, + padding: [10, 20, 30, 40], + gap: [8, 6], + grid: {cols: 12, rowHeight: 18, compactType: null}, +}; +const config: Config = { + salt: 'test', + counter: 1, + aliases: {}, + connections: [], + items: [{id: 'a', type, namespace: 'default', data: {text: 'retained'}}], + layout: [{i: 'a', x: 0, y: 0, w: 6, h: 4}], + containers: [box], + layoutVersion: 2, +}; +let nextInstance = 0; +let context: DashKitCtxShape; +class Plugin extends React.Component { + identity = ++nextInstance; + render() { + return ( + + ); + } + getMeta = () => Promise.resolve({identity: this.identity}); +} +function Probe() { + context = React.useContext(DashKitContext); + return null; +} +const ContextProbe = withContext(Probe, gridStackLayoutAdapter); +beforeAll(() => { + DashKitGridStack.reloadPlugins({ + type, + defaultLayout: {w: 6, h: 4}, + renderer: (props, ref) => , + }); + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get: () => 640, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + get: () => 400, + }); +}); + +// Critical: yes. A move must not reset a plugin's state/ref or lose saved document membership. +test('controlled reparent preserves the portal target, widget instance and metadata ref', async () => { + const ref = React.createRef(); + const {container, rerender, unmount} = render( + , + ); + const before = requireValue( + container.querySelector('[data-identity]'), + 'widget element', + ).getAttribute('data-identity'); + const target = requireValue( + container.querySelector('[data-identity]'), + 'widget element', + ).parentElement; + const nested = {...config, layout: [{...config.layout[0], containerId: 'box'}]}; + rerender(); + expect( + requireValue(container.querySelector('[data-identity]'), 'widget element').getAttribute( + 'data-identity', + ), + ).toBe(before); + expect( + requireValue(container.querySelector('[data-identity]'), 'widget element').parentElement, + ).toBe(target); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toEqual([ + {identity: Number(before)}, + ]); + expect(container.querySelectorAll('[data-qa="dashkit-grid-item"]')).toHaveLength(1); + unmount(); +}); + +test('global-only layout and static promotion preserve document geometry and metadata', () => { + const promoted = DashKitGridStack.setItem({ + config, + item: config.items[0], + options: {useGlobalItems: true}, + }); + expect(promoted.items).toEqual([]); + expect(promoted.globalItems).toEqual(config.items); + expect(promoted.layout).toEqual(config.layout); + expect(promoted.containers).toEqual(config.containers); + const onChange = jest.fn(); + const view = render(); + fireEvent.click(view.getByText('retained')); + expect(onChange.mock.calls[0][0].itemsStateAndParams.a.state).toEqual({clicked: true}); + expect(onChange.mock.calls[0][0].config.globalItems).toEqual(config.items); + const local = DashKitGridStack.setItem({ + config: promoted, + item: requireValue(promoted.globalItems, 'promoted.globalItems')[0], + options: {useGlobalItems: false}, + }); + expect(local.items).toEqual(config.items); + expect(local.globalItems).toEqual([]); + expect(local.layout).toEqual(config.layout); + view.unmount(); +}); + +test('StrictMode mount/unmount cleans native DD and retains one live metadata ref', async () => { + const ref = React.createRef(); + const view = render( + + + , + ); + const host = view.container.querySelector('[data-dashkit-id="a"]') as HTMLElement; + const grid = view.container.querySelector('.grid-stack') as HTMLElement & { + gridstack?: GridStack; + }; + expect(view.container.querySelectorAll('[data-identity]')).toHaveLength(1); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toHaveLength( + 1, + ); + expect(GridStack.getDD().isDraggable(host)).toBe(true); + view.rerender( + + + , + ); + expect(view.container.querySelector('[data-dashkit-id="a"]')).toBe(host); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toHaveLength( + 1, + ); + view.unmount(); + expect(ref.current).toBeNull(); + expect(grid.gridstack).toBeUndefined(); + expect(GridStack.getDD().isDraggable(host)).toBe(false); +}); + +test('suppressed container and widget transactions advance the full baseline used by parameter edits', () => { + const instance = new DashKit(DashKit.defaultProps as never); + const onChange = jest.fn(); + const events: Array<{ + layout: Config['layout']; + previousLayout: Config['layout']; + containers?: GridStackContainer[]; + }> = []; + instance.on('change', (event) => { + events.push(event); + event.preventDefault(); + }); + const props = { + ...DashKit.defaultProps, + config, + editMode: true, + registerManager, + forwardedMetaRef: null, + emitDashKitEvent: instance[_emitSymbol], + onChange, + }; + const {rerender} = render(); + const moved = [{...config.layout[0], x: 2, containerId: 'box'}]; + const movedContainers = [{...box, x: 3}]; + act(() => context.layoutChange(moved, movedContainers)); + act(() => context.layoutChange([{...moved[0], y: 3}], movedContainers)); + expect(onChange).not.toHaveBeenCalled(); + expect(events[1].previousLayout).toEqual(moved); + act(() => context.onItemStateAndParamsChange('a', {state: {clicked: true}})); + expect(onChange.mock.calls[0][0].config).toMatchObject({ + layout: [{...moved[0], y: 3}], + containers: movedContainers, + }); + const count = events.length; + rerender( + , + ); + expect(context.layout[0]).toMatchObject(config.layout[0]); + expect(context.containers).toEqual([box]); + expect(events).toHaveLength(count); +}); + +// Critical: yes. Loading tall content must not replace a valid nested dashboard with an error. +test.each([ + {padding: [0, 0, 0, 0], gap: [2, 2], y: 0, maxRows: undefined}, + {padding: [3, 0, 4, 0], gap: [2, 3], y: 1.25, maxRows: undefined}, + {padding: [3, 0, 4, 0], gap: [2, 3], y: 1.25, maxRows: 4}, +])( + 'nested autoheight bounds visual sizing, resets and retains saved height: %j', + async ({padding, gap, y, maxRows}) => { + let widget: PluginWidgetProps | undefined; + const renderer = jest.fn((props: PluginWidgetProps) => { + widget = props; + return ( + + ); + }); + const autoType = 'nested-autoheight'; + DashKitGridStack.reloadPlugins({ + type: autoType, + defaultLayout: {w: 2, h: 2, minH: 1}, + renderer, + }); + const container: GridStackContainer = { + ...box, + h: 6, + padding: padding as GridStackContainer['padding'], + gap: gap as GridStackContainer['gap'], + grid: {...box.grid, maxRows}, + }; + const nested: Config = { + ...config, + containers: [container], + items: [{...config.items[0], type: autoType}], + layout: [{i: 'a', x: 0, y, w: 2, h: 2, containerId: 'box'}], + }; + const groups = [ + { + gridProperties: () => ({ + cols: 12, + rowHeight: 18, + margin: [2, 2] as [number, number], + containerPadding: [0, 0] as [number, number], + compactType: null, + }), + }, + ]; + const before = JSON.stringify(nested); + const onChange = jest.fn(); + const fixture = (document = nested) => ( + + ); + const view = render(fixture()); + const plugin = view.getByText('Tall content'); + const host = requireValue( + view.container.querySelector('[data-dashkit-id="a"]'), + 'widget host', + ) as import('../runtime').GridItemHTMLElement; + const current = () => requireValue(widget, 'mounted plugin props'); + const grow = () => { + const props = current(); + const entry = requireValue( + props.layout.find(({i}) => i === 'a'), + 'widget layout', + ); + props.adjustWidgetLayout({ + widgetId: 'a', + adjustedWidgetLayout: {...entry, h: 20, minH: 20, maxH: 20}, + }); + }; + const capacity = Math.min( + maxRows ?? Infinity, + (6 * 20 - 2 - padding[0] - padding[2] + gap[1]) / (18 + gap[1]), + ); + const remaining = capacity - y; + act(grow); + expect(current().layout[0]).toMatchObject({h: remaining, minH: remaining, maxH: remaining}); + expect(requireValue(host.gridstackNode, 'native node').h).toBeCloseTo(remaining, 10); + expect(onChange).not.toHaveBeenCalled(); + const renders = renderer.mock.calls.length; + act(grow); + expect(renderer).toHaveBeenCalledTimes(renders); + expect(view.getByText('Tall content')).toBe(plugin); + const shrunk = {...nested, containers: [{...container, h: 4}]}; + view.rerender(fixture(shrunk)); + const shrunkRemaining = + Math.min( + maxRows ?? Infinity, + (4 * 20 - 2 - padding[0] - padding[2] + gap[1]) / (18 + gap[1]), + ) - y; + expect(current().layout[0]).toMatchObject({ + h: shrunkRemaining, + minH: shrunkRemaining, + maxH: shrunkRemaining, + }); + expect(requireValue(host.gridstackNode, 'native node').h).toBeCloseTo(shrunkRemaining, 10); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.click(plugin); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].config.layout).toEqual(nested.layout); + act(() => current().adjustWidgetLayout({widgetId: 'a', needSetDefault: true})); + expect(current().layout[0].h).toBe(2); + expect(requireValue(host.gridstackNode, 'native node')).toMatchObject({h: 2, minH: 1}); + expect(requireValue(host.gridstackNode, 'native node').maxH).toBeUndefined(); + await act(async () => { + const node = requireValue(host.gridstackNode, 'native node'); + const grid = requireValue(node.grid, 'native grid') as GridStack & { + triggerEvent(event: Event, target: HTMLElement): void; + }; + grid.triggerEvent(new Event('dragstart'), host); + grid.engine.moveNodeCheck(node, {x: 1, y}); + grid.triggerEvent(new Event('dragstop'), host); + await Promise.resolve(); + }); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange.mock.calls[1][0].config.layout).toEqual([{...nested.layout[0], x: 1}]); + expect(onChange.mock.calls[1][0].config.containers).toEqual(shrunk.containers); + expect(view.getByText('Tall content')).toBe(plugin); + expect(JSON.stringify(nested)).toBe(before); + const {containerId: _containerId, ...rootLayout} = nested.layout[0]; + view.rerender(fixture({...shrunk, layout: [{...rootLayout, x: 0, y: 0}]})); + expect(view.getByText('Tall content')).toBe(plugin); + expect(view.container.querySelector('[data-dashkit-id="a"]')).toBe(host); + expect(requireValue(host.gridstackNode, 'native node')).toMatchObject({h: 2, minH: 1}); + expect(requireValue(host.gridstackNode, 'native node').maxH).toBeUndefined(); + expect(onChange).toHaveBeenCalledTimes(2); + view.rerender(fixture(shrunk)); + act(grow); + const error = jest.spyOn(console, 'error').mockImplementation(() => undefined); + try { + expect(() => + view.rerender(fixture({...shrunk, layout: [{...nested.layout[0], h: 20}]})), + ).toThrow('exceeds its container content bounds'); + } finally { + error.mockRestore(); + } + view.unmount(); + }, +); + +test('statics retain container membership and reject nonempty or unknown removal', () => { + expect(() => + DashKitGridStack.removeItem({config, id: 'box', itemsStateAndParams: {}}), + ).not.toThrow(); + const nested = {...config, layout: [{...config.layout[0], containerId: 'box'}]}; + expect(() => + DashKitGridStack.removeItem({config: nested, id: 'box', itemsStateAndParams: {}}), + ).toThrow('move or remove children'); + expect(() => + DashKitGridStack.removeItem({config, id: 'missing', itemsStateAndParams: {}}), + ).toThrow('unknown item'); + const next = DashKitGridStack.setItem({ + config, + item: {type, data: {}, layout: {x: 0, y: 0, w: 2, h: 2, containerId: 'box'}}, + }); + expect(next.layout.at(-1)?.containerId).toBe('box'); + expect(next.items[0]).toEqual(config.items[0]); +}); + +test('new-component item helpers never delegate layout to the legacy compactor', () => { + const compact = jest.spyOn(legacyGrid, 'compact').mockImplementation(() => { + throw new Error('legacy layout must not run'); + }); + try { + const options = { + updateLayout: config.layout, + reflowLayoutOptions: {defaultProps: {cols: 12}}, + }; + expect(() => + DashKitGridStack.setItem({ + config, + item: {type, data: {}, layout: {x: 0, y: 0, w: 4, h: 2}}, + options, + }), + ).not.toThrow(); + expect(compact).not.toHaveBeenCalled(); + } finally { + compact.mockRestore(); + } +}); + +test('parameter event from actual mounted plugin carries current nested membership', () => { + const onChange = jest.fn(); + const nested = {...config, layout: [{...config.layout[0], containerId: 'box'}]}; + const {getByText} = render(); + fireEvent.click(getByText('retained')); + expect(onChange.mock.calls[0][0].config.layout[0]).toMatchObject({containerId: 'box'}); + expect(onChange.mock.calls[0][0].itemsStateAndParams.a.state).toEqual({clicked: true}); +}); + +test('nested mobile traversal renders each widget once in hierarchy order with refs and parameter identity', () => { + DashKitGridStack.setSettings({isMobile: true}); + const nested: Config = { + ...config, + items: ['a', 'b', 'c'].map((id) => ({id, type, namespace: 'default', data: {text: id}})), + containers: [ + {...box, x: 5, y: 2}, + {...box, id: 'inside', containerId: 'box', x: 0, y: 3, w: 6, h: 4}, + ], + layout: [ + {i: 'a', x: 0, y: 0, w: 4, h: 2}, + {i: 'b', x: 0, y: 1, w: 4, h: 2, containerId: 'box'}, + {i: 'c', x: 0, y: 0, w: 4, h: 2, containerId: 'inside'}, + ], + }; + const mounted = jest.fn(); + const onChange = jest.fn(); + const ref = React.createRef(); + try { + const view = render( + , + ); + expect( + [...view.container.querySelectorAll('[data-identity]')].map((el) => el.textContent), + ).toEqual(['a', 'b', 'c']); + expect(requireValue(ref.current, 'DashKit ref').getItemsMeta()).toHaveLength(3); + expect(mounted.mock.calls.filter(([, state]) => state.isMounted)).toHaveLength(3); + fireEvent.click(view.getByText('b')); + expect(onChange.mock.calls[0][0].itemsStateAndParams.b.state).toEqual({clicked: true}); + expect(onChange.mock.calls[0][0].config.layout[1].containerId).toBe('box'); + view.rerender( + ({...item, orderId: 3 - index})), + }} + editMode={false} + onChange={onChange} + />, + ); + expect( + [...view.container.querySelectorAll('[data-identity]')].map((el) => el.textContent), + ).toEqual(['c', 'b', 'a']); + expect(requireValue(ref.current, 'DashKit ref').getItemsMeta()).toHaveLength(3); + view.unmount(); + } finally { + DashKitGridStack.setSettings({isMobile: false}); + } +}); + +test('force reload targets the current plugin ref after controlled reparent', () => { + const reload = jest.fn(); + class Reloadable extends React.Component { + render() { + return reloadable; + } + reload = (data: unknown) => reload(this.props.id, data); + } + const reloadType = 'gridstack-reload-test'; + DashKitGridStack.reloadPlugins({ + type: reloadType, + renderer: (props, ref) => , + }); + const reloadConfig: Config = { + ...config, + items: config.items.map((item) => ({...item, type: reloadType})), + }; + const ref = React.createRef(); + const view = render(); + requireValue(ref.current, 'DashKit ref').reloadItems({targetIds: ['missing'], force: true}); + expect(reload).not.toHaveBeenCalled(); + view.rerender( + , + ); + requireValue(ref.current, 'DashKit ref').reloadItems({targetIds: ['a'], force: true}); + expect(reload).toHaveBeenCalledTimes(1); + expect(reload).toHaveBeenCalledWith('a', {silentLoading: true, noVeil: true}); + view.unmount(); +}); + +test('partial gridProperties retain the legacy rendered defaults and numeric style values', () => { + const view = render( + ({style: {height: 400, width: 600}})}]} + editMode + />, + ); + const widget = view.container.querySelector('[data-qa="dashkit-grid-item"]') as HTMLElement; + expect(widget.dataset.dashkitGridCols).toBe('12'); + expect(widget.dataset.dashkitGridRowHeight).toBe('150'); + expect(widget.dataset.dashkitGridMarginX).toBe('10'); + expect(widget.dataset.dashkitGridPaddingY).toBe('10'); + const zone = view.container.querySelector('.dashkit-gridstack__zone') as HTMLElement; + expect(zone.style.height).toBe('400px'); + expect(zone.style.width).toBe('600px'); + view.unmount(); +}); + +test('nested grids inherit zone interactions without treating maxRows as minimum height', () => { + const groups = [ + { + gridProperties: () => ({ + cols: 12, + rowHeight: 18, + maxRows: 20, + isDraggable: false, + isResizable: false, + resizeHandles: ['nw', 'ne', 'sw', 'se'] as Array<'nw' | 'ne' | 'sw' | 'se'>, + }), + }, + ]; + const view = render( + , + ); + const host = requireValue(view.container.querySelector('[data-dashkit-id="a"]'), 'widget host'); + expect(host.classList.contains('ui-draggable-disabled')).toBe(true); + expect(host.classList.contains('ui-resizable-disabled')).toBe(true); + expect(host.querySelectorAll('.ui-resizable-handle')).toHaveLength(0); + const root = view.container.querySelector('.grid-stack') as HTMLElement & { + gridstack: {opts: {minRow: number}; _updateContainerHeight(): void}; + }; + root.gridstack._updateContainerHeight(); + expect(root.gridstack.opts.minRow || 0).toBe(0); + expect(parseFloat(root.style.height)).toBe(270); + view.rerender( + ({ + ...groups[0].gridProperties(), + isDraggable: true, + isResizable: true, + }), + }, + ]} + editMode + />, + ); + expect(host.querySelectorAll('.ui-resizable-handle')).toHaveLength(4); + view.unmount(); +}); + +test('explicit container compaction is independent from global legacy verticalCompact false', () => { + const defaults = registerManager.gridLayout; + DashKitGridStack.setSettings({gridLayout: {verticalCompact: false}}); + try { + const nested = { + ...config, + containers: [{...box, grid: {...box.grid, compactType: 'horizontal' as const}}], + layout: [{...config.layout[0], x: 6, containerId: 'box'}], + }; + const view = render(); + const grids = [...view.container.querySelectorAll('.grid-stack')] as Array< + HTMLElement & {gridstack: {engine: {mode: {compactType: unknown}}}} + >; + expect(grids[0].gridstack.engine.mode.compactType).toBeNull(); + expect(grids[1].gridstack.engine.mode.compactType).toBe('horizontal'); + expect( + DashKitGridStack.reflowLayout({layout: nested.layout, containers: nested.containers})[0] + .x, + ).toBe(0); + view.unmount(); + } finally { + DashKitGridStack.setSettings({ + gridLayout: {...defaults, verticalCompact: defaults.verticalCompact}, + }); + } +}); + +test('external same-grid item reorder changes native stacking without remounting portals', () => { + const flat: Config = { + ...config, + containers: undefined, + layoutVersion: undefined, + items: [config.items[0], {...config.items[0], id: 'b'}], + layout: [config.layout[0], {...config.layout[0], i: 'b'}], + }; + const view = render(); + const root = requireValue(view.container.querySelector('.grid-stack'), 'grid root'); + const hosts = [...root.querySelectorAll(':scope > [data-dashkit-id]')]; + const identities = hosts.map((host) => host.querySelector('[data-identity]')); + view.rerender( + , + ); + expect([...root.querySelectorAll(':scope > [data-dashkit-id]')]).toEqual(hosts.reverse()); + expect(hosts.map((host) => host.querySelector('[data-identity]'))).toEqual( + identities.reverse(), + ); + view.unmount(); +}); + +test('removed or remounted zone roots dispose native grids and reattach the same nested portal', async () => { + const nested = {...config, layout: [{...config.layout[0], containerId: 'box'}]}; + const ref = React.createRef(); + const onChange = jest.fn(); + const view = render( + , + ); + const widget = view.container.querySelector('[data-identity]'); + const root = view.container.querySelector('.grid-stack') as HTMLElement & {gridstack?: unknown}; + const meta = await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta()); + view.rerender( + , + ); + expect(root.gridstack).toBeUndefined(); + expect(view.container.querySelector('[data-identity]')).toBeNull(); + view.rerender( + , + ); + expect(view.container.querySelector('[data-identity]')).toBe(widget); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toEqual( + meta, + ); + view.rerender( +
{children}
}]} + onChange={onChange} + />, + ); + expect(view.container.querySelector('[data-identity]')).toBe(widget); + expect(view.container.querySelectorAll('.grid-stack')).toHaveLength(2); + expect(onChange).not.toHaveBeenCalled(); + view.unmount(); +}); + +test('controlled pin to an empty or newly introduced zone keeps the widget and its ref', async () => { + const flat = {...config, containers: undefined, layoutVersion: undefined}; + const ref = React.createRef(); + const onChange = jest.fn(); + const view = render( + , + ); + const widget = view.container.querySelector('[data-identity]'); + const meta = await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta()); + const pinned = {...flat, layout: [{...flat.layout[0], parent: 'fixed'}]}; + view.rerender( + , + ); + expect(view.container.querySelector('[data-identity]')).toBe(widget); + expect(view.container.querySelectorAll('[data-qa="dashkit-grid-item"]')).toHaveLength(1); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toEqual( + meta, + ); + view.rerender( + , + ); + view.rerender( + , + ); + expect(view.container.querySelector('[data-identity]')).toBe(widget); + expect(onChange).not.toHaveBeenCalled(); + view.unmount(); +}); + +// Critical: yes. A stale gesture must not overwrite independent controlled document edits. +test.each([ + ['drag', 'layout'], + ['resize', 'layout'], + ['drag', 'containers'], + ['resize', 'containers'], +] as const)( + 'controlled %s cancellation accepts a new %s revision without echo and retains the next gesture', + async (gesture, revision) => { + const initial: Config = { + ...config, + containers: [{...box, w: 2, h: 2, padding: [0, 0, 0, 0]}], + items: [config.items[0], {...config.items[0], id: 'b'}], + layout: [ + {i: 'a', x: 0, y: 0, w: 2, h: 2}, + {i: 'b', x: 6, y: 0, w: 2, h: 2}, + ], + }; + const groups: DashKitGroup[] = [ + { + gridProperties: () => ({ + cols: 12, + rowHeight: 20, + margin: [0, 0], + containerPadding: [0, 0], + compactType: null, + allowOverlap: true, + }), + }, + ]; + const onChange = jest.fn(); + const events = jest.fn(); + const ref = React.createRef(); + const fixture = (document: Config) => ( + + ); + const view = render(fixture(initial)); + requireValue(ref.current, 'DashKit ref').on('change', events); + const host = requireValue( + view.container.querySelector('[data-dashkit-id="a"]'), + 'active host', + ); + const plugin = host.querySelector('[data-identity]'); + const meta = await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta()); + const grid = requireValue(host.gridstackNode?.grid, 'active grid') as GridStack & { + triggerEvent(event: Event, target: HTMLElement): void; + }; + act(() => { + grid.triggerEvent(new Event(`${gesture}start`), host); + grid.engine.moveNodeCheck(requireValue(host.gridstackNode, 'active node'), { + x: 1, + w: gesture === 'resize' ? 3 : 2, + resizing: gesture === 'resize', + }); + }); + const external: Config = { + ...initial, + ...(revision === 'layout' + ? { + layout: [ + {...initial.layout[0], x: 4}, + {...initial.layout[1], y: 5}, + ], + } + : {containers: [{...requireValue(initial.containers, 'containers')[0], x: 2}]}), + }; + view.rerender(fixture(external)); + expect(host.gridstackNode).toMatchObject({x: external.layout[0].x, y: 0, w: 2, h: 2}); + expect( + view.container.querySelector('[data-dashkit-id="box"]') + ?.gridstackNode?.x, + ).toBe(revision === 'containers' ? 2 : 0); + await act(async () => { + grid.triggerEvent(new Event(`${gesture}stop`), host); + await Promise.resolve(); + }); + expect(host.gridstackNode).toMatchObject({x: external.layout[0].x, y: 0, w: 2, h: 2}); + expect(onChange).not.toHaveBeenCalled(); + expect(events).not.toHaveBeenCalled(); + expect(host.querySelector('[data-identity]')).toBe(plugin); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toEqual( + meta, + ); + const next = requireValue( + view.container.querySelector('[data-dashkit-id="b"]'), + 'next host', + ); + await act(async () => { + grid.triggerEvent(new Event('dragstart'), next); + grid.engine.moveNodeCheck(requireValue(next.gridstackNode, 'next node'), {x: 7}); + grid.triggerEvent(new Event('dragstop'), next); + await Promise.resolve(); + }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(events).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].config.layout).toEqual([ + external.layout[0], + {...external.layout[1], x: 7}, + ]); + expect(onChange.mock.calls[0][0].config.containers).toEqual(external.containers); + view.unmount(); + }, +); + +// Critical: yes. Native pointer cleanup must not resave a stale document or break the next edit. +test.each(['drag', 'resize'] as const)( + 'controlled revision stops native %s pointer handlers before accepting the next gesture', + async (gesture) => { + const measure = jest + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const host = this.closest('[data-dashkit-id]'); + const node = host?.gridstackNode; + const x = + this.style.position === 'fixed' + ? parseFloat(this.style.left) || 0 + : (node?.x || 0) * (640 / 12); + const y = + this.style.position === 'fixed' + ? parseFloat(this.style.top) || 0 + : (node?.y || 0) * 20; + const width = this.style.width === '1px' ? 1 : (node?.w || 12) * (640 / 12); + const height = this.style.height === '1px' ? 1 : (node?.h || 20) * 20; + return { + x, + y, + left: x, + top: y, + right: x + width, + bottom: y + height, + width, + height, + toJSON: () => ({}), + }; + }); + const initial = { + ...config, + containers: undefined, + layoutVersion: undefined, + layout: [{i: 'a', x: 0, y: 0, w: 6, h: 4}], + }; + const groups: DashKitGroup[] = [ + { + gridProperties: () => ({ + cols: 12, + rowHeight: 20, + margin: [0, 0], + containerPadding: [0, 0], + compactType: null, + }), + }, + ]; + const onChange = jest.fn(); + const onStart = jest.fn(); + const view = render( + , + ); + const host = requireValue( + view.container.querySelector('[data-dashkit-id="a"]'), + 'pointer host', + ); + const plugin = host.querySelector('[data-identity]'); + const target = requireValue( + host.querySelector( + gesture === 'drag' ? '.grid-stack-item-content' : '.ui-resizable-se', + ), + 'native pointer target', + ); + fireEvent.mouseDown(target, {clientX: 100, clientY: 30, buttons: 1}); + fireEvent.mouseMove(document, {clientX: 110, clientY: 40, buttons: 1}); + fireEvent.mouseMove(document, {clientX: 165, clientY: 55, buttons: 1}); + expect(onStart).toHaveBeenCalledTimes(1); + const external = {...initial, layout: [{...initial.layout[0], x: 2, y: 3}]}; + view.rerender( + , + ); + await act(async () => { + await Promise.resolve(); + }); + fireEvent.mouseMove(document, {clientX: 300, clientY: 200, buttons: 1}); + fireEvent.mouseUp(document, {clientX: 300, clientY: 200}); + await act(async () => { + await Promise.resolve(); + }); + expect(host.gridstackNode).toMatchObject({x: 2, y: 3, w: 6, h: 4}); + expect(onChange).not.toHaveBeenCalled(); + expect(host.querySelector('[data-identity]')).toBe(plugin); + fireEvent.mouseDown( + requireValue( + host.querySelector('.grid-stack-item-content'), + 'next pointer target', + ), + {clientX: 150, clientY: 80, buttons: 1}, + ); + fireEvent.mouseMove(document, {clientX: 160, clientY: 80, buttons: 1}); + fireEvent.mouseMove(document, {clientX: 215, clientY: 80, buttons: 1}); + fireEvent.mouseUp(document, {clientX: 215, clientY: 80}); + await act(async () => { + await Promise.resolve(); + }); + expect(onStart).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].config.layout).toEqual([{i: 'a', x: 3, y: 3, w: 6, h: 4}]); + expect(host.querySelector('[data-identity]')).toBe(plugin); + view.unmount(); + measure.mockRestore(); + }, +); + +// Critical: yes. Hidden destinations must not leak stale membership into the next saved edit. +test.each([false, true])( + 'controlled move to an absent zone parks the same widget/subtree (nested=%s)', + async (nested) => { + const initial: Config = { + ...config, + containers: nested + ? [{...box, parent: 'left', w: 6, padding: [0, 0, 0, 0]}] + : undefined, + items: [config.items[0], {...config.items[0], id: 'b'}], + layout: [ + { + i: 'a', + parent: 'left', + x: 0, + y: 0, + w: 2, + h: 2, + ...(nested ? {containerId: 'box'} : {}), + }, + {i: 'b', parent: 'left', x: 8, y: 0, w: 2, h: 2}, + ], + }; + const group: DashKitGroup = { + id: 'left', + gridProperties: () => ({ + cols: 12, + rowHeight: 20, + margin: [0, 0], + containerPadding: [0, 0], + compactType: null, + allowOverlap: true, + }), + }; + const groups = [group]; + const onChange = jest.fn(); + const ref = React.createRef(); + const fixture = (document: Config, renderedGroups = groups) => ( + + ); + const view = render(fixture(initial)); + const host = requireValue( + view.container.querySelector('[data-dashkit-id="a"]'), + 'widget host', + ); + const plugin = host.querySelector('[data-identity]'); + const meta = await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta()); + const moved: Config = { + ...initial, + layout: [{...initial.layout[0], parent: 'inactive'}, initial.layout[1]], + containers: initial.containers?.map((entry) => ({...entry, parent: 'inactive'})), + }; + view.rerender(fixture(moved)); + expect(view.container.contains(host)).toBe(false); + expect(host.querySelector('[data-identity]')).toBe(plugin); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toEqual( + meta, + ); + expect(onChange).not.toHaveBeenCalled(); + const next = requireValue( + view.container.querySelector('[data-dashkit-id="b"]'), + 'visible host', + ); + const grid = requireValue(next.gridstackNode?.grid, 'visible grid') as GridStack & { + triggerEvent(event: Event, target: HTMLElement): void; + }; + await act(async () => { + grid.triggerEvent(new Event('dragstart'), next); + grid.engine.moveNodeCheck(requireValue(next.gridstackNode, 'visible node'), {x: 9}); + grid.triggerEvent(new Event('dragstop'), next); + await Promise.resolve(); + }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].config.layout).toEqual([ + moved.layout[0], + {...moved.layout[1], x: 9}, + ]); + expect(onChange.mock.calls[0][0].config.containers).toEqual(moved.containers); + view.rerender( + fixture(onChange.mock.calls[0][0].config, [...groups, {...group, id: 'inactive'}]), + ); + expect(view.container.querySelector('[data-dashkit-id="a"]')).toBe(host); + expect(host.querySelector('[data-identity]')).toBe(plugin); + expect(host.gridstackNode).toMatchObject({id: 'a', x: 0, y: 0, w: 2, h: 2}); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toEqual( + meta, + ); + expect(onChange).toHaveBeenCalledTimes(1); + view.unmount(); + }, +); + +test('invalid controlled membership is rejected before a replacement document callback', () => { + const onChange = jest.fn(); + const view = render(); + const error = jest.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => + view.rerender( + , + ), + ).toThrow('invalid containerId'); + expect(onChange).not.toHaveBeenCalled(); + error.mockRestore(); +}); + +test('reload interval respects hidden and edit states, force targets and unmount cleanup', () => { + jest.useFakeTimers(); + const reload = jest.fn(); + class TimedPlugin extends React.Component { + render() { + return timed; + } + reload = () => reload(this.props.id); + } + const timedType = 'gridstack-timed-plugin'; + DashKitGridStack.reloadPlugins({ + type: timedType, + renderer: (props, ref) => , + }); + const timed = {...config, items: [{...config.items[0], type: timedType}]}; + const ref = React.createRef(); + const hidden = jest.spyOn(document, 'hidden', 'get').mockReturnValue(false); + const view = render( + , + ); + act(() => jest.advanceTimersByTime(1100)); + expect(reload).not.toHaveBeenCalled(); + view.rerender( + , + ); + expect(reload).toHaveBeenCalledTimes(1); + hidden.mockReturnValue(true); + act(() => jest.advanceTimersByTime(1100)); + expect(reload).toHaveBeenCalledTimes(1); + requireValue(ref.current, 'DashKit ref').reloadItems({targetIds: ['a'], force: true}); + expect(reload).toHaveBeenCalledTimes(2); + hidden.mockReturnValue(false); + fireEvent(document, new Event('visibilitychange')); + act(() => jest.advanceTimersByTime(1100)); + expect(reload).toHaveBeenCalledTimes(3); + view.unmount(); + act(() => jest.advanceTimersByTime(5000)); + expect(reload).toHaveBeenCalledTimes(3); + hidden.mockRestore(); + jest.useRealTimers(); +}); + +test('copy uses current saved nested dimensions, prop transformer and storage error callback', () => { + const copy = jest.fn(); + const transform = jest.fn((options) => ({...options, copyContext: {source: 'prop'}})); + const deprecatedTransform = jest.fn(); + const storage = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => undefined); + const view = render( + , + ); + const warning = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + fireEvent.click(view.getByText('Copy fixture')); + expect(copy.mock.calls[0][0]).toBeNull(); + expect(copy.mock.calls[0][1]).toMatchObject({ + targetId: 'a', + layout: {w: 7, h: 5}, + copyContext: {source: 'prop'}, + data: config.items[0].data, + }); + expect(deprecatedTransform).not.toHaveBeenCalled(); + storage.mockImplementation(() => { + throw new Error('storage denied'); + }); + fireEvent.click(view.getByText('Copy fixture')); + expect(copy.mock.calls[1][0]).toEqual(new Error('storage denied')); + warning.mockRestore(); + storage.mockRestore(); + view.unmount(); +}); + +// Critical: yes. A valid subtree must reach native fit checks in destination units, not source pixels. +test('native bounded droppable accepts fitting container units and rejects an oversized subtree', () => { + const measured = jest + .spyOn(HTMLElement.prototype, 'clientWidth', 'get') + .mockImplementation(function (this: HTMLElement) { + return this.closest('[data-dashkit-id="box"]') ? 280 : 640; + }); + const target = { + ...box, + x: 300, + w: 280, + h: 300, + padding: [0, 0, 0, 0] as [number, number, number, number], + grid: {...box.grid, maxRows: 10}, + }; + const small = {...target, id: 'small', x: 0, w: 80, h: 80}; + const large = {...small, id: 'large', y: 100, w: 300, h: 200}; + const document = {...config, items: [], layout: [], containers: [target, small, large]}; + const view = render( + ({ + cols: 640, + rowHeight: 1, + margin: [0, 0], + containerPadding: [0, 0], + compactType: null, + maxRows: 400, + }), + }, + ]} + />, + ); + const root = view.container.querySelector( + '[data-dashkit-id="box"] .grid-stack', + ) as HTMLElement & {ddElement: {ddDroppable: {option: {accept: (el: Element) => boolean}}}}; + expect( + root.ddElement.ddDroppable.option.accept( + requireValue(view.container.querySelector('[data-dashkit-id="small"]'), 'widget host'), + ), + ).toBe(true); + expect( + root.ddElement.ddDroppable.option.accept( + requireValue(view.container.querySelector('[data-dashkit-id="large"]'), 'widget host'), + ), + ).toBe(false); + view.unmount(); + measured.mockRestore(); +}); + +test.each(['initial', 'controlled'] as const)( + 'impossible %s container spacing is rejected before native update or change callback', + (phase) => { + const onChange = jest.fn(); + const invalid = { + ...config, + containers: [{...box, padding: [1000, 0, 1000, 0] as [number, number, number, number]}], + }; + const error = jest.spyOn(console, 'error').mockImplementation(() => undefined); + if (phase === 'initial') { + expect(() => + render(), + ).toThrow('usable width or height'); + } else { + const view = render(); + const root = view.container.querySelector('.grid-stack') as HTMLElement & { + gridstack: import('../runtime').GridStack; + }; + const update = jest.spyOn(root.gridstack, 'update'); + expect(() => + view.rerender(), + ).toThrow('usable width or height'); + expect(update).not.toHaveBeenCalled(); + } + expect(onChange).not.toHaveBeenCalled(); + error.mockRestore(); + }, +); + +// Critical: yes. A denied/no-op native resize must not append a bogus history transaction. +test('GridStack no-op layout normalizes optional default-zone keys on both baseline and next config', () => { + const instance = new DashKit(DashKit.defaultProps as never); + const event = jest.fn(); + instance.on('change', event); + const onChange = jest.fn(); + const flat = {...config, containers: undefined, layoutVersion: undefined}; + const props = { + ...DashKit.defaultProps, + config: flat, + editMode: true, + registerManager, + forwardedMetaRef: null, + emitDashKitEvent: instance[_emitSymbol], + onChange, + }; + render(); + act(() => context.layoutChange(context.layout.map(({parent: _parent, ...entry}) => entry))); + expect(event).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); +}); + +// Critical: yes. Explicit subtree deletion must dispose descendants before their parent grid. +test('controlled removal of an entire nested subtree cleans native grids and plugin refs atomically', async () => { + const inner = { + ...box, + id: 'inner', + containerId: 'box', + x: 0, + y: 0, + w: 6, + h: 4, + padding: [0, 0, 0, 0] as [number, number, number, number], + grid: {...box.grid, cols: 6}, + }; + const nested = { + ...config, + containers: [box, inner], + layout: [{...config.layout[0], w: 3, h: 2, containerId: 'inner'}], + }; + const ref = React.createRef(); + const onChange = jest.fn(); + const view = render( + , + ); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toHaveLength( + 1, + ); + view.rerender( + , + ); + expect(view.container.querySelectorAll('[data-identity]')).toHaveLength(0); + expect(view.container.querySelectorAll('.grid-stack')).toHaveLength(1); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toEqual([]); + expect(onChange).not.toHaveBeenCalled(); +}); + +test('a hidden sibling root does not bypass measured container validation in a visible zone', () => { + const measured = jest + .spyOn(HTMLElement.prototype, 'clientWidth', 'get') + .mockImplementation(function (this: HTMLElement) { + return this.closest('[data-hidden-grid]') ? 0 : 640; + }); + const invalid = { + ...config, + items: [], + layout: [], + containers: [ + { + ...box, + parent: 'visible', + padding: [1000, 0, 1000, 0] as [number, number, number, number], + }, + ], + }; + const groups = ['visible', 'hidden'].map((id) => ({ + id, + render: (key: string, children: React.ReactNode) => ( +
+ {children} +
+ ), + })); + const error = jest.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => render()).toThrow( + 'usable width or height', + ); + error.mockRestore(); + measured.mockRestore(); +}); + +// Critical: yes. Physical nested bounds must prevent invalid native edits without truncating saved fractions. +test.each([undefined, 4, 100])( + 'nested runtime intersects physical height with maxRows %s', + (maxRows) => { + const container = {...box, grid: {...box.grid, maxRows}}; + const nested = { + ...config, + containers: [container], + layout: [{...config.layout[0], containerId: 'box'}], + }; + const onChange = jest.fn(); + const view = render(); + const root = view.container.querySelector( + '[data-dashkit-id="box"] .grid-stack', + ) as HTMLElement & { + gridstack: { + engine: {maxRow: number; willItFit(node: {w: number; h: number}): boolean}; + opts: {maxRow: number}; + }; + }; + // The root has 18px rows/2px gap: container height198, content158, child step24/gap6. + const limit = Math.min(maxRows ?? Infinity, (198 - 10 - 30 + 6) / 24); + expect(root.gridstack.engine.maxRow).toBe(limit); + expect(root.gridstack.opts.maxRow).toBe(limit); + expect(root.gridstack.engine.willItFit({w: 1, h: limit + 1})).toBe(false); + expect(root.gridstack.engine.willItFit({w: 1, h: 1})).toBe(true); + expect(onChange).not.toHaveBeenCalled(); + expect(container.grid.maxRows).toBe(maxRows); + view.unmount(); + }, +); + +test('fractional nested geometry survives controlled parent resizing and restoration without echo', () => { + const nested = { + ...config, + layout: [{...config.layout[0], x: 0.25, y: 1.25, w: 3.5, h: 5.5, containerId: 'box'}], + }; + const onChange = jest.fn(); + const view = render(); + const plugin = view.container.querySelector('[data-identity]'); + const host = view.container.querySelector('[data-dashkit-id="a"]') as HTMLElement & { + gridstackNode: { + x: number; + y: number; + w: number; + h: number; + grid: {engine: {maxRow: number}}; + }; + }; + const original = { + x: host.gridstackNode.x, + y: host.gridstackNode.y, + w: host.gridstackNode.w, + h: host.gridstackNode.h, + }; + expect(original).toEqual({x: 0.25, y: 1.25, w: 3.5, h: 5.5}); + const initialLimit = host.gridstackNode.grid.engine.maxRow; + view.rerender( + , + ); + expect(host.gridstackNode.grid.engine.maxRow).toBe(initialLimit + 40 / 24); + view.rerender(); + expect(host.gridstackNode).toMatchObject(original); + expect(host.gridstackNode.grid.engine.maxRow).toBe(initialLimit); + expect(view.container.querySelector('[data-identity]')).toBe(plugin); + expect(onChange).not.toHaveBeenCalled(); + view.unmount(); +}); + +// Critical: yes. A malformed native proposal must be restored before history sees it. +// This checks the native callback seam; actual pointer admission is covered in the browser matrix. +test('native final proposal validation rejects overflow before emitting and retains the next edit', async () => { + const nested = {...config, layout: [{...config.layout[0], containerId: 'box'}]}; + const onChange = jest.fn(); + const onResizeStop = jest.fn(); + const view = render( + , + ); + const host = view.container.querySelector('[data-dashkit-id="a"]') as HTMLElement & { + gridstackNode: { + x: number; + h: number; + grid: {triggerEvent(event: Event, target: HTMLElement): void}; + }; + }; + const plugin = view.container.querySelector('[data-identity]'); + const grid = host.gridstackNode.grid; + await act(async () => { + grid.triggerEvent(new Event('resizestart'), host); + host.gridstackNode.h = 200; + grid.triggerEvent(new Event('resizestop'), host); + await Promise.resolve(); + }); + expect(onChange).not.toHaveBeenCalled(); + expect(onResizeStop).toHaveBeenCalledTimes(1); + expect(host.gridstackNode.h).toBe(4); + expect(view.container.querySelector('[data-identity]')).toBe(plugin); + await act(async () => { + grid.triggerEvent(new Event('resizestart'), host); + host.gridstackNode.h = 5; + grid.triggerEvent(new Event('resizestop'), host); + await Promise.resolve(); + }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].config.layout[0].h).toBe(5); + expect(view.container.querySelector('[data-identity]')).toBe(plugin); + view.unmount(); +}); + +// Critical: yes. Canceling an add must not persist displaced neighbors on the next save. +test.each([ + 'dragend', + 'leave', + 'veto', + 'fit', + 'drop-bounds', + 'drop-error', + 'controlled', + 'edit-off', +] as const)( + 'palette collision followed by %s restores neighbors before a later saved gesture', + async (cancellation) => { + const flat: Config = { + ...config, + containers: undefined, + layoutVersion: undefined, + items: [config.items[0], {...config.items[0], id: 'b'}], + layout: [ + {i: 'a', x: 0, y: 0, w: 3, h: 2}, + {i: 'b', x: 8, y: 0, w: 3, h: 2}, + ], + }; + const onChange = jest.fn(); + let allowed = true; + const groups = [ + { + gridProperties: () => ({ + cols: 12, + rowHeight: 18, + margin: [0, 0] as [number, number], + containerPadding: [0, 0] as [number, number], + compactType: null, + }), + }, + ]; + const fixture = (document = flat, editMode = true) => ( + allowed}> +
+ + { + if (cancellation === 'drop-error') { + throw new Error('drop rejected'); + } + }} + /> +
+
+ ); + const view = render(fixture()); + const root = view.container.querySelector('.grid-stack') as HTMLElement & { + gridstack: { + engine: GridStack['engine']; + triggerEvent(event: Event, target: HTMLElement): void; + }; + }; + const host = view.container.querySelector( + '[data-dashkit-id="a"]', + ) as import('../runtime').GridItemHTMLElement; + const next = view.container.querySelector( + '[data-dashkit-id="b"]', + ) as import('../runtime').GridItemHTMLElement; + const identity = host.querySelector('[data-identity]'); + jest.spyOn(root, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + right: 640, + bottom: 400, + width: 640, + height: 400, + x: 0, + y: 0, + toJSON: () => ({}), + }); + const over = (x = 0, y = 0) => { + const event = new Event('dragover', {bubbles: true, cancelable: true}); + Object.assign(event, {clientX: x, clientY: y}); + fireEvent(root, event); + }; + fireEvent.dragStart(view.getByRole('button', {name: '+ Add'}), { + dataTransfer: {setDragImage: jest.fn()}, + }); + over(); + expect(requireValue(host.gridstackNode, 'host.gridstackNode').y).toBe(2); + expect(onChange).not.toHaveBeenCalled(); + let expected = flat.layout[0]; + if (cancellation === 'dragend') { + fireEvent.dragEnd(window); + } + if (cancellation === 'leave') { + const event = new Event('dragleave', {bubbles: true}); + Object.assign(event, {clientX: -1, clientY: -1, relatedTarget: null}); + fireEvent(root, event); + } + if (cancellation === 'veto') { + allowed = false; + over(); + } + if (cancellation === 'fit') { + const fit = jest.spyOn(root.gridstack.engine, 'willItFit').mockReturnValue(false); + over(); + fit.mockRestore(); + } + if (cancellation === 'drop-error') { + const errors: string[] = []; + const reject = (event: ErrorEvent) => { + errors.push(event.message); + event.preventDefault(); + }; + window.addEventListener('error', reject); + fireEvent.drop(root); + window.removeEventListener('error', reject); + expect(errors).toEqual(['drop rejected']); + } + if (cancellation === 'drop-bounds') { + root.gridstack.engine.maxRow = 1; + fireEvent.drop(root); + } + if (cancellation === 'controlled') { + expected = {...expected, x: 1, y: 1}; + view.rerender(fixture({...flat, layout: [expected, flat.layout[1]]})); + fireEvent.dragEnd(window); + } + if (cancellation === 'edit-off') { + view.rerender(fixture(flat, false)); + } + expect(host.gridstackNode).toMatchObject({ + id: expected.i, + x: expected.x, + y: expected.y, + w: expected.w, + h: expected.h, + }); + expect(view.container.querySelectorAll('[data-dashkit-id]')).toHaveLength(2); + expect(host.querySelector('[data-identity]')).toBe(identity); + expect(onChange).not.toHaveBeenCalled(); + if (cancellation === 'edit-off') { + view.rerender(fixture()); + } + await act(async () => { + root.gridstack.triggerEvent(new Event('dragstart'), next); + root.gridstack.engine.moveNodeCheck( + requireValue(next.gridstackNode, 'next.gridstackNode'), + {x: 7, y: 0}, + ); + root.gridstack.triggerEvent(new Event('dragstop'), next); + await Promise.resolve(); + }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].config.layout[0]).toMatchObject(expected); + expect(onChange.mock.calls[0][0].config.layout[1]).toMatchObject({i: 'b', x: 7, y: 0}); + view.unmount(); + }, +); + +// Critical: yes. Child-grid columns must not shrink an unrelated saved root widget. +test.each([2, 20])( + 'nested width %s keeps root nowrap resizing in root units', + async (childWidth) => { + const groups: DashKitGroup[] = [ + { + id: DEFAULT_GROUP, + gridProperties: () => ({ + cols: 12, + rowHeight: 20, + margin: [0, 0], + containerPadding: [0, 0], + compactType: 'horizontal-nowrap', + }), + }, + ]; + const nested: Config = { + ...config, + items: ['root', 'child'].map((id) => ({...config.items[0], id, data: {text: id}})), + layout: [ + {i: 'root', x: 6, y: 0, w: 2, h: 2}, + {i: 'child', containerId: 'box', x: 0, y: 0, w: childWidth, h: 2}, + ], + containers: [ + { + ...box, + x: 0, + y: 0, + w: 6, + h: 6, + padding: [0, 0, 0, 0], + gap: [0, 0], + grid: {cols: 24, rowHeight: 20, compactType: null}, + }, + ], + }; + const before = JSON.stringify(nested); + const onChange = jest.fn(); + const view = render( + , + ); + const host = requireValue( + view.container.querySelector('[data-dashkit-id="root"]'), + 'root host', + ); + const plugin = host.querySelector('[data-identity]'); + const grid = requireValue(host.gridstackNode?.grid, 'root grid') as GridStack & { + triggerEvent(event: Event, target: HTMLElement): void; + }; + for (const [requested, expected] of [ + [4, 4], + [20, 6], + ]) { + await act(async () => { + grid.triggerEvent(new Event('resizestart'), host); + grid.engine.moveNodeCheck(requireValue(host.gridstackNode, 'root node'), { + x: 6, + y: 0, + w: requested, + h: 2, + resizing: true, + }); + grid.triggerEvent(new Event('resizestop'), host); + await Promise.resolve(); + }); + expect(host.gridstackNode).toMatchObject({x: 6, y: 0, w: expected, h: 2}); + expect(onChange.mock.calls.at(-1)[0].config.layout).toEqual([ + {...nested.layout[0], w: expected}, + nested.layout[1], + ]); + expect(onChange.mock.calls.at(-1)[0].config.containers).toEqual(nested.containers); + expect(host.querySelector('[data-identity]')).toBe(plugin); + } + expect(onChange).toHaveBeenCalledTimes(2); + expect(JSON.stringify(nested)).toBe(before); + view.unmount(); + }, +); + +// Critical: yes. Controlled reparenting must remove stale flat constraints and restore them on return. +test('nowrap constraints stay correct across flat to nested to flat revisions', async () => { + const groups: DashKitGroup[] = [ + { + id: DEFAULT_GROUP, + gridProperties: () => ({ + cols: 12, + rowHeight: 20, + margin: [0, 0], + containerPadding: [0, 0], + compactType: 'horizontal-nowrap', + }), + }, + ]; + const flat: Config = { + ...config, + containers: undefined, + layoutVersion: undefined, + items: ['root', 'child'].map((id) => ({...config.items[0], id, data: {text: id}})), + layout: [ + {i: 'root', x: 8, y: 0, w: 2, h: 2}, + {i: 'child', x: 0, y: 0, w: 8, h: 2}, + ], + }; + const nested: Config = { + ...flat, + layoutVersion: 2, + layout: [ + {...flat.layout[0], x: 6}, + {...flat.layout[1], containerId: 'box'}, + ], + containers: [ + { + ...box, + x: 0, + y: 0, + w: 6, + h: 6, + padding: [0, 0, 0, 0], + gap: [0, 0], + grid: {cols: 24, rowHeight: 20, compactType: null}, + }, + ], + }; + const onChange = jest.fn(); + const fixture = (document: Config) => ( + + ); + const view = render(fixture(flat)); + const host = requireValue( + view.container.querySelector('[data-dashkit-id="root"]'), + 'root host', + ); + const plugin = host.querySelector('[data-identity]'); + expect(host.gridstackNode?.maxW).toBe(4); + view.rerender(fixture(nested)); + expect(onChange).not.toHaveBeenCalled(); + const resize = async (width: number) => { + const grid = requireValue(host.gridstackNode?.grid, 'root grid') as GridStack & { + triggerEvent(event: Event, target: HTMLElement): void; + }; + await act(async () => { + grid.triggerEvent(new Event('resizestart'), host); + grid.engine.moveNodeCheck(requireValue(host.gridstackNode, 'root node'), { + w: width, + resizing: true, + }); + grid.triggerEvent(new Event('resizestop'), host); + await Promise.resolve(); + }); + }; + await resize(6); + expect(host.gridstackNode).toMatchObject({x: 6, y: 0, w: 6, h: 2}); + expect(onChange.mock.calls.at(-1)[0].config.layout).toEqual([ + {...nested.layout[0], w: 6}, + nested.layout[1], + ]); + expect(onChange.mock.calls.at(-1)[0].config.containers).toEqual(nested.containers); + view.rerender(fixture(flat)); + expect(onChange).toHaveBeenCalledTimes(1); + expect(host.gridstackNode?.maxW).toBe(4); + await resize(10); + expect(host.gridstackNode).toMatchObject({x: 8, y: 0, w: 4, h: 2}); + expect(onChange.mock.calls.at(-1)[0].config.layout).toEqual([ + {...flat.layout[0], w: 4}, + flat.layout[1], + ]); + expect(onChange.mock.calls.at(-1)[0].config.containers).toBeUndefined(); + expect(host.querySelector('[data-identity]')).toBe(plugin); + expect(onChange).toHaveBeenCalledTimes(2); + view.unmount(); +}); + +// Critical: yes. Chart-body interactions must not accidentally move saved widgets. +test('custom plugin handles bind after portal render and update without remount', async () => { + const customType = 'native-custom-handle-regression'; + const bodyDown = jest.fn(); + DashKitGridStack.reloadPlugins({ + type: customType, + defaultLayout: {w: 2, h: 2, minW: 1, minH: 1}, + renderer: (props, ref) => ( +
+ + {props.data.handleVisible && ( + + Handle A + + )} + + + Chart body + +
+ ), + }); + const measure = jest + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const node = this.closest('[data-dashkit-id]')?.gridstackNode; + const x = + this.style.position === 'fixed' + ? parseFloat(this.style.left) || 0 + : (node?.x || 0) * 50; + const y = + this.style.position === 'fixed' + ? parseFloat(this.style.top) || 0 + : (node?.y || 0) * 20; + const width = this.style.width === '1px' ? 1 : (node?.w || 12) * 50; + const height = this.style.height === '1px' ? 1 : (node?.h || 20) * 20; + return { + x, + y, + width, + height, + left: x, + top: y, + right: x + width, + bottom: y + height, + toJSON: () => ({}), + }; + }); + const initial: Config = { + ...config, + containers: undefined, + layoutVersion: undefined, + items: [ + { + ...config.items[0], + type: customType, + data: {text: 'Custom plugin', handleVisible: true, handleVersion: 1}, + }, + ], + layout: [{i: 'a', x: 0, y: 0, w: 2, h: 2}], + }; + const onChange = jest.fn(); + const onDragStart = jest.fn(); + const groups: DashKitGroup[] = [ + { + gridProperties: () => ({ + cols: 12, + rowHeight: 20, + margin: [0, 0], + containerPadding: [0, 0], + compactType: null, + resizeHandles: ['se'], + resizeHandle: () => Custom resize control, + }), + }, + ]; + const ref = React.createRef(); + const fixture = (document: Config, handle?: string, editMode = true) => ( + + ); + const view = render(fixture(initial, 'handle-a')); + const host = requireValue( + view.container.querySelector('[data-dashkit-id="a"]'), + 'custom host', + ); + const plugin = host.querySelector('[data-identity]'); + const meta = await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta()); + const flush = () => + act(async () => { + await Promise.resolve(); + }); + const drag = async (target: Element) => { + const x = (host.gridstackNode?.x || 0) * 50 + 10; + fireEvent.mouseDown(target, {button: 0, clientX: x, clientY: 10, buttons: 1}); + fireEvent.mouseMove(document, {clientX: x + 5, clientY: 10, buttons: 1}); + fireEvent.mouseMove(document, {clientX: x + 50, clientY: 10, buttons: 1}); + fireEvent.mouseUp(document, {clientX: x + 50, clientY: 10}); + await flush(); + }; + try { + await flush(); + const resizeControl = view.getByText('Custom resize control'); + const resizeTarget = resizeControl.parentElement; + await drag(view.getByText('Chart body')); + expect(bodyDown).toHaveBeenCalledTimes(1); + expect(onDragStart).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(host.gridstackNode?.x).toBe(0); + await drag(view.getByText('Handle A')); + expect(onChange.mock.calls.at(-1)[0].config.layout[0].x).toBe(1); + view.rerender(fixture(initial, 'handle-b')); + await flush(); + const count = onChange.mock.calls.length; + await drag(view.getByText('Handle A')); + expect(onChange).toHaveBeenCalledTimes(count); + await drag(view.getByText('Handle B')); + expect(onChange).toHaveBeenCalledTimes(count + 1); + expect(onChange.mock.calls.at(-1)[0].config.layout[0].x).toBe(2); + await drag( + requireValue(view.getByTestId('handle-icon').querySelector('path'), 'handle icon path'), + ); + expect(onChange).toHaveBeenCalledTimes(count + 2); + expect(onChange.mock.calls.at(-1)[0].config.layout[0].x).toBe(3); + expect(view.getByText('Custom resize control')).toBe(resizeControl); + expect(resizeControl.parentElement).toBe(resizeTarget); + view.rerender(fixture(initial, 'handle-a', false)); + await flush(); + const viewCount = onChange.mock.calls.length; + await drag(view.getByText('Handle A')); + expect(onChange).toHaveBeenCalledTimes(viewCount); + view.rerender(fixture(initial, 'handle-b', false)); + await flush(); + view.rerender(fixture(initial, 'handle-b')); + await flush(); + const restoredX = host.gridstackNode?.x || 0; + await drag(view.getByText('Handle B')); + expect(onChange).toHaveBeenCalledTimes(viewCount + 1); + expect(onChange.mock.calls.at(-1)[0].config.layout[0].x).toBe(restoredX + 1); + const hidden: Config = { + ...initial, + items: [ + { + ...initial.items[0], + data: {text: 'Custom plugin', handleVisible: false, handleVersion: 2}, + }, + ], + }; + view.rerender(fixture(hidden, 'handle-a')); + await flush(); + const noHandleCount = onChange.mock.calls.length; + await drag(view.getByText('Chart body')); + expect(onChange).toHaveBeenCalledTimes(noHandleCount); + const revealed: Config = { + ...initial, + items: [ + { + ...initial.items[0], + data: {text: 'Custom plugin', handleVisible: true, handleVersion: 3}, + }, + ], + }; + view.rerender(fixture(revealed, 'handle-a')); + await flush(); + await drag(view.getByText('Handle A')); + expect(onChange).toHaveBeenCalledTimes(noHandleCount + 1); + view.rerender(fixture(initial)); + await flush(); + const defaultCount = onChange.mock.calls.length; + await drag(view.getByText('Chart body')); + expect(onChange).toHaveBeenCalledTimes(defaultCount + 1); + const restoredResizeControl = view.getByText('Custom resize control'); + expect(host.contains(restoredResizeControl)).toBe(true); + const resizeX = ((host.gridstackNode?.x || 0) + (host.gridstackNode?.w || 0)) * 50; + const resizeCount = onChange.mock.calls.length; + fireEvent.mouseDown(restoredResizeControl, { + button: 0, + clientX: resizeX, + clientY: 40, + buttons: 1, + }); + fireEvent.mouseMove(document, {clientX: resizeX + 50, clientY: 40, buttons: 1}); + fireEvent.mouseUp(document, {clientX: resizeX + 50, clientY: 40}); + await flush(); + expect(onChange).toHaveBeenCalledTimes(resizeCount + 1); + expect(onChange.mock.calls.at(-1)[0].config.layout[0].w).toBe(3); + expect(host.querySelector('[data-identity]')).toBe(plugin); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toEqual( + meta, + ); + view.unmount(); + expect(GridStack.getDD().isDraggable(host)).toBe(false); + } finally { + measure.mockRestore(); + } +}); + +// Critical: yes. Root width leaking into children mis-sizes mounted widgets and their saved projections. +test('nested grids measure local content width through controlled resize with an explicit root width', async () => { + const defaults = registerManager.gridLayout; + const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); + const widthType = 'nested-local-width'; + const widths = new Map(); + DashKitGridStack.reloadPlugins({ + type: widthType, + defaultLayout: {w: 6, h: 2}, + renderer: (props, ref) => { + widths.set(props.id, props.width); + return ; + }, + }); + const rootProperties = { + cols: 640, + rowHeight: 1, + width: 640, + margin: [0, 0] as [number, number], + containerPadding: [0, 0] as [number, number], + compactType: null, + isDraggable: false, + isResizable: false, + }; + let outerWidth = 320; + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get() { + if (this.closest('[data-dashkit-id="inner"]')) { + return (outerWidth - 40) / 2 - 20; + } + return this.closest('[data-dashkit-id="box"]') ? outerWidth - 40 : 500; + }, + }); + const nested: Config = { + ...config, + items: ['root', 'child', 'deep'].map((id) => ({ + id, + type: widthType, + namespace: 'default', + data: {text: id}, + })), + layout: [ + {i: 'root', x: 400, y: 0, w: 100, h: 40}, + {i: 'child', x: 0, y: 0, w: 6, h: 2, containerId: 'box'}, + {i: 'deep', x: 0, y: 0, w: 6, h: 2, containerId: 'inner'}, + ], + containers: [ + { + ...box, + w: outerWidth, + h: 200, + padding: [0, 20, 0, 20], + gap: [0, 0], + grid: {cols: 12, rowHeight: 20, compactType: null}, + }, + { + ...box, + id: 'inner', + containerId: 'box', + y: 3, + w: 6, + h: 4, + padding: [0, 10, 0, 10], + gap: [0, 0], + grid: {cols: 12, rowHeight: 20, compactType: null}, + }, + ], + }; + const before = JSON.stringify(nested); + const ref = React.createRef(); + const onChange = jest.fn(); + let view: ReturnType | undefined; + try { + DashKitGridStack.setSettings({gridLayout: rootProperties}); + view = render(); + const identities = [...view.container.querySelectorAll('[data-identity]')]; + const meta = await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta()); + const verify = (document: Config) => { + const projection = getGridStackLayoutRects(document, { + [DEFAULT_GROUP]: {width: 640, gridProperties: rootProperties}, + }); + for (const id of ['root', 'child', 'deep']) { + expect(widths.get(id)).toBe(projection[id].width); + const host = requireValue( + view?.container.querySelector(`[data-dashkit-id="${id}"]`), + 'widget host', + ); + expect(host.classList.contains('ui-draggable-disabled')).toBe(true); + expect(host.classList.contains('ui-resizable-disabled')).toBe(true); + } + const grids = [ + ...requireValue(view, 'view').container.querySelectorAll( + '.grid-stack', + ), + ]; + expect(grids.map((grid) => grid.style.width)).toEqual([ + '640px', + `${outerWidth - 40}px`, + `${(outerWidth - 40) / 2 - 20}px`, + ]); + }; + verify(nested); + outerWidth = 400; + const resized = { + ...nested, + containers: requireValue(nested.containers, 'containers').map((entry) => + entry.id === 'box' ? {...entry, w: outerWidth} : entry, + ), + }; + view.rerender(); + verify(resized); + expect([...view.container.querySelectorAll('[data-identity]')]).toEqual(identities); + expect(await Promise.all(requireValue(ref.current, 'DashKit ref').getItemsMeta())).toEqual( + meta, + ); + expect(onChange).not.toHaveBeenCalled(); + expect(JSON.stringify(nested)).toBe(before); + } finally { + view?.unmount(); + DashKitGridStack.setSettings({ + gridLayout: { + ...defaults, + width: defaults.width, + compactType: defaults.compactType, + isDraggable: defaults.isDraggable, + isResizable: defaults.isResizable, + }, + }); + if (clientWidth) { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', clientWidth); + } + } +}); + +test('empty nested drop zones fill their container with a fixed-height root style', () => { + const defaults = registerManager.gridLayout; + const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); + const clientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight'); + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get(this: HTMLElement) { + return this.closest('[data-dashkit-id="box"]') ? 280 : 640; + }, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + get(this: HTMLElement) { + if (this.style.height.endsWith('px')) { + return parseFloat(this.style.height); + } + const node = + this.closest('[data-dashkit-id="box"]')?.gridstackNode; + if (this.style.height === '100%' && node?.grid) { + return (node.h || 0) * node.grid.getCellHeight(true); + } + return 0; + }, + }); + const nested: Config = { + ...config, + items: [], + layout: [], + containers: [ + { + ...box, + w: 280, + h: 200, + padding: [0, 0, 0, 0], + gap: [0, 0], + grid: {cols: 12, rowHeight: 20, compactType: null}, + }, + ], + }; + const before = JSON.stringify(nested); + const onChange = jest.fn(); + let view: ReturnType | undefined; + try { + DashKitGridStack.setSettings({ + gridLayout: { + cols: 640, + rowHeight: 1, + width: 640, + margin: [0, 0], + containerPadding: [0, 0], + compactType: null, + autoSize: false, + style: {height: 400}, + }, + }); + view = render(); + const root = requireValue( + view.container.querySelector('.grid-stack'), + 'root grid', + ); + const child = requireValue( + view.container.querySelector('[data-dashkit-id="box"] .grid-stack'), + 'child grid', + ); + const wrapper = requireValue(child.parentElement, 'child wrapper'); + expect(root.parentElement?.style.height).toBe('400px'); + expect(wrapper.style.height).toBe('100%'); + expect(wrapper.clientHeight).toBe(200); + expect(child.style.height).toBe('200px'); + expect(child.style.width).toBe('280px'); + const resized: Config = { + ...nested, + containers: [{...requireValue(nested.containers, 'containers')[0], h: 240}], + }; + view.rerender(); + expect(view.container.querySelector('[data-dashkit-id="box"] .grid-stack')).toBe(child); + expect(wrapper.style.height).toBe('100%'); + expect(wrapper.clientHeight).toBe(240); + expect(child.style.height).toBe('240px'); + expect(root.parentElement?.style.height).toBe('400px'); + expect(onChange).not.toHaveBeenCalled(); + expect(JSON.stringify(nested)).toBe(before); + } finally { + view?.unmount(); + DashKitGridStack.setSettings({ + gridLayout: { + ...defaults, + width: defaults.width, + compactType: defaults.compactType, + autoSize: defaults.autoSize, + style: defaults.style, + }, + }); + if (clientWidth) { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', clientWidth); + } + if (clientHeight) { + Object.defineProperty(HTMLElement.prototype, 'clientHeight', clientHeight); + } + } +}); diff --git a/src/components/GridStack/__tests__/document.test.ts b/src/components/GridStack/__tests__/document.test.ts new file mode 100644 index 0000000..e9364fc --- /dev/null +++ b/src/components/GridStack/__tests__/document.test.ts @@ -0,0 +1,127 @@ +import type {Config, GridStackContainer} from '../../../shared'; +import {validateConfig, validateGrid} from '../document'; +import {getGridGeometry, getGridStackLayoutRects, visibleGeometry} from '../geometry'; + +const container: GridStackContainer = { + id: 'box', + x: 0, + y: 0, + w: 12, + h: 10, + padding: [12, 20, 24, 16], + gap: [8, 6], + grid: {cols: 12, rowHeight: 18, compactType: null}, +}; +const config: Config = { + salt: 'test', + counter: 1, + aliases: {}, + connections: [], + items: [{id: 'widget', type: 'test', namespace: 'default', data: {text: 'saved metadata'}}], + layout: [{i: 'widget', x: 0.5, y: 1.25, w: 6, h: 4, containerId: 'box'}], + containers: [container], + layoutVersion: 2, +}; + +// Critical: yes. Rejecting a malformed parent graph prevents silent saved-content loss. +test('accepts additive nested data and rejects nonempty container removal without mutating input', () => { + const saved = JSON.parse(JSON.stringify(config)); + expect(() => validateConfig(config)).not.toThrow(); + expect(() => validateConfig({...config, containers: []})).toThrow('move its children'); + expect(config).toEqual(saved); +}); + +test.each([ + [{...container, id: 'widget'}, 'duplicate'], + [{...container, containerId: 'box'}, 'cyclic'], + [{...container, containerId: 'missing'}, 'unknown'], + [{...container, x: Infinity}, 'finite geometry'], + [{...container, padding: [-1, 0, 0, 0]}, 'padding'], + [{...container, grid: {...container.grid, cols: 0}}, 'grid settings'], +] as const)('rejects invalid nested graph or geometry', (entry, error) => { + expect(() => validateConfig({...config, containers: [entry as GridStackContainer]})).toThrow( + error, + ); +}); + +test('gap and padding produce independent visible geometry', () => { + const grid = getGridGeometry(640, { + cols: 12, + rowHeight: 18, + margin: [8, 6], + containerPadding: [16, 12], + }); + const first = visibleGeometry({x: 0, y: 0, w: 3, h: 2}, grid); + const second = visibleGeometry({x: 3, y: 0, w: 3, h: 2}, grid); + expect(second.left - first.left - first.width).toBeCloseTo(8); + expect(first.height).toBe(42); + expect(grid.paddingX).toBe(16); + expect(grid.paddingY).toBe(12); +}); + +test('projection accumulates nested origin and padding without changing saved local geometry', () => { + const saved = JSON.parse(JSON.stringify(config)); + const project = require('../geometry').getGridStackLayoutRects; + const rects = project(config, { + __default: { + width: 640, + gridProperties: {cols: 12, rowHeight: 18, margin: [8, 6], containerPadding: [0, 0]}, + }, + }); + // Parent visible width is 640. Child usable width is 604; column step is (604+8)/12 = 51. + expect(rects.widget).toEqual({ + x: 16 + 0.5 * 51, + y: 12 + 1.25 * 24, + width: 6 * 51 - 8, + height: 4 * 24 - 6, + }); + expect(config).toEqual(saved); +}); + +test('projection preserves arbitrary legacy widget IDs as own serializable keys', () => { + const special = { + ...config, + items: [{...config.items[0], id: '__proto__'}], + layout: [{...config.layout[0], i: '__proto__'}], + }; + const rects = getGridStackLayoutRects(special, { + __default: {width: 640, gridProperties: {cols: 12, rowHeight: 18}}, + }); + expect(Object.keys(rects)).toEqual(['__proto__']); + expect(Object.keys(JSON.parse(JSON.stringify(rects)))).toEqual(['__proto__']); +}); + +test('rejects present invalid membership and zero columns rather than defaulting them', () => { + expect(() => validateGrid({cols: 0})).toThrow('grid'); + expect(() => + validateConfig({...config, layout: [{...config.layout[0], containerId: ''}]}), + ).toThrow('containerId'); +}); + +// Critical: yes. Malformed controlled content must fail before clipping/exporting saved children. +test.each([ + [{...container, h: 2, padding: [1000, 0, 1000, 0]}, config.layout, 'usable width or height'], + [{...container, padding: [0, 640, 0, 1]}, config.layout, 'usable width or height'], + [container, [{...config.layout[0], x: 10}], 'content bounds'], + [container, [{...config.layout[0], y: 20}], 'content bounds'], +] as const)( + 'rejects impossible padding or child extents at the projection boundary', + (box, layout, error) => { + const invalid = {...config, containers: [box as GridStackContainer], layout: [...layout]}; + const before = JSON.stringify(invalid); + expect(() => + getGridStackLayoutRects(invalid, { + __default: { + width: 640, + gridProperties: { + cols: 12, + rowHeight: 18, + margin: [0, 0], + containerPadding: [0, 0], + }, + }, + }), + ).toThrow(error); + expect(JSON.stringify(invalid)).toBe(before); + }, +); diff --git a/src/components/GridStack/__tests__/engine.test.ts b/src/components/GridStack/__tests__/engine.test.ts new file mode 100644 index 0000000..aeed9ae --- /dev/null +++ b/src/components/GridStack/__tests__/engine.test.ts @@ -0,0 +1,201 @@ +/** @jest-environment jsdom */ +import {DashKitEngine} from '../engine'; +import {requireValue} from '../invariants'; + +const nodes = (engine: DashKitEngine) => + engine.nodes + .map(({id, x, y, w, h}) => ({id, x, y, w, h})) + .sort((a, b) => requireValue(a.id, 'a.id').localeCompare(requireValue(b.id, 'b.id'))); + +// Critical: yes. Native bounded simulations must not move unrelated saved report widgets. +test('overlap survives bounded batch load, move, resize and removal', () => { + const engine = new DashKitEngine({column: 640, maxRow: 400}); + engine.mode = {allowOverlap: true, compactType: null}; + engine.batchUpdate(); + const a = engine.addNode({id: 'a', x: 40, y: 40, w: 160, h: 100}); + const b = engine.addNode({id: 'b', x: 120, y: 80, w: 180, h: 120}); + engine.batchUpdate(false); + engine.moveNodeCheck(b as (typeof engine.nodes)[number], {x: 80, y: 60}); + expect(nodes(engine)).toEqual([ + {id: 'a', x: 40, y: 40, w: 160, h: 100}, + {id: 'b', x: 80, y: 60, w: 180, h: 120}, + ]); + engine.moveNodeCheck(b as (typeof engine.nodes)[number], {x: 999, y: 999}); + expect(b).toMatchObject({x: 460, y: 280}); + engine.moveNodeCheck(b as (typeof engine.nodes)[number], { + x: 20, + y: 20, + w: 900, + h: 900, + resizing: true, + }); + expect(b).toMatchObject({x: 20, y: 20, w: 620, h: 380}); + engine.removeNode(b, false); + expect(a).toMatchObject({x: 40, y: 40, w: 160, h: 100}); +}); + +test.each([ + [ + 'vertical', + [ + {id: 'a', x: 2, y: 0, w: 3, h: 2}, + {id: 'b', x: 1, y: 1, w: 3, h: 2}, + ], + [ + {id: 'a', x: 2, y: 0, w: 3, h: 2}, + {id: 'b', x: 1, y: 2, w: 3, h: 2}, + ], + ], + [ + 'horizontal', + [ + {id: 'a', x: 6, y: 0, w: 3, h: 2}, + {id: 'b', x: 0, y: 2, w: 3, h: 2}, + ], + [ + {id: 'a', x: 0, y: 0, w: 3, h: 2}, + {id: 'b', x: 0, y: 2, w: 3, h: 2}, + ], + ], +] as const)('compacts %s without reversing collision priority', (compactType, input, expected) => { + const engine = new DashKitEngine({column: 12}); + engine.mode = {compactType}; + engine.batchUpdate(); + input.forEach((entry) => engine.addNode({...entry})); + engine.batchUpdate(false); + expect(nodes(engine)).toEqual(expected); +}); + +test('preventCollision rejects an occupied move and preserves min/max constraints', () => { + const engine = new DashKitEngine({column: 12, maxRow: 10}); + engine.mode = {compactType: null, preventCollision: true}; + engine.addNode({id: 'a', x: 0, y: 0, w: 3, h: 2}); + const b = engine.addNode({id: 'b', x: 6, y: 0, w: 3, h: 2, minW: 2, minH: 2, maxW: 4, maxH: 3}); + expect(engine.moveNodeCheck(b as (typeof engine.nodes)[number], {x: 1, y: 0})).toBe(false); + expect(b).toMatchObject({x: 6, y: 0}); + engine.moveNodeCheck(b as (typeof engine.nodes)[number], {w: 10, h: 9, resizing: true}); + expect(b).toMatchObject({w: 4, h: 3}); + engine.moveNodeCheck(b as (typeof engine.nodes)[number], {w: 1, h: 1, resizing: true}); + expect(b).toMatchObject({w: 2, h: 2}); +}); + +test('nowrap consumes remaining width and rejects growth beyond the row', () => { + const engine = new DashKitEngine({column: 12, maxRow: 2}); + engine.mode = {compactType: 'horizontal-nowrap'}; + engine.addNode({id: 'a', x: 0, y: 0, w: 8, h: 2}); + const b = engine.addNode({id: 'b', x: 8, y: 0, w: 3, h: 2}); + engine.moveNodeCheck(b as (typeof engine.nodes)[number], {w: 9, y: 3, resizing: true}); + expect(b).toMatchObject({x: 8, y: 0, w: 4}); +}); + +test('finite nowrap fit rejects a second row even when vertical bounds have room', () => { + const engine = new DashKitEngine({column: 12, maxRow: 4}); + engine.mode = {compactType: 'horizontal-nowrap'}; + engine.batchUpdate(); + engine.addNode({id: 'a', x: 0, y: 0, w: 8, h: 2}); + engine.addNode({id: 'b', x: 8, y: 0, w: 4, h: 2}); + engine.batchUpdate(false); + expect(engine.willItFit({w: 1, h: 1})).toBe(false); + expect(nodes(engine)).toEqual([ + {id: 'a', x: 0, y: 0, w: 8, h: 2}, + {id: 'b', x: 8, y: 0, w: 4, h: 2}, + ]); +}); + +// Critical: yes. Repeated native palette motion must keep a fitting preview instead of flickering/rejecting it. +test('nowrap fit excludes the existing preview from both width budget and simulation', () => { + const engine = new DashKitEngine({column: 12, maxRow: 4}); + engine.mode = {compactType: 'horizontal-nowrap'}; + engine.addNode({id: 'saved', x: 0, y: 0, w: 8, h: 2}); + engine.addNode({id: 'preview', x: 8, y: 0, w: 4, h: 2}); + const before = nodes(engine); + for (let motion = 0; motion < 3; motion++) { + expect(engine.willItFit({id: 'preview', x: 8, y: 0, w: 4, h: 2})).toBe(true); + } + expect(nodes(engine)).toEqual(before); + expect(engine.willItFit({id: 'another', w: 1, h: 1})).toBe(false); +}); + +// Critical: yes. Packing must not silently rewrite an allowed overlapping gesture before save. +test.each([null, 'vertical', 'horizontal', 'horizontal-nowrap'] as const)( + 'public engine move respects overlap with %s compaction', + (compactType) => { + for (const allowOverlap of [true, false]) { + const engine = new DashKitEngine({column: 12, maxRow: 10, float: true}); + engine.mode = {compactType, allowOverlap}; + engine.restoring = true; + engine.addNode({id: 'a', x: 0, y: 0, w: 4, h: 2}); + const b = engine.addNode({id: 'b', x: 6, y: 0, w: 3, h: 2}); + engine.restoring = false; + engine.moveNodeCheck(b as (typeof engine.nodes)[number], {x: 2, y: 1}); + if (allowOverlap) { + expect(b).toMatchObject({x: 2, y: compactType === 'horizontal-nowrap' ? 0 : 1}); + expect(engine.nodes[0]).toMatchObject({x: 0, y: 0}); + } else { + const [a, moved] = engine.nodes; + expect( + a.x < moved.x + moved.w && + moved.x < a.x + a.w && + a.y < moved.y + moved.h && + moved.y < a.y + a.h, + ).toBe(false); + } + } + }, +); + +// Critical: yes. Native moves and admission must not enlarge accepted saved widgets or displace edge positions. +test.each([ + {x: 0.5, y: 0.5}, + {x: 11.5, y: 3.5}, +])( + 'fractional load, move and fit retain dimensions at %j and resizing still enforces constraints', + ({x, y}) => { + const engine = new DashKitEngine({column: 12, maxRow: 4, float: true}); + engine.mode = {compactType: null}; + const constraints = {minW: 2, minH: 2, maxW: 3, maxH: 3}; + const saved = {id: 'fraction', x, y, w: 0.5, h: 0.5, ...constraints}; + const a = engine.addNode({...saved}) as (typeof engine.nodes)[number]; + const other = engine.addNode({id: 'other', x: 5, y: 0, w: 2, h: 2}); + expect(a).toMatchObject(saved); + engine.batchUpdate(); + engine.batchUpdate(false); + expect(engine.moveNodeCheck(a, {x, y})).toBe(false); + expect(engine.getDirtyNodes()).toEqual([]); + const candidate = {...saved}; + expect(engine.willItFit(candidate)).toBe(true); + expect(candidate).toMatchObject({...saved, _willFitPos: {x, y, w: 0.5, h: 0.5}}); + expect(nodes(engine)).toEqual([ + {id: 'fraction', x, y, w: 0.5, h: 0.5}, + {id: 'other', x: 5, y: 0, w: 2, h: 2}, + ]); + expect(engine.moveNodeCheck(a, {x: 11.75, y: 3.75})).toBe(x !== 11.5 || y !== 3.5); + expect(a).toMatchObject({...constraints, x: 11.5, y: 3.5, w: 0.5, h: 0.5}); + engine.batchUpdate(); + engine.batchUpdate(false); + engine.nodeBoundFix(a); + expect(engine.getDirtyNodes()).toEqual([]); + expect(engine.moveNodeCheck(a, {x: -0.25, y: -0.25})).toBe(true); + expect(a).toMatchObject({x: 0, y: 0, w: 0.5, h: 0.5}); + expect(other).toMatchObject({x: 5, y: 0, w: 2, h: 2}); + engine.moveNodeCheck(a, {w: 0.25, h: 0.25, resizing: true}); + expect(a).toMatchObject({...constraints, w: 2, h: 2}); + engine.moveNodeCheck(a, {w: 8, h: 8, resizing: true}); + expect(a).toMatchObject({...constraints, w: 3, h: 3}); + expect(saved).toEqual({id: 'fraction', x, y, w: 0.5, h: 0.5, ...constraints}); + }, +); + +test.each([1, 2])('nowrap moves retain subunit saved width with minW %s', (minW) => { + const engine = new DashKitEngine({column: 12, maxRow: 4}); + engine.mode = {compactType: 'horizontal-nowrap', allowOverlap: true}; + const node = engine.addNode({id: 'fraction', x: 0, y: 0, w: 0.5, h: 0.5, minW, minH: 1}); + expect(engine.moveNodeCheck(node as (typeof engine.nodes)[number], {x: 3, y: 0})).toBe(true); + expect(node).toMatchObject({x: 3, y: 0, w: 0.5, h: 0.5, minW, minH: 1}); + expect(engine.moveNodeCheck(node as (typeof engine.nodes)[number], {x: 11.5, y: 0})).toBe(true); + expect(node).toMatchObject({x: 11.5, y: 0, w: 0.5, h: 0.5}); + expect( + engine.moveNodeCheck(node as (typeof engine.nodes)[number], {w: 0.25, resizing: true}), + ).toBe(false); + expect(node).toMatchObject({x: 11.5, y: 0, w: 0.5, h: 0.5}); +}); diff --git a/src/components/GridStack/__tests__/statics.test.ts b/src/components/GridStack/__tests__/statics.test.ts new file mode 100644 index 0000000..f21f903 --- /dev/null +++ b/src/components/GridStack/__tests__/statics.test.ts @@ -0,0 +1,424 @@ +/** @jest-environment jsdom */ +import type {Config, ConfigLayout, GridStackContainer} from '../../../shared'; +import type {CompactType, DashKitGroup} from '../../../typings'; +import {DashKit} from '../../DashKit/DashKit'; +import {DashKitGridStack} from '../DashKitGridStack'; +import {DashKitEngine} from '../engine'; + +const type = 'gridstack-static-boundaries'; +DashKitGridStack.reloadPlugins({ + type, + renderer: () => null, + defaultLayout: {w: 4, h: 2, minW: 1, minH: 1}, +}); +const groups = (compactType: CompactType): DashKitGroup[] => [ + {gridProperties: () => ({cols: 12, compactType})}, +]; +const box: GridStackContainer = { + id: 'box', + x: 0, + y: 0, + w: 12, + h: 6, + padding: [0, 0, 0, 0], + gap: [0, 0], + grid: {cols: 12, rowHeight: 18, compactType: null}, +}; +const config: Config = { + salt: 'statics', + counter: 1, + items: [], + layout: [], + aliases: {}, + connections: [], + containers: [box], +}; +const rects = (layout: ConfigLayout[]) => layout.map(({i, x, y, w, h}) => ({i, x, y, w, h})); + +const child = {i: 'child', containerId: 'box', x: 1, y: 1, w: 2, h: 2}; +// Critical: yes. Invalid saved membership must fail before reflow can generate a corrupt document. +test.each<[string, ConfigLayout[], GridStackContainer[], string]>([ + [ + 'unknown immediate membership', + [{...child, containerId: 'missing'}], + [box], + 'unknown container "missing"', + ], + [ + 'unknown ancestor', + [child], + [{...box, containerId: 'missing'}], + 'unknown container "missing"', + ], + ['membership without descriptors', [child], [], 'unknown container "box"'], + [ + 'cycle', + [child], + [ + {...box, containerId: 'inner'}, + {...box, id: 'inner', containerId: 'box'}, + ], + 'cyclic container membership', + ], + ['unused cycle', [], [{...box, containerId: 'box'}], 'cyclic container membership'], + ['duplicate containers', [child], [box, {...box}], 'duplicate or reserved id "box"'], + ['widget/container collision', [{...child, i: 'box'}], [box], 'duplicate or reserved id "box"'], + ['duplicate widgets', [child, {...child}], [box], 'duplicate or reserved id "child"'], + ['empty membership', [{...child, containerId: ''}], [box], 'invalid containerId'], + ['inconsistent zone', [{...child, parent: 'other'}], [box], 'inconsistent root zone'], + ['reserved container', [], [{...box, id: '__default'}], 'duplicate or reserved id'], + ['invalid spacing', [child], [{...box, gap: [-1, 0]}], 'padding and gap'], + ['invalid grid', [child], [{...box, grid: {...box.grid, cols: 0}}], 'grid settings'], + ['invalid nested geometry', [{...child, x: -1}], [box], 'invalid finite geometry'], +])( + 'public reflow rejects %s before settings or engine work without changing input', + (_name, layout, containers, error) => { + const gridProperties = jest.fn(() => ({cols: 12})); + const options = {layout, containers, groups: [{gridProperties}]}; + const before = JSON.stringify(options); + const update = jest.spyOn(DashKitEngine.prototype, 'batchUpdate'); + try { + expect(() => DashKitGridStack.reflowLayout(options)).toThrow(error); + expect(gridProperties).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(JSON.stringify(options)).toBe(before); + } finally { + update.mockRestore(); + } + }, +); + +test('public reflow validates incoming membership and preserves valid nested fractions and descriptors', () => { + const inner: GridStackContainer = { + ...box, + id: 'inner', + containerId: 'box', + h: 4, + padding: [4, 4, 4, 4], + gap: [2, 2], + grid: {...box.grid, maxRows: 2.75}, + }; + const options = { + layout: [{...child, containerId: 'inner', x: 0.5, y: 0.5, h: 1.5}], + newLayoutItem: {...child, i: 'new', containerId: 'inner', x: 4, y: 0.25, h: 1.25}, + containers: [{...box, h: 10}, inner], + groups: groups(null), + }; + const before = JSON.stringify(options); + expect(DashKitGridStack.reflowLayout(options)).toEqual([ + ...options.layout, + options.newLayoutItem, + ]); + expect(JSON.stringify(options)).toBe(before); + expect(options.containers[0].grid.maxRows).toBeUndefined(); + expect(() => + DashKitGridStack.reflowLayout({ + ...options, + newLayoutItem: {...options.newLayoutItem, containerId: 'missing'}, + }), + ).toThrow('unknown container "missing"'); +}); + +test('nested setItem normalizes legitimate infinite plugin defaults before reflow validation', () => { + const infiniteType = `${type}-infinite`; + DashKitGridStack.reloadPlugins({ + type: infiniteType, + renderer: () => null, + defaultLayout: {x: 0, y: Infinity, w: Infinity, h: 2, minW: 1, minH: 1}, + }); + const next = DashKitGridStack.setItem({ + config, + item: { + type: infiniteType, + data: {}, + layout: {x: 0, y: Infinity, w: Infinity, h: 2, containerId: 'box'}, + }, + groups: groups(null), + options: {updateLayout: []}, + }); + expect(next.layout[0]).toMatchObject({x: 0, y: 0, w: 12, h: 2, containerId: 'box'}); + expect(next.containers).toEqual(config.containers); +}); + +// Critical: yes. Helpers generate saved geometry; overlapping containers can hide existing content. +test('append and reflow treat empty and occupied sibling containers as immutable footprints', () => { + const before = JSON.stringify(config); + const added = DashKitGridStack.setItem({ + config, + item: {type, data: {}}, + groups: groups('vertical'), + }); + expect(added.layout[0]).toMatchObject({x: 0, y: 6, w: 4, h: 2}); + expect(added.containers).toEqual([box]); + const layout = [ + {i: 'child', containerId: 'box', x: 0, y: 0, w: 4, h: 2}, + {i: 'outside', x: 0, y: 10, w: 4, h: 2}, + ]; + expect( + DashKitGridStack.reflowLayout({layout, containers: [box], groups: groups('vertical')}), + ).toEqual([layout[0], {...layout[1], y: 6}]); + expect(JSON.stringify(config)).toBe(before); + const beside = {...box, w: 6}; + expect( + DashKitGridStack.reflowLayout({ + layout: [{i: 'a', x: 8, y: 4, w: 4, h: 2}], + containers: [beside], + groups: groups('horizontal'), + }), + ).toEqual([{i: 'a', x: 6, y: 4, w: 4, h: 2}]); + expect(beside).toEqual({...box, w: 6}); +}); + +const fixtures: ConfigLayout[][] = [ + [ + {i: 'a', x: 0, y: 0, w: 3, h: 1}, + {i: 'b', x: 0, y: 0, w: 3, h: 1}, + ], + [ + {i: 'a', x: 0, y: 0, w: 8, h: 2}, + {i: 'b', x: 2, y: 0, w: 6, h: 2}, + {i: 'c', x: 4, y: 0, w: 4, h: 1}, + ], + [ + {i: 'a', x: 4, y: 2, w: 4, h: 2}, + {i: 'b', x: 9, y: 0, w: 3, h: 3}, + {i: 'c', x: 8, y: 2, w: 4, h: 2}, + ], +]; +// Critical: yes. Same public operation must not rearrange a user's saved dashboard differently. +test.each(['vertical', 'horizontal', 'horizontal-nowrap'] as const)( + '%s public reflow preserves legacy collision, gaps and row-wrap priority', + (compactType) => { + for (const layout of fixtures) { + const input = JSON.stringify(layout); + const options = {layout, groups: groups(compactType)}; + expect(rects(DashKitGridStack.reflowLayout(options))).toEqual( + rects( + DashKit.reflowLayout({...options, layout: layout.map((entry) => ({...entry}))}), + ), + ); + const newLayoutItem = {i: 'new', x: 0, y: 0, w: 4, h: 2}; + expect(rects(DashKitGridStack.reflowLayout({...options, newLayoutItem}))).toEqual( + rects( + DashKit.reflowLayout({ + ...options, + layout: layout.map((entry) => ({...entry})), + newLayoutItem: {...newLayoutItem}, + }), + ), + ); + expect(JSON.stringify(layout)).toBe(input); + } + }, +); + +test.each(['vertical', 'horizontal'] as const)( + '%s setItem keeps new-item priority and canonical saved order', + (compactType) => { + const original: Config = { + ...config, + containers: undefined, + items: [{id: 'old', type, namespace: 'default', data: {}}], + layout: [{i: 'old', x: 0, y: 0, w: 4, h: 2}], + }; + const options = { + config: original, + item: {type, data: {}, layout: {x: 0, y: 0, w: 4, h: 2}}, + groups: groups(compactType), + }; + const legacy = DashKit.setItem(options); + const next = DashKitGridStack.setItem(options); + expect(next.items).toEqual(legacy.items); + expect(rects(next.layout)).toEqual(rects(legacy.layout)); + expect(next.layout.map(({i}) => i)).toEqual(next.items.map(({id}) => id)); + }, +); + +// Critical: yes. No-space insertion must not persist an overflowed second row. +test('finite nowrap insertion budgets sibling widgets and containers and rejects a full row', () => { + const full = {...config, containers: [{...box, w: 12}]}; + const input = JSON.stringify(full); + expect(() => + DashKitGridStack.setItem({ + config: full, + item: {type, data: {}}, + groups: groups('horizontal-nowrap'), + }), + ).toThrow('no space'); + expect(JSON.stringify(full)).toBe(input); + const available = {...config, containers: [{...box, w: 9}]}; + const next = DashKitGridStack.setItem({ + config: available, + item: {type, data: {}}, + groups: groups('horizontal-nowrap'), + }); + expect(next.layout[0]).toMatchObject({x: 9, y: 0, w: 3, h: 2}); + expect(next.containers).toEqual(available.containers); +}); + +// Critical: yes. Legacy helpers must reject nested input before dropping its membership. +test('legacy static entry points reject incoming nested membership while new reflow and flat legacy remain valid', () => { + const newLayoutItem = {i: 'incoming', x: 0, y: 0, w: 1, h: 1, containerId: 'box'}; + expect(() => DashKit.reflowLayout({layout: [], newLayoutItem})).toThrow( + 'requires @gravity-ui/dashkit/gridstack', + ); + const flat = {...config, containers: undefined}; + expect(() => + DashKit.setItem({config: flat, item: {type, data: {}, layout: newLayoutItem}}), + ).toThrow('requires @gravity-ui/dashkit/gridstack'); + expect(() => + DashKit.setItem({ + config: flat, + item: {type, data: {}}, + options: {updateLayout: [newLayoutItem]}, + }), + ).toThrow('requires @gravity-ui/dashkit/gridstack'); + expect( + DashKit.reflowLayout({layout: [], newLayoutItem: {i: 'incoming', x: 0, y: 0, w: 1, h: 1}}), + ).toEqual([{i: 'incoming', x: 0, y: 0, w: 1, h: 1}]); + expect(DashKitGridStack.reflowLayout({layout: [], newLayoutItem, containers: [box]})).toEqual([ + newLayoutItem, + ]); +}); + +// Critical: yes. Static admission must not generate a saved child beyond its physical parent. +test.each([undefined, 2, 100])( + 'nested static admission respects physical and configured maxRows %s', + (maxRows) => { + const nested = { + ...config, + containers: [ + { + ...box, + h: 4, + padding: [4, 0, 6, 0] as [number, number, number, number], + gap: [0, 6] as [number, number], + grid: {...box.grid, maxRows}, + }, + ], + }; + const grid: DashKitGroup[] = [ + {gridProperties: () => ({cols: 12, rowHeight: 18, margin: [0, 2], compactType: null})}, + ]; + // 4*20-2-4-6 = 68px content, with child step24/gap6 => 74/24 rows. + const limit = Math.min(maxRows ?? Infinity, 74 / 24); + const added = DashKitGridStack.setItem({ + config: nested, + groups: grid, + item: {type, data: {}, layout: {x: 0, y: 0, w: 2, h: limit, containerId: 'box'}}, + }); + expect(added.layout[0].h).toBe(limit); + expect(added.containers).toEqual(nested.containers); + expect(() => + DashKitGridStack.setItem({ + config: nested, + groups: grid, + item: { + type, + data: {}, + layout: {x: 0, y: 0, w: 2, h: limit + 1, containerId: 'box'}, + }, + }), + ).toThrow('exceeds its container content bounds'); + expect(() => + DashKitGridStack.setItem({ + config: {...added, layout: [{...added.layout[0], h: limit + 1}]}, + groups: grid, + item: added.items[0], + }), + ).toThrow('exceeds its container content bounds'); + expect(() => + DashKitGridStack.reflowLayout({ + groups: grid, + containers: nested.containers, + layout: [{i: 'invalid', x: 0, y: 1, w: 2, h: limit, containerId: 'box'}], + }), + ).toThrow('exceeds its container content bounds'); + expect(nested.layout).toEqual([]); + }, +); + +// Critical: yes. Unrelated public edits must not silently enlarge valid fractional saved geometry. +test.each([undefined, 'box', 'inner'])( + 'reflow and setItem preserve sub-unit saved geometry in %s without mutating callers', + (containerId) => { + const containers: GridStackContainer[] = [ + {...box, x: 0, y: 0, w: 6, h: 6}, + {...box, id: 'inner', containerId: 'box', x: 6, y: 0, w: 6, h: 4}, + ]; + const fraction = {i: 'fraction', x: 11.5, y: 0.5, w: 0.5, h: 0.5, containerId}; + const untouched = {i: 'untouched', x: 8, y: 8, w: 2, h: 2}; + const document: Config = { + ...config, + containers, + items: ['fraction', 'untouched'].map((id) => ({ + id, + type, + namespace: 'default', + data: {}, + })), + layout: [fraction, untouched], + }; + const input = JSON.stringify(document); + const incoming = {i: 'incoming', x: 8, y: 12, w: 1, h: 1}; + expect( + DashKitGridStack.reflowLayout({ + layout: document.layout, + containers, + groups: groups(null), + }), + ).toEqual(document.layout); + expect( + DashKitGridStack.reflowLayout({ + layout: document.layout, + containers, + groups: groups(null), + newLayoutItem: incoming, + }), + ).toEqual([...document.layout, incoming]); + const next = DashKitGridStack.setItem({ + config: document, + groups: groups(null), + item: {type, data: {}, layout: incoming}, + }); + expect(next.layout.slice(0, 2)).toEqual(document.layout); + expect(next.layout[2]).toMatchObject({x: 8, y: 12, w: 1, h: 1}); + expect(next.containers).toEqual(containers); + expect(next.items.slice(0, 2)).toEqual(document.items); + expect(JSON.stringify(document)).toBe(input); + expect(incoming).toEqual({i: 'incoming', x: 8, y: 12, w: 1, h: 1}); + }, +); + +// Critical: yes. A valid child in a sub-unit physical row budget must remain saveable after reflow. +test('fractional geometry fits a container with less than one row of physical capacity', () => { + const grid: DashKitGroup[] = [ + { + gridProperties: () => ({ + cols: 12, + rowHeight: 40, + margin: [0, 0], + containerPadding: [0, 0], + compactType: null, + }), + }, + ]; + const containers = [{...box, h: 0.75, grid: {...box.grid, rowHeight: 40}}]; + const layout = [{i: 'fraction', containerId: 'box', x: 11.5, y: 0.25, w: 0.5, h: 0.5}]; + const document: Config = { + ...config, + containers, + layout, + items: [{id: 'fraction', type, namespace: 'default', data: {}}], + }; + const before = JSON.stringify(document); + expect(DashKitGridStack.reflowLayout({layout, containers, groups: grid})).toEqual(layout); + const next = DashKitGridStack.setItem({ + config: document, + groups: grid, + item: {type, data: {}, layout: {x: 0, y: 2, w: 1, h: 1}}, + }); + expect(next.layout[0]).toEqual(layout[0]); + expect(next.containers).toEqual(containers); + expect(JSON.stringify(document)).toBe(before); +}); diff --git a/src/components/GridStack/document.ts b/src/components/GridStack/document.ts new file mode 100644 index 0000000..e189746 --- /dev/null +++ b/src/components/GridStack/document.ts @@ -0,0 +1,222 @@ +import {DEFAULT_GROUP, TEMPORARY_ITEM_ID} from '../../constants/common'; +import {extractIdsFromConfig, getAllConfigItems} from '../../shared'; +import type {Config, ConfigLayout, GridStackContainer} from '../../shared'; +import type {DashKitGroup, ReactGridLayoutProps} from '../../typings'; + +function fail(message: string): never { + throw new Error(`DashKitGridStack: ${message}`); +} +const positive = (value: number) => Number.isFinite(value) && value > 0; + +export function validateConfig(config: Config, groups?: DashKitGroup[]) { + if ( + !Array.isArray(config.items) || + !Array.isArray(config.layout) || + (config.containers !== undefined && !Array.isArray(config.containers)) + ) { + fail('config items, layout and optional containers must be arrays'); + } + const items = getAllConfigItems(config); + validateLayout( + config.layout, + config.containers, + groups, + items.flatMap((item) => [ + item.id, + ...(item.data.tabs || []).map(({id}) => id), + ...(item.data.group || []).map(({id}) => id), + ]), + ); + const layoutIds = new Set(); + config.layout.forEach((entry) => { + if (layoutIds.has(entry.i) || !items.some((item) => item.id === entry.i)) { + fail(`duplicate or unknown widget layout "${entry.i}"`); + } + layoutIds.add(entry.i); + }); +} + +export function validateLayout( + layout: ConfigLayout[], + containerEntries: GridStackContainer[] = [], + groups?: DashKitGroup[], + itemIds = layout.map(({i}) => i), +) { + const ids = new Set(); + const zones = new Set(groups?.map((group) => group.id || DEFAULT_GROUP) || [DEFAULT_GROUP]); + const addId = (id: string) => { + if ( + !id || + ids.has(id) || + id === DEFAULT_GROUP || + id === TEMPORARY_ITEM_ID || + zones.has(id) + ) { + fail(`duplicate or reserved id "${id}"`); + } + ids.add(id); + }; + itemIds.forEach(addId); + const containers = new Map(containerEntries.map((entry) => [entry.id, entry])); + containerEntries.forEach((container) => { + addId(container.id); + if ( + !Array.isArray(container.padding) || + !Array.isArray(container.gap) || + container.padding.length !== 4 || + container.gap.length !== 2 || + [...container.padding, ...container.gap].some( + (value) => !Number.isFinite(value) || value < 0, + ) + ) { + fail(`container "${container.id}" needs finite, nonnegative padding and gap`); + } + if (!container.grid) { + fail(`invalid grid settings for container "${container.id}"`); + } + const {cols, rowHeight, compactType, maxRows} = container.grid; + if ( + !Number.isInteger(cols) || + !positive(cols) || + !positive(rowHeight) || + (maxRows !== undefined && !positive(maxRows)) || + ![null, 'vertical', 'horizontal', 'horizontal-nowrap'].includes(compactType) + ) { + fail(`invalid grid settings for container "${container.id}"`); + } + }); + const geometry = (entry: Omit, id: string) => { + if ( + ![entry.x, entry.y, entry.w, entry.h].every(Number.isFinite) || + entry.x < 0 || + entry.y < 0 || + !positive(entry.w) || + !positive(entry.h) + ) { + fail(`invalid finite geometry for "${id}"`); + } + if ( + entry.containerId !== undefined && + (typeof entry.containerId !== 'string' || !entry.containerId) + ) { + fail(`invalid containerId for "${id}"`); + } + const visited = new Set([id]); + let parent = entry.containerId; + while (parent) { + if (visited.has(parent)) { + fail(`cyclic container membership at "${id}"`); + } + visited.add(parent); + const container = containers.get(parent); + if (!container) { + fail(`unknown container "${parent}" for "${id}"; move its children before removal`); + } + if ((entry.parent || DEFAULT_GROUP) !== (container.parent || DEFAULT_GROUP)) { + fail(`inconsistent root zone for "${id}" and container "${parent}"`); + } + parent = container.containerId; + } + }; + layout.forEach((entry) => geometry(entry, entry.i)); + containerEntries.forEach((entry) => geometry(entry, entry.id)); +} + +export function validateGrid(props: ReactGridLayoutProps) { + const cols = props.cols ?? 36; + if ( + !Number.isInteger(cols) || + !positive(cols) || + !positive(props.rowHeight ?? 18) || + (props.maxRows !== undefined && props.maxRows !== Infinity && !positive(props.maxRows)) || + [...(props.margin || [2, 2]), ...(props.containerPadding || [0, 0])].some( + (value) => !Number.isFinite(value) || value < 0, + ) + ) { + fail('invalid grid columns, rowHeight, maxRows or spacing'); + } +} + +export const getGridKey = (entry: {parent?: string; containerId?: string}) => + entry.containerId ? `container:${entry.containerId}` : `zone:${entry.parent || DEFAULT_GROUP}`; + +export function configIds(config: Config) { + return [...extractIdsFromConfig(config), ...(config.containers || []).map(({id}) => id)]; +} + +// Project native positions as one document, retaining local coordinates when a subtree moves. +export function projectLayoutDocument( + layout: ConfigLayout[], + containers: GridStackContainer[] | undefined, + readPosition: (id: string) => Omit | undefined, +) { + const project = >(entry: T, id: string): T => { + const current = readPosition(id); + if (!current) { + return {...entry}; + } + const next = {...entry, ...current}; + if (!next.parent) { + delete next.parent; + } + if (!next.containerId) { + delete next.containerId; + } + return next; + }; + const nextContainers = containers?.map((entry) => project(entry, entry.id)); + const byId = new Map(nextContainers?.map((entry) => [entry.id, entry])); + nextContainers?.forEach((entryInput) => { + const entry = entryInput; + const visited = new Set([entry.id]); + let parent = entry.containerId; + while (parent) { + if (visited.has(parent)) { + fail(`cyclic container membership at "${entry.id}"`); + } + visited.add(parent); + const container = byId.get(parent); + if (!container) { + fail(`unknown container "${parent}" for "${entry.id}"`); + } + if (container.parent) { + entry.parent = container.parent; + } else { + delete entry.parent; + } + parent = container.containerId; + } + }); + return { + containers: nextContainers, + layout: layout.map((entry) => { + const next = project(entry, entry.i); + if (next.containerId) { + const container = byId.get(next.containerId); + if (!container) { + fail(`unknown container "${next.containerId}" for "${entry.i}"`); + } + if (container.parent) { + next.parent = container.parent; + } else { + delete next.parent; + } + } + return next; + }), + }; +} + +export function getContainerDescendants(id: string, containers: GridStackContainer[]) { + const result = new Set([id]); + for (let changed = true; changed; ) { + changed = false; + containers.forEach((entry) => { + if (entry.containerId && result.has(entry.containerId) && !result.has(entry.id)) { + result.add(entry.id); + changed = true; + } + }); + } + return result; +} diff --git a/src/components/GridStack/engine.ts b/src/components/GridStack/engine.ts new file mode 100644 index 0000000..dc6ab02 --- /dev/null +++ b/src/components/GridStack/engine.ts @@ -0,0 +1,270 @@ +import type {CompactType} from '../../typings'; + +import {requireValue} from './invariants'; +import {GridStackEngine, Utils} from './runtime'; +import type {GridStackMoveOpts, GridStackNode} from './runtime'; + +export type EngineMode = { + compactType?: CompactType; + allowOverlap?: boolean; + preventCollision?: boolean; +}; + +/** A per-grid engine: the stock maxRow simulators instantiate the stock class. */ +type EngineNode = GridStackNode & { + x: number; + y: number; + w: number; + h: number; + _dirty?: boolean; + _orig?: {x: number; y: number; w: number; h: number}; + _id?: number; + _willFitPos?: GridStackMoveOpts; +}; +type Internals = { + _packNodes(): GridStackEngine; + _fixCollisions( + node: GridStackNode, + next?: GridStackNode, + collide?: GridStackNode, + options?: GridStackMoveOpts, + ): boolean; + _notify(): GridStackEngine; +}; +// GridStack strips its @internal methods from declarations, but supports engine subclasses. +const EngineBase = GridStackEngine as unknown as new ( + options?: ConstructorParameters[0], +) => GridStackEngine & Internals; + +export class DashKitEngine extends EngineBase { + declare nodes: EngineNode[]; + mode: EngineMode = {}; + restoring = false; + dropSize?: {w: number; h: number}; + + nodeBoundFix(nodeInput: GridStackNode, resizing?: boolean) { + const node = nodeInput; + if (this.restoring) { + return this; + } + if (resizing) { + super.nodeBoundFix(node, resizing); + } else { + const {minW, minH, maxW, maxH, x, y, w, h} = node; + const {_dirty, _orig} = node as EngineNode; + delete node.minW; + delete node.minH; + delete node.maxW; + delete node.maxH; + try { + super.nodeBoundFix(node, resizing); + const fractionalW = w !== undefined && w > 0 && w < 1; + const fractionalH = h !== undefined && h > 0 && h < 1; + // Stock bounds impose a one-unit minimum even on saved geometry and moves. + if (fractionalW) { + node.w = Math.min(w, this.column); + node.x = Math.max(0, Math.min(x ?? 0, this.column - node.w)); + } + if (fractionalH) { + node.h = Math.min(h, this.maxRow || Infinity); + node.y = Math.max(0, Math.min(y ?? 0, (this.maxRow || Infinity) - node.h)); + } + if (fractionalW || fractionalH) { + (node as EngineNode)._dirty = + _dirty || + (_orig + ? !Utils.samePos(node, _orig) + : node.x !== x || node.y !== y || node.w !== w || node.h !== h); + } + } finally { + Object.assign(node, {minW, minH, maxW, maxH}); + } + } + return this; + } + + collide(...args: Parameters) { + return this.restoring || this.mode.allowOverlap ? undefined : super.collide(...args); + } + + collideAll(...args: Parameters) { + return this.restoring || this.mode.allowOverlap ? [] : super.collideAll(...args); + } + + _packNodes() { + if ( + this.restoring || + this.batchMode || + this.mode.allowOverlap || + this.mode.compactType === null + ) { + return this; + } + const horizontal = + this.mode.compactType === 'horizontal' || this.mode.compactType === 'horizontal-nowrap'; + const axis = horizontal ? 'x' : 'y'; + const ordered = [...this.nodes].sort((a, b) => { + // Legacy horizontal ties use the host's sort semantics, which differ across browsers. + if (horizontal) { + return a.x > b.x || (a.x === b.x && a.y > b.y) ? 1 : -1; + } + return a.y - b.y || a.x - b.x; + }); + const placed: EngineNode[] = ordered.filter((node) => node.locked); + const advance = (nodeInput: EngineNode, target: number) => { + const node = nodeInput; + node[axis] += 1; + node._dirty = true; + const index = ordered.indexOf(node); + for (let i = index + 1; i < ordered.length; i++) { + const other = ordered[i]; + if (other.locked) { + continue; + } + if (other.y > node.y + node.h) { + break; + } + if (Utils.isIntercepted(node, other)) { + advance(other, target + (horizontal ? node.w : node.h)); + } + } + node[axis] = target; + }; + ordered.forEach((nodeInput) => { + const node = nodeInput; + if (node.locked) { + return; + } + const before = {x: node.x, y: node.y}; + if (!horizontal) { + node.y = Math.min( + node.y, + placed.reduce((bottom, other) => Math.max(bottom, other.y + other.h), 0), + ); + } + while (node[axis] > 0 && !placed.some((other) => Utils.isIntercepted(node, other))) { + node[axis] = Math.max(0, node[axis] - 1); + } + let collision: EngineNode | undefined; + while ((collision = placed.find((other) => Utils.isIntercepted(node, other)))) { + advance(node, collision[axis] + (horizontal ? collision.w : collision.h)); + if (horizontal && node.x + node.w > this.column) { + node.x = this.column - node.w; + node.y += 1; + while ( + node.x > 0 && + !placed.some((other) => Utils.isIntercepted(node, other)) + ) { + node.x -= 1; + } + } + } + node._dirty = node._dirty || before.x !== node.x || before.y !== node.y; + placed.push(node); + }); + return this; + } + + _fixCollisions( + node: EngineNode, + next = node, + collide?: GridStackNode, + options: GridStackMoveOpts = {}, + ) { + if (this.restoring || this.batchMode || this.mode.allowOverlap) { + return false; + } + if ( + this.mode.compactType !== 'horizontal' && + this.mode.compactType !== 'horizontal-nowrap' + ) { + return super._fixCollisions(node, next, collide, options); + } + const collisions = this.collideAll(node, next, options.skip); + for (const other of collisions) { + const x = next.x + next.w; + const wraps = x + requireValue(other.w, 'other.w') > this.column; + this.moveNode(other, { + x: wraps ? 0 : x, + y: wraps ? next.y + next.h : other.y, + skip: node, + nested: true, + pack: false, + }); + } + return false; + } + + moveNodeCheck(node: EngineNode, optionsInput: GridStackMoveOpts) { + const options = optionsInput; + if (this.mode.compactType === 'horizontal-nowrap') { + const maxW = + this.column - + this.nodes.reduce((sum, other) => sum + (other === node ? 0 : other.w), 0); + options.w = Math.min(options.w ?? node.w, maxW); + options.y = 0; + const minW = node.minW || 1; + if (options.w < (options.resizing ? minW : Math.min(node.w, minW))) { + return false; + } + } + const next = {...node, ...options}; + this.nodeBoundFix(next, Boolean(options.resizing)); + if (this.mode.preventCollision && !this.mode.allowOverlap && this.collide(node, next)) { + return false; + } + if (node.w === next.w && node.h === next.h && node.x === next.x && node.y === next.y) { + return false; + } + const before = this.nodes.map((entry) => ({...entry})); + const moved = this.moveNode(node, {...options, pack: true}); + if (this.maxRow && this.getRow() > this.maxRow) { + before.forEach((entry) => + Object.assign( + requireValue( + this.nodes.find((candidate) => candidate._id === entry._id), + 'rollback node', + ), + entry, + ), + ); + this._notify(); + return false; + } + return moved; + } + + willItFit(nodeInput: GridStackNode & {w: number; h: number}) { + const node = nodeInput; + const input = this.dropSize ? {...node, ...this.dropSize} : node; + this.dropSize = undefined; + if ( + input.w > this.column || + (this.maxRow && input.h > this.maxRow) || + (this.mode.compactType === 'horizontal-nowrap' && + input.w + + this.nodes.reduce( + (sum, entry) => sum + (input.id && entry.id === input.id ? 0 : entry.w), + 0, + ) > + this.column) + ) { + return false; + } + const clone = new DashKitEngine({column: this.column, maxRow: this.maxRow, float: true}); + clone.mode = this.mode; + clone.nodes = this.nodes + .filter((entry) => !input.id || entry.id !== input.id) + .map((entry) => ({...entry, el: undefined, grid: undefined})); + const candidate = {...input, _id: undefined, el: undefined, grid: undefined}; + if (this.mode.allowOverlap && candidate.x === undefined) { + candidate.x = candidate.y = 0; + } + clone.addNode(candidate); + const fits = !this.maxRow || clone.getRow() <= this.maxRow; + if (fits) { + (node as EngineNode)._willFitPos = Utils.copyPos({}, candidate); + } + return fits; + } +} diff --git a/src/components/GridStack/geometry.ts b/src/components/GridStack/geometry.ts new file mode 100644 index 0000000..40bbe01 --- /dev/null +++ b/src/components/GridStack/geometry.ts @@ -0,0 +1,157 @@ +import {DEFAULT_GROUP} from '../../constants/common'; +import type {Config, ConfigLayout, GridStackContainer} from '../../shared'; +import type {ReactGridLayoutProps} from '../../typings'; + +import {validateConfig, validateGrid} from './document'; +import {requireValue} from './invariants'; + +export function getGridGeometry(width: number, props: ReactGridLayoutProps) { + const [gapX, gapY] = props.margin || [10, 10]; + const [paddingX, paddingY] = props.containerPadding || props.margin || [10, 10]; + const cols = props.cols ?? 12; + const rowHeight = props.rowHeight ?? 150; + const stepX = (width - 2 * paddingX + gapX) / cols; + const stepY = rowHeight + gapY; + return {gapX, gapY, paddingX, paddingY, cols, rowHeight, stepX, stepY}; +} + +export function visibleGeometry( + layout: Pick, + geometry: ReturnType, +) { + return { + left: layout.x * geometry.stepX, + top: layout.y * geometry.stepY, + width: Math.max(0, layout.w * geometry.stepX - geometry.gapX), + height: Math.max(0, layout.h * geometry.stepY - geometry.gapY), + }; +} + +export function getContainerMaxRows(container: GridStackContainer, parent: ReactGridLayoutProps) { + const height = + visibleGeometry(container, getGridGeometry(0, parent)).height - + container.padding[0] - + container.padding[2]; + // Keep the exact physical budget: flooring it would truncate valid saved fractions. + return Math.min( + container.grid.maxRows ?? Infinity, + (height + container.gap[1]) / (container.grid.rowHeight + container.gap[1]), + ); +} + +export type GridStackLayoutRect = {x: number; y: number; width: number; height: number}; +export type GridStackRootGrids = Record< + string, + {width: number; gridProperties: ReactGridLayoutProps} +>; + +// Project saved local geometry for snapshots without mutating the document or mounting a grid. +export function getGridStackLayoutRects(config: Config, rootGrids: GridStackRootGrids) { + validateConfig(config); + const result: Record = Object.create(null); + const grids = new Map< + string, + { + x: number; + y: number; + width?: number; + height?: number; + geometry: ReturnType; + } + >(); + Object.entries(rootGrids).forEach(([id, {width, gridProperties}]) => { + validateGrid(gridProperties); + if (!Number.isFinite(width) || width <= 0) { + throw new Error(`DashKitGridStack: invalid root width for "${id}"`); + } + const geometry = getGridGeometry(width, gridProperties); + grids.set(`zone:${id}`, {x: geometry.paddingX, y: geometry.paddingY, geometry}); + }); + const checkExtent = ( + id: string, + rect: ReturnType, + parent: {width?: number; height?: number}, + ) => { + if ( + parent.width !== undefined && + (rect.width <= 0 || + rect.height <= 0 || + rect.left + rect.width > parent.width + 1e-7 || + rect.top + rect.height > + requireValue(parent.height, 'parent content height') + 1e-7) + ) { + throw new Error(`DashKitGridStack: child "${id}" exceeds its container content bounds`); + } + }; + const pending = [...(config.containers || [])]; + while (pending.length) { + let resolved = false; + for (let index = pending.length - 1; index >= 0; index--) { + const container = pending[index]; + const parent = grids.get( + container.containerId + ? `container:${container.containerId}` + : `zone:${container.parent || DEFAULT_GROUP}`, + ); + if (!parent) { + continue; + } + const rect = visibleGeometry(container, parent.geometry); + checkExtent(container.id, rect, parent); + const [top, right, bottom, left] = container.padding; + const width = rect.width - left - right; + const height = rect.height - top - bottom; + if (width <= 0 || height <= 0) { + throw new Error( + `DashKitGridStack: padding exceeds container "${container.id}" usable width or height`, + ); + } + const geometry = getGridGeometry(width, { + ...container.grid, + margin: container.gap, + containerPadding: [0, 0], + }); + if (geometry.stepX <= 0) { + throw new Error( + `DashKitGridStack: padding exceeds container "${container.id}" width`, + ); + } + grids.set(`container:${container.id}`, { + x: parent.x + rect.left + left, + y: parent.y + rect.top + top, + geometry, + width, + height: Math.min( + height, + (container.grid.maxRows ?? Infinity) * geometry.stepY - geometry.gapY, + ), + }); + pending.splice(index, 1); + resolved = true; + } + if (!resolved) { + throw new Error( + 'DashKitGridStack: cannot project cyclic, missing-container or missing-zone geometry', + ); + } + } + config.layout.forEach((entry) => { + const grid = grids.get( + entry.containerId + ? `container:${entry.containerId}` + : `zone:${entry.parent || DEFAULT_GROUP}`, + ); + if (!grid) { + throw new Error(`DashKitGridStack: missing grid for widget "${entry.i}"`); + } + const rect = visibleGeometry(entry, grid.geometry); + checkExtent(entry.i, rect, grid); + result[entry.i] = { + x: grid.x + rect.left, + y: grid.y + rect.top, + width: rect.width, + height: rect.height, + }; + }); + return result; +} diff --git a/src/components/GridStack/grid-properties.ts b/src/components/GridStack/grid-properties.ts new file mode 100644 index 0000000..40440c1 --- /dev/null +++ b/src/components/GridStack/grid-properties.ts @@ -0,0 +1,61 @@ +import {DEFAULT_GROUP} from '../../constants/common'; +import type {GridStackContainer} from '../../shared'; +import type {DashKitGroup, ReactGridLayoutProps} from '../../typings'; + +import {getGridKey} from './document'; +import {getContainerMaxRows} from './geometry'; +import {requireValue} from './invariants'; + +// Static edits and mounted grids must resolve the same geometry and inherited interactions. +export function getGridProperties( + key: string, + defaults: ReactGridLayoutProps, + groups?: DashKitGroup[], + containers?: GridStackContainer[], +): ReactGridLayoutProps { + if (key.startsWith('container:')) { + const container = requireValue( + containers?.find(({id}) => key === `container:${id}`), + 'container descriptor', + ); + const zone = getGridProperties( + `zone:${container.parent || DEFAULT_GROUP}`, + defaults, + groups, + ); + return { + ...defaults, + isDraggable: zone.isDraggable, + isResizable: zone.isResizable, + isBounded: zone.isBounded, + preventCollision: zone.preventCollision, + resizeHandles: zone.resizeHandles, + resizeHandle: zone.resizeHandle, + useCSSTransforms: zone.useCSSTransforms, + transformScale: zone.transformScale, + transformScaleRef: zone.transformScaleRef, + noOverlay: zone.noOverlay, + autoSize: false, + ...container.grid, + width: undefined, + style: undefined, + verticalCompact: undefined, + margin: container.gap, + containerPadding: [0, 0], + maxRows: getContainerMaxRows( + container, + getGridProperties(getGridKey(container), defaults, groups, containers), + ), + }; + } + const group = groups?.find(({id}) => key === `zone:${id || DEFAULT_GROUP}`); + const properties = group?.gridProperties ? group.gridProperties({...defaults}) : defaults; + // A partial group callback retains the legacy renderer's geometry defaults. + return { + ...properties, + cols: properties.cols ?? 12, + rowHeight: properties.rowHeight ?? 150, + margin: properties.margin || [10, 10], + containerPadding: properties.containerPadding || properties.margin || [10, 10], + }; +} diff --git a/src/components/GridStack/invariants.ts b/src/components/GridStack/invariants.ts new file mode 100644 index 0000000..461040d --- /dev/null +++ b/src/components/GridStack/invariants.ts @@ -0,0 +1,6 @@ +export function requireValue(value: T | null | undefined, name: string): T { + if (value === null || value === undefined) { + throw new Error(`DashKitGridStack: missing ${name}`); + } + return value; +} diff --git a/src/components/GridStack/layout-adapter.ts b/src/components/GridStack/layout-adapter.ts new file mode 100644 index 0000000..697d2fb --- /dev/null +++ b/src/components/GridStack/layout-adapter.ts @@ -0,0 +1,10 @@ +import type {LayoutStateAdapter} from '../../hocs/withContext'; +import {convertEnrichedLayoutToConfigLayout} from '../../utils/enrichLayoutWithDefaults'; + +export const gridStackLayoutAdapter: LayoutStateAdapter = { + serializeLayout: (layout) => + convertEnrichedLayoutToConfigLayout(layout).map(({parent, ...entry}) => + parent ? {...entry, parent} : entry, + ), + optimistic: true, +}; diff --git a/src/components/GridStack/native-grid.ts b/src/components/GridStack/native-grid.ts new file mode 100644 index 0000000..754fd4b --- /dev/null +++ b/src/components/GridStack/native-grid.ts @@ -0,0 +1,276 @@ +import type {ReactGridLayoutProps} from '../../typings'; + +import {requireValue} from './invariants'; +import {GridStack, Utils} from './runtime'; +import type {DDElementHost, GridItemHTMLElement, GridStackNode, GridStackOptions} from './runtime'; + +type Transform = {xScale: number; yScale: number; xOffset: number; yOffset: number}; +type UI = {position: {left: number; top: number}; size?: {width: number; height: number}}; +type NativeInternals = { + dragTransform: Transform; + placeholder: GridItemHTMLElement; + _extraDragRow?: number; + _updateContainerHeight(): GridStack; + _writePosAttr(el: GridItemHTMLElement, node: GridStackNode): GridStack; + _onStartMoving( + el: GridItemHTMLElement, + event: Event, + ui: UI, + node: GridStackNode, + width: number, + height: number, + ): void; + _dragOrResize( + el: GridItemHTMLElement, + event: Event, + ui: UI, + node: GridStackNode, + width: number, + height: number, + ): void; +}; +const NativeBase = GridStack as unknown as new ( + root: HTMLElement, + options: GridStackOptions, +) => GridStack & NativeInternals; + +/** Keep DashKit transform options at the native DOM boundary, outside React's portal subtree. */ +export class DashKitNativeGrid extends NativeBase { + properties?: () => ReactGridLayoutProps; + onCancel?: () => void; + + cancelDrag() { + // React owns these portal targets; native cancellation must not delete external subtrees. + this.onCancel?.(); + } + + _updateContainerHeight() { + const props = this.properties?.(); + if (!props) { + return super._updateContainerHeight(); + } + if (this.engine && !this.engine.batchMode) { + const bottom = this.engine.nodes.reduce( + (max, node) => + Math.max(max, requireValue(node.y, 'node.y') + requireValue(node.h, 'node.h')), + 0, + ); + const paddingY = props.containerPadding?.[1] ?? 0; + const fixed = props.autoSize === false || props.style?.height !== undefined; + const wrapper = this.el.parentElement; + // Read the fixed canvas before writing heights; GridStack's default height is replaced here. + const fixedHeight = fixed ? wrapper?.clientHeight || 0 : 0; + this.el.setAttribute( + 'gs-current-row', + String(this.getRow() + (this._extraDragRow || 0)), + ); + if (wrapper && !fixed) { + wrapper.style.height = `${bottom ? Math.max(0, bottom * this.getCellHeight(true) - (props.margin?.[1] ?? 2) + 2 * paddingY) : 0}px`; + } + this.el.style.minHeight = ''; + this.el.style.height = fixed + ? `${Math.max(0, fixedHeight - 2 * paddingY)}px` + : `${Math.max(0, bottom * this.getCellHeight(true) - (props.margin?.[1] ?? 2))}px`; + } + return this; + } + + setDragHandles(el: GridItemHTMLElement, selector: string, elements: Element[]) { + const draggable = (el as DDElementHost).ddElement?.ddDraggable; + if (!draggable) { + return; + } + // GridStack 12.6 caches handle targets; preserve native callbacks and React resize portals. + const native = draggable as typeof draggable & {dragEls: Element[]}; + if ( + native.option.handle === selector && + native.dragEls.length === elements.length && + elements.every((element, index) => element === native.dragEls[index]) + ) { + return; + } + native.disable(); + native.updateOption({handle: selector}); + native.dragEls = elements; + native.enable(); + this.opts.draggable = {...this.opts.draggable, handle: selector}; + this.prepareDragDrop(el); + } + + refreshPositions() { + this._updateContainerHeight(); + this.engine.nodes.forEach((node) => node.el && this._writePosAttr(node.el, node)); + } + + _writePosAttr(elInput: GridItemHTMLElement, node: GridStackNode) { + const el = elInput; + super._writePosAttr(el, node); + if ( + (!el.classList.contains('ui-draggable-dragging') && + !el.classList.contains('ui-resizable-resizing')) || + el.classList.contains('grid-stack-placeholder') + ) { + if (this.properties?.().useCSSTransforms === false) { + el.style.transform = ''; + } else { + // The canvas owns this unscaled pixel width; DOM measurement here forces layout per node. + const width = parseFloat(this.el.style.width); + const cellWidth = Number.isFinite(width) + ? width / this.getColumn() + : this.cellWidth(); + el.style.transform = `translate(${(node.x || 0) * cellWidth}px, ${(node.y || 0) * this.getCellHeight(true)}px)`; + el.style.left = '0px'; + el.style.top = '0px'; + } + } + return this; + } + + _onStartMoving( + elInput: GridItemHTMLElement, + event: Event, + uiInput: UI, + node: GridStackNode, + width: number, + height: number, + ) { + const el = elInput; + let ui = uiInput; + const host = el as GridItemHTMLElement & { + ddElement?: { + ddDraggable?: { + mouseDownEvent?: MouseEvent; + dragOffset?: {offsetX: number; offsetTop: number}; + _dragFollow(event: MouseEvent): void; + ui(): UI; + }; + }; + }; + const drag = event.type === 'dragstart' ? host.ddElement?.ddDraggable : undefined; + el.style.transform = ''; + if (event.type === 'dragstart') { + ui = this.boundDrag(el, drag?.ui() || ui); + } + super._onStartMoving(el, event, ui, node, width, height); + this.refreshScale(el); + if (drag?.mouseDownEvent && drag.dragOffset) { + const pointer = event as MouseEvent; + // Native dragging anchors at the first move; retain the grab point from mousedown. + const dx = pointer.clientX - drag.mouseDownEvent.clientX; + drag.dragOffset.offsetX += this.opts.rtl ? -dx : dx; + drag.dragOffset.offsetTop += pointer.clientY - drag.mouseDownEvent.clientY; + drag._dragFollow(pointer); + this._dragOrResize( + el, + Utils.initEvent(pointer, {type: 'drag'}), + drag.ui(), + node, + width, + height, + ); + } + } + + _dragOrResize( + el: GridItemHTMLElement, + event: Event, + uiInput: UI, + nodeInput: GridStackNode, + width: number, + height: number, + ) { + let ui = uiInput; + const node = nodeInput; + this.refreshScale(el); + if (event.type === 'drag') { + ui = this.boundDrag(el, ui); + } + const moving = node as GridStackNode & {_temporaryRemoved?: boolean}; + if (event.type === 'drag' && moving._temporaryRemoved && el.gridstackNode === node) { + // A short auto-sized source must keep following the pointer until another grid accepts it. + this.placeholder.gridstackNode = node; + this.el.appendChild(this.placeholder); + node.el = this.placeholder; + this.engine.addNode(node); + } + super._dragOrResize(el, event, ui, node, width, height); + } + + refreshScale(el: GridItemHTMLElement) { + const props = this.properties?.(); + const scale = props?.transformScaleRef?.current ?? props?.transformScale; + if (scale === undefined) { + return; + } + if (!Number.isFinite(scale) || scale <= 0) { + throw new Error('DashKitGridStack: transformScale must be finite and positive'); + } + const reciprocal = 1 / scale; + this.dragTransform.xScale = this.dragTransform.yScale = reciprocal; + const element = el as GridItemHTMLElement & { + ddElement?: { + ddDraggable?: { + dragTransform: Transform; + dragOffset?: {offsetX: number; offsetTop: number}; + helperContainment?: HTMLElement; + }; + ddResizable?: {rectScale: {x: number; y: number}}; + }; + }; + if (element.ddElement?.ddDraggable) { + const drag = element.ddElement.ddDraggable; + const previous = drag.dragTransform; + if (drag.dragOffset && drag.helperContainment && previous.xScale !== reciprocal) { + const current = Utils.getValuesFromTransformedElement(drag.helperContainment); + // Keep the original unscaled grab point when zoom changes during a drag. + drag.dragOffset.offsetX = + ((drag.dragOffset.offsetX + previous.xOffset) * previous.xScale) / reciprocal - + current.xOffset; + drag.dragOffset.offsetTop = + ((drag.dragOffset.offsetTop + previous.yOffset) * previous.yScale) / + reciprocal - + current.yOffset; + previous.xOffset = current.xOffset; + previous.yOffset = current.yOffset; + } + previous.xScale = previous.yScale = reciprocal; + } + if (element.ddElement?.ddResizable) { + element.ddElement.ddResizable.rectScale = {x: reciprocal, y: reciprocal}; + } + } + + private boundDrag(elInput: GridItemHTMLElement, ui: UI): UI { + const el = elInput; + if (!this.properties?.().isBounded) { + return ui; + } + const bounds = this.el.getBoundingClientRect(); + const rect = el.getBoundingClientRect(); + const scaleX = this.el.clientWidth / bounds.width || 1; + const scaleY = this.el.clientHeight / bounds.height || 1; + const helperScaleX = el.offsetWidth / rect.width || 1; + const helperScaleY = el.offsetHeight / rect.height || 1; + const dx = + Math.max(bounds.left, Math.min(rect.left, bounds.right - rect.width)) - rect.left; + const dy = Math.max(bounds.top, Math.min(rect.top, bounds.bottom - rect.height)) - rect.top; + // Native drag helpers are fixed-positioned and can escape even when the engine is bounded. + if (dx) { + if (this.opts.rtl) { + el.style.right = `${(parseFloat(el.style.right) || 0) - dx * helperScaleX}px`; + } else { + el.style.left = `${(parseFloat(el.style.left) || 0) + dx * helperScaleX}px`; + } + } + if (dy) { + el.style.top = `${(parseFloat(el.style.top) || 0) + dy * helperScaleY}px`; + } + return { + ...ui, + position: { + left: ui.position.left + (this.opts.rtl ? -dx : dx) * scaleX, + top: ui.position.top + dy * scaleY, + }, + }; + } +} diff --git a/src/components/GridStack/runtime.ts b/src/components/GridStack/runtime.ts new file mode 100644 index 0000000..5daa978 --- /dev/null +++ b/src/components/GridStack/runtime.ts @@ -0,0 +1,9 @@ +export {GridStack, GridStackEngine, Utils} from 'gridstack'; +export type { + DDElementHost, + GridStackNode, + GridStackMoveOpts, + GridStackPosition, + GridStackOptions, + GridItemHTMLElement, +} from 'gridstack'; diff --git a/src/components/GridStack/types.ts b/src/components/GridStack/types.ts new file mode 100644 index 0000000..a5e87b1 --- /dev/null +++ b/src/components/GridStack/types.ts @@ -0,0 +1,15 @@ +import type {Config} from '../../shared'; +import type {DashKitProps} from '../DashKit'; + +export type GridStackConfig = Config; + +export type DashKitGridStackProps = Omit & { + config: GridStackConfig; + onChange?: (data: { + config: GridStackConfig; + itemsStateAndParams: NonNullable; + groups?: DashKitProps['groups']; + }) => void; +}; + +export type {GridStackContainer} from '../../shared'; diff --git a/src/components/Item/__tests__/updates.test.tsx b/src/components/Item/__tests__/updates.test.tsx new file mode 100644 index 0000000..d64e497 --- /dev/null +++ b/src/components/Item/__tests__/updates.test.tsx @@ -0,0 +1,231 @@ +/** @jest-environment jsdom */ +import assert from 'node:assert/strict'; + +import React from 'react'; + +import {fireEvent, render} from '@testing-library/react'; + +import {DashKitContext} from '../../../context/DashKitContext'; +import type {DashKitCtxShape} from '../../../context/DashKitContext'; +import type {ConfigItem} from '../../../shared'; +import type {PluginRef, PluginWidgetProps} from '../../../typings'; +import {RegisterManager} from '../../../utils/register-manager'; +import Item from '../Item'; + +class Widget extends React.Component { + state = {clicks: 0}; + render() { + return ( +
+ {this.props.data.text as string} + + + +
+ ); + } +} + +function mountItem(isolateItemUpdates?: boolean) { + const registerManager = new RegisterManager(); + const renderer = jest.fn( + (props: PluginWidgetProps, ref: ((value: PluginRef) => void) | undefined) => ( + + ), + ); + const item: ConfigItem = { + id: 'widget', + type: 'item-updates', + namespace: 'default', + data: {text: 'initial'}, + }; + registerManager.registerPlugin({type: item.type, renderer}); + const layout = [{i: item.id, x: 0, y: 0, w: 2, h: 2}]; + const settings = {autoupdateInterval: 0, silentLoading: false, isolateItemUpdates}; + const context: DashKitCtxShape = { + config: {salt: 'updates', counter: 1, items: [item], layout, aliases: {}, connections: []}, + editMode: false, + context: {}, + settings, + globalParams: {}, + registerManager, + forwardedMetaRef: null, + externalLayoutRevision: 0, + configItems: [item], + layout, + layoutChange: jest.fn(), + temporaryLayout: null, + memorizeOriginalLayout: jest.fn(), + revertToOriginalLayout: jest.fn(), + itemsState: {[item.id]: {}}, + itemsParams: {[item.id]: {}}, + onItemStateAndParamsChange: jest.fn(), + getItemsMeta: jest.fn(), + reloadItems: jest.fn(), + onDrop: jest.fn(), + onDropDragOver: jest.fn(), + outerDnDEnable: false, + dragOverPlugin: null, + }; + const adjustWidgetLayout = jest.fn(); + const forwardedPluginRef = jest.fn(); + const fixture = ( + next = context, + overrides: Partial> = {}, + ) => ( + + + + ); + const view = render(fixture()); + const current = () => { + const call = renderer.mock.calls.at(-1); + assert(call); + return call[0]; + }; + return {context, item, renderer, registerManager, fixture, view, current, forwardedPluginRef}; +} + +// Critical: yes. Selector CRUD must not re-run unchanged expensive widget renderers when explicitly isolated. +test('opt-in suppresses unrelated selector/layout updates and preserves own data, state, params and geometry updates', () => { + const {context, item, renderer, fixture, view, current} = mountItem(true); + let next = context; + const update = (patch: Partial) => { + next = {...next, ...patch}; + view.rerender(fixture(next)); + }; + const selector = {...item, id: 'selector'}; + const selectorLayout = {i: selector.id, x: 8, y: 0, w: 2, h: 2}; + update({configItems: [item, selector], layout: [...context.layout, selectorLayout]}); + update({layout: [...context.layout, {...selectorLayout, x: 9}]}); + update({configItems: [item], layout: context.layout.map((entry) => ({...entry}))}); + expect(renderer).toHaveBeenCalledTimes(1); + update({layout: [{...context.layout[0], x: 1}]}); + expect(renderer).toHaveBeenCalledTimes(2); + expect(current().layout[0].x).toBe(1); + update({configItems: [{...item, data: {text: 'own edit'}}]}); + expect(renderer).toHaveBeenCalledTimes(3); + expect(view.getByText('own edit')).toBeTruthy(); + update({itemsState: {[item.id]: {selected: true}}}); + expect(renderer).toHaveBeenCalledTimes(4); + expect(current().state).toEqual({selected: true}); + update({itemsParams: {[item.id]: {value: ['updated']}}}); + expect(renderer).toHaveBeenCalledTimes(5); + expect(current().params).toEqual({value: ['updated']}); +}); + +test.each([undefined, false])( + 'default isolateItemUpdates=%s retains full-layout renderer updates', + (enabled) => { + const {context, item, renderer, fixture, view, current} = mountItem(enabled); + view.rerender( + fixture({ + ...context, + layout: [...context.layout, {i: 'selector', x: 4, y: 0, w: 2, h: 2}], + }), + ); + expect(renderer).toHaveBeenCalledTimes(2); + expect(current().layout).toHaveLength(2); + view.rerender(fixture({...context, configItems: [{...item}], layout: [...context.layout]})); + expect(renderer).toHaveBeenCalledTimes(3); + expect(current().layout).toEqual(context.layout); + }, +); + +test('toggling the opt-in preserves the widget instance and default rendering resumes', () => { + const {context, renderer, fixture, view, forwardedPluginRef} = mountItem(false); + const button = view.getByText('Clicks 0'); + fireEvent.click(button); + const ref = forwardedPluginRef.mock.calls[0][0]; + const isolated = {...context, settings: {...context.settings, isolateItemUpdates: true}}; + view.rerender(fixture(isolated)); + const count = renderer.mock.calls.length; + const layout = [...context.layout, {i: 'selector', x: 4, y: 0, w: 2, h: 2}]; + view.rerender(fixture({...isolated, layout})); + expect(renderer).toHaveBeenCalledTimes(count); + expect(view.getByText('Clicks 1')).toBe(button); + expect(forwardedPluginRef).toHaveBeenCalledTimes(1); + expect(forwardedPluginRef.mock.calls[0][0]).toBe(ref); + view.rerender(fixture({...context, layout})); + expect(renderer).toHaveBeenCalledTimes(count + 1); + view.rerender(fixture({...context, layout: [...layout]})); + expect(renderer).toHaveBeenCalledTimes(count + 2); + expect(view.getByText('Clicks 1')).toBe(button); +}); + +test('own settings, context, grid and callbacks remain live with isolation enabled', () => { + const {context, renderer, fixture, view, current} = mountItem(true); + let next = {...context, context: {label: 'changed'}}; + view.rerender(fixture(next)); + expect(renderer).toHaveBeenCalledTimes(2); + expect(current().context).toEqual({label: 'changed'}); + next = {...next, settings: {...next.settings, silentLoading: true}}; + view.rerender(fixture(next)); + expect(renderer).toHaveBeenCalledTimes(3); + expect(current().settings.silentLoading).toBe(true); + const adjustWidgetLayout = jest.fn(); + const ready = jest.fn(); + const forwardedPluginRef = jest.fn(); + view.rerender( + fixture(next, { + adjustWidgetLayout, + onItemRender: ready, + forwardedPluginRef, + gridLayout: {cols: 24}, + }), + ); + expect(renderer).toHaveBeenCalledTimes(4); + expect(current().gridLayout.cols).toBe(24); + expect(forwardedPluginRef).toHaveBeenCalledTimes(1); + fireEvent.click(view.getByText('Adjust')); + expect(adjustWidgetLayout).toHaveBeenCalledWith({widgetId: 'widget', needSetDefault: true}); + current().onBeforeLoad()(); + expect(ready).toHaveBeenCalledTimes(1); + const onItemStateAndParamsChange = jest.fn(); + view.rerender(fixture({...next, onItemStateAndParamsChange})); + fireEvent.click(view.getByText('Params')); + expect(onItemStateAndParamsChange).toHaveBeenCalledWith( + 'widget', + {state: {clicked: true}}, + undefined, + ); +}); + +test('registry plugin reload invalidates isolation without replacing the mounted widget', () => { + const {context, item, renderer, registerManager, fixture, view, forwardedPluginRef} = + mountItem(true); + const button = view.getByText('Clicks 0'); + fireEvent.click(button); + const replacement = jest.fn( + (props: PluginWidgetProps, ref: ((value: PluginRef) => void) | undefined) => ( + + ), + ); + registerManager.reloadPlugin({type: item.type, renderer: replacement}); + view.rerender(fixture({...context})); + expect(renderer).toHaveBeenCalledTimes(1); + expect(replacement).toHaveBeenCalledTimes(1); + expect(view.getByText('Clicks 1')).toBe(button); + expect(forwardedPluginRef).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/Item/types.ts b/src/components/Item/types.ts index 42c5ed5..7ceba36 100644 --- a/src/components/Item/types.ts +++ b/src/components/Item/types.ts @@ -1,6 +1,6 @@ import type {ConfigItem, ItemParams} from '../../shared/types'; import type {PluginRef, PluginWidgetProps} from '../../typings'; -import type {RegisterManager} from '../../utils/register-manager'; +import type {RegisterManager, RegisterManagerPlugin} from '../../utils/register-manager'; import type {DashKitProps} from '../DashKit'; export type RendererProps = Omit, 'onBeforeLoad'>; @@ -10,6 +10,7 @@ export type ItemProps = { isPlaceholder?: boolean; item: ConfigItem; registerManager: RegisterManager; + plugin?: RegisterManagerPlugin; rendererProps: RendererProps; onItemRender?: DashKitProps['onItemRender']; onItemMountChange?: DashKitProps['onItemMountChange']; diff --git a/src/components/MobileLayout/MobileLayout.tsx b/src/components/MobileLayout/MobileLayout.tsx index c9c18a4..a7b0686 100644 --- a/src/components/MobileLayout/MobileLayout.tsx +++ b/src/components/MobileLayout/MobileLayout.tsx @@ -15,7 +15,7 @@ import './MobileLayout.scss'; const b = cn('dashkit-mobile-layout'); -type MobileLayoutProps = {}; +type MobileLayoutProps = {'data-dashkit-engine'?: string}; type MobileLayoutState = { itemsWithActiveAutoheight: Record; @@ -32,6 +32,8 @@ export default class MobileLayout extends React.PureComponent< sortedLayoutItems: Record> | null = null; _memoLayout = this.context.layout; + _memoContainers = this.context.config.containers; + _memoItems = this.context.configItems; _memoForwardedPluginRef: Array<(refObject: PluginRef) => void> = []; _memoAdjustWidgetLayout: Record void> = {}; @@ -55,7 +57,10 @@ export default class MobileLayout extends React.PureComponent< let indexOffset = 0; return ( -
+
{groups.map((group) => { const groupId = group.id || DEFAULT_GROUP; const items = sortedItems[groupId] || []; @@ -105,11 +110,20 @@ export default class MobileLayout extends React.PureComponent< } getSortedLayoutItems() { - if (this.sortedLayoutItems && this.context.layout === this._memoLayout) { + const nested = this.context.config.containers?.length || this._memoContainers?.length; + if ( + this.sortedLayoutItems && + this.context.layout === this._memoLayout && + (!nested || + (this.context.config.containers === this._memoContainers && + this.context.configItems === this._memoItems)) + ) { return this.sortedLayoutItems; } this._memoLayout = this.context.layout; + this._memoContainers = this.context.config.containers; + this._memoItems = this.context.configItems; const hasOrderId = Boolean(this.context.configItems.find((item) => item.orderId)); diff --git a/src/components/MobileLayout/helpers.ts b/src/components/MobileLayout/helpers.ts index 388c0cd..301b39c 100644 --- a/src/components/MobileLayout/helpers.ts +++ b/src/components/MobileLayout/helpers.ts @@ -33,6 +33,37 @@ export const getSortedConfigItems = ( ) => { const sortComparator = getWidgetsSortComparator(hasOrderId); + const containers = config.containers; + if (containers?.length) { + const entries = configItems.map((item) => ({ + ...item, + ...config.layout.find(({i}) => i === item.id), + })); + if (hasOrderId) { + return entries.sort((a, b) => sortByOrderComparator(a, b, 'orderId')); + } + const result: typeof entries = []; + const visit = (containerId?: string) => { + const children = [ + ...entries + .filter((item) => item.containerId === containerId) + .map((item) => ({id: item.id, x: item.x || 0, y: item.y || 0, item})), + ...containers + .filter((item) => item.containerId === containerId) + .map((item) => ({...item, item: undefined})), + ].sort((a, b) => a.y - b.y || a.x - b.x); + children.forEach((child) => { + if (child.item) { + result.push(child.item); + } else { + visit(child.id); + } + }); + }; + visit(); + return result; + } + return configItems .map((item, index) => Object.assign({}, item, config.layout[index])) .sort(sortComparator); diff --git a/src/context/DashKitContext.ts b/src/context/DashKitContext.ts index edc071a..0ac3bbf 100644 --- a/src/context/DashKitContext.ts +++ b/src/context/DashKitContext.ts @@ -9,6 +9,7 @@ import type { ConfigLayout, DraggedOverItem, GlobalParams, + GridStackContainer, ItemDragProps, ItemParams, ItemState, @@ -54,7 +55,8 @@ export type DashKitCtxShape = DashkitPropsPassedToCtx & { externalLayoutRevision: number; configItems: ConfigItem[]; layout: ConfigLayout[]; - layoutChange: (layout: ConfigLayout[]) => void; + containers?: GridStackContainer[]; + layoutChange: (layout: ConfigLayout[], containers?: GridStackContainer[]) => void; temporaryLayout: TemporaryLayout | null; memorizeOriginalLayout: ( widgetId: string, @@ -84,9 +86,11 @@ export type DashKitCtxShape = DashkitPropsPassedToCtx & { gridProps: ReactGridLayoutProps, groupLayout: ConfigLayout[], sharedItem?: DraggedOverItem, + defaultsOverride?: Partial, ) => {w?: number; h?: number} | false | undefined; outerDnDEnable: boolean; dragOverPlugin: null | RegisterManagerPlugin; + dragProps?: ItemDragProps | null; }; const DashKitContext = React.createContext({} as DashKitCtxShape); diff --git a/src/gridstack.ts b/src/gridstack.ts new file mode 100644 index 0000000..76f2989 --- /dev/null +++ b/src/gridstack.ts @@ -0,0 +1,8 @@ +export {DashKitGridStack} from './components/GridStack/DashKitGridStack'; +export type { + DashKitGridStackProps, + GridStackConfig, + GridStackContainer, +} from './components/GridStack/types'; +export {getGridStackLayoutRects} from './components/GridStack/geometry'; +export type {GridStackLayoutRect, GridStackRootGrids} from './components/GridStack/geometry'; diff --git a/src/hocs/prepareItem.tsx b/src/hocs/prepareItem.tsx index 9339814..96e6b15 100644 --- a/src/hocs/prepareItem.tsx +++ b/src/hocs/prepareItem.tsx @@ -30,6 +30,30 @@ type PrepareItemProps = { export function prepareItem( WrappedComponent: React.ComponentType, ): React.ComponentClass { + const MemoizedItem = React.memo(WrappedComponent, (previous, next) => { + if (!next.rendererProps.settings.isolateItemUpdates) { + return false; + } + const sameItemProps = Object.entries(next).every( + ([key, value]) => key === 'rendererProps' || previous[key as keyof ItemProps] === value, + ); + if (!sameItemProps) { + return false; + } + return Object.entries(next.rendererProps).every(([key, value]) => { + const previousValue = previous.rendererProps[key as keyof RendererProps]; + if (key === 'layout') { + return isEqual( + previous.rendererProps.layout.find(({i}) => i === previous.item.id), + next.rendererProps.layout.find(({i}) => i === next.item.id), + ); + } + if (key === 'gridLayout' || key === 'params' || key === 'state') { + return isEqual(previousValue, value); + } + return previousValue === value; + }); + }); return class PrepareItem extends React.Component { static contextType = DashKitContext; context!: React.ContextType; @@ -61,6 +85,12 @@ export function prepareItem( return ( this.props.item !== nextProps.item || this.props.layout !== nextProps.layout || + this.props.gridLayout !== nextProps.gridLayout || + this.props.adjustWidgetLayout !== nextProps.adjustWidgetLayout || + this.props.forwardedPluginRef !== nextProps.forwardedPluginRef || + this.props.onItemRender !== nextProps.onItemRender || + this.props.onItemMountChange !== nextProps.onItemMountChange || + this.props.isPlaceholder !== nextProps.isPlaceholder || ctx.itemsParams[id] !== nextContext.itemsParams[id] || ctx.itemsState[id] !== nextContext.itemsState[id] || ctx.editMode !== nextContext.editMode || @@ -75,10 +105,11 @@ export function prepareItem( const {registerManager} = this.context; return ( - ConfigLayout[]; + /** Native layouts need the context to retain edits until controlled props catch up. */ + optimistic: boolean; +} + +const legacyLayoutAdapter: LayoutStateAdapter = { + serializeLayout: (layout) => layout, + optimistic: false, +}; + const hasGetMeta = (value: PluginRef): value is {getMeta: () => Promise} => { return ( typeof value === 'object' && @@ -93,7 +104,10 @@ const hasReload = ( ); }; -function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContextResult { +function useMemoStateContext( + props: DashKitWithContextProps, + layoutAdapter: LayoutStateAdapter, +): UseMemoStateContextResult { // Since we don't want to store widget parameters with auto-height enabled in the store or server, // the actual layout (visible to the user at any moment) will be stored in state. // However, to ensure the store gets the layout without considering auto-height widgets, @@ -114,6 +128,9 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex [props.config.layout, props.registerManager], ); + const baselineContainers = React.useRef(props.config.containers); + const [containers, setContainers] = React.useState(props.config.containers); + const [previousContainers, setPreviousContainers] = React.useState(props.config.containers); const internalBaselineLayoutRef = React.useRef(enrichedPropsLayout); const [visualLayout, setVisualLayout] = React.useState(enrichedPropsLayout); const [previousEnrichedPropsLayout, setPreviousEnrichedPropsLayout] = @@ -169,14 +186,24 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex // values stored are not adjusted (as if auto-height was disabled), we correct them using // the originalLayouts stored without adjustments. const onLayoutChange = React.useCallback( - (layout: ConfigLayout[]) => { + ( + layout: ConfigLayout[], + nextContainers: GridStackContainer[] | undefined = baselineContainers.current, + ) => { const currentInnerLayout = layout.map((item) => { if (item.i in originalLayouts.current) { - const {parent: _parent, ...originalCopy} = originalLayouts.current[item.i]; + const { + parent: _parent, + containerId: _containerId, + ...originalCopy + } = originalLayouts.current[item.i]; if (item.parent) { (originalCopy as ConfigLayout).parent = item.parent; } + if (item.containerId) { + (originalCopy as ConfigLayout).containerId = item.containerId; + } originalCopy.w = item.w; originalCopy.x = item.x; originalCopy.y = item.y; @@ -189,14 +216,21 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex const baselineConfig = { ...props.config, - layout: getBaselineLayout(), + layout: layoutAdapter.serializeLayout(getBaselineLayout()), + ...(baselineContainers.current + ? {containers: baselineContainers.current, layoutVersion: 2 as const} + : {}), }; const newConfig = UpdateManager.updateLayout({ - layout: currentInnerLayout, + layout: layoutAdapter.serializeLayout(currentInnerLayout), config: baselineConfig, }); + if (nextContainers !== undefined) { + newConfig.containers = nextContainers; + newConfig.layoutVersion = 2; + } emitDashKitChangeEvent({ config: baselineConfig, newConfig, @@ -205,8 +239,13 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex }); internalBaselineLayoutRef.current = newConfig.layout; + baselineContainers.current = nextContainers; + if (layoutAdapter.optimistic) { + setVisualLayout(currentInnerLayout); + setContainers(nextContainers); + } }, - [getBaselineLayout, props.config, props.emitDashKitEvent, onChange], + [getBaselineLayout, props.config, layoutAdapter, props.emitDashKitEvent, onChange], ); const getLayoutItem = React.useCallback( @@ -220,6 +259,17 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex return convertEnrichedLayoutToConfigLayout(getBaselineLayout()); }, [getBaselineLayout]); + const getOuterConfig = React.useCallback( + () => ({ + ...props.config, + layout: getOuterLayout(), + ...(baselineContainers.current + ? {containers: baselineContainers.current, layoutVersion: 2 as const} + : {}), + }), + [props.config, getOuterLayout], + ); + // to calculate items, only memorization of items and globalItems is important const configItems = React.useMemo( () => getAllConfigItems(props.config), @@ -245,7 +295,7 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex onChange( UpdateManager.removeItem({ id, - config: {...props.config, layout: getOuterLayout()}, + config: getOuterConfig(), itemsStateAndParams: props.itemsStateAndParams, }), ); @@ -255,9 +305,8 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex resetTemporaryLayout, temporaryLayout, onChange, - props.config, props.itemsStateAndParams, - getOuterLayout, + getOuterConfig, ], ); @@ -265,7 +314,7 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex DashKitCtxShape['onItemStateAndParamsChange'] >( (id, stateAndParams, options) => { - const currentConfig = {...props.config, layout: getOuterLayout()}; + const currentConfig = getOuterConfig(); onChange({ config: currentConfig, // config is not strictly required here; ideally it should be removed along with the default props @@ -278,7 +327,7 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex }), }); }, - [props.config, props.itemsStateAndParams, getOuterLayout, onChange], + [props.itemsStateAndParams, getOuterConfig, onChange], ); const memorizeOriginalLayout = React.useCallback( @@ -311,6 +360,11 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex }, []); React.useMemo(() => { + if (containers?.length) { + // Nested engines enforce nowrap budgets locally, including container footprints. + nowrapAdjustedLayouts.current = {}; + return; + } const groups = props.groups; // Use visualLayout instead of props.layout (which is now optional) const layout = visualLayout; @@ -369,7 +423,7 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex }); }); } - }, [props.registerManager, props.groups, visualLayout]); + }, [props.registerManager, props.groups, visualLayout, containers]); // Synchronize visual layout during render, so children never see new config with old layout. // Keep the previous external layout separate from the drag baseline: a drag updates the latter. @@ -386,6 +440,15 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex } } + if (previousContainers !== props.config.containers) { + setPreviousContainers(props.config.containers); + if (!isEqual(baselineContainers.current, props.config.containers)) { + baselineContainers.current = props.config.containers; + setContainers(props.config.containers); + setExternalLayoutRevision((revision) => revision + 1); + } + } + React.useLayoutEffect(() => { if (pendingExternalBaselineLayout) { if ( @@ -439,7 +502,9 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex const resultLayout = React.useMemo(() => { const adjusted = adjustedLayouts.current; const original = originalLayouts.current; - const nowrapAdjust = nowrapAdjustedLayouts.current; + const nowrapAdjust: NowrapAdjustedLayouts = containers?.length + ? {} + : nowrapAdjustedLayouts.current; return visualLayout.map((item) => { const widgetId = item.i; @@ -447,7 +512,11 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex if (widgetId in adjusted || widgetId in nowrapAdjust) { original[widgetId] = item; // eslint-disable-next-line no-unused-vars - const {parent: _parent2, ...adjustedItem} = adjusted[widgetId] || item; + const { + parent: _parent2, + containerId: _containerId2, + ...adjustedItem + } = adjusted[widgetId] || item; adjustedItem.w = item.w; adjustedItem.x = item.x; @@ -457,6 +526,9 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex (adjustedItem as ConfigLayout).parent = item.parent; } + if (item.containerId) { + (adjustedItem as ConfigLayout).containerId = item.containerId; + } if (widgetId in nowrapAdjust) { (adjustedItem as ConfigLayout & {maxW?: number}).maxW = nowrapAdjust[widgetId]; } @@ -469,7 +541,7 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex return item; } }); - }, [visualLayout, layoutUpdateCounter]); + }, [visualLayout, layoutUpdateCounter, containers]); const reloadItems = React.useCallback((pluginsRefs, data) => { pluginsRefs.forEach((ref) => ref && hasReload(ref) && ref.reload(data)); @@ -495,7 +567,7 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex }, [dragPropsContext, props.registerManager]); const onDropDragOver = React.useCallback( - (_e, group, gridProps, groupLayout, sharedItem) => { + (_e, group, gridProps, groupLayout, sharedItem, defaultsOverride) => { if (temporaryLayout) { resetTemporaryLayout(); return false; @@ -507,10 +579,17 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex const {type, h, w} = sharedItem; dragItemType = type; const _defaults = props.registerManager.getItem(type); - defaultLayout = _defaults ? {..._defaults.defaultLayout, h, w} : {h, w}; + defaultLayout = _defaults + ? { + ..._defaults.defaultLayout, + ...pick(sharedItem, 'minW', 'minH', 'maxW', 'maxH'), + h, + w, + } + : {h, w}; } else if (dragOverPlugin) { dragItemType = dragOverPlugin.type; - defaultLayout = dragOverPlugin.defaultLayout; + defaultLayout = {...dragOverPlugin.defaultLayout, ...defaultsOverride}; } else { return false; } @@ -540,7 +619,7 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex const { h = defaultLayout?.h || DEFAULT_WIDGET_HEIGHT, w = defaultLayout?.w || DEFAULT_WIDGET_WIDTH, - } = dragPropsContext?.layout || {}; + } = defaultsOverride || dragPropsContext?.layout || {}; const itemLayout = { h: maxH ? Math.min(h, maxH) : h, @@ -585,20 +664,27 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex dragProps: dragPropsContext, }); - onDropProp?.({ - newLayout: newLayout.reduce((memo, l) => { - if (l.i !== item.i) { - memo.push(pick(l, CONFIG_LAYOUT_FIELDS)); - } - - return memo; - }, []), - itemLayout: pick(item, CONFIG_LAYOUT_FIELDS), - commit: resetTemporaryLayout, - dragProps: dragPropsContext, - }); + try { + onDropProp?.({ + newLayout: newLayout.reduce((memo, l) => { + if (l.i !== item.i) { + memo.push(pick(l, CONFIG_LAYOUT_FIELDS)); + } + + return memo; + }, []), + itemLayout: pick(item, CONFIG_LAYOUT_FIELDS), + commit: resetTemporaryLayout, + dragProps: dragPropsContext, + }); + } catch (error) { + if (layoutAdapter.optimistic) { + resetTemporaryLayout(); + } + throw error; + } }, - [dragPropsContext, onDropProp, setTemporaryLayout, resetTemporaryLayout], + [dragPropsContext, onDropProp, layoutAdapter, setTemporaryLayout, resetTemporaryLayout], ); const dashkitContextValue = React.useMemo( @@ -622,6 +708,7 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex forwardedMetaRef: props.forwardedMetaRef, layout: resultLayout, + containers, temporaryLayout, layoutChange: onLayoutChange, memorizeOriginalLayout, @@ -638,6 +725,7 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex onDropDragOver, outerDnDEnable, dragOverPlugin, + dragProps: dragPropsContext, onItemFocus: props.onItemFocus, onItemBlur: props.onItemBlur, @@ -653,6 +741,7 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex [ externalLayoutRevision, resultLayout, + containers, temporaryLayout, props.config, configItems, @@ -680,6 +769,7 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex props.draggableHandleClassName, outerDnDEnable, dragOverPlugin, + dragPropsContext, props.onItemFocus, props.onItemBlur, @@ -730,9 +820,15 @@ function useMemoStateContext(props: DashKitWithContextProps): UseMemoStateContex return {controlsContextValue, dashkitContextValue}; } -export function withContext(Component: React.ComponentType) { +export function withContext( + Component: React.ComponentType, + layoutAdapter: LayoutStateAdapter = legacyLayoutAdapter, +) { const WithContext = (props: DashKitWithContextProps) => { - const {dashkitContextValue, controlsContextValue} = useMemoStateContext(props); + const {dashkitContextValue, controlsContextValue} = useMemoStateContext( + props, + layoutAdapter, + ); return ( diff --git a/src/shared/types/common.ts b/src/shared/types/common.ts index 4249e6f..c294b2d 100644 --- a/src/shared/types/common.ts +++ b/src/shared/types/common.ts @@ -17,6 +17,10 @@ export type ItemDragProps = { }; export type DraggedOverItem = Omit & { + minW?: number; + minH?: number; + maxW?: number; + maxH?: number; type: string; i?: ConfigLayout['i']; }; diff --git a/src/shared/types/config.ts b/src/shared/types/config.ts index 04e829c..bd9d9c2 100644 --- a/src/shared/types/config.ts +++ b/src/shared/types/config.ts @@ -2,6 +2,7 @@ import {StringParams} from './common'; export interface AdditionalWidgetLayout { parent?: string; + containerId?: string; } export interface ConfigLayout extends AdditionalWidgetLayout { @@ -19,6 +20,7 @@ export const CONFIG_LAYOUT_FIELDS = [ 'x', 'y', 'parent', + 'containerId', ] as const satisfies ReadonlyArray; export type ConfigLayoutField = (typeof CONFIG_LAYOUT_FIELDS)[number]; @@ -86,6 +88,8 @@ export interface ConfigConnection { export interface Config { id?: string; + layoutVersion?: 2; + containers?: GridStackContainer[]; salt: string; counter: number; items: ConfigItem[]; @@ -94,3 +98,17 @@ export interface Config { aliases: ConfigAliases; connections: ConfigConnection[]; } + +/** Persisted nested layout. Runtime zone render functions remain in DashKitGroup. */ +export type GridStackContainer = Omit & { + id: string; + padding: [number, number, number, number]; + gap: [number, number]; + grid: { + cols: number; + rowHeight: number; + compactType: 'vertical' | 'horizontal' | 'horizontal-nowrap' | null; + allowOverlap?: boolean; + maxRows?: number; + }; +}; diff --git a/src/typings/common.ts b/src/typings/common.ts index 6e1771b..977a54e 100644 --- a/src/typings/common.ts +++ b/src/typings/common.ts @@ -48,6 +48,8 @@ export type MenuItem = (typeof MenuItems)[keyof typeof MenuItems] | OverlayCusto export interface SettingsProps { autoupdateInterval: number; silentLoading: boolean; + /** Limit renderer updates to this widget; defaults to full-layout updates. */ + isolateItemUpdates?: boolean; } export interface ContextProps { diff --git a/src/typings/config.ts b/src/typings/config.ts index 9f606f4..f8c7909 100644 --- a/src/typings/config.ts +++ b/src/typings/config.ts @@ -52,6 +52,7 @@ export interface DashKitGroup { } export type ItemManipulationCallback = (eventData: { + group?: string; layout: Layout[]; oldItem: Layout; newItem: Layout; diff --git a/src/typings/events.ts b/src/typings/events.ts index 0ba5417..6fde672 100644 --- a/src/typings/events.ts +++ b/src/typings/events.ts @@ -1,10 +1,10 @@ -import type {ConfigLayout} from '../shared'; +import type {ConfigLayout, GridStackContainer} from '../shared'; /** * @experimental This API can change in minor releases. */ export type DashKitLayoutPatch = Pick & - Partial>; + Partial>; /** * @experimental This API can change in minor releases. @@ -21,6 +21,8 @@ export type DashKitChangeEvent = DashKitBaseEvent & { patches: DashKitLayoutPatch[]; layout: ConfigLayout[]; previousLayout: ConfigLayout[]; + containers?: GridStackContainer[]; + previousContainers?: GridStackContainer[]; }; /** diff --git a/src/utils/enrichLayoutWithDefaults.ts b/src/utils/enrichLayoutWithDefaults.ts index 64ecf92..16efcda 100644 --- a/src/utils/enrichLayoutWithDefaults.ts +++ b/src/utils/enrichLayoutWithDefaults.ts @@ -33,5 +33,6 @@ export function convertEnrichedLayoutToConfigLayout(layout: WidgetLayout[]): Con x: i.x, y: i.y, parent: i.parent, + ...(i.containerId ? {containerId: i.containerId} : {}), })); } diff --git a/src/utils/events.ts b/src/utils/events.ts index c55023e..67eff57 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -27,7 +27,8 @@ export function emitDashKitChangeEvent({ !isEqual( convertEnrichedLayoutToConfigLayout(newConfig.layout), convertEnrichedLayoutToConfigLayout(config.layout), - ) + ) || + !isEqual(newConfig.containers, config.containers) ) { let defaultPrevented = false; let cachedPatches: DashKitLayoutPatch[] | undefined; @@ -47,6 +48,11 @@ export function emitDashKitChangeEvent({ return defaultPrevented; }, }; + // Keep getters on the event: downlevel object spread would copy their current values. + if (newConfig.containers || config.containers) { + event.containers = newConfig.containers; + event.previousContainers = config.containers; + } emitDashKitEvent('change', event); if (!defaultPrevented) { diff --git a/src/utils/update-manager.ts b/src/utils/update-manager.ts index d0d40f9..d430284 100644 --- a/src/utils/update-manager.ts +++ b/src/utils/update-manager.ts @@ -489,7 +489,7 @@ export class UpdateManager { counter = newIdData.counter; const newItem = {...item, id: newIdData.id, data: newItemData.data, namespace}; - const saveDefaultLayout = pick(layout, ['h', 'w', 'x', 'y', 'parent']); + const saveDefaultLayout = pick(layout, ['h', 'w', 'x', 'y', 'parent', 'containerId']); const updateActions = { counter: {$set: counter}, diff --git a/tests/browser/boundaries.spec.ts b/tests/browser/boundaries.spec.ts new file mode 100644 index 0000000..9a737e5 --- /dev/null +++ b/tests/browser/boundaries.spec.ts @@ -0,0 +1,642 @@ +import assert from 'node:assert/strict'; + +import type {Config} from '../../src/shared'; + +import {checked, storyUrl, test} from './fixtures'; +import type {NativeElement} from './types'; +// Critical: yes. Native edits must preserve saved geometry and a usable editor. +test('subtree admission, finite bounds, scale continuation and StrictMode', async ({ + page, + evidence, +}) => { + const frames = () => + page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + const snapshot = () => + page.evaluate(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return { + config: JSON.parse( + required( + required(document.querySelector('[data-qa="saved-config"]')) + .textContent, + ), + ) as Config, + events: required( + required(document.querySelector('[data-qa="event-count"]')) + .textContent, + ), + widgets: [...document.querySelectorAll('[data-widget-id]')].map( + (el) => ({ + id: el.dataset.widgetId, + instance: el.dataset.widgetInstance, + transform: el.closest('.dashkit-gridstack__host')?.style + .transform, + rect: el.getBoundingClientRect().toJSON(), + }), + ), + containers: [ + ...document.querySelectorAll('.dashkit-gridstack__container'), + ].map((el) => ({ + id: required(el.parentElement).dataset.dashkitId, + transform: required(el.parentElement).style.transform, + rect: el.getBoundingClientRect().toJSON(), + })), + }; + }); + const sameRects = ( + actual: Awaited>, + expected: Awaited>, + ) => { + for (const key of ['widgets', 'containers'] as const) { + for (const entry of expected[key]) { + const next = actual[key].find(({id}) => id === entry.id); + assert(next, `missing ${entry.id}`); + for (const field of ['x', 'y', 'width', 'height'] as const) { + assert( + Math.abs(next.rect[field] - entry.rect[field]) < 0.01, + `${entry.id} ${field}: ${next.rect[field]} != ${entry.rect[field]}`, + ); + } + } + } + }; + const dragTo = async ( + rect: { + x: number; + y: number; + width: number; + height: number; + bottom: number; + }, + destination: { + x: number; + y: number; + }, + ) => { + const x = rect.x + rect.width / 2, + y = rect.bottom - 12; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 5, y + 5); + await page.mouse.move(destination.x, destination.y, {steps: 16}); + await page.mouse.up(); + await frames(); + }; + await page.goto(storyUrl('bounded-subtrees')); + await page.locator('[data-widget-id="b"]').waitFor(); + assert.equal( + await page.locator('[data-dashkit-engine]').getAttribute('data-dashkit-engine'), + 'gridstack', + ); + await page.getByRole('button', {name: 'Read refs', exact: true}).click(); + const refs = await page.locator('[data-qa="meta"]').textContent(); + const before = await snapshot(); + const right = await page.locator('[data-dashkit-id="target"] .grid-stack').boundingBox(); + await dragTo(checked(before.containers.find(({id}) => id === 'small')).rect, { + x: checked(right).x + 70, + y: checked(right).y + 90, + }); + const inside = await snapshot(); + assert.equal( + checked(checked(inside.config.containers).find(({id}) => id === 'small')).parent, + 'right', + ); + assert.equal( + checked(checked(inside.config.containers).find(({id}) => id === 'small')).containerId, + 'target', + ); + assert.equal( + checked(checked(inside.config.containers).find(({id}) => id === 'twig')).parent, + 'right', + ); + assert(inside.config.layout.every(({parent}) => parent === 'right')); + assert.deepEqual( + inside.config.layout.map(({i, x, y, w, h, containerId}) => ({i, x, y, w, h, containerId})), + before.config.layout.map(({i, x, y, w, h, containerId}) => ({i, x, y, w, h, containerId})), + ); + assert.deepEqual( + inside.widgets.map(({id, instance}) => [id, instance]), + before.widgets.map(({id, instance}) => [id, instance]), + ); + assert.equal(inside.events, 'Events: 1'); + await page.getByRole('button', {name: 'Read refs', exact: true}).click(); + assert.equal(await page.locator('[data-qa="meta"]').textContent(), refs); + await page.getByRole('button', {name: 'Undo subtree', exact: true}).click(); + await frames(); + const undone = await snapshot(); + assert.deepEqual(undone.config, before.config); + assert.equal(undone.events, 'Events: 1'); + await dragTo(checked(undone.containers.find(({id}) => id === 'small')).rect, { + x: checked(right).x + 70, + y: checked(right).y + 90, + }); + const nestedAgain = await snapshot(); + const left = await page + .locator('[data-boundary-zone="left"] > .dashkit-gridstack__zone > .grid-stack') + .boundingBox(); + await dragTo(checked(nestedAgain.containers.find(({id}) => id === 'small')).rect, { + x: checked(left).x + 80, + y: checked(left).y + 90, + }); + const outside = await snapshot(); + assert.equal( + checked(checked(outside.config.containers).find(({id}) => id === 'small')).parent, + 'left', + ); + assert.equal( + checked(checked(outside.config.containers).find(({id}) => id === 'small')).containerId, + undefined, + ); + assert.equal( + checked(checked(outside.config.containers).find(({id}) => id === 'twig')).parent, + 'left', + ); + assert(outside.config.layout.every(({parent}) => parent === 'left')); + assert.deepEqual( + outside.config.layout, + before.config.layout.map((entry) => { + const layout: Config['layout'][number] & { + minW?: number; + minH?: number; + } = {...entry}; + delete layout.minW; + delete layout.minH; + return layout; + }), + ); + assert.equal(outside.events, 'Events: 3'); + for (const destination of [ + {x: checked(right).x + 100, y: checked(right).y + 100}, + {x: checked(left).x + 340, y: checked(left).y + 150}, + {x: 800, y: 550}, + ]) { + const current = await snapshot(); + await dragTo(checked(current.containers.find(({id}) => id === 'large')).rect, destination); + const rejected = await snapshot(); + assert.deepEqual(rejected.config, current.config); + assert.equal(rejected.events, current.events); + sameRects(rejected, current); + assert.deepEqual( + rejected.widgets.map(({id, instance}) => [id, instance]), + current.widgets.map(({id, instance}) => [id, instance]), + ); + } + const cancelBefore = await snapshot(); + const cancelRect = checked(cancelBefore.containers.find(({id}) => id === 'small')).rect; + const cx = cancelRect.x + cancelRect.width / 2, + cy = cancelRect.bottom - 12; + await page.mouse.move(cx, cy); + await page.mouse.down(); + await page.mouse.move(cx + 5, cy + 5); + await page.mouse.move(checked(right).x + 70, checked(right).y + 90, {steps: 16}); + await page.keyboard.press('Escape'); + await page.mouse.up(); + await frames(); + const rejected = await snapshot(); + assert.deepEqual(rejected.config, cancelBefore.config); + assert.equal(rejected.events, cancelBefore.events); + sameRects(rejected, cancelBefore); + assert.deepEqual( + rejected.widgets.map(({id, instance}) => [id, instance]), + cancelBefore.widgets.map(({id, instance}) => [id, instance]), + ); + const readyRect = checked(rejected.containers.find(({id}) => id === 'small')).rect; + await dragTo(readyRect, { + x: readyRect.x + readyRect.width / 2 + 35, + y: readyRect.bottom - 12 + 20, + }); + const nextGesture = await snapshot(); + const oldSmall = checked(rejected.config.containers).find(({id}) => id === 'small'); + const nextSmall = checked(nextGesture.config.containers).find(({id}) => id === 'small'); + assert.equal( + checked(nextSmall).x, + Math.min(checked(oldSmall).x + 35, checked(left).width - checked(oldSmall).w), + ); + assert.equal(checked(nextSmall).y, checked(oldSmall).y + 20); + assert.equal(nextGesture.events, 'Events: 4'); + assert.deepEqual(nextGesture.config.layout, rejected.config.layout); + assert.deepEqual( + nextGesture.widgets.map(({id, instance}) => [id, instance]), + rejected.widgets.map(({id, instance}) => [id, instance]), + ); + await page.getByRole('button', {name: 'Undo subtree', exact: true}).click(); + await frames(); + const nextUndo = await snapshot(); + assert.deepEqual(nextUndo.config, rejected.config); + sameRects(nextUndo, rejected); + evidence.push({ + check: 'bounded native container subtree transfer converts source pixels before fit, preserves descendants/refs, one transaction, undo, Escape cancel and actual oversize/outside/gap rejection', + before, + inside, + undone, + outside, + rejected, + nextGesture, + nextUndo, + }); + const nestedBefore = await snapshot(); + await page.getByRole('button', {name: 'Palette allowed: true', exact: true}).click(); + const nestedTarget = page.locator('[data-dashkit-id="target"] .grid-stack'); + await page + .getByRole('button', {name: '+ Widget', exact: true}) + .dragTo(nestedTarget, {targetPosition: {x: 150, y: 100}}); + assert.deepEqual((await snapshot()).config, nestedBefore.config); + assert.equal(await page.locator('[data-qa="drop-count"]').textContent(), '0'); + await page.getByRole('button', {name: 'Palette allowed: false', exact: true}).click(); + const nestedSource = await page + .getByRole('button', {name: '+ Widget', exact: true}) + .boundingBox(); + const nestedRoot = await nestedTarget.boundingBox(); + await page.mouse.move( + checked(nestedSource).x + checked(nestedSource).width / 2, + checked(nestedSource).y + checked(nestedSource).height / 2, + ); + await page.mouse.down(); + await page.mouse.move(checked(nestedRoot).x + 150, checked(nestedRoot).y + 100, {steps: 12}); + await page.mouse.move(checked(nestedRoot).x + 160, checked(nestedRoot).y + 100); + await frames(); + await page.locator('[data-dashkit-id="__dropping-elem__"]').waitFor({state: 'visible'}); + assert.equal(await page.locator('[data-dashkit-id="__dropping-elem__"]').count(), 1); + await page.mouse.move(850, 650, {steps: 8}); + await page.mouse.up(); + await frames(); + assert.equal(await page.locator('[data-dashkit-id="__dropping-elem__"]').count(), 0); + assert.deepEqual((await snapshot()).config, nestedBefore.config); + await page + .getByRole('button', {name: '+ Widget', exact: true}) + .dragTo(nestedTarget, {targetPosition: {x: 150, y: 100}}); + const nestedCommit = await snapshot(); + assert.equal(nestedCommit.config.items.length, 3); + assert.equal(nestedCommit.events, nestedBefore.events); + assert.equal(await page.locator('[data-qa="drop-count"]').textContent(), '1'); + assert.deepEqual(nestedCommit.config.layout.slice(0, 2), nestedBefore.config.layout); + assert.equal(nestedCommit.config.layout[2].containerId, 'target'); + assert.equal(nestedCommit.config.layout[2].parent, 'right'); + assert.equal(await page.locator('[data-dashkit-id="__dropping-elem__"]').count(), 0); + evidence.push({ + check: 'nested palette validator false and cancel leave no preview, membership/geometry or callback changes before one valid destination commit', + nestedBefore, + nestedCommit, + }); + await page.goto(storyUrl('finite-nowrap')); + await page.locator('[data-widget-id="a"]').waitFor(); + await page.getByRole('button', {name: 'Remove b', exact: true}).click(); + await frames(); + const visible = await snapshot(); + const content = await page.locator('[data-widget-id="a"]').getAttribute('data-widget-renders'); + await page.locator('[data-widget-id="a"]').evaluate((el) => { + ( + window as Window & { + __boundaryWidget?: Element; + } + ).__boundaryWidget = el; + }); + await page.locator('[data-qa="contract-canvas"]').evaluate((el) => { + el.style.setProperty('display', 'none'); + }); + await frames(); + await frames(); + await page.locator('[data-qa="contract-canvas"]').evaluate((el) => { + el.style.removeProperty('display'); + }); + await frames(); + await frames(); + const revealed = await snapshot(); + assert.deepEqual(revealed.config, visible.config); + assert.equal(revealed.events, visible.events); + assert.equal( + await page.locator('[data-widget-id="a"]').evaluate( + (el) => + el === + ( + window as Window & { + __boundaryWidget?: Element; + } + ).__boundaryWidget, + ), + true, + ); + assert.equal( + await page.locator('[data-widget-id="a"]').getAttribute('data-widget-renders'), + content, + ); + assert.equal(revealed.widgets[0].rect.width, visible.widgets[0].rect.width); + evidence.push({ + check: 'zero-width hidden to visible settles with identical content node/render count, config and no echo', + visible, + revealed, + }); + const a = checked(revealed.widgets.find(({id}) => id === 'a')).rect; + const handle = await page.locator('[data-dashkit-id="a"] .ui-resizable-se').boundingBox(); + const hx = checked(handle).x + checked(handle).width / 2, + hy = checked(handle).y + checked(handle).height / 2; + await page.mouse.move(hx, hy); + await page.mouse.down(); + await page.mouse.move(hx + 5, hy); + await page.mouse.move(hx + 54, hy, {steps: 8}); + await page.mouse.up(); + await frames(); + const afterRevealDrag = await snapshot(); + assert.equal(afterRevealDrag.events, 'Events: 1'); + assert.equal(afterRevealDrag.config.layout[0].w, 9); + assert.equal(afterRevealDrag.widgets[0].rect.width - a.width, 54); + evidence.push({ + check: 'first native gesture after zero-width reveal uses settled geometry', + afterRevealDrag, + }); + await page.goto(storyUrl('horizontal-packing')); + await page.locator('[data-widget-id="b"]').waitFor(); + const golden = JSON.parse( + checked(await page.locator('[data-qa="legacy-reflow"]').textContent()), + ) as Config['layout']; + await page.getByRole('button', {name: 'Compact', exact: true}).click(); + await frames(); + const horizontal = await snapshot(); + assert.deepEqual(horizontal.config.layout, golden); + const packedA = horizontal.config.layout.find(({i}) => i === 'a'); + const packedB = horizontal.config.layout.find(({i}) => i === 'b'); + assert.equal(Math.max(checked(packedA).y, checked(packedB).y), 2); + const upper = checked( + horizontal.widgets.find( + ({id}) => id === (checked(packedA).y < checked(packedB).y ? 'a' : 'b'), + ), + ).rect; + const lower = checked( + horizontal.widgets.find( + ({id}) => id === (checked(packedA).y < checked(packedB).y ? 'b' : 'a'), + ), + ).rect; + assert(lower.top >= upper.bottom + 6); + evidence.push({ + check: 'native horizontal gaps and overflow row-wrap match actual legacy public reflow exactly', + golden, + horizontal, + }); + await page.goto(storyUrl('finite-nowrap')); + await page.locator('[data-widget-id="b"]').waitFor(); + const full = await snapshot(); + const fullHandle = await page.locator('[data-dashkit-id="a"] .ui-resizable-se').boundingBox(); + const fx = checked(fullHandle).x + checked(fullHandle).width / 2, + fy = checked(fullHandle).y + checked(fullHandle).height / 2; + await page.mouse.move(fx, fy); + await page.mouse.down(); + await page.mouse.move(fx + 5, fy); + await page.mouse.move(fx + 108, fy, {steps: 8}); + await page.mouse.up(); + await frames(); + assert.deepEqual((await snapshot()).config, full.config); + assert.equal((await snapshot()).events, full.events); + await page + .getByRole('button', {name: '+ Widget', exact: true}) + .dragTo(page.locator('.grid-stack').first(), {targetPosition: {x: 450, y: 30}}); + const noSpace = await snapshot(); + assert.deepEqual(noSpace.config, full.config); + assert.equal(noSpace.events, full.events); + assert.equal(await page.locator('[data-dashkit-id="__dropping-elem__"]').count(), 0); + await page.getByRole('button', {name: 'Remove b', exact: true}).click(); + await frames(); + await page.getByRole('button', {name: 'Palette allowed: true', exact: true}).click(); + const available = await snapshot(); + await page + .getByRole('button', {name: '+ Widget', exact: true}) + .dragTo(page.locator('.grid-stack').first(), {targetPosition: {x: 450, y: 30}}); + assert.deepEqual((await snapshot()).config, available.config); + assert.equal(await page.locator('[data-dashkit-id="__dropping-elem__"]').count(), 0); + await page.getByRole('button', {name: 'Palette allowed: false', exact: true}).click(); + const source = await page.getByRole('button', {name: '+ Widget', exact: true}).boundingBox(); + const root = await page.locator('.grid-stack').first().boundingBox(); + await page.mouse.move( + checked(source).x + checked(source).width / 2, + checked(source).y + checked(source).height / 2, + ); + await page.mouse.down(); + await page.mouse.move(checked(root).x + 450, checked(root).y + 30, {steps: 12}); + await page.mouse.move(checked(root).x + 460, checked(root).y + 30); + await frames(); + await page.locator('[data-dashkit-id="__dropping-elem__"]').waitFor({state: 'visible'}); + assert.equal(await page.locator('[data-dashkit-id="__dropping-elem__"]').count(), 1); + await page.mouse.move(850, 650, {steps: 8}); + await page.mouse.up(); + await frames(); + assert.equal(await page.locator('[data-dashkit-id="__dropping-elem__"]').count(), 0); + assert.deepEqual((await snapshot()).config, available.config); + await page + .getByRole('button', {name: '+ Widget', exact: true}) + .dragTo(page.locator('.grid-stack').first(), {targetPosition: {x: 450, y: 30}}); + const committed = await snapshot(); + assert.equal(committed.config.items.length, 2); + assert.equal(committed.events, 'Events: 0'); + assert.equal(await page.locator('[data-qa="drop-count"]').textContent(), '1'); + assert.equal( + committed.config.layout.reduce((sum, entry) => sum + entry.w, 0), + 12, + ); + assert(committed.config.layout.every(({y, h}) => y === 0 && h === 2)); + assert.equal(await page.locator('[data-dashkit-id="__dropping-elem__"]').count(), 0); + evidence.push({ + check: 'finite nowrap native no-space drop, validator false, canceled preview cleanup then valid commit consumes only remaining width', + full, + noSpace, + available, + committed, + }); + await page.setViewportSize({width: 1800, height: 1200}); + await page.goto(storyUrl('report-canvas')); + await page.locator('[data-widget-id="b"]').waitFor(); + const liveBefore = await snapshot(); + const b = checked(liveBefore.widgets.find(({id}) => id === 'b')).rect; + const sx = b.x + 30, + sy = b.y + 60; + await page.mouse.move(sx, sy); + await page.mouse.down(); + await page.mouse.move(sx + 5, sy + 5); + await page.mouse.move(sx + 45, sy + 35, {steps: 8}); + assert.equal(await page.locator('.ui-draggable-dragging').count(), 1); + // Change the public mutable ref/CSS through the fixture while the native pointer stays down. + await page + .getByRole('button', {name: 'Ref2', exact: true}) + .evaluate((el) => (el as HTMLElement).click()); + await page.mouse.move(sx + 65, sy + 45); + await frames(); + const nodePosition = () => + page.locator('[data-dashkit-id="b"]').evaluate((el) => ({ + x: (el as NativeElement).gridstackNode.x, + y: (el as NativeElement).gridstackNode.y, + w: (el as NativeElement).gridstackNode.w, + h: (el as NativeElement).gridstackNode.h, + })); + const live1 = await nodePosition(); + await page.mouse.move(sx + 105, sy + 65, {steps: 4}); + await frames(); + const live2 = await nodePosition(); + assert.equal(live2.x - live1.x, 20); + assert.equal(live2.y - live1.y, 10); + await page.mouse.up(); + await frames(); + const liveAfter = await snapshot(); + assert.equal(liveAfter.events, 'Events: 1'); + assert.deepEqual( + liveAfter.config.layout.find(({i}) => i === 'a'), + liveBefore.config.layout.find(({i}) => i === 'a'), + ); + assert.deepEqual( + liveAfter.widgets.map(({id, instance}) => [id, instance]), + liveBefore.widgets.map(({id, instance}) => [id, instance]), + ); + assert.deepEqual( + Object.fromEntries( + (Object.keys(live2) as Array).map((key) => [ + key, + checked(liveAfter.config.layout.find(({i}) => i === 'b'))[key], + ]), + ), + live2, + ); + evidence.push({ + check: 'live mutable scaleRef1->2 during active native drag converts later40x20 screen delta to20x10 units and commits once', + liveBefore, + live1, + live2, + liveAfter, + }); + await page.goto(storyUrl('legacy-scale-continuation')); + await page.locator('[data-widget-id="b"]').waitFor(); + assert.equal( + await page.locator('[data-dashkit-engine]').getAttribute('data-dashkit-engine'), + 'legacy', + ); + const legacyStart = await snapshot(); + const legacyB = checked(legacyStart.widgets.find(({id}) => id === 'b')).rect; + const lx = legacyB.x + 30, + ly = legacyB.y + 60; + await page.mouse.move(lx, ly); + await page.mouse.down(); + await page.mouse.move(lx + 5, ly + 5); + await page.mouse.move(lx + 45, ly + 35, {steps: 8}); + await page + .getByRole('button', {name: 'Ref2', exact: true}) + .evaluate((el) => (el as HTMLElement).click()); + await page.mouse.move(lx + 65, ly + 45); + await frames(); + const localRect = () => + page.locator('[data-widget-id="b"]').evaluate((el) => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + const rect = el.getBoundingClientRect(), + gridRect = required(el.closest('.react-grid-layout')).getBoundingClientRect(); + return { + x: (rect.x - gridRect.x) / 2, + y: (rect.y - gridRect.y) / 2, + width: rect.width / 2, + height: rect.height / 2, + }; + }); + const legacy1 = await localRect(); + // Let React commit each native move before the next delta is calculated. + for (let step = 1; step <= 4; step++) { + await page.mouse.move(lx + 65 + step * 10, ly + 45 + step * 5); + await frames(); + } + const legacy2 = await localRect(); + await page.mouse.up(); + await frames(); + assert.equal(legacy2.x - legacy1.x, live2.x - live1.x); + assert.equal(legacy2.y - legacy1.y, live2.y - live1.y); + assert.equal(legacy2.width, live2.w); + assert.equal(legacy2.height, live2.h); + evidence.push({ + check: 'actual legacy/new live zoom continuation agrees on20x10 unscaled delta and unchanged size (native activation thresholds recorded separately)', + legacy1, + legacy2, + legacyAfter: await snapshot(), + gridstack1: live1, + gridstack2: live2, + }); + await page.setViewportSize({width: 1000, height: 1000}); + await page.goto(storyUrl('strict-lifecycle')); + await page.locator('[data-widget-id="a"]').waitFor(); + await page.getByRole('button', {name: 'Read refs', exact: true}).click(); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + JSON.parse( + required( + required(document.querySelector('[data-qa="meta"]')).textContent, + ), + ).length === 2 + ); + }); + const strictMeta = JSON.parse(checked(await page.locator('[data-qa="meta"]').textContent())); + const strictBefore = await snapshot(); + const oldHost = await page.locator('[data-dashkit-id="a"]').elementHandle(); + const oldRoot = await page.locator('.grid-stack').first().elementHandle(); + await page.getByRole('button', {name: 'Reverse layers', exact: true}).click(); + await page.getByRole('button', {name: 'Read refs', exact: true}).click(); + assert.deepEqual( + JSON.parse(checked(await page.locator('[data-qa="meta"]').textContent())), + [...strictMeta].reverse(), + ); + await page.getByRole('button', {name: 'Toggle mount', exact: true}).click(); + assert.equal(await page.locator('[data-dashkit-engine]').count(), 0); + assert.equal( + await checked(oldRoot).evaluate((el) => (el as NativeElement).gridstack === undefined), + true, + ); + assert.equal( + await checked(oldHost).evaluate( + (el) => !(el as NativeElement).ddElement?.ddDraggable && !el.isConnected, + ), + true, + ); + await page.getByRole('button', {name: 'Toggle mount', exact: true}).click(); + await page.locator('[data-widget-id="a"]').waitFor(); + await page.getByRole('button', {name: 'Read refs', exact: true}).click(); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + JSON.parse( + required( + required(document.querySelector('[data-qa="meta"]')).textContent, + ), + ).length === 2 + ); + }); + const strictAfter = await snapshot(); + assert.equal(await page.locator('[data-dashkit-id="a"]').count(), 1); + assert.equal(await page.locator('[data-dashkit-id="b"]').count(), 1); + assert.notEqual( + checked(strictAfter.widgets.find(({id}) => id === 'a')).instance, + checked(strictBefore.widgets.find(({id}) => id === 'a')).instance, + ); + assert.equal(strictAfter.events, 'Events: 0'); + evidence.push({ + check: 'actual new-component StrictMode retains current refs on controlled reorder, unmount destroys native DD/grid and remount registers once', + strictBefore, + strictMeta, + strictAfter, + }); +}); diff --git a/tests/browser/canvas.spec.ts b/tests/browser/canvas.spec.ts new file mode 100644 index 0000000..a41a166 --- /dev/null +++ b/tests/browser/canvas.spec.ts @@ -0,0 +1,913 @@ +import assert from 'node:assert/strict'; + +import type {Page} from '@playwright/test'; + +import type {Config} from '../../src/shared'; + +import {checked, storyUrl, test} from './fixtures'; +import type {NativeElement} from './types'; +const near = (actual: number, expected: number) => + assert(Math.abs(actual - expected) <= 1, `${actual} != ${expected}`); +function canvas(page: Page) { + const state = () => + page.evaluate(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return { + engine: document.querySelector('[data-dashkit-engine]')?.dataset + .dashkitEngine, + config: JSON.parse( + required( + required(document.querySelector('[data-qa="saved-config"]')) + .textContent, + ), + ) as Config, + events: required( + required(document.querySelector('[data-qa="event-count"]')) + .textContent, + ), + widgets: [...document.querySelectorAll('[data-widget-id]')].map( + (el) => ({ + id: el.dataset.widgetId, + instance: el.dataset.widgetInstance, + rect: el.getBoundingClientRect().toJSON(), + }), + ), + root: required(document.querySelector('.grid-stack')) + .getBoundingClientRect() + .toJSON(), + }; + }); + const reset = async () => { + await page.goto(storyUrl('report-canvas')); + await page.locator('[data-widget-id="b"]').waitFor(); + const result = await state(); + assert.equal(result.engine, 'gridstack'); + return result; + }; + const drag = async (x: number, y: number, dx: number, dy: number, initialStep = 5) => { + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + Math.sign(dx) * initialStep, y + Math.sign(dy) * initialStep); + await page.mouse.move(x + dx, y + dy, {steps: 8}); + await page.mouse.up(); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + required( + required(document.querySelector('[data-qa="event-count"]')) + .textContent, + ) !== 'Events: 0' + ); + }); + }; + const widget = (data: Awaited>, id: string) => + data.widgets.find((entry) => entry.id === id); + const layout = (data: Awaited>, id: string) => + data.config.layout.find((entry) => entry.i === id); + return {state, reset, drag, widget, layout}; +} +// Critical: yes. Real pointer actions must retain the saved document and plugin identity. +test('portal flex geometry and passive resize preserve identity', async ({page, evidence}) => { + await page.goto(storyUrl('portal-flex-zones')); + await page.locator('[data-portal-case]').last().waitFor(); + const portalState = () => + page.evaluate(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return [...document.querySelectorAll('[data-portal-case]')].map( + (owner) => { + const target = owner.querySelector('[data-portal-target]'); + const zone = + required(target).querySelector('.dashkit-gridstack__zone') || + owner.querySelector('.dashkit-gridstack__zone'); + const root = zone?.querySelector('.grid-stack'); + const panel = root?.querySelector('[data-dashkit-id="panel"]'); + const nested = panel?.querySelector('.grid-stack'); + const child = panel?.querySelector('[data-widget-id="b"]'); + return { + name: owner.getAttribute('data-portal-case'), + frame: required( + owner.querySelector('[data-portal-frame]'), + ).getBoundingClientRect().width, + flex: required( + owner.querySelector('[data-portal-flex-item]'), + ).getBoundingClientRect().width, + zone: zone?.getBoundingClientRect().toJSON(), + root: root?.getBoundingClientRect().toJSON(), + outside: zone + ? !required( + owner.querySelector('[data-dashkit-engine]'), + ).contains(zone) + : null, + containment: zone ? getComputedStyle(zone).contain : null, + panel: panel?.getBoundingClientRect().toJSON(), + nested: nested?.getBoundingClientRect().toJSON(), + child: child?.getBoundingClientRect().toJSON(), + identities: [ + ...owner.querySelectorAll('[data-widget-id]'), + ].map((el) => [el.dataset.widgetId, el.dataset.widgetInstance]), + events: required( + required(owner.querySelector('[data-portal-events]')) + .textContent, + ), + ancestors: zone + ? [ + zone, + target, + required(target).parentElement, + required(required(target).parentElement).parentElement, + ].map((el) => ({ + className: required(el).className, + width: required(el).getBoundingClientRect().width, + display: getComputedStyle(required(el)).display, + minWidth: getComputedStyle(required(el)).minWidth, + contain: getComputedStyle(required(el)).contain, + })) + : [], + }; + }, + ); + }); + const frames = () => + page.evaluate( + () => + new Promise((resolve) => { + let count = 0; + const tick = () => (++count === 24 ? resolve() : requestAnimationFrame(tick)); + requestAnimationFrame(tick); + }), + ); + const portalBefore = await portalState(); + for (const button of await page.getByRole('button', {name: 'Toggle pin', exact: true}).all()) { + await button.click(); + } + await frames(); + const pinned = await portalState(); + await frames(); + const settled = await portalState(); + evidence.push({ + check: 'portal/flex source and pinned ancestors after two native frame windows', + before: portalBefore, + pinned, + settled, + }); + for (const [index, entry] of settled.entries()) { + assert.equal(entry.outside, true); + assert.equal(entry.flex, entry.frame - 80); + assert.equal( + entry.zone.width, + checked(entry.name).startsWith('explicit') ? 384 : entry.frame - 80, + ); + near(entry.root.width, entry.zone.width + 8); + near(entry.zone.height, checked(entry.name).endsWith('fixed') ? 180 : 138); + assert.equal(entry.zone.width, pinned[index].zone.width); + near(entry.nested.x - entry.panel.x, 11); + near(entry.nested.y - entry.panel.y, 3); + near(entry.nested.width, entry.panel.width - 8 - 11 - 7 + 4); + near(entry.child.x, entry.nested.x); + near(entry.child.y, entry.nested.y); + near(entry.child.width, entry.nested.width / 2 - 4); + near(entry.child.height, 38); + assert.deepEqual(entry.identities, portalBefore[index].identities); + assert.equal(entry.events, '0'); + } + await page.evaluate(() => + document + .querySelectorAll('[data-portal-case]') + .forEach((el) => el.style.setProperty('width', '520px')), + ); + await frames(); + const resized = await portalState(); + for (const entry of resized) { + assert.equal(entry.flex, 440); + assert.equal(entry.zone.width, checked(entry.name).startsWith('explicit') ? 384 : 440); + near(entry.zone.height, checked(entry.name).endsWith('fixed') ? 180 : 138); + assert.equal(entry.events, '0'); + } + for (const button of await page.getByRole('button', {name: 'Toggle pin', exact: true}).all()) { + await button.click(); + } + await frames(); + const unpinned = await portalState(); + unpinned.forEach((entry, index) => { + assert.equal(entry.outside, false); + assert.equal(entry.zone.width, checked(entry.name).startsWith('explicit') ? 384 : 520); + near(entry.zone.height, checked(entry.name).endsWith('fixed') ? 180 : 138); + assert.deepEqual(entry.identities, portalBefore[index].identities); + assert.equal(entry.events, '0'); + }); + evidence.push({ + check: 'portal fluid/explicit width, auto/fixed height, nested hosts, passive flex resize and unpin retain plugins without change events', + resized, + unpinned, + }); +}); +// Critical: yes. Real pointer actions must retain the saved document and plugin identity. +test('native drag, eight handles, scale, palette, focus and visual autoheight', async ({ + page, + evidence, +}) => { + const {state, reset, drag, widget, layout} = canvas(page); + let before = await reset(); + let rect = checked(widget(before, 'a')).rect; + const overlapDelta = {x: 45, y: 35}; + await drag(rect.x + 30, rect.y + 60, overlapDelta.x, overlapDelta.y); + let after = await state(); + assert.equal(checked(layout(after, 'a')).x, checked(layout(before, 'a')).x + overlapDelta.x); + assert.equal(checked(layout(after, 'a')).y, checked(layout(before, 'a')).y + overlapDelta.y); + assert.deepEqual(layout(after, 'b'), layout(before, 'b')); + assert.equal(checked(widget(after, 'a')).instance, checked(widget(before, 'a')).instance); + assert.equal(after.events, 'Events: 1'); + evidence.push({ + check: 'native overlapping drag, one event, stable instances', + before, + after, + }); + await page.getByRole('button', {name: 'Restore', exact: true}).click(); + after = await state(); + assert.deepEqual(after.config.layout, before.config.layout); + near(checked(widget(after, 'a')).rect.x, rect.x); + assert.equal(after.events, 'Events: 1'); + evidence.push({check: 'controlled restore without echo event', after}); + before = await reset(); + const hit = () => + page.evaluate(() => + document + .elementFromPoint(150, 150) + ?.closest('[data-dashkit-id]') + ?.getAttribute('data-dashkit-id'), + ); + assert.equal(await hit(), 'b'); + await page.getByRole('button', {name: 'Reverse layers', exact: true}).click(); + assert.equal(await hit(), 'a'); + after = await state(); + assert.equal(checked(widget(after, 'a')).instance, checked(widget(before, 'a')).instance); + assert.equal(checked(widget(after, 'b')).instance, checked(widget(before, 'b')).instance); + assert.equal(after.events, before.events); + evidence.push({ + check: 'controlled item order changes overlap hit testing without remount', + after, + }); + for (const corner of ['nw', 'ne', 'sw', 'se']) { + before = await reset(); + const handle = await page + .locator(`[data-dashkit-id="b"] .ui-resizable-${corner}`) + .boundingBox(); + await drag( + checked(handle).x + checked(handle).width / 2, + checked(handle).y + checked(handle).height / 2, + corner.includes('w') ? -25 : 25, + corner.includes('n') ? -25 : 25, + ); + after = await state(); + const old = checked(widget(before, 'b')).rect; + const next = checked(widget(after, 'b')).rect; + assert(next.width > old.width && next.height > old.height); + near( + corner.includes('w') ? next.right : next.left, + corner.includes('w') ? old.right : old.left, + ); + near( + corner.includes('n') ? next.bottom : next.top, + corner.includes('n') ? old.bottom : old.top, + ); + near(next.width, checked(layout(after, 'b')).w); + near(next.height, checked(layout(after, 'b')).h); + assert.deepEqual(layout(after, 'a'), layout(before, 'a')); + assert.equal(checked(widget(after, 'b')).instance, checked(widget(before, 'b')).instance); + assert.equal(after.events, 'Events: 1'); + evidence.push({ + check: `native ${corner} corner, anchored opposite corner and saved size`, + before, + after, + }); + } + before = await reset(); + await page.getByRole('button', {name: 'Toggle zoom', exact: true}).click(); + before = await state(); + rect = checked(widget(before, 'a')).rect; + const zoom = 0.5; + const zoomDelta = {x: 25, y: 25}; + await drag(rect.x + 15, rect.y + 30, zoomDelta.x, zoomDelta.y); + after = await state(); + assert.equal( + checked(layout(after, 'a')).x - checked(layout(before, 'a')).x, + zoomDelta.x / zoom, + ); + assert.equal( + checked(layout(after, 'a')).y - checked(layout(before, 'a')).y, + zoomDelta.y / zoom, + ); + assert.deepEqual(layout(after, 'b'), layout(before, 'b')); + evidence.push({ + check: 'native CSS scale 0.5 drag converts screen delta into pixel units', + before, + after, + }); + const scaledHandle = await page.locator('[data-dashkit-id="b"] .ui-resizable-se').boundingBox(); + const scaledBefore = checked(widget(after, 'b')).rect; + await drag( + checked(scaledHandle).x + checked(scaledHandle).width / 2, + checked(scaledHandle).y + checked(scaledHandle).height / 2, + 12, + 12, + ); + after = await state(); + assert.equal(checked(layout(after, 'b')).w, 204); + assert.equal(checked(layout(after, 'b')).h, 144); + near(checked(widget(after, 'b')).rect.left, scaledBefore.left); + near(checked(widget(after, 'b')).rect.top, scaledBefore.top); + evidence.push({ + check: 'native scaled corner resize keeps opposite corner anchored in unscaled units', + after, + }); + await page.setViewportSize({width: 1800, height: 1200}); + const scale = 2; + const scaledDelta = {x: 90, y: 70}; + for (const [kind, fixtureUrl] of [ + ['scalar', storyUrl('scalar-zoom')], + ['ref', storyUrl('report-canvas')], + ]) { + await page.goto(fixtureUrl); + await page.locator('[data-widget-id="a"]').waitFor(); + const renderCount = await page + .locator('[data-widget-id="a"]') + .getAttribute('data-widget-renders'); + await page + .getByRole('button', {name: kind === 'scalar' ? 'Zoom2' : 'Ref2', exact: true}) + .click(); + if (kind === 'ref') { + assert.equal( + await page.locator('[data-widget-id="a"]').getAttribute('data-widget-renders'), + renderCount, + ); + } + before = await state(); + near(checked(widget(before, 'a')).rect.width, 320); + rect = checked(widget(before, 'a')).rect; + await drag(rect.x + 60, rect.y + 120, scaledDelta.x, scaledDelta.y, 10); + after = await state(); + assert.equal( + checked(layout(after, 'a')).x, + checked(layout(before, 'a')).x + scaledDelta.x / scale, + ); + assert.equal( + checked(layout(after, 'a')).y, + checked(layout(before, 'a')).y + scaledDelta.y / scale, + ); + assert.deepEqual(layout(after, 'b'), layout(before, 'b')); + assert.equal(checked(widget(after, 'a')).instance, checked(widget(before, 'a')).instance); + evidence.push({ + check: + kind === 'scalar' + ? 'native scalar scale2 drag preserves unscaled delta and identity' + : 'mutable scaleRef changed without React rerender drives native scale2 drag', + before, + after, + }); + } + await page.setViewportSize({width: 1000, height: 1000}); + for (const edge of ['n', 's', 'e', 'w'] as const) { + await page.goto(storyUrl('all-handles')); + await page.locator('[data-widget-id="b"]').waitFor(); + before = await state(); + const handle = await page + .locator(`[data-dashkit-id="b"] .ui-resizable-${edge}`) + .boundingBox(); + await drag( + checked(handle).x + checked(handle).width / 2, + checked(handle).y + checked(handle).height / 2, + {e: 25, w: -25, n: 0, s: 0}[edge], + {s: 25, n: -25, e: 0, w: 0}[edge], + ); + after = await state(); + const old = checked(widget(before, 'b')).rect, + next = checked(widget(after, 'b')).rect; + if (edge === 'n' || edge === 's') { + assert.equal(checked(layout(after, 'b')).w, 180); + assert.equal(checked(layout(after, 'b')).h, 145); + near(next.left, old.left); + near(edge === 'n' ? next.bottom : next.top, edge === 'n' ? old.bottom : old.top); + } else { + assert.equal(checked(layout(after, 'b')).w, 205); + assert.equal(checked(layout(after, 'b')).h, 120); + near(next.top, old.top); + near(edge === 'w' ? next.right : next.left, edge === 'w' ? old.right : old.left); + } + assert.deepEqual(layout(after, 'a'), layout(before, 'a')); + evidence.push({ + check: `native ${edge.toUpperCase()} edge resize anchors opposite edge and preserves neighbor`, + before, + after, + }); + } + before = await reset(); + rect = checked(widget(before, 'b')).rect; + await drag( + rect.x + rect.width / 2, + rect.y + rect.height / 2, + before.root.right - 10 - rect.x - rect.width / 2, + before.root.bottom - 10 - rect.y - rect.height / 2, + ); + after = await state(); + assert.equal(checked(layout(after, 'b')).x + checked(layout(after, 'b')).w, 640); + assert.equal(checked(layout(after, 'b')).y + checked(layout(after, 'b')).h, 400); + assert.deepEqual(layout(after, 'a'), layout(before, 'a')); + evidence.push({ + check: 'native drag clamps to fixed right and bottom bounds', + before, + after, + }); + before = await reset(); + rect = checked(widget(before, 'b')).rect; + await drag( + rect.x + rect.width / 2, + rect.y + rect.height / 2, + before.root.left + 10 - rect.x - rect.width / 2, + before.root.top + 10 - rect.y - rect.height / 2, + ); + after = await state(); + assert.equal(checked(layout(after, 'b')).x, 0); + assert.equal(checked(layout(after, 'b')).y, 0); + assert.deepEqual(layout(after, 'a'), layout(before, 'a')); + evidence.push({ + check: 'native drag beyond left/top clamps to origin without disturbing overlap neighbor', + before, + after, + }); + before = await reset(); + await page + .getByRole('button', {name: '+ Widget', exact: true}) + .dragTo(page.locator('.grid-stack').first(), {targetPosition: {x: 450, y: 300}}); + after = await state(); + assert.equal(after.config.items.length, 3); + assert.equal(after.config.counter, 2); + const added = after.config.layout.find( + (entry) => !before.config.layout.some((old) => old.i === entry.i), + ); + assert.deepEqual( + {x: checked(added).x, y: checked(added).y, w: checked(added).w, h: checked(added).h}, + {x: 450, y: 300, w: 100, h: 80}, + ); + assert.deepEqual(after.config.layout.slice(0, 2), before.config.layout); + evidence.push({ + check: 'native HTML5 palette drop commits geometry and preserves existing widgets', + before, + after, + }); + before = await reset(); + await page.getByRole('button', {name: 'Toggle edit', exact: true}).click(); + await page.locator('[data-qa="dashkit-grid-item"]').first().focus(); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + required( + required(document.querySelector('[data-qa="focused-item"]')) + .textContent, + ) === 'a' + ); + }); + assert( + await page + .locator('[data-dashkit-id="a"]') + .evaluate((el) => el.classList.contains('dashkit-grid-item_is-focused')), + ); + await page.getByRole('button', {name: 'Toggle edit', exact: true}).focus(); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + required( + required(document.querySelector('[data-qa="focused-item"]')) + .textContent, + ) === '' + ); + }); + await page.keyboard.press('Tab'); + assert.equal( + await page + .getByRole('textbox', {name: 'Keyboard probe', exact: true}) + .evaluate((el) => el === document.activeElement), + true, + ); + await page.locator('[data-qa="grow-a"]').click(); + after = await state(); + near(checked(widget(after, 'a')).rect.height, 140); + assert.equal(checked(layout(after, 'a')).h, 100); + assert.equal(after.events, 'Events: 0'); + await page.getByRole('button', {name: 'Toggle edit', exact: true}).click(); + rect = checked(widget(after, 'a')).rect; + await drag(rect.x + 30, rect.y + 100, 45, 35); + after = await state(); + assert.equal(checked(layout(after, 'a')).h, 100); + near(checked(widget(after, 'a')).rect.height, 140); + await page.getByRole('button', {name: 'Toggle edit', exact: true}).click(); + await page.getByRole('button', {name: 'Reverse layers', exact: true}).click(); + await page.locator('[data-qa="reset-a"]').click(); + after = await state(); + near(checked(widget(after, 'a')).rect.height, 100); + evidence.push({ + check: 'native focus callbacks/stacking and keyboard tab, visual autoheight survives move and reset preserves saved height', + after, + }); +}); +// Critical: yes. Real pointer actions must retain the saved document and plugin identity. +test('collision, nowrap, custom handles, reparent, callbacks and view/edit convergence', async ({ + page, + evidence, +}) => { + const {state, drag, widget, layout} = canvas(page); + await page.goto(storyUrl('collision')); + await page.locator('[data-widget-id="b"]').waitFor(); + let before = await state(); + let rect = checked(widget(before, 'b')).rect; + await drag(rect.x + 80, rect.y + 70, -200, 0); + let after = await state(); + assert( + checked(layout(after, 'b')).x >= + checked(layout(after, 'a')).x + checked(layout(after, 'a')).w, + ); + assert.deepEqual(layout(after, 'a'), layout(before, 'a')); + evidence.push({ + check: 'native preventCollision stops before occupied rectangle without pushing its neighbor', + before, + after, + }); + await page.goto(storyUrl('horizontal-nowrap')); + await page.locator('[data-widget-id="b"]').waitFor(); + before = await state(); + let resize = await page.locator('[data-dashkit-id="b"] .ui-resizable-se').boundingBox(); + await drag( + checked(resize).x + checked(resize).width / 2, + checked(resize).y + checked(resize).height / 2, + 600, + 0, + ); + after = await state(); + assert.equal(checked(layout(after, 'b')).x + checked(layout(after, 'b')).w, 640); + assert.equal(checked(layout(after, 'b')).y, 0); + assert.deepEqual(layout(after, 'a'), layout(before, 'a')); + evidence.push({ + check: 'native horizontal-nowrap resize consumes remaining row width without wrapping or displacing neighbor', + before, + after, + }); + await page.goto(storyUrl('custom-handles')); + await page.locator('[data-widget-id="b"]').waitFor(); + before = await state(); + assert.equal(await page.locator('[data-dashkit-id="b"] [data-custom-handle]').count(), 4); + resize = await page.locator('[data-dashkit-id="b"] [data-custom-handle="se"]').boundingBox(); + await drag( + checked(resize).x + checked(resize).width / 2, + checked(resize).y + checked(resize).height / 2, + 25, + 25, + ); + after = await state(); + assert.equal(checked(layout(after, 'b')).w, 205); + assert.equal(checked(layout(after, 'b')).h, 145); + assert.equal(checked(widget(after, 'b')).instance, checked(widget(before, 'b')).instance); + evidence.push({ + check: 'custom React corner handle drives native resize and retains plugin state', + before, + after, + }); + const nestedURL = storyUrl('nested-containers'); + await page.goto(nestedURL); + await page.locator('[data-widget-id="b"]').waitFor(); + before = await state(); + const panel = await page.locator('[data-dashkit-id="panel"]').boundingBox(); + const child = await page.locator('[data-dashkit-id="panel"] .grid-stack').boundingBox(); + rect = checked(widget(before, 'b')).rect; + await drag( + rect.x + rect.width / 2, + rect.y + rect.height / 2, + checked(child).x + 110 - rect.x - rect.width / 2, + checked(child).y + 80 - rect.y - rect.height / 2, + ); + after = await state(); + assert.equal(checked(layout(after, 'b')).containerId, 'panel'); + assert.equal(checked(layout(after, 'b')).w, 9); + assert.equal(checked(layout(after, 'b')).h, 5); + assert.equal(checked(widget(after, 'b')).instance, checked(widget(before, 'b')).instance); + near( + checked(widget(after, 'b')).rect.x, + checked(panel).x + 16 + checked(layout(after, 'b')).x * 21, + ); + near( + checked(widget(after, 'b')).rect.y, + checked(panel).y + 12 + checked(layout(after, 'b')).y * 24, + ); + near(checked(widget(after, 'b')).rect.width, 181); + near(checked(widget(after, 'b')).rect.height, 114); + assert.deepEqual(layout(after, 'a'), layout(before, 'a')); + evidence.push({ + check: 'native nested transfer preserves identity and converts visible geometry with independent padding/gap', + before, + after, + }); + assert.equal(await page.locator('[data-dashkit-id="b"] .ui-resizable-handle').count(), 4); + const nestedHandle = await page.locator('[data-dashkit-id="b"] .ui-resizable-se').boundingBox(); + before = after; + await drag( + checked(nestedHandle).x + checked(nestedHandle).width / 2, + checked(nestedHandle).y + checked(nestedHandle).height / 2, + 21, + 24, + ); + after = await state(); + assert.equal(checked(layout(after, 'b')).w, 10); + assert.equal(checked(layout(after, 'b')).h, 6); + assert.equal(checked(widget(after, 'b')).instance, checked(widget(before, 'b')).instance); + const parentHandle = await page + .locator('[data-dashkit-id="panel"] > .ui-resizable-se') + .boundingBox(); + await drag( + checked(parentHandle).x + checked(parentHandle).width / 2, + checked(parentHandle).y + checked(parentHandle).height / 2, + -200, + -200, + ); + after = await state(); + const resizedPanel = await page.locator('[data-dashkit-id="panel"]').boundingBox(); + assert( + checked(widget(after, 'b')).rect.right <= + checked(resizedPanel).x + checked(resizedPanel).width - 20 + 1, + ); + assert( + checked(widget(after, 'b')).rect.bottom <= + checked(resizedPanel).y + checked(resizedPanel).height - 24 + 1, + ); + assert.equal(checked(widget(after, 'b')).instance, checked(widget(before, 'b')).instance); + evidence.push({ + check: 'nested native handles inherit all corners and parent shrink retains child extents and identity', + after, + resizedPanel, + }); + before = after; + rect = checked(widget(before, 'b')).rect; + await drag( + rect.x + rect.width / 2, + rect.y + rect.height / 2, + before.root.x + 200 - rect.x - rect.width / 2, + before.root.y + 300 - rect.y - rect.height / 2, + ); + after = await state(); + assert.equal(checked(layout(after, 'b')).containerId, undefined); + assert.equal(checked(widget(after, 'b')).instance, checked(widget(before, 'b')).instance); + near(checked(widget(after, 'b')).rect.width, checked(widget(before, 'b')).rect.width); + near(checked(widget(after, 'b')).rect.height, checked(widget(before, 'b')).rect.height); + evidence.push({ + check: 'native nested drag out preserves unscaled size and widget state', + before, + after, + }); + const zonesURL = storyUrl('cross-zones'); + await page.goto(zonesURL); + await page.locator('[data-widget-id="b"]').waitFor(); + before = await state(); + const destination = await page.locator('.grid-stack').nth(1).boundingBox(); + rect = checked(widget(before, 'b')).rect; + await drag( + rect.x + rect.width / 2, + rect.y + rect.height / 2, + checked(destination).x + 130 - rect.x - rect.width / 2, + checked(destination).y + 100 - rect.y - rect.height / 2, + ); + after = await state(); + assert.equal(checked(layout(after, 'b')).parent, 'right'); + assert.equal(checked(layout(after, 'a')).parent, 'left'); + assert.equal(checked(widget(after, 'b')).instance, checked(widget(before, 'b')).instance); + assert.equal(after.events, 'Events: 1'); + const callbacks = JSON.parse( + checked(await page.locator('[data-qa="callback-log"]').textContent()), + ) as Array<{ + phase: string; + old: { + minW: number; + }; + next: { + minW: number; + }; + placeholder: { + placeholder: boolean; + } | null; + group: string; + ids: string[]; + }>; + assert.deepEqual( + callbacks.filter(({phase}) => phase !== 'move').map(({phase}) => phase), + ['start', 'stop', 'grid', 'grid'], + ); + assert.equal(callbacks[0].placeholder, null); + assert.equal(checked(callbacks.find(({phase}) => phase === 'stop')).placeholder, null); + const movement = callbacks.find(({phase}) => phase === 'move'); + assert(movement); + assert.equal(movement.old.minW, 20); + assert.equal(movement.next.minW, 20); + assert.equal(checked(movement.placeholder).placeholder, true); + assert.deepEqual( + callbacks.filter(({phase}) => phase === 'grid').map(({group, ids}) => ({group, ids})), + [ + {group: 'left', ids: ['a']}, + {group: 'right', ids: ['b']}, + ], + ); + evidence.push({ + check: 'native sibling-zone transfer is one transaction and preserves plugin identity', + before, + after, + }); + await page.goto(storyUrl('dashboard')); + await page.locator('[data-widget-id="b"]').waitFor(); + before = await state(); + for (let count = 0; count < 6; count++) { + await page.getByRole('button', {name: 'Toggle edit', exact: true}).click(); + } + after = await state(); + assert.equal(after.events, 'Events: 0'); + assert.deepEqual(after.config, before.config); + assert.equal(checked(widget(after, 'a')).instance, checked(widget(before, 'a')).instance); + assert.equal(checked(widget(after, 'b')).instance, checked(widget(before, 'b')).instance); + evidence.push({ + check: 'mounted dashboard converges across six view/edit switches', + before, + after, + }); + const widths = await page.evaluate(async () => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + const values = []; + for (let count = 0; count < 12; count++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + values.push( + required( + document.querySelector('.dashkit-gridstack__zone'), + ).getBoundingClientRect().width, + ); + } + return values; + }); + assert( + widths.every((width) => Math.abs(width - widths[0]) < 1), + JSON.stringify(widths), + ); + await page.setViewportSize({width: 800, height: 1000}); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + required( + document.querySelector('[data-widget-id="a"]'), + ).getBoundingClientRect().width < 280 + ); + }); + after = await state(); + assert.deepEqual(after.config, before.config); + assert.equal(checked(widget(after, 'a')).instance, checked(widget(before, 'a')).instance); + assert.equal(after.events, 'Events: 0'); + evidence.push({ + check: 'fluid dashboard width converges and passive resize preserves config and identity', + widths, + after, + }); + await page.goto(storyUrl('height-compatibility')); + await page.locator('[data-height-engine="gridstack"] [data-widget-id="a"]').waitFor(); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + Math.abs( + required( + document.querySelector( + '[data-height-engine="legacy"] [data-height-zone="short"]', + ), + ).getBoundingClientRect().height - 42, + ) < 1 + ); + }); + const heights = await page.evaluate(() => + [...document.querySelectorAll('[data-height-engine]')].map((el) => ({ + engine: el.getAttribute('data-height-engine'), + zones: [...el.querySelectorAll('[data-height-zone]')].map((zone) => ({ + id: zone.getAttribute('data-height-zone'), + height: zone.getBoundingClientRect().height, + widget: zone + .querySelector('[data-widget-id]') + ?.getBoundingClientRect() + .toJSON(), + })), + })), + ); + heights.forEach(({zones}) => { + near(zones[0].height, 42); + near(zones[1].height, 0); + }); + near(heights[0].zones[0].widget.width, heights[1].zones[0].widget.width); + near(heights[0].zones[0].widget.height, heights[1].zones[0].widget.height); + evidence.push({ + check: 'legacy/new native short and empty zone heights match with independent gap/padding and maxRows100', + heights, + }); +}); +// Critical: yes. Native touchscreen edits must commit one unscaled transaction. +test.describe('native touchscreen', () => { + test.skip( + ({browserName}) => browserName !== 'chromium', + 'Input.dispatchTouchEvent requires Chromium CDP', + ); + test.use({hasTouch: true}); + test('native touchscreen drag', async ({page, evidence}) => { + await page.goto(storyUrl('report-canvas')); + await page.locator('[data-widget-id="a"]').waitFor(); + const touchRect = await page.locator('[data-widget-id="a"]').boundingBox(); + const touchIdentity = await page + .locator('[data-widget-id="a"]') + .getAttribute('data-widget-instance'); + const touchBefore = JSON.parse( + checked(await page.locator('[data-qa="saved-config"]').textContent()), + ) as Config; + const touchDelta = {x: 45, y: 35}; + const session = await page.context().newCDPSession(page); + const touch = async (type: 'touchStart' | 'touchMove' | 'touchEnd', dx = 0, dy = 0) => + session.send('Input.dispatchTouchEvent', { + type, + touchPoints: + type === 'touchEnd' + ? [] + : [{x: checked(touchRect).x + 30 + dx, y: checked(touchRect).y + 60 + dy}], + }); + await touch('touchStart', 0, 0); + await touch('touchMove', 5, 5); + for (let count = 1; count <= 8; count++) { + await touch( + 'touchMove', + 5 + ((touchDelta.x - 5) * count) / 8, + 5 + ((touchDelta.y - 5) * count) / 8, + ); + } + await touch('touchEnd'); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + required( + required(document.querySelector('[data-qa="event-count"]')) + .textContent, + ) === 'Events: 1' + ); + }); + const touchConfig = JSON.parse( + checked(await page.locator('[data-qa="saved-config"]').textContent()), + ) as Config; + assert.equal(touchConfig.layout[0].x, touchBefore.layout[0].x + touchDelta.x); + assert.equal(touchConfig.layout[0].y, touchBefore.layout[0].y + touchDelta.y); + assert.equal( + await page.locator('[data-widget-id="a"]').getAttribute('data-widget-instance'), + touchIdentity, + ); + evidence.push({ + check: 'browser-native touchscreen drag commits unscaled geometry once and retains plugin identity', + config: touchConfig, + }); + }); +}); diff --git a/tests/browser/containment.spec.ts b/tests/browser/containment.spec.ts new file mode 100644 index 0000000..8b0add7 --- /dev/null +++ b/tests/browser/containment.spec.ts @@ -0,0 +1,384 @@ +import assert from 'node:assert/strict'; + +import type {Config} from '../../src/shared'; + +import {checked, storyUrl, test} from './fixtures'; +import type {NativeElement} from './types'; +// Critical: yes. Native edits must preserve saved geometry and a usable editor. +test('nested physical bounds and continued gestures', async ({page, evidence}) => { + const frames = () => + page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + const state = () => + page.evaluate(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return { + config: JSON.parse( + required( + required(document.querySelector('[data-qa="saved-config"]')) + .textContent, + ), + ) as Config, + events: required( + required(document.querySelector('[data-qa="event-count"]')) + .textContent, + ), + widgets: [...document.querySelectorAll('[data-widget-id]')].map( + (el) => ({ + id: el.dataset.widgetId, + instance: el.dataset.widgetInstance, + }), + ), + }; + }); + const held = async (x: number, y: number, dx: number, dy: number) => { + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + Math.sign(dx) * 5, y + Math.sign(dy) * 5); + await page.mouse.move(x + dx, y + dy, {steps: 16}); + await frames(); + }; + await page.goto(storyUrl('nested-containers')); + await page.locator('[data-widget-id="b"]').waitFor(); + const item = await page.locator('[data-widget-id="b"]').boundingBox(); + const target = await page.locator('[data-dashkit-id="panel"] .grid-stack').boundingBox(); + await held( + checked(item).x + checked(item).width / 2, + checked(item).y + checked(item).height / 2, + checked(target).x + 110 - checked(item).x - checked(item).width / 2, + checked(target).y + 80 - checked(item).y - checked(item).height / 2, + ); + await page.mouse.up(); + await frames(); + const before = await state(); + assert.equal(checked(before.config.layout.find((x) => x.i === 'b')).containerId, 'panel'); + assert.equal(checked(before.config.containers)[0].grid.maxRows, undefined); + const handle = await page.locator('[data-dashkit-id="b"] .ui-resizable-se').boundingBox(); + const point = { + x: checked(handle).x + checked(handle).width / 2, + y: checked(handle).y + checked(handle).height / 2, + }; + assert( + checked( + await page.evaluate(({x, y}) => document.elementFromPoint(x, y)?.className, point), + ).includes('ui-resizable-se'), + ); + await held(point.x, point.y, 20, 300); + const pending = await page.locator('[data-dashkit-id="b"]').evaluate((el) => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return { + node: Object.fromEntries( + (['x', 'y', 'w', 'h'] as const).map((k) => [ + k, + (el as NativeElement).gridstackNode[k], + ]), + ), + maxRow: (el as NativeElement).gridstackNode.grid.engine.maxRow, + nativeMaxRow: (el as NativeElement).gridstackNode.grid.opts.maxRow, + contentHeight: required(el.parentElement).clientHeight, + }; + }); + assert(pending.node.y * 24 + pending.node.h * 24 - 6 <= 224 + 1e-7, JSON.stringify(pending)); + assert.equal(pending.maxRow, (224 + 6) / 24); + assert.equal(pending.nativeMaxRow, pending.maxRow); + await page.mouse.up(); + await frames(); + const after = await state(); + assert.equal(after.events, 'Events: 2'); + assert.equal(checked(after.config.containers)[0].grid.maxRows, undefined); + assert.deepEqual(after.widgets, before.widgets); + const saved = after.config.layout.find((x) => x.i === 'b'); + assert(checked(saved).y * 24 + checked(saved).h * 24 - 6 <= 224 + 1e-7); + const nextHandle = await page.locator('[data-dashkit-id="b"] .ui-resizable-se').boundingBox(); + await held( + checked(nextHandle).x + checked(nextHandle).width / 2, + checked(nextHandle).y + checked(nextHandle).height / 2, + -24, + -48, + ); + await page.mouse.up(); + await frames(); + const next = await state(); + assert.equal(next.events, 'Events: 3'); + assert(checked(next.config.layout.find((x) => x.i === 'b')).h < checked(saved).h); + await page.getByRole('button', {name: 'Restore', exact: true}).click(); + await frames(); + assert.deepEqual((await state()).config, after.config); + assert.equal((await state()).events, next.events); + evidence.push({ + check: 'nested native SE overflow respects physical padding/gap bound before commit, preserves identity/descriptor and next resize/undo', + before, + pending, + after, + next, + }); + const openBounds = async () => { + await page.goto(storyUrl('nested-bounds')); + await page.locator('[data-widget-id="b"]').waitFor(); + }; + const boundState = () => + page.locator('[data-dashkit-id="b"]').evaluate((el) => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + const node = (el as NativeElement).gridstackNode; + const content = required(el.parentElement).getBoundingClientRect(); + const widget = required( + el.querySelector('[data-widget-id]'), + ).getBoundingClientRect(); + return { + node: Object.fromEntries((['x', 'y', 'w', 'h'] as const).map((k) => [k, node[k]])), + maxRow: node.grid.engine.maxRow, + nativeMaxRow: node.grid.opts.maxRow, + content: content.toJSON(), + widget: widget.toJSON(), + }; + }); + const contained = (current: Awaited>) => { + assert(current.node.y >= 0 && current.node.x >= 0); + assert(current.node.y + current.node.h <= current.maxRow + 1e-7); + assert(current.node.x + current.node.w <= 12 + 1e-7); + for (const side of ['left', 'top']) { + assert( + current.widget[side] >= current.content[side] - 1, + `${side}: ${JSON.stringify(current)}`, + ); + } + for (const side of ['right', 'bottom']) { + assert( + current.widget[side] <= current.content[side] + 1, + `${side}: ${JSON.stringify(current)}`, + ); + } + }; + for (const direction of ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se']) { + await openBounds(); + const start = await state(); + const edge = await page + .locator(`[data-dashkit-id="b"] .ui-resizable-${direction}`) + .boundingBox(); + const origin = await page.evaluate( + ({bounds, handleDirection}) => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + for (const yf of [0.5, 0.9, 0.1]) { + for (const xf of [0.5, 0.9, 0.1]) { + const candidate = { + x: required(bounds).x + required(bounds).width * xf, + y: required(bounds).y + required(bounds).height * yf, + }; + if ( + document + .elementFromPoint(candidate.x, candidate.y) + ?.classList.contains(`ui-resizable-${handleDirection}`) + ) { + return candidate; + } + } + } + return null; + }, + {bounds: edge, handleDirection: direction}, + ); + assert(origin, `no native hit point for ${direction}: ${JSON.stringify(edge)}`); + let dx = 0; + let dy = 0; + if (direction.includes('w')) { + dx = -Math.min(400, origin.x - 3); + } else if (direction.includes('e')) { + dx = 400; + } + if (direction.includes('n')) { + dy = -Math.min(400, origin.y - 3); + } else if (direction.includes('s')) { + dy = 400; + } + await held(origin.x, origin.y, dx, dy); + const pendingEdge = await boundState(); + contained(pendingEdge); + await page.mouse.up(); + await frames(); + const end = await state(); + assert.equal(end.events, 'Events: 1', JSON.stringify({direction, pendingEdge, start, end})); + assert.deepEqual(end.widgets, start.widgets); + contained(await boundState()); + await page.getByRole('button', {name: 'Restore', exact: true}).click(); + await frames(); + assert.deepEqual((await state()).config, start.config); + assert.equal((await state()).events, end.events); + evidence.push({ + check: `native nested ${direction} handle stops at physical content bounds and undo restores exact saved input without echo`, + pendingEdge, + }); + } + for (const maxRows of [6, 100]) { + await openBounds(); + await page.getByRole('button', {name: `Cap ${maxRows}`, exact: true}).click(); + const edge = await page.locator('[data-dashkit-id="b"] .ui-resizable-se').boundingBox(); + await held( + checked(edge).x + checked(edge).width / 2, + checked(edge).y + checked(edge).height / 2, + 20, + 400, + ); + const pendingCap = await boundState(); + assert.equal(pendingCap.maxRow, Math.min(maxRows, 230 / 24)); + assert.equal(pendingCap.nativeMaxRow, pendingCap.maxRow); + contained(pendingCap); + await page.mouse.up(); + await frames(); + assert.equal(checked((await state()).config.containers)[0].grid.maxRows, maxRows); + assert.equal((await state()).events, 'Events: 1'); + evidence.push({ + check: `native explicit maxRows${maxRows} intersects physical height without rewriting descriptor`, + pendingCap, + }); + } + await openBounds(); + await page.getByRole('button', {name: 'Fractional child', exact: true}).click(); + const fractional = await state(); + const fractionNode = await boundState(); + assert.deepEqual(fractionNode.node, {x: 0.25, y: 1.25, w: 3.5, h: 8}); + contained(fractionNode); + await page.getByRole('button', {name: 'Toggle hidden', exact: true}).click(); + await frames(); + await page.getByRole('button', {name: 'Toggle hidden', exact: true}).click(); + await frames(); + assert.deepEqual(await state(), fractional); + assert.deepEqual((await boundState()).node, fractionNode.node); + const parentHandle = await page + .locator('[data-dashkit-id="panel"] > .ui-resizable-se') + .boundingBox(); + await held( + checked(parentHandle).x + checked(parentHandle).width / 2, + checked(parentHandle).y + checked(parentHandle).height / 2, + 20, + 48, + ); + await page.mouse.up(); + await frames(); + const expanded = await boundState(); + assert(expanded.maxRow > fractionNode.maxRow); + assert.deepEqual(expanded.node, fractionNode.node); + assert.equal((await state()).events, 'Events: 1'); + await page.getByRole('button', {name: 'Restore', exact: true}).click(); + await frames(); + assert.deepEqual(await state(), {...fractional, events: 'Events: 1'}); + assert.equal((await boundState()).maxRow, fractionNode.maxRow); + evidence.push({ + check: 'fractional valid nested input survives hidden/reveal, native parent resize and controlled undo without echo; admission budget refreshes', + fractionNode, + expanded, + }); + await openBounds(); + const dragPoint = () => + page.locator('[data-widget-id="b"]').evaluate((el) => { + const rect = el.getBoundingClientRect(); + for (let y = rect.top + 3; y < rect.bottom - 3; y += 4) { + for (let x = rect.left + 3; x < rect.right - 3; x += 4) { + const hit = document.elementFromPoint(x, y); + if ( + hit?.closest('[data-dashkit-id="b"]') && + !hit.closest( + 'button,input,textarea,select,.ui-resizable-handle,.dashkit_draggable_cancel', + ) + ) { + return {x, y, hit: hit.className}; + } + } + } + return null; + }); + const beforeChild = await state(); + const pointBefore = await dragPoint(); + assert(pointBefore, 'child has no draggable content hit'); + await held(pointBefore.x, pointBefore.y, 21, 24); + await page.mouse.up(); + await frames(); + const movedChild = await state(); + assert.equal(movedChild.config.layout[0].x, beforeChild.config.layout[0].x + 1); + assert.equal(movedChild.config.layout[0].y, beforeChild.config.layout[0].y + 1); + assert.equal(movedChild.events, 'Events: 1'); + const cancelPoint = await dragPoint(); + assert(cancelPoint); + // Move this child out into the root grid, then cancel the actual native reparent. + await held(cancelPoint.x, cancelPoint.y, 350, 40); + assert.equal(await page.locator('.grid-stack-placeholder').count(), 1); + await page.keyboard.press('Escape'); + await page.mouse.up(); + await frames(); + const cancelledChild = await state(); + assert.deepEqual(cancelledChild, movedChild); + const pointAfter = await dragPoint(); + assert(pointAfter, 'restored child lost its draggable content'); + await held(pointAfter.x, pointAfter.y, 21, 24); + await page.mouse.up(); + await frames(); + const nextChild = await state(); + assert.equal(nextChild.config.layout[0].x, movedChild.config.layout[0].x + 1); + assert.equal(nextChild.config.layout[0].y, movedChild.config.layout[0].y + 1); + assert.equal(nextChild.events, 'Events: 2'); + assert.deepEqual(nextChild.widgets, movedChild.widgets); + await page.getByRole('button', {name: 'Restore', exact: true}).click(); + await frames(); + assert.deepEqual((await state()).config, movedChild.config); + assert.equal((await state()).events, 'Events: 2'); + evidence.push({ + check: 'supported child drag works before and after native reparent/Escape, keeps instance and exact config, then one next transaction/undo', + beforeChild, + movedChild, + cancelledChild, + nextChild, + pointBefore, + pointAfter, + }); + for (const height of [6, 18]) { + await openBounds(); + await page.getByRole('button', {name: `Empty content ${height}`, exact: true}).click(); + await frames(); + const empty = await state(); + await page + .getByRole('button', {name: '+ Widget', exact: true}) + .dragTo(page.locator('[data-dashkit-id="panel"] .grid-stack'), { + targetPosition: {x: 70, y: height / 2}, + }); + await frames(); + const dropped = await state(); + if (height === 6) { + assert.deepEqual(dropped, empty); + assert.equal(await page.locator('[data-qa="drop-count"]').textContent(), '0'); + } else { + assert.equal(dropped.config.items.length, 1); + assert.equal(dropped.config.layout[0].containerId, 'panel'); + assert.equal(dropped.config.layout[0].h, 1); + assert.equal(dropped.config.layout[0].y, 0); + assert.equal(await page.locator('[data-qa="drop-count"]').textContent(), '1'); + } + assert.equal(await page.locator('[data-dashkit-id="__dropping-elem__"]').count(), 0); + evidence.push({ + check: `native palette content height${height} has ${height === 6 ? 'zero full rows and rejects impossible minimum' : 'one row and admits one bounded child'}`, + dropped, + }); + } +}); diff --git a/tests/browser/fixtures.ts b/tests/browser/fixtures.ts new file mode 100644 index 0000000..7e68310 --- /dev/null +++ b/tests/browser/fixtures.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; + +import {test as base, expect} from '@playwright/test'; + +export const test = base.extend<{evidence: unknown[]}>({ + evidence: async ({page}, provide, testInfo) => { + const evidence: unknown[] = []; + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(String(error))); + await provide(evidence); + await testInfo.attach('native-contracts', { + body: JSON.stringify(evidence, null, 2), + contentType: 'application/json', + }); + expect(errors, 'uncaught browser errors').toEqual([]); + }, +}); + +export function storyUrl(story: string, args: Record = {}) { + const search = new URLSearchParams({id: `components-gridstack--${story}`, viewMode: 'story'}); + if (Object.keys(args).length) { + search.set( + 'args', + Object.entries(args) + .map(([key, value]) => `${key}:${value === null ? '!null' : value}`) + .join(';'), + ); + } + return `/iframe.html?${search}`; +} + +export function checked(value: T | null | undefined): T { + assert(value !== null && value !== undefined, 'Missing native fixture value'); + return value; +} diff --git a/tests/browser/flat-contract.spec.ts b/tests/browser/flat-contract.spec.ts new file mode 100644 index 0000000..71bdcf8 --- /dev/null +++ b/tests/browser/flat-contract.spec.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; + +import type {Config} from '../../src/shared'; + +import {checked, storyUrl, test} from './fixtures'; +// Critical: yes. Both mounted renderers must save the same native gesture and restore it silently. +for (const engine of ['legacy', 'gridstack']) { + test(`[${engine}] common flat native drag, resize, restore and identity`, async ({ + page, + evidence, + }) => { + await page.goto(storyUrl('flat-contract', {legacy: engine === 'legacy'})); + await page.locator('[data-widget-id="b"]').waitFor(); + assert.equal( + await page.locator('[data-dashkit-engine]').getAttribute('data-dashkit-engine'), + engine, + ); + const saved = async () => + JSON.parse( + checked(await page.locator('[data-qa="saved-config"]').textContent()), + ) as Config; + const identities = () => + page + .locator('[data-widget-id]') + .evaluateAll((elements) => + elements.map((el) => [ + el.getAttribute('data-widget-id'), + el.getAttribute('data-widget-instance'), + ]), + ); + const original = await saved(); + const originalIdentities = await identities(); + const widget = page.locator('[data-widget-id="a"]'); + const before = checked(await widget.boundingBox()); + const drag = async (x: number, y: number, dx: number, dy: number) => { + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 5, y + 5); + await page.mouse.move(x + dx, y + dy, {steps: 1}); + await page.mouse.up(); + }; + await drag(before.x + 30, before.y + 60, 45, 35); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + required(document.querySelector('[data-qa="event-count"]')).textContent === + 'Events: 1' + ); + }); + const moved = await saved(); + assert.deepEqual(moved.layout, [{...original.layout[0], x: 85, y: 75}, original.layout[1]]); + assert.deepEqual(await identities(), originalIdentities); + await page.getByRole('button', {name: 'Restore', exact: true}).click(); + assert.deepEqual(await saved(), original); + assert.equal(await page.locator('[data-qa="event-count"]').textContent(), 'Events: 1'); + const restored = checked(await widget.boundingBox()); + assert(Math.abs(restored.x - before.x) <= 1); + assert(Math.abs(restored.y - before.y) <= 1); + const corner = await page + .locator( + '[data-qa="dashkit-grid-item"]:has([data-widget-id="b"]) .react-resizable-handle-se, [data-dashkit-id="b"] > .ui-resizable-se', + ) + .boundingBox(); + assert(corner); + await drag(corner.x + corner.width / 2, corner.y + corner.height / 2, 25, 25); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + required(document.querySelector('[data-qa="event-count"]')).textContent === + 'Events: 2' + ); + }); + const resized = await saved(); + assert.deepEqual(resized.layout, [ + original.layout[0], + {...original.layout[1], w: 205, h: 145}, + ]); + assert.deepEqual(await identities(), originalIdentities); + evidence.push({ + check: 'same native drag, controlled restore and corner resize preserve saved layout and widget identity', + engine, + original, + moved, + resized, + }); + }); +} diff --git a/tests/browser/preview-overlap.spec.ts b/tests/browser/preview-overlap.spec.ts new file mode 100644 index 0000000..f29e52b --- /dev/null +++ b/tests/browser/preview-overlap.spec.ts @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; + +import type {Config} from '../../src/shared'; + +import {checked, storyUrl, test} from './fixtures'; +import type {NativeElement} from './types'; +// Critical: yes. Native edits must preserve saved geometry and a usable editor. +test('palette cancellation and flat/nested overlap matrix', async ({page, evidence}) => { + const saved = () => + page + .locator('[data-qa="preview-overlap-config"]') + .textContent() + .then((text) => JSON.parse(checked(text)) as Config); + const host = (id: string) => page.locator(`[data-dashkit-id="${id}"]`); + const position = (id: string) => + host(id).evaluate((el) => { + const {x, y, w, h} = (el as NativeElement).gridstackNode; + return {x, y, w, h}; + }); + const drag = async (id: string, dx: number, dy: number) => { + const rect = await host(id).boundingBox(); + const x = checked(rect).x + checked(rect).width / 2; + const y = checked(rect).y + checked(rect).height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + Math.sign(dx) * 5, y + Math.sign(dy) * 5); + await page.mouse.move(x + dx, y + dy, {steps: 12}); + await page.mouse.up(); + }; + for (const nested of [false, true]) { + await page.goto(storyUrl('preview-overlap', {nested})); + await host('b').waitFor(); + const before = await saved(); + const palette = await page.getByRole('button', {name: '+ Add', exact: true}).boundingBox(); + const root = nested + ? page.locator('[data-dashkit-id="box"] .grid-stack') + : page.locator('.grid-stack').first(); + const bounds = await root.boundingBox(); + await page.mouse.move( + checked(palette).x + checked(palette).width / 2, + checked(palette).y + checked(palette).height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + checked(palette).x + checked(palette).width / 2 + 10, + checked(palette).y + checked(palette).height / 2, + { + steps: 3, + }, + ); + await page.mouse.move(checked(bounds).x - 10, checked(bounds).y - 10, {steps: 12}); + await page.mouse.move(checked(bounds).x + 5, checked(bounds).y + 5); + await page.mouse.move(checked(bounds).x + 6, checked(bounds).y + 6); + await page.waitForFunction(() => + document.querySelector('[data-dashkit-id="__dropping-elem__"]'), + ); + const collided = await position('a'); + assert.equal(collided.y, 2, 'preview must really displace the neighbor'); + assert.deepEqual(await saved(), before, 'preview is not a save'); + // Leaving cancels the preview before the palette source clears its drag context. + await page.mouse.move( + checked(bounds).x + checked(bounds).width + 40, + checked(bounds).y + checked(bounds).height + 10, + { + steps: 6, + }, + ); + await page.waitForFunction( + () => !document.querySelector('[data-dashkit-id="__dropping-elem__"]'), + ); + assert.deepEqual(await position('a'), {x: 0, y: 0, w: 4, h: 2}); + await page.keyboard.press('Escape'); + await page.mouse.up(); + await page.waitForFunction( + () => !document.querySelector('[data-dashkit-id="__dropping-elem__"]'), + ); + assert.deepEqual(await position('a'), {x: 0, y: 0, w: 4, h: 2}); + assert.deepEqual(await saved(), before); + await drag('b', -50, 0); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + JSON.parse( + required( + required( + document.querySelector( + '[data-qa="preview-overlap-config"]', + ), + ).textContent, + ), + ).layout[1].x === 7 + ); + }); + const after = await saved(); + assert.deepEqual(after.layout[0], before.layout[0]); + assert.equal(after.layout[1].x, 7); + evidence.push({ + check: 'native preview collision, leave/Escape cancellation, later save', + nested, + before, + collided, + after, + }); + // A successful add keeps the collision-adjusted neighbors and the selected position. + await page.goto(storyUrl('preview-overlap', {nested})); + await host('b').waitFor(); + await page + .getByRole('button', {name: '+ Add', exact: true}) + .dragTo(root, {targetPosition: {x: 5, y: 5}}); + await host('added').waitFor(); + const placed = await saved(); + assert.equal(checked(placed.layout.find(({i}) => i === 'a')).y, 2); + assert.deepEqual(await position('added'), {x: 0, y: 0, w: 3, h: 2}); + assert.equal(await page.locator('[data-qa="preview-overlap-drops"]').textContent(), '1'); + evidence.push({ + check: 'successful palette collision keeps placement and displaced layout', + nested, + placed, + }); + for (const compactType of [null, 'vertical', 'horizontal', 'horizontal-nowrap']) { + for (const allowOverlap of [true, false]) { + await page.goto(storyUrl('preview-overlap', {nested, compactType, allowOverlap})); + await host('b').waitFor(); + const mode = await host('b').evaluate( + (el) => (el as NativeElement).gridstackNode.grid.engine.mode, + ); + assert.equal(mode.compactType, compactType); + assert.equal(mode.allowOverlap, allowOverlap); + await drag('b', -300, compactType === 'horizontal-nowrap' ? 0 : 40); + await page.waitForFunction(() => { + function required(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('Missing native fixture value'); + } + return value; + } + return ( + JSON.parse( + required( + required( + document.querySelector( + '[data-qa="preview-overlap-config"]', + ), + ).textContent, + ), + ).layout[1].x !== 8 + ); + }); + const afterMove = await saved(); + const a = afterMove.layout[0]; + const b = afterMove.layout[1]; + if (allowOverlap) { + assert.equal(b.x, 2); + assert.equal(b.y, compactType === 'horizontal-nowrap' ? 0 : 1); + assert.deepEqual(a, {...a, x: 0, y: 0}); + assert.deepEqual(await position('b'), {x: b.x, y: b.y, w: 3, h: 2}); + } else { + assert( + !(a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h), + ); + } + evidence.push({ + check: 'native overlap gesture saved exact engine position', + nested, + compactType, + allowOverlap, + afterMove, + }); + } + } + } +}); diff --git a/tests/browser/tsconfig.json b/tests/browser/tsconfig.json new file mode 100644 index 0000000..a11f915 --- /dev/null +++ b/tests/browser/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "declaration": false + }, + "include": ["./**/*.ts", "../../playwright.config.ts", "../../.storybook/main.ts"] +} diff --git a/tests/browser/types.ts b/tests/browser/types.ts new file mode 100644 index 0000000..e00d694 --- /dev/null +++ b/tests/browser/types.ts @@ -0,0 +1,14 @@ +import type {DashKitEngine} from '../../src/components/GridStack/engine'; +import type {GridStack, GridStackNode} from '../../src/components/GridStack/runtime'; + +export type NativeElement = HTMLElement & { + gridstackNode: GridStackNode & { + x: number; + y: number; + w: number; + h: number; + grid: GridStack & {engine: DashKitEngine}; + }; + gridstack?: GridStack; + ddElement?: {ddDraggable?: object}; +};