diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index b46feead4067..c8235d408e27 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -65,6 +65,7 @@ "@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table", "@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map", "@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table", + "@superset-ui/plugin-chart-univer": "file:./plugins/plugin-chart-univer", "@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud", "@superset-ui/plugin-chart-world-map": "file:./plugins/plugin-chart-world-map", "@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl", @@ -4079,12 +4080,17 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@flatten-js/interval-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.3.tgz", + "integrity": "sha512-xhFWUBoHJFF77cJO1D6REjdgJEMRf2Y2Z+eKEPav8evGKcLSnj1ud5pLXQSbGuxF3VSvT1rWhMfVpXEKJLTL+A==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/utils": "^0.2.12" } @@ -4094,18 +4100,29 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@floating-ui/utils": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@fontsource/fira-code": { "version": "5.3.0", @@ -4211,6 +4228,61 @@ "react": "^17 || ^18" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.6", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.6.tgz", + "integrity": "sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/proto-loader/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -7131,6 +7203,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@jsonforms/core": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.8.0.tgz", @@ -11389,6 +11471,725 @@ "integrity": "sha512-Hqc0UIHd0IZ2+JbovzTz7lYG7CY9D8yCB81zyP92Zl9UDSPcw2cLbTySW14keAQeAo1sjGjUw0+KHxr0Y2h/hA==", "license": "MIT" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.16.tgz", + "integrity": "sha512-nXPI1tzJsnQw8oXvB2r49rO9TIbbJ8VFeaWDiBsqY8+iwO5xb7WjUsrXDagjAgPKTdhW8WODQnAltUhBx8I+sg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.16.tgz", + "integrity": "sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.2.0.tgz", + "integrity": "sha512-l5CX8HdNL+9cc4s7HkNyplumSA3FIx68sg9dttBHKr286fRBwq1MBniDVTeI/tkof92/APntMQvC1YG1Khb0Cg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.5.tgz", + "integrity": "sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.20.tgz", + "integrity": "sha512-35WHRrycfnj2m4LvPiKMKHGLM3Y2WvUA744CkRdxjs1BGDX1c8PVMfmiSGTUwnHRbqdtIoxveYTXvcJSt7VfgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.25.tgz", + "integrity": "sha512-45QO76jn/a+cRl6OPgZKCKnIMdfYZaOa6SuAJeKHFiM3orX6bnscuFQJDx3qS4Agp/RE+eKv+9gmUL8t3Ma7bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.25", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.2.0.tgz", + "integrity": "sha512-I5S7vsAOozx2lWSBAyuXjWIJ/A8qx4fjbv6115WoaRYQSb1yZDaeNHonUAQzXyE8oD+0whJHbQRl4HIE2gQ0Xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.24.tgz", + "integrity": "sha512-0w/tZHRo1fv4WsfcyaOdi5eDftFA8wQkr8WCOOuxw7fx9S2ERfshQioM269aFZsmyAHd0X1LWoZ2BQTlA/JYUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.25", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.25.tgz", + "integrity": "sha512-e8JaQc0ijBbTx37YVI09Nfq8ThUWJ4L1rvZ3Hvaufehco9/ug9+U3PHz9ue6w3gKd91UcKf9fgDWejeSO+VSKQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.2.0.tgz", + "integrity": "sha512-e70Nh+0iQ+hRAIM2nAGib5BhaDL2Rjlzxw9kIzsU2nz+t3/t82Z5qtPhY5nTnfj8FbmrU6RRIKH8RF3ynCe+vw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.8", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.8.tgz", + "integrity": "sha512-y92/9iIdG1FyNvow0Y0qP5WJZ7oRTAKDVKCy9kJnhZfItwIKYtt0KKQeOhBKfXz0ETizduKwDRbrQHrTQScs5A==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.5", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.18.tgz", + "integrity": "sha512-ygFIJ+QXNKf72AAq8x3hd22+ws7pYpmc1S0E4kW0Je+qD4qEb3FvxeKVQRGrVDMOkQaYfsB+Qs+KDh81a1gAOw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.11.tgz", + "integrity": "sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.4.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.20.tgz", + "integrity": "sha512-+ZjbkU21fTSJdn4nq/IFKUdKS+JSZHORBZ8ma1f6YpOMNzRt0xf3sVAWVK/+ktHCajPy4HYDsyHXZIZmOMxzIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.16.tgz", + "integrity": "sha512-vugPjGQBXbJVVkftKqv/BVeZ108SyRGT3FR1oa14az6qyLgX8jPhnZYHgdSB7Gw8UJusqwb9eaK6mluOnsn/Vg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.4.0.tgz", + "integrity": "sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz", + "integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, "node_modules/@rc-component/async-validator": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-6.0.0.tgz", @@ -13151,6 +13952,10 @@ "resolved": "plugins/plugin-chart-table", "link": true }, + "node_modules/@superset-ui/plugin-chart-univer": { + "resolved": "plugins/plugin-chart-univer", + "link": true + }, "node_modules/@superset-ui/plugin-chart-word-cloud": { "resolved": "plugins/plugin-chart-word-cloud", "link": true @@ -15280,6 +16085,445 @@ "integrity": "sha512-qQsguKXZVKdCixOHX9jqnX/K/1HekPDpGKyEcXHT+zR6EjGA7S4boSuelL4uuPv6YfhN0n8c4UxW+v/Z3gM2iw==", "license": "ISC" }, + "node_modules/@univerjs/core": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/core/-/core-1.0.4.tgz", + "integrity": "sha512-ofbW7Pt/+YW1IOnhjWao4CHz36AMlaWG24fQZOk0Lc+UT3vyefWvHL1U3wiup/daVibut6nVYwJkNtoxgoxB5w==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/protocol": "1.0.4", + "@univerjs/themes": "1.0.4", + "@wendellhu/redi": "1.1.3", + "async-lock": "^1.4.1", + "fast-diff": "1.3.0", + "kdbush": "^4.1.0", + "lodash-es": "^4.18.1", + "ot-json1": "^1.0.2", + "rbush": "^4.0.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/design": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/design/-/design-1.0.4.tgz", + "integrity": "sha512-4avfRxVxzwB+K68ogahLRP/n1Uvp8K6ljO4jQyraZEoQPRO4GMlCdhMOaEydbC5ks5jgi5TG7mb3G2xJpc9Diw==", + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-direction": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-hover-card": "^1.1.23", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slot": "^1.3.3", + "@univerjs/icons": "1.43.1", + "class-variance-authority": "^0.7.1", + "cmdk": "1.1.1", + "sonner": "^2.0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/@univerjs/docs": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/docs/-/docs-1.0.4.tgz", + "integrity": "sha512-V34Y6ju2q+1QFVz5EX1ZnUt81kbLlXtlyLdQ/lGo566skTkmLWzSr3hXO7FCB62bVF2XpgQLW/aHeeYKHHk4LQ==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4", + "@univerjs/engine-render": "1.0.4", + "@univerjs/protocol": "1.0.4", + "@univerjs/rpc": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/docs-ui": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/docs-ui/-/docs-ui-1.0.4.tgz", + "integrity": "sha512-NF4G0YTbelolkMM8wFuy5/WhxoRrfz6XnJyoQbttS86l3b8eALP4mnMTZOcs0QTmqX+VepW83LYTy3ZOVLJoNQ==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4", + "@univerjs/design": "1.0.4", + "@univerjs/docs": "1.0.4", + "@univerjs/drawing": "1.0.4", + "@univerjs/engine-render": "1.0.4", + "@univerjs/icons": "1.43.1", + "@univerjs/protocol": "1.0.4", + "@univerjs/ui": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/drawing": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/drawing/-/drawing-1.0.4.tgz", + "integrity": "sha512-K3iqOz9y4NaB18a2FcMYiX6T3ez+w74FM5l8u3t0bWYOlhcNEkZdH1QIbqQkj7rMmnl0YbNOlqL/Lm6739WPQg==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/engine-formula": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/engine-formula/-/engine-formula-1.0.4.tgz", + "integrity": "sha512-elHPZBr8mB2+dGStQcfuHsFk0ALKtEOaOhp82pZaPn30iUVZXEki4WEyF50zuHRUdK4YfDF0qRY82J7k6Yqb4Q==", + "license": "Apache-2.0", + "dependencies": { + "@flatten-js/interval-tree": "1.1.3", + "@univerjs/core": "1.0.4", + "@univerjs/rpc": "1.0.4", + "decimal.js": "^10.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/engine-render": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/engine-render/-/engine-render-1.0.4.tgz", + "integrity": "sha512-fs07MboylLqF00Fb0wH0HBMU9qA+Snj8XRTQllgzYkh7yJWig7xvcvfEbzwWV94FqC4SFlxykyn6FCnJzbEY5Q==", + "license": "Apache-2.0", + "dependencies": { + "@floating-ui/dom": "^1.8.0", + "@floating-ui/utils": "^0.2.12", + "@univerjs/core": "1.0.4", + "franc-min": "^6.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/icons": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@univerjs/icons/-/icons-1.43.1.tgz", + "integrity": "sha512-NZsOSvI2WUR9afa8jXdyxnr1AL7crFNsEgk8epYgnewrFZ47bkcXa50Jdo0LPgkwbqqpP1BuIJHwv1SX+ELGhw==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@univerjs/network": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/network/-/network-1.0.4.tgz", + "integrity": "sha512-yYEIp05kTx1QbSzfD1sh70Woskx4NzVPX06ZjguT3ahdORPVzRQY9dytXFlhMEQdAQzQpvlIxZxEfVMTIr5c/A==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/preset-sheets-core": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/preset-sheets-core/-/preset-sheets-core-1.0.4.tgz", + "integrity": "sha512-0McYkKcMnmW05HWMewgZMskBGxpBc3A9Cj3B7HtqXeCNL8yteMSOn4oIGS+fnRdPd0USyl8Tg88JD+ZOnFZvDA==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/design": "1.0.4", + "@univerjs/docs": "1.0.4", + "@univerjs/docs-ui": "1.0.4", + "@univerjs/engine-formula": "1.0.4", + "@univerjs/engine-render": "1.0.4", + "@univerjs/network": "1.0.4", + "@univerjs/rpc": "1.0.4", + "@univerjs/sheets": "1.0.4", + "@univerjs/sheets-formula": "1.0.4", + "@univerjs/sheets-formula-ui": "1.0.4", + "@univerjs/sheets-numfmt": "1.0.4", + "@univerjs/sheets-numfmt-ui": "1.0.4", + "@univerjs/sheets-ui": "1.0.4", + "@univerjs/ui": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/presets": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/presets/-/presets-1.0.4.tgz", + "integrity": "sha512-hofH98BJkJoKd/pLyO6aON5VVKHfjo1BxcBbPU0JsKvXKM5zGGR+yIo7kCdtrjkyt3vpzYiOUyU+vqWSqmT8eA==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4", + "@univerjs/drawing": "1.0.4", + "@univerjs/engine-formula": "1.0.4", + "@univerjs/engine-render": "1.0.4", + "@univerjs/network": "1.0.4", + "@univerjs/protocol": "1.0.4", + "@univerjs/rpc": "1.0.4", + "@univerjs/telemetry": "1.0.4", + "@univerjs/themes": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + } + }, + "node_modules/@univerjs/protocol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/protocol/-/protocol-1.0.4.tgz", + "integrity": "sha512-az3yTfbiFuxrHd8Uw76kPGkQkFiUMcx2eAdHdIslSTHjM7ATouZs+UhbcZCkYuIcCF0y3GSphVqyNM4/4SzcNg==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + } + }, + "node_modules/@univerjs/rpc": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/rpc/-/rpc-1.0.4.tgz", + "integrity": "sha512-gYYdmNHESZvv51q9A5mFuUJWuty68C2n5czCxqZptSI5bXBuFQBL4yFiNhym4gJAyfoCHtjKW6HwCxRSLtYzBQ==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/sheets": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/sheets/-/sheets-1.0.4.tgz", + "integrity": "sha512-pNeExtjXB+jEMn8iqPPXZD6vSrI7BpUkTunpGURvDkLvbsGemFlkI8pEPCTp1lSwdpFqykLi7D4H8haDombYFQ==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4", + "@univerjs/engine-formula": "1.0.4", + "@univerjs/engine-render": "1.0.4", + "@univerjs/protocol": "1.0.4", + "@univerjs/rpc": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/sheets-formula": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/sheets-formula/-/sheets-formula-1.0.4.tgz", + "integrity": "sha512-kcO97Op1k1fgEII3lujYf3dnP0j8STWC2SE0TpenQg/dWrhQOvmDgzC9AKkFsf/DRxvMuAkBgikXnxGM4GCFLA==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4", + "@univerjs/docs": "1.0.4", + "@univerjs/engine-formula": "1.0.4", + "@univerjs/rpc": "1.0.4", + "@univerjs/sheets": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/sheets-formula-ui": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/sheets-formula-ui/-/sheets-formula-ui-1.0.4.tgz", + "integrity": "sha512-d4WxP1bHf0bPsz4349JaaQVCBUNbeN2o3la0hHV5EopOOyhuiACv4l7atDsYdUbTMW0/sga5vKdV959jEdQ5tg==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4", + "@univerjs/design": "1.0.4", + "@univerjs/docs": "1.0.4", + "@univerjs/docs-ui": "1.0.4", + "@univerjs/engine-formula": "1.0.4", + "@univerjs/engine-render": "1.0.4", + "@univerjs/icons": "1.43.1", + "@univerjs/sheets": "1.0.4", + "@univerjs/sheets-formula": "1.0.4", + "@univerjs/sheets-ui": "1.0.4", + "@univerjs/ui": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/sheets-numfmt": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/sheets-numfmt/-/sheets-numfmt-1.0.4.tgz", + "integrity": "sha512-/4KRC4Xg3ORGkUawUujH72xBlrRU/A5i3LEZrqnR3DoRN4/TJzn405efc0PsZTpagyvYMF5oDzDUcld3eTUisA==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4", + "@univerjs/engine-formula": "1.0.4", + "@univerjs/sheets": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/sheets-numfmt-ui": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/sheets-numfmt-ui/-/sheets-numfmt-ui-1.0.4.tgz", + "integrity": "sha512-ceMm7+My4trFC4pESjfzI9Wa0052uSlKgqN3aczjzohXaynAi0zv4WlMcz82thQ+/zV+R2l8og1qYo+Ms8YC8w==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4", + "@univerjs/design": "1.0.4", + "@univerjs/engine-formula": "1.0.4", + "@univerjs/engine-render": "1.0.4", + "@univerjs/icons": "1.43.1", + "@univerjs/sheets": "1.0.4", + "@univerjs/sheets-numfmt": "1.0.4", + "@univerjs/sheets-ui": "1.0.4", + "@univerjs/ui": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/sheets-ui": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/sheets-ui/-/sheets-ui-1.0.4.tgz", + "integrity": "sha512-1X1YILv3tjpFqCFzqzsvKRKeq5aatklvH7W2Ia+cc8BeK9wzzK1+QWrEQsjrmd8yfb9phrLSCZwvxYCbhVnxBg==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4", + "@univerjs/design": "1.0.4", + "@univerjs/docs": "1.0.4", + "@univerjs/docs-ui": "1.0.4", + "@univerjs/engine-formula": "1.0.4", + "@univerjs/engine-render": "1.0.4", + "@univerjs/icons": "1.43.1", + "@univerjs/protocol": "1.0.4", + "@univerjs/sheets": "1.0.4", + "@univerjs/telemetry": "1.0.4", + "@univerjs/ui": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@univerjs/telemetry": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/telemetry/-/telemetry-1.0.4.tgz", + "integrity": "sha512-HP6lO6WrNR4PrbJdT9IqoMXFnlYBF3q8WF30qsvA2mdUi03b3A02T6lF6f9+5wa3b5G2AxtT5weQwGvAwAHFPg==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + } + }, + "node_modules/@univerjs/themes": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/themes/-/themes-1.0.4.tgz", + "integrity": "sha512-clN1kdT+S4dv82jzpZaY6ogSIairfe/8KKDvYjZfPzlJpV0wa2VIaiO0xGuGLy1MSM/MMq2oEZxYTporkk69Rg==", + "license": "Apache-2.0", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + } + }, + "node_modules/@univerjs/ui": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@univerjs/ui/-/ui-1.0.4.tgz", + "integrity": "sha512-ZPt/SUr8mAzjaFOHOCSi8as1R9g13oXSdy/ayABQRfL4CbcxX1mz9jQlUne9EdfjjrY/TJsceU0EAHDsBTWEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/core": "1.0.4", + "@univerjs/design": "1.0.4", + "@univerjs/engine-render": "1.0.4", + "@univerjs/icons": "1.43.1", + "@univerjs/protocol": "1.0.4", + "@wendellhu/redi": "1.1.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/univer" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "rxjs": ">=7.0.0" + } + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.2.tgz", @@ -16400,6 +17644,23 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/@wendellhu/redi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@wendellhu/redi/-/redi-1.1.3.tgz", + "integrity": "sha512-XjSi+q350RzOE0WCHB1tspodtdmtzdpMlscOQvlDY27IFft5Pm/nbzsBnTX5Wg5vjtLM/T3ehlJ98wXhqN9xfg==", + "license": "MIT", + "workspaces": [ + "docs" + ], + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/@x0k/json-schema-merge": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.6.tgz", @@ -16778,7 +18039,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -16983,6 +18243,18 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -17233,6 +18505,12 @@ "node": ">= 0.4" } }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -18905,6 +20183,18 @@ "dev": true, "license": "MIT" }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -18984,7 +20274,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -18999,7 +20288,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -19056,6 +20344,22 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -19067,6 +20371,16 @@ "node": ">= 0.12.0" } }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -20629,7 +21943,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, "license": "MIT" }, "node_modules/decode-named-character-reference": { @@ -20910,6 +22223,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -22750,6 +24069,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-equals": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.2.tgz", @@ -23601,6 +24926,19 @@ "node": ">= 0.6" } }, + "node_modules/franc-min": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/franc-min/-/franc-min-6.2.0.tgz", + "integrity": "sha512-1uDIEUSlUZgvJa2AKYR/dmJC66v/PvGQ9mWfI9nOr/kPpMFyvswK0gPXOwpYJYiYD008PpHLkGfG58SPjQJFxw==", + "license": "MIT", + "dependencies": { + "trigram-utils": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -24480,6 +25818,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -26310,7 +27657,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -32384,6 +33730,12 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -34366,6 +35718,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/n-gram": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/n-gram/-/n-gram-2.0.2.tgz", + "integrity": "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/nanoid": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.1.tgz", @@ -35913,6 +37275,24 @@ "node": ">=0.10.0" } }, + "node_modules/ot-json1": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ot-json1/-/ot-json1-1.0.2.tgz", + "integrity": "sha512-IhxkqVWQqlkWULoi/Q2AdzKk0N5vQRbUMUwubFXFCPcY4TsOZjmp2YKrk0/z1TeiECPadWEK060sdFdQ3Grokg==", + "license": "ISC", + "dependencies": { + "ot-text-unicode": "4" + } + }, + "node_modules/ot-text-unicode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ot-text-unicode/-/ot-text-unicode-4.0.0.tgz", + "integrity": "sha512-W7ZLU8QXesY2wagYFv47zErXud3E93FGImmSGJsQnBzE+idcPPyo2u2KMilIrTwBh4pbCizy71qRjmmV6aDhcQ==", + "license": "ISC", + "dependencies": { + "unicount": "1.1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -37410,6 +38790,35 @@ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "license": "ISC" }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/protocol-buffers-schema": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", @@ -39176,6 +40585,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-resizable": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-4.0.2.tgz", @@ -39332,6 +40788,28 @@ "react": "*" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-syntax-highlighter": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", @@ -40085,7 +41563,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -40362,7 +41839,6 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -41195,6 +42671,22 @@ "node": ">= 14" } }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/sort-asc": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", @@ -41974,7 +43466,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -42012,7 +43503,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string.prototype.trim": { @@ -42089,7 +43579,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -43491,6 +44980,20 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/trigram-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/trigram-utils/-/trigram-utils-2.0.1.tgz", + "integrity": "sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==", + "license": "MIT", + "dependencies": { + "collapse-white-space": "^2.0.0", + "n-gram": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -44181,6 +45684,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unicount": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unicount/-/unicount-1.1.0.tgz", + "integrity": "sha512-RlwWt1ywVW4WErPGAVHw/rIuJ2+MxvTME0siJ6lk9zBhpDfExDbspe6SRlWT3qU6AucNjotPl9qAJRVjP7guCQ==", + "license": "ISC" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -44523,6 +46032,27 @@ "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", "license": "BSD" }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-event-callback": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/use-event-callback/-/use-event-callback-0.1.0.tgz", @@ -44565,6 +46095,28 @@ } } }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -46191,7 +47743,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -47526,6 +49077,24 @@ "node": ">=12" } }, + "plugins/plugin-chart-univer": { + "name": "@superset-ui/plugin-chart-univer", + "version": "0.20.3", + "license": "Apache-2.0", + "dependencies": { + "@univerjs/preset-sheets-core": "1.0.4", + "@univerjs/presets": "1.0.4", + "lodash-es": "^4.18.1" + }, + "peerDependencies": { + "@apache-superset/core": "*", + "@superset-ui/chart-controls": "*", + "@superset-ui/core": "*", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "rxjs": "^7.8.1" + } + }, "plugins/plugin-chart-word-cloud": { "name": "@superset-ui/plugin-chart-word-cloud", "version": "0.20.4", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 10eb933e6338..545180c9444d 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -142,6 +142,7 @@ "@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table", "@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map", "@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table", + "@superset-ui/plugin-chart-univer": "file:./plugins/plugin-chart-univer", "@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud", "@superset-ui/plugin-chart-world-map": "file:./plugins/plugin-chart-world-map", "@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl", diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts index 49181bd7cf0f..bca36305ed80 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/types/VizType.ts @@ -64,6 +64,7 @@ export enum VizType { Timeseries = 'echarts_timeseries', Tree = 'tree_chart', Treemap = 'treemap_v2', + UniverSheet = 'univer_sheet', Waterfall = 'waterfall', WordCloud = 'word_cloud', WorldMap = 'world_map', diff --git a/superset-frontend/plugins/plugin-chart-univer/README.md b/superset-frontend/plugins/plugin-chart-univer/README.md new file mode 100644 index 000000000000..cd9124dbbeff --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/README.md @@ -0,0 +1,49 @@ + + +## @superset-ui/plugin-chart-univer + +An Excel-like spreadsheet chart built on [Univer](https://github.com/dream-num/univer) (open source core only). + +- **Explore:** the query result is loaded into a full spreadsheet editor (toolbar, formula bar, + sheet tabs, context menu). Edits are written to the `univer_workbook` control and saved with the chart. +- **Dashboards:** the saved sheet is shown read-only, without toolbars or editors. + +### Data refresh + +With **Refresh data on load** on (the default), the query result is written into the top-left corner +of the first sheet every time the chart loads. Formatting, formulas and anything outside that area are +kept, and the query values are not stored in the chart. Put your own formulas to the right of the data +or on another sheet, because the data area grows when the query returns more rows or columns. + +With it off, the sheet is shown exactly as it was saved. + +### Usage + +```js +import { UniverChartPlugin } from '@superset-ui/plugin-chart-univer'; + +new UniverChartPlugin().configure({ key: 'univer_sheet' }).register(); +``` + +### Univer packages + +Only Univer's open source packages (`@univerjs/presets` and `@univerjs/preset-sheets-core`, +Apache-2.0) are used. Pivot tables, row groups and the Excel export are implemented in this +plugin and in `superset/utils/univer_excel.py` instead of Univer's paid add-ons. diff --git a/superset-frontend/plugins/plugin-chart-univer/package.json b/superset-frontend/plugins/plugin-chart-univer/package.json new file mode 100644 index 000000000000..60aa68746fba --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/package.json @@ -0,0 +1,44 @@ +{ + "name": "@superset-ui/plugin-chart-univer", + "version": "0.20.3", + "description": "Superset Chart - Excel-like spreadsheet powered by Univer", + "keywords": [ + "superset" + ], + "homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-univer#readme", + "bugs": { + "url": "https://github.com/apache/superset/issues" + }, + "license": "Apache-2.0", + "author": "Superset", + "repository": { + "type": "git", + "url": "https://github.com/apache/superset.git", + "directory": "superset-frontend/plugins/plugin-chart-univer" + }, + "files": [ + "esm", + "lib" + ], + "sideEffects": [ + "*.css" + ], + "main": "lib/index.js", + "module": "esm/index.js", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@univerjs/preset-sheets-core": "1.0.4", + "@univerjs/presets": "1.0.4", + "lodash-es": "^4.18.1" + }, + "peerDependencies": { + "@apache-superset/core": "*", + "@superset-ui/chart-controls": "*", + "@superset-ui/core": "*", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "rxjs": "^7.8.1" + } +} diff --git a/superset-frontend/plugins/plugin-chart-univer/src/UniverChart.tsx b/superset-frontend/plugins/plugin-chart-univer/src/UniverChart.tsx new file mode 100644 index 000000000000..c0f78e6d5eff --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/UniverChart.tsx @@ -0,0 +1,258 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useEffect, useMemo, useRef } from 'react'; +import { debounce } from 'lodash-es'; +import { styled } from '@apache-superset/core/theme'; +import { mountUniver } from './univer/mountUniver'; +import { UniverHandle } from './runtimeTypes'; +import { UniverChartProps, WORKBOOK_CONTROL } from './types'; +import { + buildGrid, + buildPivotGrid, + buildWorkbook, + groupRows, + DEFAULT_LAYOUT, + columnWidth, + fillWidth, + applyOutline, + sizeHeaders, + parseSavedState, + serializeState, + StretchedColumns, + stretchColumns, + unstretchColumns, +} from './utils/workbook'; +import { + combineGroups, + EMPTY_OUTLINE, + MANUAL_GROUPS, + Outline, + outlineDepth, + outlineMargin, + resolveOutline, +} from './utils/outline'; + +// wait for a burst of edits (typing, pasting, dragging) to finish before saving +const SAVE_DELAY_MS = 300; +// wait for the chart to stop resizing before widening the columns again +const RESIZE_DELAY_MS = 100; + +const Container = styled.div<{ width: number; height: number }>` + width: ${({ width }) => width}px; + height: ${({ height }) => height}px; + position: relative; +`; + +export default function UniverChart(props: UniverChartProps) { + const { + width, + height, + data, + colnames, + coltypes, + labels, + editable, + savedState, + liveData, + showHeaders, + setControlValue, + pivotRows, + keyColumns, + rowGrouping, + } = props; + const containerRef = useRef(null); + + // `labels` is a new object on every render, compare it by content so that + // saving an edit doesn't rebuild the whole spreadsheet + const labelsKey = JSON.stringify(labels); + const { grid, layout, groups } = useMemo(() => { + const result = pivotRows + ? buildPivotGrid( + data, + colnames, + coltypes, + JSON.parse(labelsKey), + pivotRows, + ) + : { + grid: buildGrid(data, colnames, coltypes, JSON.parse(labelsKey)), + layout: DEFAULT_LAYOUT, + }; + return rowGrouping && keyColumns && keyColumns > 1 + ? // a pivot has its own Total row + groupRows(result.grid, result.layout, keyColumns, !pivotRows) + : { ...result, groups: [] }; + }, [data, colnames, coltypes, labelsKey, pivotRows, rowGrouping, keyColumns]); + + // The saved state changes in two ways: the chart saved an edit (the sheet on + // screen already shows it, so nothing to do), or something else changed it, + // like "Reset spreadsheet" or loading another chart (rebuild the sheet). + const lastSavedRef = useRef(null); + const latestStateRef = useRef(savedState); + const externalRef = useRef({ state: savedState, version: 0 }); + latestStateRef.current = savedState; + if (savedState !== externalRef.current.state) { + externalRef.current = { + state: savedState, + version: + externalRef.current.version + + (savedState === lastSavedRef.current ? 0 : 1), + }; + } + const externalVersion = externalRef.current.version; + + const setControlValueRef = useRef(setControlValue); + setControlValueRef.current = setControlValue; + + // columns are widened to fill the chart, that is not saved (see + // unstretchColumns) so the sheet fits again at any other size + const widthRef = useRef(width); + widthRef.current = width; + const handleRef = useRef(null); + const stretchedRef = useRef({}); + // collapsible groups of the sheet on screen + const outlineRef = useRef(EMPTY_OUTLINE); + + useEffect(() => { + const container = containerRef.current; + if (!container) return undefined; + + const saved = parseSavedState(latestStateRef.current); + // groups are tied to the query's rows when they come from the query + const savedOutline = resolveOutline( + saved?.outline, + liveData || saved?.stripped ? grid : [], + layout.headerRows, + keyColumns, + ); + // without manual groups, only the collapsed state of the automatic ones + // is taken from the saved chart + const savedRows = MANUAL_GROUPS + ? savedOutline.rows + : savedOutline.rows.filter(group => + groups.some( + auto => auto.start === group.start && auto.end === group.end, + ), + ); + outlineRef.current = { + rows: combineGroups(groups, savedRows), + columns: MANUAL_GROUPS ? savedOutline.columns : [], + }; + const mountedOutline = outlineRef.current; + const headerLabels = editable || showHeaders; + const workbookData = sizeHeaders( + applyOutline( + buildWorkbook(grid, saved, liveData, layout), + mountedOutline, + grid, + ), + headerLabels, + mountedOutline, + ); + const fitted = stretchColumns(workbookData, widthRef.current); + stretchedRef.current = fitted.stretched; + + let handle: UniverHandle | null = null; + const save = debounce(() => { + if (!handle) return; + const state = serializeState( + unstretchColumns(handle.save(), stretchedRef.current), + grid, + liveData, + layout, + { groups: outlineRef.current, keyColumns }, + ); + if (state === lastSavedRef.current || state === latestStateRef.current) { + return; + } + lastSavedRef.current = state; + // a rebuild right after this flush must start from this state, the + // new props haven't arrived yet + latestStateRef.current = state; + setControlValueRef.current?.(WORKBOOK_CONTROL, state); + }, SAVE_DELAY_MS); + + handle = mountUniver({ + container, + workbook: fitted.workbook, + editable, + onChange: editable ? save : undefined, + // the margin for row groups grows and shrinks as groups change + getFillWidth: () => + fillWidth(fitted.workbook, widthRef.current) + + outlineMargin(outlineDepth(mountedOutline.rows)) - + outlineMargin(outlineDepth(outlineRef.current.rows)), + outline: mountedOutline, + headerLabels, + outlineSummaryAbove: groups.length > 0, + onOutlineChange: editable + ? outline => { + outlineRef.current = outline; + save(); + } + : undefined, + }); + handleRef.current = handle; + + return () => { + // keep edits made just before the chart re-renders or Explore closes + save.flush(); + handleRef.current = null; + handle?.dispose(); + }; + }, [ + grid, + layout, + groups, + keyColumns, + editable, + liveData, + showHeaders, + externalVersion, + ]); + + useEffect(() => { + const restretch = debounce(() => { + const handle = handleRef.current; + if (!handle) return; + const current = handle.save(); + const base = unstretchColumns(current, stretchedRef.current); + const { stretched } = stretchColumns(base, width); + const widths: Record = {}; + const columns = new Set([ + ...Object.keys(stretchedRef.current), + ...Object.keys(stretched), + ]); + columns.forEach(key => { + const c = Number(key); + const target = stretched[c]?.w ?? columnWidth(base, c); + if (target !== undefined && target !== columnWidth(current, c)) { + widths[c] = target; + } + }); + // set before the widths change, so saving sees them as stretched + stretchedRef.current = stretched; + if (Object.keys(widths).length) handle.setColumnWidths(widths); + }, RESIZE_DELAY_MS); + restretch(); + return () => restretch.cancel(); + }, [width]); + + return ; +} diff --git a/superset-frontend/plugins/plugin-chart-univer/src/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-univer/src/images/thumbnail.png new file mode 100644 index 000000000000..296702c954af Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-univer/src/images/thumbnail.png differ diff --git a/superset-frontend/plugins/plugin-chart-univer/src/index.ts b/superset-frontend/plugins/plugin-chart-univer/src/index.ts new file mode 100644 index 000000000000..c43768e56454 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/index.ts @@ -0,0 +1,20 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +// eslint-disable-next-line import/prefer-default-export +export { default as UniverChartPlugin } from './plugin'; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/buildQuery.ts b/superset-frontend/plugins/plugin-chart-univer/src/plugin/buildQuery.ts new file mode 100644 index 000000000000..e07877d8ee0f --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/buildQuery.ts @@ -0,0 +1,82 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + buildQueryContext, + ensureIsArray, + getColumnLabel, + getMetricLabel, + normalizeOrderBy, + PostProcessingFlatten, + PostProcessingPivot, + QueryFormColumn, +} from '@superset-ui/core'; +import { PIVOT_MODE } from './controls/shared'; +import { UniverQueryFormData } from '../types'; + +// label of the Total row and columns in pivot mode +export const PIVOT_TOTAL = 'Total'; + +/** + * Groups by the row and column fields, then pivots the result with Superset's + * `pivot` post-processing: one row per row field combination and one column + * per metric and column field combination. `flatten` joins the column levels + * with ", " (see buildPivotGrid, which splits them into header rows). + */ +function buildPivotQuery(formData: UniverQueryFormData) { + const rows = ensureIsArray(formData.univer_pivot_rows); + const columns = ensureIsArray( + formData.univer_pivot_columns, + ).filter(column => !rows.includes(column)); + return buildQueryContext(formData, baseQueryObject => { + const metrics = ensureIsArray(baseQueryObject.metrics); + const pivot: PostProcessingPivot = { + operation: 'pivot', + options: { + index: rows.map(getColumnLabel), + columns: columns.map(getColumnLabel), + // each cell already holds one aggregated value, sum also adds up totals + aggregates: Object.fromEntries( + metrics.map(metric => [getMetricLabel(metric), { operator: 'sum' }]), + ), + drop_missing_columns: true, + marginal_distributions: formData.univer_pivot_totals ?? true, + marginal_distribution_name: PIVOT_TOTAL, + }, + }; + const flatten: PostProcessingFlatten = { operation: 'flatten' }; + return [ + { + ...baseQueryObject, + columns: [...rows, ...columns], + orderby: metrics[0] ? [[metrics[0], false]] : [], + post_processing: [pivot, flatten], + }, + ]; + }); +} + +export default function buildQuery(formData: UniverQueryFormData) { + if (formData.query_mode === PIVOT_MODE) return buildPivotQuery(formData); + return buildQueryContext(formData, baseQueryObject => [ + { + ...baseQueryObject, + orderby: normalizeOrderBy(baseQueryObject).orderby, + }, + ]); +} diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controlPanel.tsx new file mode 100644 index 000000000000..214aa9995d64 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controlPanel.tsx @@ -0,0 +1,84 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + ControlPanelConfig, + getStandardizedControls, +} from '@superset-ui/chart-controls'; +import { t } from '@apache-superset/core/translation'; +import { allColumnsControlSetItem } from './controls/columns'; +import { groupByControlSetItem } from './controls/groupBy'; +import { + rowLimitControlSetItem, + timeSeriesLimitMetricControlSetItem, +} from './controls/limits'; +import { metricsControlSetItem } from './controls/metrics'; +import { + orderByControlSetItem, + orderDescendingControlSetItem, +} from './controls/orderBy'; +import { + pivotColumnsControlSetItem, + pivotRowsControlSetItem, + pivotTotalsControlSetItem, +} from './controls/pivot'; +import { queryModeControlSetItem } from './controls/queryMode'; +import { + liveDataControlSetItem, + rowGroupingControlSetItem, + showHeadersControlSetItem, + workbookControlSetItem, +} from './controls/univer'; + +const config: ControlPanelConfig = { + controlPanelSections: [ + { + label: t('Query'), + expanded: true, + controlSetRows: [ + [queryModeControlSetItem], + [groupByControlSetItem], + [pivotRowsControlSetItem], + [pivotColumnsControlSetItem], + [metricsControlSetItem, allColumnsControlSetItem], + [pivotTotalsControlSetItem], + [timeSeriesLimitMetricControlSetItem, orderByControlSetItem], + [orderDescendingControlSetItem], + [rowLimitControlSetItem], + ['adhoc_filters'], + ], + }, + { + label: t('Spreadsheet'), + expanded: true, + controlSetRows: [ + [workbookControlSetItem], + [liveDataControlSetItem], + [rowGroupingControlSetItem], + [showHeadersControlSetItem], + ], + }, + ], + formDataOverrides: formData => ({ + ...formData, + groupby: getStandardizedControls().popAllColumns(), + metrics: getStandardizedControls().popAllMetrics(), + }), +}; + +export default config; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/columns.tsx b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/columns.tsx new file mode 100644 index 000000000000..5d8b86cfe002 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/columns.tsx @@ -0,0 +1,58 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + ControlSetItem, + ExtraControlProps, + sharedControls, + Dataset, + ColumnMeta, +} from '@superset-ui/chart-controls'; +import { ensureIsArray } from '@superset-ui/core'; +import { t } from '@apache-superset/core/translation'; +import { getQueryMode, isRawMode } from './shared'; + +const dndAllColumns: typeof sharedControls.groupby = { + type: 'DndColumnSelect', + label: t('Columns'), + description: t('Columns to display'), + default: [], + mapStateToProps({ datasource, controls }, controlState) { + const newState: ExtraControlProps = {}; + if (datasource) { + if (datasource?.columns[0]?.hasOwnProperty('filterable')) { + newState.options = (datasource as Dataset)?.columns?.filter( + (c: ColumnMeta) => c.filterable, + ); + } else newState.options = datasource.columns; + } + newState.queryMode = getQueryMode(controls); + newState.externalValidationErrors = + isRawMode({ controls }) && ensureIsArray(controlState?.value).length === 0 + ? [t('must have a value')] + : []; + return newState; + }, + visibility: isRawMode, + resetOnHide: false, +}; + +export const allColumnsControlSetItem: ControlSetItem = { + name: 'all_columns', + config: dndAllColumns, +}; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/groupBy.tsx b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/groupBy.tsx new file mode 100644 index 000000000000..b80754727484 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/groupBy.tsx @@ -0,0 +1,44 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + ControlPanelState, + ControlSetItem, + ControlState, + sharedControls, +} from '@superset-ui/chart-controls'; +import { isAggMode, validateAggControlValues } from './shared'; + +export const groupByControlSetItem: ControlSetItem = { + name: 'groupby', + override: { + visibility: isAggMode, + resetOnHide: false, + mapStateToProps: (state: ControlPanelState, controlState: ControlState) => { + const { controls } = state; + const originalMapStateToProps = sharedControls?.groupby?.mapStateToProps; + const newState = originalMapStateToProps?.(state, controlState) ?? {}; + newState.externalValidationErrors = validateAggControlValues(controls, [ + controls.metrics?.value, + controlState.value, + ]); + return newState; + }, + rerender: ['metrics'], + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/limits.ts b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/limits.ts new file mode 100644 index 000000000000..b34a75b93bf4 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/limits.ts @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ControlSetItem } from '@superset-ui/chart-controls'; +import { isAggMode } from './shared'; + +export const rowLimitControlSetItem: ControlSetItem = { + name: 'row_limit', + override: { + // the whole result is loaded into the spreadsheet, keep it modest + default: 1000, + }, +}; + +export const timeSeriesLimitMetricControlSetItem: ControlSetItem = { + name: 'timeseries_limit_metric', + override: { + visibility: isAggMode, + resetOnHide: false, + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/metrics.tsx b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/metrics.tsx new file mode 100644 index 000000000000..12484075e10c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/metrics.tsx @@ -0,0 +1,67 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + ControlPanelState, + ControlSetItem, + ControlState, + Dataset, + ColumnMeta, + defineSavedMetrics, +} from '@superset-ui/chart-controls'; +import { ensureIsArray } from '@superset-ui/core'; +import { t } from '@apache-superset/core/translation'; +import { + isAggOrPivotMode, + isPivotMode, + validateAggControlValues, +} from './shared'; + +const validatePivotMetrics = (value: unknown) => + ensureIsArray(value).length ? [] : [t('Pivot needs at least one metric')]; + +export const metricsControlSetItem: ControlSetItem = { + name: 'metrics', + override: { + validators: [], + visibility: isAggOrPivotMode, + mapStateToProps: ( + { controls, datasource, form_data }: ControlPanelState, + controlState: ControlState, + ) => ({ + columns: datasource?.columns[0]?.hasOwnProperty('filterable') + ? (datasource as Dataset)?.columns?.filter( + (c: ColumnMeta) => c.filterable, + ) + : datasource?.columns, + savedMetrics: defineSavedMetrics(datasource), + // current active adhoc metrics + selectedMetrics: + form_data.metrics || (form_data.metric ? [form_data.metric] : []), + datasource, + externalValidationErrors: isPivotMode({ controls }) + ? validatePivotMetrics(controlState.value) + : validateAggControlValues(controls, [ + controls.groupby?.value, + controlState.value, + ]), + }), + rerender: ['groupby'], + resetOnHide: false, + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/orderBy.tsx b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/orderBy.tsx new file mode 100644 index 000000000000..4740af0c1a68 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/orderBy.tsx @@ -0,0 +1,57 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ControlSetItem, Dataset } from '@superset-ui/chart-controls'; +import { t } from '@apache-superset/core/translation'; +import { isEmpty } from 'lodash-es'; +import { isAggMode, isRawMode } from './shared'; + +export const orderByControlSetItem: ControlSetItem = { + name: 'order_by_cols', + config: { + type: 'SelectControl', + label: t('Ordering'), + description: t('Order results by selected columns'), + multi: true, + default: [], + mapStateToProps: ({ datasource }) => ({ + choices: datasource?.hasOwnProperty('order_by_choices') + ? (datasource as Dataset)?.order_by_choices + : datasource?.columns || [], + }), + visibility: isRawMode, + resetOnHide: false, + }, +}; + +export const orderDescendingControlSetItem: ControlSetItem = { + name: 'order_desc', + config: { + type: 'CheckboxControl', + label: t('Sort descending'), + default: true, + description: t('Whether to sort descending or ascending'), + visibility: ({ controls }) => + !!( + isAggMode({ controls }) && + controls?.timeseries_limit_metric.value && + !isEmpty(controls?.timeseries_limit_metric.value) + ), + resetOnHide: false, + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/pivot.tsx b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/pivot.tsx new file mode 100644 index 000000000000..6c3d743a850d --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/pivot.tsx @@ -0,0 +1,76 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + ControlPanelState, + ControlSetItem, + ControlState, + sharedControls, +} from '@superset-ui/chart-controls'; +import { ensureIsArray } from '@superset-ui/core'; +import { t } from '@apache-superset/core/translation'; +import { isPivotMode } from './shared'; + +export const PIVOT_ROWS = 'univer_pivot_rows'; +export const PIVOT_COLUMNS = 'univer_pivot_columns'; +export const PIVOT_TOTALS = 'univer_pivot_totals'; + +export const pivotRowsControlSetItem: ControlSetItem = { + name: PIVOT_ROWS, + config: { + ...sharedControls.groupby, + label: t('Rows'), + description: t('Columns to group by on the rows'), + visibility: isPivotMode, + resetOnHide: false, + mapStateToProps: (state: ControlPanelState, controlState: ControlState) => { + const newState = + sharedControls.groupby.mapStateToProps?.(state, controlState) ?? {}; + newState.externalValidationErrors = + isPivotMode(state) && !ensureIsArray(controlState?.value).length + ? [t('Pivot needs at least one row')] + : []; + return newState; + }, + }, +}; + +export const pivotColumnsControlSetItem: ControlSetItem = { + name: PIVOT_COLUMNS, + config: { + ...sharedControls.groupby, + label: t('Columns'), + description: t('Columns to group by on the columns'), + visibility: isPivotMode, + resetOnHide: false, + }, +}; + +export const pivotTotalsControlSetItem: ControlSetItem = { + name: PIVOT_TOTALS, + config: { + type: 'CheckboxControl', + label: t('Show totals'), + description: t( + 'Add a Total row and a Total column for each metric. Totals add up the values in the table.', + ), + default: true, + visibility: isPivotMode, + resetOnHide: false, + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/queryMode.tsx b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/queryMode.tsx new file mode 100644 index 000000000000..4fdf7d92a85e --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/queryMode.tsx @@ -0,0 +1,50 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + ControlConfig, + ControlSetItem, + QueryModeLabel, +} from '@superset-ui/chart-controls'; +import { QueryMode } from '@superset-ui/core'; +import { t } from '@apache-superset/core/translation'; +import { getQueryMode, PIVOT_MODE } from './shared'; + +const queryMode: ControlConfig<'RadioButtonControl'> = { + type: 'RadioButtonControl', + label: t('Query mode'), + default: null, + options: [ + [QueryMode.Aggregate, QueryModeLabel[QueryMode.Aggregate]], + [QueryMode.Raw, QueryModeLabel[QueryMode.Raw]], + [PIVOT_MODE, t('Pivot')], + ], + mapStateToProps: ({ controls }) => ({ value: getQueryMode(controls) }), + rerender: [ + 'all_columns', + 'groupby', + 'metrics', + 'univer_pivot_rows', + 'univer_pivot_columns', + ], +}; + +export const queryModeControlSetItem: ControlSetItem = { + name: 'query_mode', + config: queryMode, +}; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/shared.ts b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/shared.ts new file mode 100644 index 000000000000..e88309a42555 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/shared.ts @@ -0,0 +1,71 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + ControlPanelsContainerProps, + ControlStateMapping, +} from '@superset-ui/chart-controls'; +import { ensureIsArray, QueryFormColumn, QueryMode } from '@superset-ui/core'; +import { t } from '@apache-superset/core/translation'; + +/** Pivot mode: the query result pivoted on the backend, see buildQuery */ +export const PIVOT_MODE = 'pivot'; + +export type UniverQueryMode = QueryMode | typeof PIVOT_MODE; + +export function getQueryMode(controls: ControlStateMapping): UniverQueryMode { + const mode = controls?.query_mode?.value; + if ( + mode === QueryMode.Aggregate || + mode === QueryMode.Raw || + mode === PIVOT_MODE + ) { + return mode as UniverQueryMode; + } + const rawColumns = controls?.all_columns?.value as + | QueryFormColumn[] + | undefined; + const hasRawColumns = rawColumns && rawColumns.length > 0; + return hasRawColumns ? QueryMode.Raw : QueryMode.Aggregate; +} + +/** + * Visibility check + */ +export function isQueryMode(mode: UniverQueryMode) { + return ({ controls }: Pick) => + getQueryMode(controls) === mode; +} + +export const isAggMode = isQueryMode(QueryMode.Aggregate); +export const isRawMode = isQueryMode(QueryMode.Raw); +export const isPivotMode = isQueryMode(PIVOT_MODE); +export const isAggOrPivotMode = ({ + controls, +}: Pick) => + isAggMode({ controls }) || isPivotMode({ controls }); + +export const validateAggControlValues = ( + controls: ControlStateMapping, + values: any[], +) => { + const areControlsEmpty = values.every(val => ensureIsArray(val).length === 0); + return areControlsEmpty && isAggMode({ controls }) + ? [t('Group By or Metrics must have a value')] + : []; +}; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/univer.tsx b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/univer.tsx new file mode 100644 index 000000000000..05367bc521ce --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/controls/univer.tsx @@ -0,0 +1,101 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + ControlSetItem, + CustomControlConfig, +} from '@superset-ui/chart-controls'; +import { ensureIsArray } from '@superset-ui/core'; +import { t } from '@apache-superset/core/translation'; +import { Button } from '@superset-ui/core/components'; +import { WORKBOOK_CONTROL } from '../../types'; +import { isAggMode, isPivotMode } from './shared'; + +/** + * Holds the spreadsheet saved from the chart itself. The chart writes it with + * `setControlValue`, so the control only shows a way to start over. + */ +const WorkbookControl = ({ + value, + onChange, +}: CustomControlConfig<{ value?: string | null }>) => ( + +); + +export const workbookControlSetItem: ControlSetItem = { + name: WORKBOOK_CONTROL, + config: { + type: WorkbookControl, + label: t('Spreadsheet'), + description: t( + 'Edit the spreadsheet in the chart area, then save the chart to keep the changes. Reset removes all edits.', + ), + default: null, + renderTrigger: true, + }, +}; + +export const liveDataControlSetItem: ControlSetItem = { + name: 'univer_live_data', + config: { + type: 'CheckboxControl', + label: t('Refresh data on load'), + description: t( + 'Fill the query result area with fresh data every time the chart loads. Formatting and cells outside that area are kept, but edits to values inside it are replaced. Turn off to keep the sheet exactly as saved.', + ), + default: true, + renderTrigger: true, + }, +}; + +export const showHeadersControlSetItem: ControlSetItem = { + name: 'univer_show_headers', + config: { + type: 'CheckboxControl', + label: t('Show row and column headers on dashboards'), + description: t('Show the A, B, C… and 1, 2, 3… headers outside Explore'), + default: false, + renderTrigger: true, + }, +}; + +export const rowGroupingControlSetItem: ControlSetItem = { + name: 'univer_row_grouping', + config: { + type: 'CheckboxControl', + label: t('Group rows by dimensions'), + description: t( + 'Show each value of the first dimensions once, merged down its rows, with a Subtotals row on top that adds up the metrics. Groups can be collapsed to their Subtotals row.', + ), + default: false, + renderTrigger: true, + // needs two or more dimensions (row fields in pivot mode) + visibility: ({ controls }) => + (isAggMode({ controls }) && + ensureIsArray(controls?.groupby?.value).length > 1) || + (isPivotMode({ controls }) && + ensureIsArray(controls?.univer_pivot_rows?.value).length > 1), + }, +}; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/index.ts b/superset-frontend/plugins/plugin-chart-univer/src/plugin/index.ts new file mode 100644 index 000000000000..59c5a45154fc --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/index.ts @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import { t } from '@apache-superset/core/translation'; +import thumbnail from '../images/thumbnail.png'; +import buildQuery from './buildQuery'; +import controlPanel from './controlPanel'; +import transformProps from './transformProps'; + +export default class UniverChartPlugin extends ChartPlugin { + constructor() { + const metadata = new ChartMetadata({ + category: t('Table'), + description: t( + 'Excel-like spreadsheet with formulas and formatting. Edit it in Explore and save it with the chart; dashboards show the saved sheet with fresh data.', + ), + name: t('Spreadsheet'), + tags: [t('Tabular'), t('Popular')], + thumbnail, + }); + + super({ + buildQuery, + controlPanel, + // Univer is large, only load it when a spreadsheet chart is shown + loadChart: () => import('../UniverChart'), + metadata, + transformProps, + }); + } +} diff --git a/superset-frontend/plugins/plugin-chart-univer/src/plugin/transformProps.ts b/superset-frontend/plugins/plugin-chart-univer/src/plugin/transformProps.ts new file mode 100644 index 000000000000..e6c4c13ecfe6 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/plugin/transformProps.ts @@ -0,0 +1,64 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + AppSection, + ChartProps, + ensureIsArray, + QueryMode, +} from '@superset-ui/core'; +import { UniverChartProps, UniverQueryFormData } from '../types'; +import { PIVOT_MODE } from './controls/shared'; + +function keyColumns(formData: UniverQueryFormData): number | undefined { + if (formData.query_mode === PIVOT_MODE) { + return ensureIsArray(formData.univer_pivot_rows).length; + } + if (formData.query_mode === QueryMode.Raw) return undefined; + return ensureIsArray(formData.groupby).length || undefined; +} + +export default function transformProps( + chartProps: ChartProps, +): UniverChartProps { + const { width, height, queriesData, hooks, appSection, datasource } = + chartProps; + const formData = chartProps.rawFormData as UniverQueryFormData; + const { data = [], colnames = [], coltypes = [] } = queriesData[0] ?? {}; + + return { + width, + height, + data, + colnames, + coltypes, + labels: datasource?.verboseMap ?? {}, + editable: appSection === AppSection.Explore, + savedState: formData.univer_workbook ?? null, + liveData: formData.univer_live_data ?? true, + showHeaders: formData.univer_show_headers ?? false, + setControlValue: hooks?.setControlValue, + pivotRows: + formData.query_mode === PIVOT_MODE + ? ensureIsArray(formData.univer_pivot_rows).length + : undefined, + keyColumns: keyColumns(formData), + rowGrouping: + !!formData.univer_row_grouping && formData.query_mode !== QueryMode.Raw, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-univer/src/runtimeTypes.ts b/superset-frontend/plugins/plugin-chart-univer/src/runtimeTypes.ts new file mode 100644 index 000000000000..2e9debc4d2f8 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/runtimeTypes.ts @@ -0,0 +1,120 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Outline } from './utils/outline'; + +/** + * Types shared by the chart and the code that drives Univer + * (univer/mountUniver.ts). They describe the parts of Univer's workbook + * format that the chart reads and writes, so the chart's pure logic doesn't + * depend on Univer's own types. + */ + +export type CellValue = string | number | boolean; + +/** Univer `ICellData` */ +export interface CellData { + /** value */ + v?: CellValue | null; + /** style id or inline style */ + s?: string | Record | null; + /** formula */ + f?: string | null; + /** shared formula id */ + si?: string | null; + /** value type */ + t?: number | null; + /** rich text */ + p?: unknown; + [key: string]: unknown; +} + +/** Univer `IRange`, a block of merged cells */ +export interface MergeRange { + startRow: number; + endRow: number; + startColumn: number; + endColumn: number; +} + +/** Univer `IWorksheetData` */ +export interface SheetData { + id?: string; + name?: string; + rowCount?: number; + columnCount?: number; + freeze?: { + xSplit: number; + ySplit: number; + startRow: number; + startColumn: number; + }; + cellData?: Record>; + mergeData?: MergeRange[]; + defaultColumnWidth?: number; + columnData?: Record; + rowData?: Record; + rowHeader?: { width: number; hidden?: number }; + columnHeader?: { height: number; hidden?: number }; + [key: string]: unknown; +} + +/** Univer `IWorkbookData` */ +export interface WorkbookData { + id?: string; + name?: string; + sheetOrder?: string[]; + styles?: Record; + sheets?: Record; + [key: string]: unknown; +} + +export interface MountUniverOptions { + container: HTMLElement; + workbook: WorkbookData; + /** false hides toolbars, formula bar, sheet tabs and blocks edits */ + editable: boolean; + /** called after every change to the document (not selection or scroll) */ + onChange?: () => void; + /** + * Width in pixels the columns of the sheet should take up together. When + * set, the column menu gets a "Fill the window" item while they take up less. + */ + getFillWidth?: () => number; + /** collapsible row and column groups, see src/utils/outline.ts */ + outline?: Outline; + /** called when the user groups, ungroups, collapses or expands */ + onOutlineChange?: (outline: Outline) => void; + /** + * false when the 1, 2, 3… and A, B, C… headers are hidden; the headers + * then only show the margin for the groups, if there are any + */ + headerLabels?: boolean; + /** the total rows of row groups are above them (automatic grouping) */ + outlineSummaryAbove?: boolean; +} + +export interface UniverHandle { + save: () => WorkbookData; + /** column index to width in pixels, on the first sheet */ + setColumnWidths: (widths: Record) => void; + dispose: () => void; +} + +export type MountUniver = (options: MountUniverOptions) => UniverHandle; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/types.ts b/superset-frontend/plugins/plugin-chart-univer/src/types.ts new file mode 100644 index 000000000000..b690c919a760 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/types.ts @@ -0,0 +1,67 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + DataRecord, + HandlerFunction, + QueryFormColumn, + QueryFormData, +} from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/common'; + +export const WORKBOOK_CONTROL = 'univer_workbook'; + +export type UniverQueryFormData = QueryFormData & { + /** JSON string of the saved spreadsheet, see `UniverSavedState` */ + univer_workbook?: string | null; + univer_live_data?: boolean; + univer_show_headers?: boolean; + univer_pivot_rows?: QueryFormColumn[]; + univer_pivot_columns?: QueryFormColumn[]; + univer_pivot_totals?: boolean; + univer_row_grouping?: boolean; +}; + +export interface UniverChartProps { + width: number; + height: number; + data: DataRecord[]; + colnames: string[]; + coltypes: GenericDataType[]; + /** Column labels for the header row (verbose names) */ + labels: Record; + /** true in Explore: full editor; false on dashboards: read-only grid */ + editable: boolean; + savedState: string | null; + liveData: boolean; + showHeaders: boolean; + /** + * In pivot mode, the number of row fields: the query result's first + * columns. The other columns hold the values, see `buildPivotGrid`. + */ + pivotRows?: number; + /** + * How many of the first columns identify a row (the dimensions), used to + * keep row groups on the same rows when the data changes. All columns + * when not set. + */ + keyColumns?: number; + /** group the rows by their first `keyColumns` columns, see groupRows */ + rowGrouping?: boolean; + setControlValue?: HandlerFunction; +} diff --git a/superset-frontend/plugins/plugin-chart-univer/src/univer/mountUniver.ts b/superset-frontend/plugins/plugin-chart-univer/src/univer/mountUniver.ts new file mode 100644 index 000000000000..4bb6afc86285 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/univer/mountUniver.ts @@ -0,0 +1,198 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Everything that touches Univer lives here. The chart only sees the small + * handle returned by mountUniver. + */ +import { + CommandType, + createUniver, + ICommandService, + ICommandInfo, + IWorkbookData, + LocaleType, + mergeLocales, +} from '@univerjs/presets'; +import { + ContextMenuGroup, + ContextMenuPosition, + FWorkbook, + IMenuManagerService, + MenuItemType, + UniverSheetsCorePreset, +} from '@univerjs/preset-sheets-core'; +import { Observable } from 'rxjs'; +import UniverPresetSheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US'; +import '@univerjs/preset-sheets-core/lib/index.css'; +import type { MountUniver, WorkbookData } from '../runtimeTypes'; +import { mountOutline } from './outline'; + +export const mountUniver: MountUniver = ({ + container, + workbook: workbookData, + editable, + onChange, + getFillWidth, + outline, + onOutlineChange, + headerLabels = true, + outlineSummaryAbove, +}) => { + // Univer's own element, so that it leaves alone the group buttons placed + // next to it in `container` (see outline.ts) + const host = document.createElement('div'); + // its own stacking context, below the buttons + host.style.cssText = 'width:100%;height:100%;position:relative;z-index:0;'; + container.appendChild(host); + const { univer, univerAPI } = createUniver({ + locale: LocaleType.EN_US, + locales: { + [LocaleType.EN_US]: mergeLocales(UniverPresetSheetsCoreEnUS), + }, + presets: [ + UniverSheetsCorePreset({ + container: host, + header: editable, + toolbar: editable, + formulaBar: editable, + footer: editable ? undefined : false, + contextMenu: editable, + // the editor needs focus handling for typing into cells, but a + // read-only dashboard chart shouldn't take focus from the page + disableAutoFocus: editable ? undefined : true, + }), + ], + }); + // the chart's WorkbookData only describes the fields it uses + const workbook: FWorkbook = univerAPI.createWorkbook( + workbookData as Partial, + ); + if (!editable) workbook.setEditable(false); + + // "Fill the window" in the column header menu, after "Fit for data": widens + // the selected columns to take up the empty space right of the last column + const freeWidth = () => { + const sheet = workbook.getActiveSheet(); + const columns = sheet.getSheet(); + let used = 0; + for (let c = 0; c < sheet.getMaxColumns(); c += 1) { + if (columns.getColVisible(c)) used += sheet.getColumnWidth(c); + } + return Math.floor((getFillWidth?.() ?? 0) - used); + }; + if (editable && getFillWidth) { + // the facade's createMenu can't hide an item, so use the services directly + // eslint-disable-next-line no-underscore-dangle + const injector = univer.__getInjector(); + const commandId = 'superset.command.fill-window-col-width'; + injector.get(ICommandService).registerCommand({ + id: commandId, + type: CommandType.COMMAND, + handler: () => { + const sheet = workbook.getActiveSheet(); + const range = workbook.getActiveRange()?.getRange(); + const free = freeWidth(); + if (!range || free < 1) return false; + const count = range.endColumn - range.startColumn + 1; + const extra = Math.floor(free / count); + for (let c = range.startColumn; c <= range.endColumn; c += 1) { + sheet.setColumnWidths(c, 1, sheet.getColumnWidth(c) + extra); + } + return true; + }, + }); + injector.get(IMenuManagerService).mergeMenu({ + [ContextMenuPosition.COL_HEADER]: { + [ContextMenuGroup.LAYOUT]: { + [commandId]: { + // "Fit for data" is 4, "Freeze" 5 + order: 4.5, + menuItemFactory: () => ({ + id: commandId, + type: MenuItemType.BUTTON, + icon: 'AutoWidthDoubleIcon', + title: 'Fill the window', + // checked each time the menu opens + hidden$: new Observable(subscriber => { + subscriber.next(freeWidth() < 1); + }), + }), + }, + }, + }, + }); + } + + const listener = + editable && onChange + ? workbook.onCommandExecuted((command: ICommandInfo) => { + // mutations are what changes the document; formula mutations only + // carry recalculated values and would otherwise fire on every load + if ( + command.type === CommandType.MUTATION && + !command.id.startsWith('formula.') + ) { + onChange(); + } + }) + : null; + + const outlineView = mountOutline({ + univer, + univerAPI, + workbook, + container, + outline: outline ?? { rows: [], columns: [] }, + editable, + headerLabels, + summaryAbove: outlineSummaryAbove, + onChange: onOutlineChange, + }); + + return { + save: () => workbook.save() as unknown as WorkbookData, + setColumnWidths: widths => { + const sheet = workbook.getSheets()[0]; + const columns = Object.keys(widths).map(Number); + if (!sheet || !columns.length) return; + // a mutation rather than a command, so the change isn't on the undo + // stack and isn't blocked when the sheet is read-only + univerAPI.syncExecuteCommand('sheet.mutation.set-worksheet-col-width', { + unitId: workbook.getId(), + subUnitId: sheet.getSheetId(), + ranges: [ + { + startRow: 0, + endRow: 0, + startColumn: Math.min(...columns), + endColumn: Math.max(...columns), + }, + ], + colWidth: widths, + }); + }, + dispose: () => { + listener?.dispose(); + outlineView.dispose(); + univer.dispose(); + host.remove(); + }, + }; +}; diff --git a/superset-frontend/plugins/plugin-chart-univer/src/univer/outline.ts b/superset-frontend/plugins/plugin-chart-univer/src/univer/outline.ts new file mode 100644 index 000000000000..4c2d6459bf77 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/univer/outline.ts @@ -0,0 +1,532 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Collapsible row and column groups in the sheet (see src/utils/outline.ts + * for the groups themselves). The row and column headers get a margin for + * the groups, where a bracket marks each group and a +/- button collapses or + * expands it. The buttons are HTML on top of Univer's canvas, placed from the + * sheet's layout and scroll position. + */ +import { + CommandType, + createUniver, + ICommandInfo, + ICommandService, + IRange, + Univer, +} from '@univerjs/presets'; +import { + ContextMenuGroup, + ContextMenuPosition, + FWorkbook, + FWorksheet, + IMenuManagerService, + IRenderManagerService, + MenuItemType, +} from '@univerjs/preset-sheets-core'; +import { + addGroup, + collapseToLevel, + groupLevels, + hiddenIndexes, + insertIndexes, + MANUAL_GROUPS, + Outline, + OutlineDimension, + outlineDepth, + outlineMargin, + OUTLINE_LEVEL_SIZE, + removeGroups, + removeIndexes, +} from '../utils/outline'; + +// Univer draws the sheet in its own light colors whatever Superset's theme, +// so the buttons drawn on top of it match the sheet rather than the theme +const BUTTON_COLORS = { + // eslint-disable-next-line theme-colors/no-literal-colors + line: '#666', + // eslint-disable-next-line theme-colors/no-literal-colors + background: '#fff', +}; + +// size of a +/- button +const BUTTON_SIZE = 11; +const LEVEL_SIZE = OUTLINE_LEVEL_SIZE; +// Univer's defaults for the size of the 1, 2, 3… and A, B, C… headers +const ROW_HEADER_WIDTH = 46; +const COLUMN_HEADER_HEIGHT = 20; + +type FUniver = ReturnType['univerAPI']; + +export interface OutlineOptions { + univer: Univer; + univerAPI: FUniver; + workbook: FWorkbook; + container: HTMLElement; + outline: Outline; + editable: boolean; + /** false when the sheet's 1, 2, 3… and A, B, C… headers are hidden */ + headerLabels: boolean; + /** the total rows of row groups are above them, see groupRows */ + summaryAbove?: boolean; + onChange?: (outline: Outline) => void; +} + +interface Layout { + start: (i: number) => number; + size: (i: number) => number; + frozen: number; + scrollStart: number; + header: number; +} + +export function mountOutline({ + univer, + univerAPI, + workbook, + container, + outline: initialOutline, + editable, + headerLabels, + summaryAbove = false, + onChange, +}: OutlineOptions): { dispose: () => void } { + let outline = initialOutline; + // eslint-disable-next-line no-underscore-dangle + const injector = univer.__getInjector(); + const sheet = (): FWorksheet => workbook.getSheets()[0]; + const disposers: (() => void)[] = []; + + const layer = document.createElement('div'); + layer.style.cssText = + 'position:absolute;inset:0;pointer-events:none;overflow:hidden;z-index:1;'; + container.appendChild(layer); + disposers.push(() => layer.remove()); + + const setHidden = ( + dimension: OutlineDimension, + start: number, + end: number, + hidden: boolean, + ) => { + const isRows = dimension === 'rows'; + const ranges: IRange[] = [ + isRows + ? { startRow: start, endRow: end, startColumn: 0, endColumn: 0 } + : { startRow: 0, endRow: 0, startColumn: start, endColumn: end }, + ]; + // mutations, so this isn't undone with the user's edits and also works + // on a read-only sheet + univerAPI.syncExecuteCommand( + `sheet.mutation.set-${isRows ? 'row' : 'col'}-${hidden ? 'hidden' : 'visible'}`, + { + unitId: workbook.getId(), + subUnitId: sheet().getSheetId(), + ranges, + }, + ); + }; + + // shows and hides the rows (or columns) of the groups as they are now + const applyVisibility = (dimension: OutlineDimension) => { + const groups = outline[dimension]; + if (!groups.length) return; + const hidden = hiddenIndexes(groups); + const first = Math.min(...groups.map(group => group.start)); + const last = Math.max(...groups.map(group => group.end)); + let runStart = first; + for (let i = first + 1; i <= last + 1; i += 1) { + if (i > last || hidden.has(i) !== hidden.has(runStart)) { + setHidden(dimension, runStart, i - 1, hidden.has(runStart)); + runStart = i; + } + } + }; + + const update = (next: Outline) => { + outline = next; + // eslint-disable-next-line @typescript-eslint/no-use-before-define + render(); + onChange?.(outline); + }; + + const toggle = (dimension: OutlineDimension, index: number) => { + const collapsed = !outline[dimension][index].collapsed; + const groups = outline[dimension].map((other, i) => + i === index ? { ...other, collapsed } : other, + ); + outline = { ...outline, [dimension]: groups }; + applyVisibility(dimension); + update(outline); + }; + + const layoutOf = (dimension: OutlineDimension): Layout | null => { + const skeleton = sheet().getSkeleton(); + if (!skeleton) return null; + const isRows = dimension === 'rows'; + const accumulation = isRows + ? skeleton.rowHeightAccumulation + : skeleton.columnWidthAccumulation; + const start = (i: number) => + i <= 0 ? 0 : (accumulation[Math.min(i, accumulation.length) - 1] ?? 0); + const freeze = sheet().getFreeze(); + const split = isRows ? freeze.ySplit : freeze.xSplit; + const frozen = + split > 0 ? (isRows ? freeze.startRow : freeze.startColumn) : 0; + // scrolled distance of the main (unfrozen) part of the sheet; the + // facade's getScrollState isn't usable from this bundle + const viewport = injector + .get(IRenderManagerService) + .getRenderUnitById(workbook.getId()) + ?.scene.getViewport('viewMain'); + const scrolled = + (isRows ? viewport?.viewportScrollY : viewport?.viewportScrollX) ?? 0; + return { + start, + size: i => start(i + 1) - start(i), + frozen, + scrollStart: scrolled, + header: isRows ? skeleton.columnHeaderHeight : skeleton.rowHeaderWidth, + }; + }; + + // position of row (or column) i on screen, relative to the canvas + const position = (layout: Layout, i: number) => + layout.header + + layout.start(i) - + (i < layout.frozen ? 0 : layout.scrollStart); + + const visibleFrom = (layout: Layout, i: number) => + i < layout.frozen + ? layout.header + : layout.header + layout.start(layout.frozen); + + const canvas = () => + [...container.querySelectorAll('canvas')].sort( + (a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight, + )[0]; + + const renderDimension = ( + dimension: OutlineDimension, + origin: { x: number; y: number }, + extent: number, + zoom: number, + color: string, + ) => { + const groups = outline[dimension]; + const layout = layoutOf(dimension); + if (!layout || !groups.length) return; + const isRows = dimension === 'rows'; + const levels = groupLevels(groups); + const count = isRows ? sheet().getMaxRows() : sheet().getMaxColumns(); + groups.forEach((group, index) => { + // the button goes on the row after the group, like Excel's, or before + // it when the group ends the sheet + const above = isRows && summaryAbove; + const after = group.end + 1 < count ? group.end + 1 : group.start - 1; + const before = group.start > 0 ? group.start - 1 : group.end + 1; + const buttonAt = above ? before : after; + const across = + levels[index] * LEVEL_SIZE + (LEVEL_SIZE - BUTTON_SIZE) / 2; + const clip = (from: number, to: number, i: number) => { + const min = visibleFrom(layout, i); + return [Math.max(from, min), Math.min(to, extent / zoom)]; + }; + + if (!group.collapsed) { + const [from, to] = clip( + position(layout, group.start), + position(layout, group.end) + layout.size(group.end), + group.start, + ); + if (to > from) { + const line = document.createElement('div'); + const along = isRows ? 'top' : 'left'; + const lengthProp = isRows ? 'height' : 'width'; + line.style.cssText = `position:absolute;${along}:${ + (isRows ? origin.y : origin.x) + from * zoom + }px;${isRows ? 'left' : 'top'}:${ + (isRows ? origin.x : origin.y) + across + BUTTON_SIZE / 2 + }px;${lengthProp}:${(to - from) * zoom}px;${ + isRows ? 'width' : 'height' + }:4px;border-${isRows ? 'left' : 'top'}:1px solid ${color};border-${ + // the tick points at the button + isRows ? (above ? 'top' : 'bottom') : 'right' + }:1px solid ${color};`; + layer.appendChild(line); + } + } + + if (buttonAt < 0) return; + const center = position(layout, buttonAt) + layout.size(buttonAt) / 2; + const [from, to] = clip(center, center, buttonAt); + if (from !== to || to < 0) return; + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = group.collapsed ? '+' : '−'; + button.title = group.collapsed ? 'Expand group' : 'Collapse group'; + button.setAttribute('aria-label', button.title); + button.style.cssText = `position:absolute;pointer-events:auto;${ + isRows ? 'top' : 'left' + }:${(isRows ? origin.y : origin.x) + center * zoom - BUTTON_SIZE / 2}px;${ + isRows ? 'left' : 'top' + }:${ + (isRows ? origin.x : origin.y) + across + }px;width:${BUTTON_SIZE}px;height:${BUTTON_SIZE}px;padding:0;margin:0;border:1px solid ${color};border-radius:2px;background:${BUTTON_COLORS.background};color:${color};font:10px/9px sans-serif;cursor:pointer;`; + button.addEventListener('pointerdown', event => event.stopPropagation()); + button.addEventListener('click', event => { + event.stopPropagation(); + toggle(dimension, index); + }); + layer.appendChild(button); + }); + }; + + // Excel's outline level buttons: 1 shows only what is outside every row + // group, the last one shows everything + const renderLevels = (origin: { x: number; y: number }, color: string) => { + const depth = outlineDepth(outline.rows); + for (let level = 1; depth && level <= depth + 1; level += 1) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = String(level); + button.title = `Show row group level ${level}`; + button.setAttribute('aria-label', button.title); + button.style.cssText = `position:absolute;pointer-events:auto;top:${ + origin.y + 2 + }px;left:${ + origin.x + (level - 1) * LEVEL_SIZE + (LEVEL_SIZE - BUTTON_SIZE) / 2 + }px;width:${BUTTON_SIZE}px;height:${BUTTON_SIZE}px;padding:0;margin:0;border:1px solid ${color};border-radius:2px;background:${BUTTON_COLORS.background};color:${color};font:9px/9px sans-serif;cursor:pointer;`; + button.addEventListener('pointerdown', event => event.stopPropagation()); + button.addEventListener('click', event => { + event.stopPropagation(); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + showLevel(level); + }); + layer.appendChild(button); + } + }; + + const showLevel = (level: number) => { + outline = { ...outline, rows: collapseToLevel(outline.rows, level) }; + applyVisibility('rows'); + update(outline); + }; + + let headerSizes = { rows: -1, columns: -1 }; + const sizeHeaders = () => { + const rows = outlineDepth(outline.rows); + const columns = outlineDepth(outline.columns); + if (rows === headerSizes.rows && columns === headerSizes.columns) return; + headerSizes = { rows, columns }; + const margin = outlineMargin; + // the labels are hidden on dashboards that hide the headers, only the + // margin for the groups is shown there + const transparent = 'transparent'; + const style = headerLabels + ? {} + : { fontColor: transparent, borderColor: transparent }; + const width = (headerLabels ? ROW_HEADER_WIDTH : 0) + margin(rows); + const height = Math.max( + (headerLabels ? COLUMN_HEADER_HEIGHT : 0) + margin(columns), + // room for the level buttons + rows ? OUTLINE_LEVEL_SIZE + 2 : 0, + ); + const target = { + unitId: workbook.getId(), + subUnitId: sheet().getSheetId(), + }; + // the sheet's layout is recalculated from its config, which the commands + // below don't change + const config = sheet().getSheet().getConfig(); + config.rowHeader = { ...config.rowHeader, width, hidden: width ? 0 : 1 }; + config.columnHeader = { + ...config.columnHeader, + height, + hidden: height ? 0 : 1, + }; + // the commands move the sheet's viewports along with the headers + univerAPI.executeCommand('sheet.command.set-row-header-width', { + ...target, + size: width, + }); + univerAPI.executeCommand('sheet.command.set-col-header-height', { + ...target, + size: height, + }); + sheet().customizeRowHeader({ headerStyle: { ...style, size: width } }); + sheet().customizeColumnHeader({ + headerStyle: { + ...style, + size: height, + ...(headerLabels && columns ? { textBaseline: 'bottom' as const } : {}), + }, + }); + sheet().refreshCanvas(); + }; + + // a timer rather than an animation frame, which doesn't run while the tab is + // in the background and would leave the buttons out of date + let frame: ReturnType | undefined; + const render = () => { + clearTimeout(frame); + frame = setTimeout(() => { + layer.replaceChildren(); + sizeHeaders(); + const target = canvas(); + if (!target) return; + const box = container.getBoundingClientRect(); + const rect = target.getBoundingClientRect(); + const origin = { x: rect.left - box.left, y: rect.top - box.top }; + const zoom = sheet().getZoom() || 1; + const color = BUTTON_COLORS.line; + renderDimension('rows', origin, rect.height, zoom, color); + renderDimension('columns', origin, rect.width, zoom, color); + renderLevels(origin, color); + }, 16); + }; + disposers.push(() => clearTimeout(frame)); + + // keep the groups on the same rows when rows or columns are added or removed + const commandListener = workbook.onCommandExecuted( + (command: ICommandInfo) => { + const params = command.params as { range?: IRange } | undefined; + const range = params?.range; + if (command.type !== CommandType.MUTATION) return; + if (range && command.id === 'sheet.mutation.insert-row') { + const count = range.endRow - range.startRow + 1; + update({ + ...outline, + rows: insertIndexes(outline.rows, range.startRow, count), + }); + } else if (range && command.id === 'sheet.mutation.remove-rows') { + update({ + ...outline, + rows: removeIndexes(outline.rows, range.startRow, range.endRow), + }); + } else if (range && command.id === 'sheet.mutation.insert-col') { + const count = range.endColumn - range.startColumn + 1; + update({ + ...outline, + columns: insertIndexes(outline.columns, range.startColumn, count), + }); + } else if (range && command.id === 'sheet.mutation.remove-col') { + update({ + ...outline, + columns: removeIndexes( + outline.columns, + range.startColumn, + range.endColumn, + ), + }); + } else { + render(); + } + }, + ); + disposers.push(() => commandListener.dispose()); + const scrollListener = univerAPI.addEvent(univerAPI.Event.Scroll, render); + disposers.push(() => scrollListener.dispose()); + const resizeObserver = new ResizeObserver(render); + resizeObserver.observe(container); + disposers.push(() => resizeObserver.disconnect()); + + if (editable && MANUAL_GROUPS) { + const commands = injector.get(ICommandService); + const menus = injector.get(IMenuManagerService); + const selection = (dimension: OutlineDimension) => { + const range = workbook.getActiveRange()?.getRange(); + if (!range) return null; + return dimension === 'rows' + ? [range.startRow, range.endRow] + : [range.startColumn, range.endColumn]; + }; + const group = (dimension: OutlineDimension) => { + const selected = selection(dimension); + if (!selected) return false; + const groups = addGroup(outline[dimension], selected[0], selected[1]); + if (!groups) return false; + update({ ...outline, [dimension]: groups }); + return true; + }; + const ungroup = (dimension: OutlineDimension) => { + const selected = selection(dimension); + if (!selected) return false; + const { groups, removed } = removeGroups( + outline[dimension], + selected[0], + selected[1], + ); + if (!removed.length) return false; + removed + .filter(old => old.collapsed) + .forEach(old => setHidden(dimension, old.start, old.end, false)); + // rows of collapsed groups that remain stay hidden + hiddenIndexes(groups).forEach(i => setHidden(dimension, i, i, true)); + update({ ...outline, [dimension]: groups }); + return true; + }; + + const items: [OutlineDimension, string, string, () => boolean][] = [ + ['rows', 'group-rows', 'Group rows', () => group('rows')], + ['rows', 'ungroup-rows', 'Ungroup rows', () => ungroup('rows')], + ['columns', 'group-columns', 'Group columns', () => group('columns')], + [ + 'columns', + 'ungroup-columns', + 'Ungroup columns', + () => ungroup('columns'), + ], + ]; + items.forEach(([dimension, name, title, handler], i) => { + const id = `superset.command.outline-${name}`; + const registration = commands.registerCommand({ + id, + type: CommandType.COMMAND, + handler, + }); + disposers.push(() => registration.dispose()); + const position = + dimension === 'rows' + ? ContextMenuPosition.ROW_HEADER + : ContextMenuPosition.COL_HEADER; + menus.mergeMenu({ + [position]: { + [ContextMenuGroup.LAYOUT]: { + [id]: { + // after "Fill the window" (4.5) and before "Freeze" (5) + order: 4.6 + i / 100, + menuItemFactory: () => ({ + id, + type: MenuItemType.BUTTON, + title, + }), + }, + }, + }, + }); + }); + } + + render(); + return { + dispose: () => disposers.reverse().forEach(dispose => dispose()), + }; +} diff --git a/superset-frontend/plugins/plugin-chart-univer/src/utils/outline.ts b/superset-frontend/plugins/plugin-chart-univer/src/utils/outline.ts new file mode 100644 index 000000000000..0ed8efb187c6 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/utils/outline.ts @@ -0,0 +1,344 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Collapsible row and column groups, like Excel's Data > Group. Univer's own + * outline is a paid add-on, so groups are kept here and collapsing a group + * hides its rows or columns. This file has the pure logic and + * univer/outline.ts the buttons in the sheet. + */ + +/** Rows or columns `start` to `end` (inclusive), 0-based. */ +export interface OutlineGroup { + start: number; + end: number; + collapsed?: boolean; +} + +export interface Outline { + rows: OutlineGroup[]; + columns: OutlineGroup[]; +} + +export type OutlineDimension = keyof Outline; + +export const EMPTY_OUTLINE: Outline = { rows: [], columns: [] }; + +/** + * Groups the user makes with Group rows / Group columns. Turned off while + * they clash with the automatic grouping ("Group rows by dimensions"): no + * menu items, and saved ones are ignored. The code is kept to turn back on. + */ +export const MANUAL_GROUPS = false; + +// width of the margin in the headers for one level of groups +export const OUTLINE_LEVEL_SIZE = 14; + +/** Width of the margin in the headers for `depth` levels of groups. */ +export const outlineMargin = (depth: number) => + depth ? depth * OUTLINE_LEVEL_SIZE + 2 : 0; + +const contains = (outer: OutlineGroup, inner: OutlineGroup) => + outer.start <= inner.start && inner.end <= outer.end; + +const overlaps = (a: OutlineGroup, b: OutlineGroup) => + a.start <= b.end && b.start <= a.end; + +/** Groups from outermost to innermost, then top to bottom. */ +export function sortGroups(groups: OutlineGroup[]): OutlineGroup[] { + return [...groups].sort( + (a, b) => a.start - b.start || b.end - b.start - (a.end - a.start), + ); +} + +/** Nesting level of each group of `groups` (0 for a group in no other). */ +export function groupLevels(groups: OutlineGroup[]): number[] { + return groups.map( + group => + groups.filter( + other => + other !== group && + contains(other, group) && + !(other.start === group.start && other.end === group.end), + ).length, + ); +} + +/** How many levels of groups there are. */ +export function outlineDepth(groups: OutlineGroup[]): number { + return groups.length ? Math.max(...groupLevels(groups)) + 1 : 0; +} + +/** + * Adds a group, or returns null when it would cross another group (groups + * nest like Excel's: one inside the other, or apart) or already exists. + */ +export function addGroup( + groups: OutlineGroup[], + start: number, + end: number, +): OutlineGroup[] | null { + const group = { start: Math.min(start, end), end: Math.max(start, end) }; + const crosses = groups.some( + other => + (other.start === group.start && other.end === group.end) || + (overlaps(other, group) && + !contains(other, group) && + !contains(group, other)), + ); + return crosses ? null : sortGroups([...groups, group]); +} + +/** + * Removes the groups inside rows (or columns) `start` to `end`, or when there + * are none, the innermost group around them. Returns the groups left and the + * ones removed. + */ +export function removeGroups( + groups: OutlineGroup[], + start: number, + end: number, +): { groups: OutlineGroup[]; removed: OutlineGroup[] } { + const selection = { start: Math.min(start, end), end: Math.max(start, end) }; + let removed = groups.filter(group => contains(selection, group)); + if (!removed.length) { + const around = groups.filter(group => contains(group, selection)); + const innermost = around.sort( + (a, b) => a.end - a.start - (b.end - b.start), + )[0]; + removed = innermost ? [innermost] : []; + } + return { + groups: groups.filter(group => !removed.includes(group)), + removed, + }; +} + +/** Rows (or columns) hidden by collapsed groups. */ +export function hiddenIndexes(groups: OutlineGroup[]): Set { + const hidden = new Set(); + groups + .filter(group => group.collapsed) + .forEach(group => { + for (let i = group.start; i <= group.end; i += 1) hidden.add(i); + }); + return hidden; +} + +/** Updates the groups after `count` rows (or columns) were inserted at `at`. */ +export function insertIndexes( + groups: OutlineGroup[], + at: number, + count: number, +): OutlineGroup[] { + return groups.map(group => ({ + ...group, + start: group.start >= at ? group.start + count : group.start, + end: group.end >= at ? group.end + count : group.end, + })); +} + +/** Updates the groups after rows (or columns) `start` to `end` were removed. */ +export function removeIndexes( + groups: OutlineGroup[], + start: number, + end: number, +): OutlineGroup[] { + const count = end - start + 1; + const shift = (i: number) => { + if (i < start) return i; + return i > end ? i - count : start; + }; + return groups + .filter(group => !(group.start >= start && group.end <= end)) + .map(group => { + const groupStart = shift(group.start); + // the end of a group that lost its last rows moves to the row before + const groupEnd = group.end > end ? group.end - count : start - 1; + return { ...group, start: groupStart, end: groupEnd }; + }) + .filter(group => group.end >= group.start); +} + +/** + * Where a group starts or ends in the saved chart: by the values of the row + * (or the header of the column) inside the query result, so a data refresh + * that adds or removes rows keeps groups on the same data; by position + * otherwise, counted from the end of the query result when after it. + */ +export type OutlineAnchor = + | { key: string } + | { index: number } + | { after: number }; + +export interface SavedOutlineGroup { + start: OutlineAnchor; + end: OutlineAnchor; + collapsed?: boolean; +} + +export interface SavedOutline { + rows: SavedOutlineGroup[]; + columns: SavedOutlineGroup[]; +} + +type GridValue = string | number | boolean | null; + +const keyText = (values: GridValue[]) => + values.map(value => (value === null ? '' : String(value))).join('\u0001'); + +/** + * Identifies the data rows and columns of the query result: a row by its + * first `keyColumns` values (the dimensions), a column by its headers. + */ +export function outlineKeys( + grid: GridValue[][], + headerRows: number, + keyColumns?: number, +): { rows: (string | null)[]; columns: (string | null)[] } { + const cols = grid[0]?.length ?? 0; + return { + rows: grid.map((row, r) => + r < headerRows ? null : keyText(row.slice(0, keyColumns || cols)), + ), + columns: Array.from({ length: cols }, (_, c) => + keyText(grid.slice(0, headerRows).map(row => row[c])), + ), + }; +} + +function anchorGroups( + groups: OutlineGroup[], + keys: (string | null)[], +): SavedOutlineGroup[] { + const anchor = (i: number): OutlineAnchor => { + if (i >= keys.length) return { after: i - keys.length }; + const key = keys[i]; + // a key that isn't unique can't tell rows apart + return key !== null && keys.indexOf(key) === keys.lastIndexOf(key) + ? { key } + : { index: i }; + }; + return groups.map(group => ({ + start: anchor(group.start), + end: anchor(group.end), + ...(group.collapsed ? { collapsed: true } : {}), + })); +} + +function resolveGroups( + groups: SavedOutlineGroup[], + keys: (string | null)[], +): OutlineGroup[] { + const resolve = (anchor: OutlineAnchor) => { + if ('key' in anchor) { + const i = keys.indexOf(anchor.key); + return i < 0 ? null : i; + } + if ('after' in anchor) return keys.length + anchor.after; + return anchor.index; + }; + return groups.reduce((result, saved) => { + const start = resolve(saved.start); + const end = resolve(saved.end); + if (start === null || end === null) return result; + const added = addGroup(result, start, end); + if (!added) return result; + return added.map(group => + group.start === Math.min(start, end) && + group.end === Math.max(start, end) && + saved.collapsed + ? { ...group, collapsed: true } + : group, + ); + }, []); +} + +/** The outline as saved with the chart. */ +export function anchorOutline( + outline: Outline, + grid: GridValue[][], + headerRows: number, + keyColumns?: number, +): SavedOutline { + const keys = outlineKeys(grid, headerRows, keyColumns); + return { + rows: anchorGroups(outline.rows, keys.rows), + columns: anchorGroups(outline.columns, keys.columns), + }; +} + +/** The saved outline on the current query result. */ +export function resolveOutline( + saved: SavedOutline | null | undefined, + grid: GridValue[][], + headerRows: number, + keyColumns?: number, +): Outline { + if (!saved) return EMPTY_OUTLINE; + const keys = outlineKeys(grid, headerRows, keyColumns); + return { + rows: resolveGroups(saved.rows ?? [], keys.rows), + columns: resolveGroups(saved.columns ?? [], keys.columns), + }; +} + +/** + * The automatic groups (see groupRows in workbook.ts) together with the + * saved ones: an automatic group the user collapsed stays collapsed, groups + * the user added are kept where they don't cross the automatic ones. + */ +export function combineGroups( + automatic: OutlineGroup[], + saved: OutlineGroup[], +): OutlineGroup[] { + return saved.reduce((groups, group) => { + const same = groups.findIndex( + other => other.start === group.start && other.end === group.end, + ); + if (same >= 0) { + return groups.map((other, i) => + i === same ? { ...other, collapsed: group.collapsed } : other, + ); + } + const added = addGroup(groups, group.start, group.end); + if (!added) return groups; + return added.map(other => + other.start === group.start && other.end === group.end + ? { ...other, collapsed: group.collapsed } + : other, + ); + }, sortGroups(automatic)); +} + +/** + * Collapses the groups nested deeper than `level` (1 shows only what is + * outside every group, like Excel's outline level buttons) and expands the + * others. + */ +export function collapseToLevel( + groups: OutlineGroup[], + level: number, +): OutlineGroup[] { + const levels = groupLevels(groups); + return groups.map((group, i) => ({ + ...group, + collapsed: levels[i] >= level - 1, + })); +} diff --git a/superset-frontend/plugins/plugin-chart-univer/src/utils/workbook.ts b/superset-frontend/plugins/plugin-chart-univer/src/utils/workbook.ts new file mode 100644 index 000000000000..c2f936a509fb --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/src/utils/workbook.ts @@ -0,0 +1,884 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { cloneDeep, omit } from 'lodash-es'; +import { DataRecord } from '@superset-ui/core'; +import { GenericDataType } from '@apache-superset/core/common'; +import { + anchorOutline, + hiddenIndexes, + Outline, + outlineDepth, + OutlineGroup, + outlineMargin, + OUTLINE_LEVEL_SIZE, + SavedOutline, +} from './outline'; +import { + CellData, + CellValue, + MergeRange, + SheetData, + WorkbookData, +} from '../runtimeTypes'; + +/** Query result as a grid: the header rows followed by the data rows. */ +export type Grid = (CellValue | null)[][]; + +/** How the query result is laid out in the grid. */ +export interface GridLayout { + /** header rows on top: 1, or one per pivot level */ + headerRows: number; + /** columns frozen on the left: the pivot's row fields */ + frozenCols: number; + /** merged header cells */ + merges: MergeRange[]; + /** total rows added by groupRows, shown in bold */ + subtotalRows?: number[]; +} + +export const DEFAULT_LAYOUT: GridLayout = { + headerRows: 1, + frozenCols: 0, + merges: [], +}; + +/** What the chart stores in `form_data.univer_workbook` (as a JSON string). */ +export interface UniverSavedState { + version: 1; + /** Size of the query result area (header row included) when it was saved. */ + dataRows: number; + dataCols: number; + /** true when query values were left out of `workbook` to save space */ + stripped: boolean; + /** + * The query's column labels when it was saved. A header cell in a stripped + * workbook that still has a value is one the user renamed, it is kept while + * the query's label for that column stays the same. + */ + headers?: (CellValue | null)[]; + /** like `headers`, for every header row */ + headerGrid?: (CellValue | null)[][]; + /** layout of the query result when it was saved */ + layout?: GridLayout; + /** collapsible row and column groups */ + outline?: SavedOutline; + workbook: WorkbookData; +} + +export const HEADER_STYLE_ID = 'superset_header'; +/** style of grouped rows' totals and merged values, see groupRows */ +export const SUBTOTAL_STYLE_ID = 'superset_subtotal'; +const SUBTOTAL_STYLE = { bl: 1, vt: 1 }; +// label of a pivot's Total row, see PIVOT_TOTAL in plugin/buildQuery.ts +const PIVOT_TOTAL_LABEL = 'Total'; +const WORKBOOK_ID = 'superset-univer'; +const SHEET_ID = 'data'; +// cell fields that hold the value, everything else (like style) is kept +const DATA_KEYS = ['v', 't', 'f', 'si', 'p']; + +// pixel widths used to fit columns to their content in a fresh workbook +export const MIN_COL_WIDTH = 80; +const MAX_COL_WIDTH = 320; +const CHAR_WIDTH = 8; +const COL_PADDING = 16; +const WIDTH_SAMPLE_ROWS = 100; +// Univer's defaults for the width of the 1, 2, 3… row header and of a column +const ROW_HEADER_WIDTH = 46; +const DEFAULT_COL_WIDTH = 88; +// room for Univer's vertical scrollbar, which is drawn over the cells +const SCROLLBAR_WIDTH = 12; + +export function parseSavedState(raw?: string | null): UniverSavedState | null { + if (!raw) return null; + try { + const state = JSON.parse(raw); + if (state?.version === 1 && state.workbook?.sheetOrder?.length) { + return state; + } + } catch { + // fall through: a broken value is treated as "nothing saved" + } + return null; +} + +function formatTemporal(value: number | string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + const iso = date.toISOString(); + // drop the time part for whole days, Superset sends timestamps in UTC + return iso.endsWith('T00:00:00.000Z') + ? iso.slice(0, 10) + : iso.slice(0, 19).replace('T', ' '); +} + +export function toCellValue( + value: unknown, + coltype?: GenericDataType, +): CellValue | null { + if (value === null || value === undefined) return null; + if ( + coltype === GenericDataType.Temporal && + (typeof value === 'number' || typeof value === 'string') + ) { + return formatTemporal(value); + } + if ( + typeof value === 'number' && + Number.isFinite(value) && + !Number.isInteger(value) + ) { + // drop floating point noise such as 524.999999999985, which the + // spreadsheet would otherwise show with all its digits + return Number(value.toPrecision(12)); + } + if ( + typeof value === 'number' || + typeof value === 'string' || + typeof value === 'boolean' + ) { + return value; + } + return JSON.stringify(value); +} + +export function buildGrid( + data: DataRecord[], + colnames: string[], + coltypes: GenericDataType[], + labels: Record = {}, +): Grid { + return [ + colnames.map(col => labels[col] ?? col), + ...data.map(row => + colnames.map((col, i) => toCellValue(row[col], coltypes[i])), + ), + ]; +} + +function gridSize(grid: Grid) { + return { rows: grid.length, cols: grid[0]?.length ?? 0 }; +} + +// splits a column name of a flattened pivot ("SUM(sales), Action") into its +// levels; commas inside a level are escaped as "\," +function splitFlatName(name: string): string[] { + return name.split(/(? part.replace(/\\,/g, ',')); +} + +/** + * Lays out a pivoted query result: the first `rowFields` columns are the + * pivot's row fields, the other columns are named "metric, value, …" by + * Superset's `flatten`. Each level gets a header row, with equal neighbouring + * headers merged. The row fields' names go in the top header row, merged + * down over the others. Row fields and metrics are shown with their `labels` + * (verbose names). + */ +export function buildPivotGrid( + data: DataRecord[], + colnames: string[], + coltypes: GenericDataType[], + labels: Record, + rowFields: number, +): { grid: Grid; layout: GridLayout } { + const levels = colnames.slice(rowFields).map(name => splitFlatName(name)); + const headerRows = Math.max(1, ...levels.map(parts => parts.length)); + const header: Grid = Array.from({ length: headerRows }, (_, r) => + colnames.map((col, c) => { + if (c < rowFields) { + return r === 0 ? (labels[col] ?? col) : null; + } + const value = levels[c - rowFields][r]; + if (value === undefined || value === '') return null; + // the top level is the metric + return r === 0 ? (labels[value] ?? value) : value; + }), + ); + + const merges: MergeRange[] = []; + if (headerRows > 1) { + for (let c = 0; c < rowFields; c += 1) { + merges.push({ + startRow: 0, + endRow: headerRows - 1, + startColumn: c, + endColumn: c, + }); + } + } + // a header spans the columns below it that share all levels above + for (let r = 0; r < headerRows - 1; r += 1) { + let start = rowFields; + for (let c = rowFields + 1; c <= colnames.length; c += 1) { + const same = + c < colnames.length && + levels[c - rowFields].slice(0, r + 1).join('\u0000') === + levels[start - rowFields].slice(0, r + 1).join('\u0000'); + if (!same) { + if (c - 1 > start && header[r][start] !== null) { + merges.push({ + startRow: r, + endRow: r, + startColumn: start, + endColumn: c - 1, + }); + } + start = c; + } + } + } + + return { + grid: [ + ...header, + ...data.map(row => + colnames.map((col, i) => toCellValue(row[col], coltypes[i])), + ), + ], + layout: { headerRows, frozenCols: rowFields, merges }, + }; +} + +function firstSheet(workbook: WorkbookData): SheetData | undefined { + const sheetId = workbook.sheetOrder?.[0]; + return sheetId ? workbook.sheets?.[sheetId] : undefined; +} + +function sameRange(a: MergeRange, b: MergeRange) { + return ( + a.startRow === b.startRow && + a.endRow === b.endRow && + a.startColumn === b.startColumn && + a.endColumn === b.endColumn + ); +} + +function layoutFreeze(layout: GridLayout): SheetData['freeze'] { + return { + xSplit: layout.frozenCols, + ySplit: layout.headerRows, + startRow: layout.headerRows, + startColumn: layout.frozenCols, + }; +} + +/** + * Replaces the header merges and frozen panes of the previous layout with + * those of the new one. Merges and freezing the user changed are kept. + * Modifies `workbook`. + */ +function applyLayout( + workbook: WorkbookData, + previous: GridLayout, + layout: GridLayout, +) { + const sheet = firstSheet(workbook); + if (!sheet) return; + const crosses = (a: MergeRange, b: MergeRange) => + a.startRow <= b.endRow && + b.startRow <= a.endRow && + a.startColumn <= b.endColumn && + b.startColumn <= a.endColumn; + sheet.mergeData = [ + // merged cells overlapping the new layout's would hide its data + ...(sheet.mergeData ?? []).filter( + range => + !previous.merges.some(old => sameRange(old, range)) && + !layout.merges.some(merge => crosses(merge, range)), + ), + ...layout.merges, + ]; + const { freeze } = sheet; + if ( + !freeze || + (freeze.ySplit === previous.headerRows && + freeze.xSplit === previous.frozenCols) + ) { + sheet.freeze = layoutFreeze(layout); + } +} + +/** + * Writes the query result into the top-left corner of the first sheet. + * Styles of existing cells are kept, so formatting survives a data refresh. + * Cells that belonged to the previous (bigger) result are cleared. + * Returns a new object, the input is not modified. + */ +export function applyGrid( + workbook: WorkbookData, + grid: Grid, + previous: { rows: number; cols: number } | null, + headerRows = 1, +): WorkbookData { + const result = cloneDeep(workbook); + const sheetId = result.sheetOrder?.[0]; + const sheet = sheetId ? result.sheets?.[sheetId] : undefined; + if (!sheet) return result; + + const { rows, cols } = gridSize(grid); + const cellData = (sheet.cellData ??= {}); + const maxRows = Math.max(rows, previous?.rows ?? 0); + const maxCols = Math.max(cols, previous?.cols ?? 0); + const hasHeaderStyle = !!result.styles?.[HEADER_STYLE_ID]; + + for (let r = 0; r < maxRows; r += 1) { + const row = (cellData[r] ??= {}); + for (let c = 0; c < maxCols; c += 1) { + // a data cell can't keep a formula or rich text from an older version + const cell: CellData = omit(row[c] ?? {}, DATA_KEYS); + const value = r < rows && c < cols ? grid[r][c] : null; + if (value !== null) cell.v = value; + if (r < headerRows && value !== null && !cell.s && hasHeaderStyle) { + cell.s = HEADER_STYLE_ID; + } + if (Object.keys(cell).length) row[c] = cell; + else delete row[c]; + } + if (!Object.keys(row).length) delete cellData[r]; + } + + return result; +} + +/** + * Sizes the first sheet to its content (query result, formulas, styled + * cells) instead of an empty grid around it. Users add rows and columns + * themselves, empty ones they added are dropped the next time the chart loads. + */ +export function fitSheetSize(workbook: WorkbookData, grid: Grid): WorkbookData { + const sheetId = workbook.sheetOrder?.[0]; + const sheet = sheetId ? workbook.sheets?.[sheetId] : undefined; + if (!sheetId || !sheet) return workbook; + + let { rows, cols } = gridSize(grid); + Object.entries(sheet.cellData ?? {}).forEach(([r, row]) => { + Object.entries(row).forEach(([c, cell]) => { + if (!cell || !Object.keys(cell).length) return; + rows = Math.max(rows, Number(r) + 1); + cols = Math.max(cols, Number(c) + 1); + }); + }); + return { + ...workbook, + sheets: { + ...workbook.sheets, + [sheetId]: { + ...sheet, + rowCount: Math.max(rows, 1), + columnCount: Math.max(cols, 1), + }, + }, + }; +} + +/** Width of a column on the first sheet, when it has one set. */ +export function columnWidth( + workbook: WorkbookData, + column: number, +): number | undefined { + const sheetId = workbook.sheetOrder?.[0]; + return sheetId + ? workbook.sheets?.[sheetId]?.columnData?.[column]?.w + : undefined; +} + +/** + * Width the columns of the first sheet have to take up together to fill a + * chart `width` pixels wide. + */ +export function fillWidth(workbook: WorkbookData, width: number): number { + const sheet = workbook.sheets?.[workbook.sheetOrder?.[0] ?? '']; + const headerWidth = sheet?.rowHeader?.hidden + ? 0 + : (sheet?.rowHeader?.width ?? ROW_HEADER_WIDTH); + return width - headerWidth - SCROLLBAR_WIDTH; +} + +/** Column index to the width it had before stretching and the width given. */ +export type StretchedColumns = Record; + +/** + * Widens the columns of the first sheet so together they fill `width` + * pixels, keeping their proportions. Columns that already overflow are left + * as they are, the sheet scrolls then. + */ +export function stretchColumns( + workbook: WorkbookData, + width: number, +): { workbook: WorkbookData; stretched: StretchedColumns } { + const sheetId = workbook.sheetOrder?.[0]; + const sheet = sheetId ? workbook.sheets?.[sheetId] : undefined; + const stretched: StretchedColumns = {}; + if (!sheetId || !sheet) return { workbook, stretched }; + + const defaultWidth = sheet.defaultColumnWidth ?? DEFAULT_COL_WIDTH; + const columnData = { ...sheet.columnData }; + const visible: number[] = []; + let total = 0; + for (let c = 0; c < (sheet.columnCount ?? 0); c += 1) { + if (!columnData[c]?.hd) { + visible.push(c); + total += columnData[c]?.w ?? defaultWidth; + } + } + const available = Math.floor(fillWidth(workbook, width)); + const scale = available / total; + if (!visible.length || !(scale > 1)) return { workbook, stretched }; + + let left = available; + visible.forEach((c, i) => { + const base = columnData[c]?.w ?? defaultWidth; + // whole pixels, the last column takes what rounding left over so the + // columns add up to exactly the view + const w = i === visible.length - 1 ? left : Math.floor(base * scale); + left -= w; + stretched[c] = { base, w }; + columnData[c] = { ...columnData[c], w }; + }); + return { + workbook: { + ...workbook, + sheets: { ...workbook.sheets, [sheetId]: { ...sheet, columnData } }, + }, + stretched, + }; +} + +/** + * Undoes `stretchColumns` before saving, so the saved widths don't depend on + * the size of the view. A column resized by the user keeps its new width. + */ +export function unstretchColumns( + workbook: WorkbookData, + stretched: StretchedColumns, +): WorkbookData { + const sheetId = workbook.sheetOrder?.[0]; + const sheet = sheetId ? workbook.sheets?.[sheetId] : undefined; + if (!sheetId || !sheet?.columnData) return workbook; + + const columnData = { ...sheet.columnData }; + Object.entries(stretched).forEach(([c, { base, w }]) => { + const column = columnData[Number(c)]; + if (column?.w === w) columnData[Number(c)] = { ...column, w: base }; + }); + return { + ...workbook, + sheets: { ...workbook.sheets, [sheetId]: { ...sheet, columnData } }, + }; +} + +function fitColumnWidths(grid: Grid): Record { + const widths: Record = {}; + const sample = grid.slice(0, WIDTH_SAMPLE_ROWS); + for (let c = 0; c < gridSize(grid).cols; c += 1) { + const chars = Math.max(...sample.map(row => String(row[c] ?? '').length)); + widths[c] = { + w: Math.min( + MAX_COL_WIDTH, + Math.max(MIN_COL_WIDTH, chars * CHAR_WIDTH + COL_PADDING), + ), + }; + } + return widths; +} + +/** + * Shows the totals and merged values of grouped rows in bold, with merged + * values at the top of their cell. Cells styled by the user keep their + * style; cells that were totals before a refresh lose the bold. Modifies + * the cells of `workbook`, returns it with the style added. + */ +function applyGroupStyles( + workbook: WorkbookData, + layout: GridLayout, +): WorkbookData { + const sheet = firstSheet(workbook); + if (!sheet) return workbook; + const cellData = (sheet.cellData ??= {}); + const styled = new Set(); + (layout.subtotalRows ?? []).forEach(r => { + Object.keys(cellData[r] ?? {}).forEach(c => styled.add(`${r}:${c}`)); + }); + layout.merges + .filter(merge => merge.startRow >= layout.headerRows) + .forEach(merge => styled.add(`${merge.startRow}:${merge.startColumn}`)); + + Object.keys(cellData).forEach(r => { + const row = cellData[Number(r)]; + Object.keys(row).forEach(c => { + if (row[Number(c)].s === SUBTOTAL_STYLE_ID && !styled.has(`${r}:${c}`)) { + row[Number(c)] = omit(row[Number(c)], 's'); + } + }); + }); + styled.forEach(key => { + const [r, c] = key.split(':').map(Number); + const row = (cellData[r] ??= {}); + if (!row[c]?.s) row[c] = { ...row[c], s: SUBTOTAL_STYLE_ID }; + }); + return styled.size + ? { + ...workbook, + styles: { ...workbook.styles, [SUBTOTAL_STYLE_ID]: SUBTOTAL_STYLE }, + } + : workbook; +} + +export function createWorkbookData( + grid: Grid, + layout: GridLayout = DEFAULT_LAYOUT, +): WorkbookData { + const { rows, cols } = gridSize(grid); + const workbook = applyGrid( + { + id: WORKBOOK_ID, + name: 'Superset', + sheetOrder: [SHEET_ID], + styles: { + [HEADER_STYLE_ID]: { + bl: 1, + bg: { + // spreadsheet content, not app UI, so it doesn't follow the theme + // eslint-disable-next-line theme-colors/no-literal-colors + rgb: '#F2F2F2', + }, + }, + }, + sheets: { + [SHEET_ID]: { + id: SHEET_ID, + name: 'Data', + rowCount: rows, + columnCount: cols, + defaultColumnWidth: MIN_COL_WIDTH, + // keep the headers (and pivot row fields) visible while scrolling + freeze: layoutFreeze(layout), + columnData: fitColumnWidths(grid), + mergeData: layout.merges, + cellData: {}, + }, + }, + }, + grid, + null, + layout.headerRows, + ); + return applyGroupStyles(workbook, layout); +} + +/** + * Removes query values from the data area before saving. They are filled in + * again from fresh query results when the chart loads, which keeps the saved + * chart small. Formatting of those cells is kept, and so are headers the user + * renamed. + */ +export function stripGrid( + workbook: WorkbookData, + grid: Grid, + headerRows = 1, +): WorkbookData { + const { rows, cols } = gridSize(grid); + const result = applyGrid(workbook, [], { rows, cols }); + const cells = firstSheet(workbook)?.cellData; + const cellData = firstSheet(result)?.cellData; + if (!cells || !cellData) return result; + + for (let r = 0; r < Math.min(headerRows, rows); r += 1) { + for (let c = 0; c < cols; c += 1) { + const value = cells[r]?.[c]?.v; + if (value !== undefined && value !== null && value !== grid[r][c]) { + cellData[r] = { + ...cellData[r], + [c]: { ...cellData[r]?.[c], v: value }, + }; + } + } + } + return result; +} + +/** Hides the A, B, C… and 1, 2, 3… headers on every sheet. */ +export function hideHeaders(workbook: WorkbookData): WorkbookData { + const sheets = Object.fromEntries( + Object.entries(workbook.sheets ?? {}).map(([id, sheet]) => [ + id, + { + ...sheet, + rowHeader: { width: 0, hidden: 1 }, + columnHeader: { height: 0, hidden: 1 }, + }, + ]), + ); + return { ...workbook, sheets }; +} + +export function buildWorkbook( + grid: Grid, + saved: UniverSavedState | null, + liveData: boolean, + layout: GridLayout = DEFAULT_LAYOUT, +): WorkbookData { + if (!saved) return createWorkbookData(grid, layout); + // a stripped sheet has no values in the data area, they must come from the query + if (!liveData && !saved.stripped) { + return fitSheetSize(cloneDeep(saved.workbook), []); + } + // the query's labels go in as is, except where the user renamed a header + const renamed = firstSheet(saved.workbook)?.cellData; + const savedHeaders = saved.headerGrid ?? [saved.headers ?? []]; + const headers = grid.slice(0, layout.headerRows).map((row, r) => + row.map((label, c) => { + const value = renamed?.[r]?.[c]?.v; + return value !== undefined && + value !== null && + savedHeaders[r]?.[c] === label + ? value + : label; + }), + ); + const workbook = applyGrid( + saved.workbook, + [...headers, ...grid.slice(layout.headerRows)], + { rows: saved.dataRows, cols: saved.dataCols }, + layout.headerRows, + ); + applyLayout(workbook, saved.layout ?? DEFAULT_LAYOUT, layout); + return fitSheetSize(applyGroupStyles(workbook, layout), grid); +} + +export function serializeState( + workbook: WorkbookData, + grid: Grid, + liveData: boolean, + layout: GridLayout = DEFAULT_LAYOUT, + outline?: { groups: Outline; keyColumns?: number }, +): string { + const { rows, cols } = gridSize(grid); + const state: UniverSavedState = { + version: 1, + dataRows: rows, + dataCols: cols, + stripped: liveData, + headers: grid[0] ?? [], + headerGrid: grid.slice(0, layout.headerRows), + layout, + ...(outline && (outline.groups.rows.length || outline.groups.columns.length) + ? { + // without live data the sheet keeps its rows, groups keep theirs + outline: anchorOutline( + outline.groups, + liveData ? grid : [], + layout.headerRows, + outline.keyColumns, + ), + } + : {}), + workbook: liveData + ? stripGrid(workbook, grid, layout.headerRows) + : workbook, + }; + return JSON.stringify(state); +} + +/** + * Hides the rows and columns of collapsed groups. Rows and columns of the + * query result are shown otherwise: what was hidden there when the chart was + * saved may be other data after a refresh. Returns a new workbook. + */ +export function applyOutline( + workbook: WorkbookData, + outline: Outline, + grid: Grid, +): WorkbookData { + const sheetId = workbook.sheetOrder?.[0]; + const sheet = sheetId ? workbook.sheets?.[sheetId] : undefined; + if (!sheetId || !sheet) return workbook; + if (!outline.rows.length && !outline.columns.length) return workbook; + const { rows, cols } = gridSize(grid); + + const hide = ( + data: Record | undefined, + count: number, + hidden: Set, + ): Record => { + const result: Record = { ...data }; + for (let i = 0; i < count; i += 1) { + if (result[i]?.hd) result[i] = { ...result[i], hd: 0 }; + } + hidden.forEach(i => { + result[i] = { ...result[i], hd: 1 }; + }); + return result; + }; + return { + ...workbook, + sheets: { + ...workbook.sheets, + [sheetId]: { + ...sheet, + rowData: hide(sheet.rowData, rows, hiddenIndexes(outline.rows)), + columnData: hide( + sheet.columnData, + cols, + hiddenIndexes(outline.columns), + ), + }, + }, + }; +} + +// Univer's default height of the A, B, C… header +const COLUMN_HEADER_HEIGHT = 20; + +/** + * Sizes the 1, 2, 3… and A, B, C… headers of every sheet: hidden when + * `labels` is false, plus the margin for the first sheet's groups. + */ +export function sizeHeaders( + workbook: WorkbookData, + labels: boolean, + outline: Outline, +): WorkbookData { + const rowMargin = outlineMargin(outlineDepth(outline.rows)); + const columnMargin = outlineMargin(outlineDepth(outline.columns)); + const first = workbook.sheetOrder?.[0]; + const sheets = Object.fromEntries( + Object.entries(workbook.sheets ?? {}).map(([id, sheet]) => { + const width = + (labels ? ROW_HEADER_WIDTH : 0) + (id === first ? rowMargin : 0); + const height = Math.max( + (labels ? COLUMN_HEADER_HEIGHT : 0) + (id === first ? columnMargin : 0), + // room for the outline level buttons of the row groups + id === first && rowMargin ? OUTLINE_LEVEL_SIZE + 2 : 0, + ); + return [ + id, + { + ...sheet, + rowHeader: { width, hidden: width ? 0 : 1 }, + columnHeader: { height, hidden: height ? 0 : 1 }, + }, + ]; + }), + ); + return { ...workbook, sheets }; +} + +/** Label of the row adding up the whole result when rows are grouped */ +export const GRAND_TOTAL = 'Grand Total'; + +function subtotal(rows: Grid, column: number): CellValue | null { + let sum: number | null = null; + rows.forEach(row => { + const value = row[column]; + if (typeof value === 'number') sum = (sum ?? 0) + value; + }); + // drop floating point noise, as toCellValue does + return sum === null ? null : Number((sum as number).toPrecision(12)); +} + +/** Label of a group's total row, in the column after the group's value */ +export const SUBTOTALS = 'Subtotals'; + +/** + * Groups the data rows by their first `dimensions` columns, like Excel's + * Data > Subtotal with the totals above: rows with the same value in the + * first column follow each other (in the order the values first appear), + * a "Subtotals" row on top adds up the other columns, and the value is merged + * into one cell down the group; the same again inside for the next columns, + * except the last. Each group's rows can be collapsed to its total row. With + * `grandTotal`, a last row adds up everything. + * + * A pivot's own Total row (see buildQuery) stays last. + */ +export function groupRows( + grid: Grid, + layout: GridLayout, + dimensions: number, + grandTotal: boolean, +): { grid: Grid; layout: GridLayout; groups: OutlineGroup[] } { + const { headerRows } = layout; + const { cols } = gridSize(grid); + if (dimensions < 2 || dimensions > cols) return { grid, layout, groups: [] }; + + let data = grid.slice(headerRows); + const last = data[data.length - 1]; + const tail = + last && + last[0] === PIVOT_TOTAL_LABEL && + last.slice(1, dimensions).every(value => value === null || value === '') + ? [last] + : []; + if (tail.length) data = data.slice(0, -1); + + const out: Grid = []; + const merges: MergeRange[] = []; + const groups: OutlineGroup[] = []; + const subtotalRows: number[] = []; + const build = (rows: Grid, level: number) => { + if (level === dimensions - 1) { + out.push(...rows); + return; + } + const parts = new Map(); + rows.forEach(row => { + const key = String(row[level] ?? ''); + parts.set(key, [...(parts.get(key) ?? []), row]); + }); + parts.forEach(part => { + const top = headerRows + out.length; + subtotalRows.push(top); + out.push( + Array.from({ length: cols }, (_, c) => { + if (c === level) return part[0][level]; + if (c === level + 1) return SUBTOTALS; + return c < dimensions ? null : subtotal(part, c); + }), + ); + build(part, level + 1); + const end = headerRows + out.length - 1; + merges.push({ + startRow: top, + endRow: end, + startColumn: level, + endColumn: level, + }); + groups.push({ start: top + 1, end }); + }); + }; + build(data, 0); + + if (grandTotal && !tail.length) { + subtotalRows.push(headerRows + out.length); + out.push( + Array.from({ length: cols }, (_, c) => { + if (c === 0) return GRAND_TOTAL; + return c < dimensions ? null : subtotal(data, c); + }), + ); + } + return { + grid: [...grid.slice(0, headerRows), ...out, ...tail], + layout: { + ...layout, + merges: [...layout.merges, ...merges], + subtotalRows, + }, + groups, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-univer/test/UniverChart.test.tsx b/superset-frontend/plugins/plugin-chart-univer/test/UniverChart.test.tsx new file mode 100644 index 000000000000..33f2bd1c4753 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/test/UniverChart.test.tsx @@ -0,0 +1,136 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { act, render } from '@superset-ui/core/spec'; +import { GenericDataType } from '@apache-superset/core/common'; +import UniverChart from '../src/UniverChart'; +import { mountUniver } from '../src/univer/mountUniver'; +import { MountUniverOptions, WorkbookData } from '../src/runtimeTypes'; +import { UniverChartProps, WORKBOOK_CONTROL } from '../src/types'; + +jest.mock('../src/univer/mountUniver', () => ({ mountUniver: jest.fn() })); + +const mockMount = mountUniver as jest.MockedFunction; + +const props: UniverChartProps = { + width: 400, + height: 300, + data: [ + { name: 'a', total: 1 }, + { name: 'b', total: 2 }, + ], + colnames: ['name', 'total'], + coltypes: [GenericDataType.String, GenericDataType.Numeric], + labels: { total: 'Total' }, + editable: true, + savedState: null, + liveData: true, + showHeaders: false, +}; + +let saved: WorkbookData; + +beforeEach(() => { + jest.useFakeTimers(); + mockMount.mockReset(); + mockMount.mockImplementation(({ workbook }: MountUniverOptions) => { + saved = workbook; + return { + save: () => saved, + setColumnWidths: jest.fn(), + dispose: jest.fn(), + }; + }); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +const firstSheetId = (workbook: WorkbookData) => + Object.keys(workbook.sheets ?? {})[0]; +const firstSheet = (workbook: WorkbookData) => + workbook.sheets?.[firstSheetId(workbook)] ?? {}; + +test('mounts the query result as an editable sheet in Explore', () => { + render(); + expect(mockMount).toHaveBeenCalledTimes(1); + const options = mockMount.mock.calls[0][0]; + expect(options.editable).toBe(true); + expect(options.onChange).toBeDefined(); + const { cellData } = firstSheet(options.workbook); + expect(cellData?.[0]?.[0]?.v).toBe('name'); + expect(cellData?.[0]?.[1]?.v).toBe('Total'); + expect(cellData?.[2]?.[1]?.v).toBe(2); +}); + +test('mounts a read-only sheet outside Explore', () => { + render(); + const options = mockMount.mock.calls[0][0]; + expect(options.editable).toBe(false); + expect(options.onChange).toBeUndefined(); +}); + +test('saves edits to the workbook control', () => { + const setControlValue = jest.fn(); + render(); + const { onChange } = mockMount.mock.calls[0][0]; + const sheet = firstSheet(saved); + saved = { + ...saved, + sheets: { + [firstSheetId(saved)]: { + ...sheet, + cellData: { + ...sheet.cellData, + 0: { ...sheet.cellData?.[0], 1: { v: 'Renamed' } }, + }, + }, + }, + }; + act(() => { + onChange?.(); + jest.runAllTimers(); + }); + expect(setControlValue).toHaveBeenCalledTimes(1); + const [control, state] = setControlValue.mock.calls[0]; + expect(control).toBe(WORKBOOK_CONTROL); + expect(state).toContain('Renamed'); +}); + +test('does not remount Univer when its own save comes back as props', () => { + const setControlValue = jest.fn(); + const { rerender } = render( + , + ); + const { onChange } = mockMount.mock.calls[0][0]; + saved = { ...saved, name: 'edited' }; + act(() => { + onChange?.(); + jest.runAllTimers(); + }); + const [, state] = setControlValue.mock.calls[0]; + rerender( + , + ); + expect(mockMount).toHaveBeenCalledTimes(1); +}); diff --git a/superset-frontend/plugins/plugin-chart-univer/test/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-univer/test/buildQuery.test.ts new file mode 100644 index 000000000000..6e1fbbaa2021 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/test/buildQuery.test.ts @@ -0,0 +1,58 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import buildQuery from '../src/plugin/buildQuery'; +import { UniverQueryFormData } from '../src/types'; + +const formData = { + datasource: '1__table', + viz_type: 'univer_sheet', + query_mode: 'pivot', + univer_pivot_rows: ['genre'], + univer_pivot_columns: ['platform', 'genre'], + metrics: ['count'], +} as UniverQueryFormData; + +test('pivot mode groups by rows and columns and pivots on the backend', () => { + const [query] = buildQuery(formData).queries; + expect(query.columns).toEqual(['genre', 'platform']); + expect(query.post_processing).toEqual([ + { + operation: 'pivot', + options: { + index: ['genre'], + columns: ['platform'], + aggregates: { count: { operator: 'sum' } }, + drop_missing_columns: true, + marginal_distributions: true, + marginal_distribution_name: 'Total', + }, + }, + { operation: 'flatten' }, + ]); +}); + +test('pivot totals can be turned off', () => { + const [query] = buildQuery({ + ...formData, + univer_pivot_totals: false, + }).queries; + expect(query.post_processing?.[0]).toMatchObject({ + options: { marginal_distributions: false }, + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-univer/test/outline.test.ts b/superset-frontend/plugins/plugin-chart-univer/test/outline.test.ts new file mode 100644 index 000000000000..eda44fe7cc47 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/test/outline.test.ts @@ -0,0 +1,177 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + addGroup, + anchorOutline, + collapseToLevel, + combineGroups, + groupLevels, + hiddenIndexes, + insertIndexes, + outlineDepth, + removeGroups, + removeIndexes, + resolveOutline, +} from '../src/utils/outline'; +import { + applyOutline, + createWorkbookData, + DEFAULT_LAYOUT, + groupRows, +} from '../src/utils/workbook'; + +test('groups nest but do not cross', () => { + const groups = addGroup([], 2, 8)!; + const nested = addGroup(groups, 3, 4)!; + expect(nested).toEqual([ + { start: 2, end: 8 }, + { start: 3, end: 4 }, + ]); + expect(groupLevels(nested)).toEqual([0, 1]); + expect(outlineDepth(nested)).toBe(2); + expect(addGroup(nested, 4, 10)).toBeNull(); + expect(addGroup(nested, 3, 4)).toBeNull(); +}); + +test('ungroup removes the groups inside the selection or the innermost one', () => { + const groups = addGroup(addGroup([], 2, 8)!, 3, 4)!; + expect(removeGroups(groups, 3, 3).removed).toEqual([{ start: 3, end: 4 }]); + expect(removeGroups(groups, 1, 9).groups).toEqual([]); +}); + +test('collapsed groups hide their rows', () => { + expect([...hiddenIndexes([{ start: 2, end: 3, collapsed: true }])]).toEqual([ + 2, 3, + ]); +}); + +test('groups move with inserted and removed rows', () => { + const groups = [{ start: 2, end: 5 }]; + expect(insertIndexes(groups, 3, 2)).toEqual([{ start: 2, end: 7 }]); + expect(insertIndexes(groups, 0, 1)).toEqual([{ start: 3, end: 6 }]); + expect(removeIndexes(groups, 0, 0)).toEqual([{ start: 1, end: 4 }]); + expect(removeIndexes(groups, 4, 9)).toEqual([{ start: 2, end: 3 }]); + expect(removeIndexes(groups, 2, 5)).toEqual([]); +}); + +test('saved groups follow their rows when the data changes', () => { + const grid = [ + ['genre', 'sales'], + ['Action', 1], + ['Puzzle', 2], + ['Racing', 3], + ]; + const saved = anchorOutline( + { + rows: [{ start: 2, end: 3, collapsed: true }], + columns: [{ start: 1, end: 1 }], + }, + grid, + 1, + 1, + ); + expect(saved.rows[0]).toEqual({ + start: { key: 'Puzzle' }, + end: { key: 'Racing' }, + collapsed: true, + }); + + // a new first row moves Puzzle and Racing down + const refreshed = [ + ['genre', 'sales'], + ['Action', 1], + ['Adventure', 5], + ['Puzzle', 2], + ['Racing', 3], + ]; + const outline = resolveOutline(saved, refreshed, 1, 1); + expect(outline.rows).toEqual([{ start: 3, end: 4, collapsed: true }]); + expect(outline.columns).toEqual([{ start: 1, end: 1 }]); + // a group whose rows are gone is dropped + expect(resolveOutline(saved, refreshed.slice(0, 3), 1, 1).rows).toEqual([]); + + const sheet = applyOutline(createWorkbookData(refreshed), outline, refreshed) + .sheets!.data; + expect(sheet.rowData).toEqual({ 3: { hd: 1 }, 4: { hd: 1 } }); +}); + +test('groupRows adds a merged Subtotals row on top of each group', () => { + const { grid, layout, groups } = groupRows( + [ + ['genre', 'platform', 'sales'], + ['Action', 'PS4', 1], + ['Puzzle', 'PS4', 2], + ['Action', 'Wii', 3.1], + ], + DEFAULT_LAYOUT, + 2, + true, + ); + expect(grid).toEqual([ + ['genre', 'platform', 'sales'], + ['Action', 'Subtotals', 4.1], + ['Action', 'PS4', 1], + ['Action', 'Wii', 3.1], + ['Puzzle', 'Subtotals', 2], + ['Puzzle', 'PS4', 2], + ['Grand Total', null, 6.1], + ]); + expect(layout.merges).toEqual([ + { startRow: 1, endRow: 3, startColumn: 0, endColumn: 0 }, + { startRow: 4, endRow: 5, startColumn: 0, endColumn: 0 }, + ]); + expect(groups).toEqual([ + { start: 2, end: 3 }, + { start: 5, end: 5 }, + ]); +}); + +test('level buttons collapse the groups below that level', () => { + const groups = [ + { start: 1, end: 9 }, + { start: 2, end: 4 }, + ]; + expect(collapseToLevel(groups, 1).map(g => g.collapsed)).toEqual([ + true, + true, + ]); + expect(collapseToLevel(groups, 2).map(g => g.collapsed)).toEqual([ + false, + true, + ]); + expect(collapseToLevel(groups, 3).map(g => g.collapsed)).toEqual([ + false, + false, + ]); +}); + +test('saved collapsed state carries over to the automatic groups', () => { + expect( + combineGroups( + [{ start: 2, end: 3 }], + [ + { start: 2, end: 3, collapsed: true }, + { start: 8, end: 9 }, + ], + ), + ).toEqual([ + { start: 2, end: 3, collapsed: true }, + { start: 8, end: 9, collapsed: undefined }, + ]); +}); diff --git a/superset-frontend/plugins/plugin-chart-univer/test/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-univer/test/transformProps.test.ts new file mode 100644 index 000000000000..3bb6922ce452 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/test/transformProps.test.ts @@ -0,0 +1,48 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { AppSection, ChartProps } from '@superset-ui/core'; +import { supersetTheme } from '@apache-superset/core/theme'; +import transformProps from '../src/plugin/transformProps'; + +const build = (appSection?: AppSection) => + transformProps( + new ChartProps({ + width: 400, + height: 300, + formData: { viz_type: 'univer_sheet', univer_workbook: '{}' }, + queriesData: [{ data: [{ a: 1 }], colnames: ['a'], coltypes: [0] }], + appSection, + theme: supersetTheme, + }), + ); + +test('only Explore gets the editable spreadsheet', () => { + expect(build(AppSection.Explore).editable).toBe(true); + expect(build(AppSection.Dashboard).editable).toBe(false); + expect(build(AppSection.Embedded).editable).toBe(false); + expect(build().editable).toBe(false); +}); + +test('reads Univer settings from the raw form data', () => { + const props = build(AppSection.Explore); + expect(props.savedState).toBe('{}'); + expect(props.liveData).toBe(true); + expect(props.showHeaders).toBe(false); + expect(props.colnames).toEqual(['a']); +}); diff --git a/superset-frontend/plugins/plugin-chart-univer/test/workbook.test.ts b/superset-frontend/plugins/plugin-chart-univer/test/workbook.test.ts new file mode 100644 index 000000000000..b38f23ff49c4 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/test/workbook.test.ts @@ -0,0 +1,268 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { GenericDataType } from '@apache-superset/core/common'; +import { + applyGrid, + buildGrid, + buildPivotGrid, + buildWorkbook, + createWorkbookData, + fitSheetSize, + hideHeaders, + HEADER_STYLE_ID, + parseSavedState, + serializeState, + stretchColumns, + toCellValue, + unstretchColumns, +} from '../src/utils/workbook'; + +const cells = (workbook: ReturnType) => + workbook.sheets!.data.cellData!; + +const grid = buildGrid( + [ + { name: 'a', total: 1 }, + { name: 'b', total: 2 }, + ], + ['name', 'total'], + [GenericDataType.String, GenericDataType.Numeric], + { total: 'Total' }, +); + +test('buildGrid adds a header row with verbose names', () => { + expect(grid).toEqual([ + ['name', 'Total'], + ['a', 1], + ['b', 2], + ]); +}); + +test('toCellValue formats dates and keeps primitives', () => { + expect(toCellValue(Date.UTC(2024, 0, 31), GenericDataType.Temporal)).toBe( + '2024-01-31', + ); + expect( + toCellValue(Date.UTC(2024, 0, 31, 13, 5, 9), GenericDataType.Temporal), + ).toBe('2024-01-31 13:05:09'); + expect(toCellValue(null)).toBeNull(); + expect(toCellValue(true)).toBe(true); + expect(toCellValue({ a: 1 })).toBe('{"a":1}'); + expect(toCellValue(524.999999999985)).toBe(525); + expect(toCellValue(0.1 + 0.2)).toBe(0.3); + expect(toCellValue(42)).toBe(42); +}); + +test('createWorkbookData styles the header and freezes it', () => { + const workbook = createWorkbookData(grid); + expect(cells(workbook)[0][1]).toEqual({ v: 'Total', s: HEADER_STYLE_ID }); + expect(cells(workbook)[2][1]).toEqual({ v: 2 }); + expect(workbook.sheets!.data.freeze!.ySplit).toBe(1); +}); + +test('createWorkbookData widens columns with long content', () => { + const workbook = createWorkbookData([ + ['SUM(global_sales)', 'n'], + ['x', 1], + ]); + expect(workbook.sheets!.data.columnData).toEqual({ + 0: { w: 152 }, + 1: { w: 80 }, + }); +}); + +test('createWorkbookData sizes the sheet to the data', () => { + const sheet = createWorkbookData(grid).sheets!.data; + expect(sheet.rowCount).toBe(3); + expect(sheet.columnCount).toBe(2); +}); + +test('fitSheetSize keeps cells added outside the data', () => { + const workbook = createWorkbookData(grid); + workbook.sheets!.data.rowCount = 40; + cells(workbook)[5] = { 3: { f: '=SUM(B2:B3)' } }; + const sheet = fitSheetSize(workbook, grid).sheets!.data; + expect(sheet.rowCount).toBe(6); + expect(sheet.columnCount).toBe(4); +}); + +test('stretchColumns fills the width and unstretchColumns restores it', () => { + // 46px row header + 12px scrollbar + two 160px columns + const workbook = createWorkbookData([['a', 'b']]); + const { workbook: wide, stretched } = stretchColumns(workbook, 378); + expect(wide.sheets!.data.columnData).toEqual({ + 0: { w: 160 }, + 1: { w: 160 }, + }); + + // the user widened the second column + wide.sheets!.data.columnData![1].w = 200; + expect(unstretchColumns(wide, stretched).sheets!.data.columnData).toEqual({ + 0: { w: 80 }, + 1: { w: 200 }, + }); +}); + +test('stretchColumns leaves overflowing columns alone', () => { + const workbook = createWorkbookData([['a', 'b']]); + expect(stretchColumns(workbook, 150)).toEqual({ workbook, stretched: {} }); +}); + +test('applyGrid refreshes values, keeps styles and clears old cells', () => { + const workbook = createWorkbookData(grid); + cells(workbook)[1][0].s = 'red'; + cells(workbook)[2][0].f = '=1+1'; + cells(workbook)[0][5] = { v: 'my note' }; + + const smaller = applyGrid(workbook, [['name'], ['z']], { rows: 3, cols: 2 }); + expect(cells(smaller)[1][0]).toEqual({ v: 'z', s: 'red' }); + expect(cells(smaller)[2]).toBeUndefined(); + expect(cells(smaller)[0][1]).toEqual({ s: HEADER_STYLE_ID }); + expect(cells(smaller)[0][5]).toEqual({ v: 'my note' }); + // the input is not modified + expect(cells(workbook)[2][0].v).toBe('b'); +}); + +test('saved state round trip with live data strips query values', () => { + const workbook = createWorkbookData(grid); + cells(workbook)[0][3] = { f: '=SUM(B2:B3)', v: 3 }; + const raw = serializeState(workbook, grid, true); + const saved = parseSavedState(raw)!; + + expect(saved.stripped).toBe(true); + expect(cells(saved.workbook)[1]).toBeUndefined(); + expect(cells(saved.workbook)[0][3]).toEqual({ f: '=SUM(B2:B3)', v: 3 }); + + const newGrid = [ + ['name', 'Total'], + ['x', 10], + ]; + const restored = buildWorkbook(newGrid, saved, true); + expect(cells(restored)[1]).toEqual({ 0: { v: 'x' }, 1: { v: 10 } }); + expect(cells(restored)[0][3].f).toBe('=SUM(B2:B3)'); +}); + +test('without live data the saved sheet is used as is', () => { + const workbook = createWorkbookData(grid); + cells(workbook)[1][0].v = 'edited'; + const saved = parseSavedState(serializeState(workbook, grid, false))!; + const restored = buildWorkbook([['name'], ['fresh']], saved, false); + expect(cells(restored)[1][0].v).toBe('edited'); +}); + +test('a stripped sheet gets data even when live data is off', () => { + const saved = parseSavedState( + serializeState(createWorkbookData(grid), grid, true), + )!; + expect(cells(buildWorkbook(grid, saved, false))[1][0].v).toBe('a'); +}); + +test('parseSavedState rejects broken values', () => { + expect(parseSavedState(null)).toBeNull(); + expect(parseSavedState('not json')).toBeNull(); + expect(parseSavedState('{"version":2}')).toBeNull(); +}); + +test('hideHeaders hides row and column headers', () => { + const sheet = hideHeaders(createWorkbookData(grid)).sheets!.data; + expect(sheet.rowHeader!.hidden).toBe(1); + expect(sheet.columnHeader!.hidden).toBe(1); +}); + +test('a renamed header is saved and kept with live data', () => { + const workbook = createWorkbookData(grid); + cells(workbook)[0][0].v = 'Genre'; + const raw = serializeState(workbook, grid, true); + expect(raw).not.toBe(serializeState(createWorkbookData(grid), grid, true)); + + const saved = parseSavedState(raw)!; + expect(cells(saved.workbook)[0][0]).toEqual({ + v: 'Genre', + s: HEADER_STYLE_ID, + }); + expect(cells(saved.workbook)[0][1]).toEqual({ s: HEADER_STYLE_ID }); + expect(cells(buildWorkbook(grid, saved, true))[0][0].v).toBe('Genre'); + + // the query now returns another column there, so its label is shown + const other = buildWorkbook([['platform', 'Total']], saved, true); + expect(cells(other)[0][0].v).toBe('platform'); +}); + +test('stretchColumns adds up to exactly the width', () => { + const workbook = createWorkbookData([['a', 'b', 'c']]); + const { workbook: wide } = stretchColumns(workbook, 1000); + const widths = Object.values(wide.sheets!.data.columnData!).map(c => c.w!); + expect(widths).toEqual([314, 314, 314]); + expect(widths.reduce((a, b) => a + b)).toBe(1000 - 46 - 12); +}); + +test('buildPivotGrid stacks and merges the pivot headers', () => { + const { grid: pivot, layout } = buildPivotGrid( + [ + { genre: 'Action', 'SUM(sales), PS4': 1, 'SUM(sales), Wii\\, U': 2 }, + { genre: 'Total', 'SUM(sales), PS4': 1, 'SUM(sales), Wii\\, U': 2 }, + ], + ['genre', 'SUM(sales), PS4', 'SUM(sales), Wii\\, U'], + [GenericDataType.String, GenericDataType.Numeric, GenericDataType.Numeric], + { genre: 'Genre' }, + 1, + ); + expect(pivot).toEqual([ + ['Genre', 'SUM(sales)', 'SUM(sales)'], + [null, 'PS4', 'Wii, U'], + ['Action', 1, 2], + ['Total', 1, 2], + ]); + expect(layout).toEqual({ + headerRows: 2, + frozenCols: 1, + merges: [ + { startRow: 0, endRow: 1, startColumn: 0, endColumn: 0 }, + { startRow: 0, endRow: 0, startColumn: 1, endColumn: 2 }, + ], + }); + + const workbook = createWorkbookData(pivot, layout); + const sheet = workbook.sheets!.data; + expect(sheet.freeze).toMatchObject({ xSplit: 1, ySplit: 2 }); + expect(sheet.mergeData).toEqual(layout.merges); + expect(cells(workbook)[1][1].s).toBe(HEADER_STYLE_ID); + + // a renamed lower header survives a refresh + cells(workbook)[1][1].v = 'PlayStation'; + const saved = parseSavedState(serializeState(workbook, pivot, true, layout))!; + const restored = buildWorkbook(pivot, saved, true, layout); + expect(cells(restored)[1][1].v).toBe('PlayStation'); + expect(restored.sheets!.data.mergeData).toEqual(layout.merges); +}); + +test('buildPivotGrid without column fields has one header row', () => { + const { grid: pivot, layout } = buildPivotGrid( + [{ genre: 'Action', 'SUM(sales)': 1 }], + ['genre', 'SUM(sales)'], + [GenericDataType.String, GenericDataType.Numeric], + {}, + 1, + ); + expect(pivot).toEqual([ + ['genre', 'SUM(sales)'], + ['Action', 1], + ]); + expect(layout).toEqual({ headerRows: 1, frozenCols: 1, merges: [] }); +}); diff --git a/superset-frontend/plugins/plugin-chart-univer/tsconfig.json b/superset-frontend/plugins/plugin-chart-univer/tsconfig.json new file mode 100644 index 000000000000..03190ec5bd4b --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + // Path Resolution: Override baseUrl to maintain correct path mappings from parent config + // (e.g., "@apache-superset/core" -> "./packages/superset-core/src") + "baseUrl": "../..", + + // Directory Overrides: Parent config paths are relative to frontend root, + // but packages need paths relative to their own directory + "outDir": "lib", + "rootDir": "src", + "declarationDir": "lib" + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"], + "exclude": [ + "src/**/*.js", + "src/**/*.jsx", + "src/**/*.test.*", + "src/**/*.stories.*"], + "references": [ + { "path": "../../packages/superset-core" }, + { "path": "../../packages/superset-ui-core" }, + { "path": "../../packages/superset-ui-chart-controls" } + ] +} diff --git a/superset-frontend/plugins/plugin-chart-univer/types/external.d.ts b/superset-frontend/plugins/plugin-chart-univer/types/external.d.ts new file mode 100644 index 000000000000..f50032b54d33 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-univer/types/external.d.ts @@ -0,0 +1,23 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +declare module '*.png' { + const value: string; + export default value; +} +declare module '*.css'; diff --git a/superset-frontend/src/components/Chart/ChartRenderer.test.tsx b/superset-frontend/src/components/Chart/ChartRenderer.test.tsx index 16405b2a37e6..b33feff5d7d6 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.test.tsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.test.tsx @@ -18,6 +18,7 @@ */ import { render, fireEvent, screen } from 'spec/helpers/testing-library'; import { + AppSection, ChartMetadata, getChartMetadataRegistry, VizType, @@ -49,6 +50,7 @@ jest.mock('@superset-ui/core', () => ({ data-test="mock-super-chart" data-is-refreshing={isRefreshing ? 'true' : 'false'} data-enable-no-results={props.enableNoResults ? 'true' : 'false'} + data-app-section={String(props.appSection)} data-async-mode={String( (props.hooks as { resolveAsyncMode?: () => boolean } | undefined)?.[ 'resolveAsyncMode' @@ -140,6 +142,36 @@ test('should render SuperChart', () => { expect(getByTestId('mock-super-chart')).toBeInTheDocument(); }); +test('tells the chart it renders on a dashboard', () => { + render(); + expect(screen.getByTestId('mock-super-chart')).toHaveAttribute( + 'data-app-section', + AppSection.Dashboard, + ); +}); + +test('tells the chart it renders in Explore', () => { + render(); + expect(screen.getByTestId('mock-super-chart')).toHaveAttribute( + 'data-app-section', + AppSection.Explore, + ); +}); + +test('tells the chart it renders embedded in standalone Explore', () => { + const { pathname } = window.location; + window.history.pushState({}, '', '/explore/?standalone=1'); + try { + render(); + expect(screen.getByTestId('mock-super-chart')).toHaveAttribute( + 'data-app-section', + AppSection.Embedded, + ); + } finally { + window.history.pushState({}, '', pathname); + } +}); + test('should use latestQueryFormData instead of formData when chartIsStale is true', () => { const { getByTestId } = render( , diff --git a/superset-frontend/src/components/Chart/ChartRenderer.tsx b/superset-frontend/src/components/Chart/ChartRenderer.tsx index c81834d52ae4..fb8ca9f53412 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.tsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.tsx @@ -28,6 +28,7 @@ import { memo, } from 'react'; import { + AppSection, SuperChart, Behavior, getChartMetadataRegistry, @@ -52,6 +53,8 @@ import { useTheme } from '@apache-superset/core/theme'; import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils'; import { EmptyState } from '@superset-ui/core/components'; import { ChartSource } from 'src/types/ChartSource'; +import { URL_PARAMS } from 'src/constants'; +import { getUrlParam } from 'src/utils/urlUtils'; import type { Datasource, ChartStatus } from 'src/explore/types'; import type { Dispatch } from 'redux'; import ChartContextMenu, { @@ -239,6 +242,17 @@ function ChartRendererComponent({ [source, suppressContextMenu], ); + // Tells plugins where they render, e.g. to allow editing only in Explore. + // Standalone Explore serves embedded charts and report screenshots. + const appSection = useMemo(() => { + if (source === ChartSource.Dashboard) { + return AppSection.Dashboard; + } + return getUrlParam(URL_PARAMS.standalone) + ? AppSection.Embedded + : AppSection.Explore; + }, [source]); + const [state, setState] = useState({ inContextMenu: false, legendState: undefined, @@ -581,6 +595,7 @@ function ChartRendererComponent({ noResults={noResultsComponent} postTransformProps={postTransformProps} emitCrossFilters={emitCrossFilters} + appSection={appSection} legendState={state.legendState} enableNoResults={bypassNoResult} legendIndex={state.legendIndex} diff --git a/superset-frontend/src/visualizations/presets/MainPreset.ts b/superset-frontend/src/visualizations/presets/MainPreset.ts index 98ee30f4d44c..a6e2a49f26ea 100644 --- a/superset-frontend/src/visualizations/presets/MainPreset.ts +++ b/superset-frontend/src/visualizations/presets/MainPreset.ts @@ -83,6 +83,7 @@ import { } from 'src/chartCustomizations/components'; import { PivotTableChartPlugin as PivotTableChartPluginV2 } from '@superset-ui/plugin-chart-pivot-table'; import { HandlebarsChartPlugin } from '@superset-ui/plugin-chart-handlebars'; +import { UniverChartPlugin } from '@superset-ui/plugin-chart-univer'; import { ChartCustomizationPlugins, FilterPlugins } from 'src/constants'; import AgGridTableChartPlugin from '@superset-ui/plugin-chart-ag-grid-table'; import TimeTableChartPlugin from '../TimeTable'; @@ -197,6 +198,7 @@ export default class MainPreset extends Preset { new EchartsTreeChartPlugin().configure({ key: VizType.Tree }), new EchartsSunburstChartPlugin().configure({ key: VizType.Sunburst }), new HandlebarsChartPlugin().configure({ key: VizType.Handlebars }), + new UniverChartPlugin().configure({ key: VizType.UniverSheet }), new EchartsBubbleChartPlugin().configure({ key: VizType.Bubble }), new CartodiagramPlugin({ defaultLayers: [ diff --git a/superset-frontend/tsconfig.json b/superset-frontend/tsconfig.json index 16400707368b..b3e94f4be10a 100644 --- a/superset-frontend/tsconfig.json +++ b/superset-frontend/tsconfig.json @@ -95,6 +95,7 @@ { "path": "./plugins/plugin-chart-handlebars" }, { "path": "./plugins/plugin-chart-pivot-table" }, { "path": "./plugins/plugin-chart-table" }, + { "path": "./plugins/plugin-chart-univer" }, { "path": "./plugins/plugin-chart-word-cloud" } ], "exclude": [] diff --git a/superset/utils/pandas_postprocessing/flatten.py b/superset/utils/pandas_postprocessing/flatten.py index 40db86db0607..cbe241c940f0 100644 --- a/superset/utils/pandas_postprocessing/flatten.py +++ b/superset/utils/pandas_postprocessing/flatten.py @@ -101,5 +101,6 @@ def flatten( df.columns = _columns if reset_index and not isinstance(df.index, pd.RangeIndex): - df = df.reset_index(level=0) + # every level of a MultiIndex, e.g. a pivot with several index columns + df = df.reset_index() return df diff --git a/tests/unit_tests/pandas_postprocessing/test_flatten.py b/tests/unit_tests/pandas_postprocessing/test_flatten.py index 99dbb7da038f..ca46b53e8526 100644 --- a/tests/unit_tests/pandas_postprocessing/test_flatten.py +++ b/tests/unit_tests/pandas_postprocessing/test_flatten.py @@ -181,3 +181,19 @@ def test_escape_column_name(): "level1\\,value2" + FLAT_COLUMN_SEPARATOR + "level2\\, value2", "level1\\,value3" + FLAT_COLUMN_SEPARATOR + "level2\\, value3", ] + + +def test_flat_should_reset_every_index_level(): + index = pd.MultiIndex.from_arrays( + [["a", "a", "b"], [1, 2, 1]], names=["letter", "number"] + ) + df = pd.DataFrame(index=index, data={"metric": [1, 2, 3]}) + assert pp.flatten(df).equals( + pd.DataFrame( + { + "letter": ["a", "a", "b"], + "number": [1, 2, 1], + "metric": [1, 2, 3], + } + ) + )