diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2343a6e7be..350710442a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", "@vitejs/plugin-react": "^4.1.1", + "apache-arrow": "^13.0.0", "axios": "^1.6.0", "chance": "^1.1.11", "chart.js": "^4.4.0", @@ -43,6 +44,7 @@ "mustache": "^4.2.0", "nodemon": "^3.0.1", "prettier-plugin-organize-imports": "^3.2.3", + "rc-table": "^7.35.2", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", "react-datepicker": "^4.21.0", @@ -85,6 +87,7 @@ "@testing-library/react": "^14.0.0", "@types/axios": "^0.14.0", "@types/chance": "^1.1.5", + "@types/chart.js": "^2.9.41", "@types/compression": "^1.7.4", "@types/cors": "^2.8.15", "@types/express": "^4.17.20", @@ -118,6 +121,26 @@ "node": ">=18" } }, + "node_modules/@75lb/deep-merge": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@75lb/deep-merge/-/deep-merge-1.1.1.tgz", + "integrity": "sha512-xvgv6pkMGBA6GwdyJbNAnDmfAIR/DfWhrj9jgWh3TY7gRm3KO46x/GPjRg6wJ0nOepwqrNxFfojebh0Df4h4Tw==", + "dependencies": { + "lodash.assignwith": "^4.2.0", + "typical": "^7.1.1" + }, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/@75lb/deep-merge/node_modules/typical": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.1.1.tgz", + "integrity": "sha512-T+tKVNs6Wu7IWiAce5BgMd7OZfNYUndHwc5MknN+UHOudi7sGZzuHdCadllRuqJ3fPtgFtIH9+lt9qRv6lmpfA==", + "engines": { + "node": ">=12.17" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "license": "MIT", @@ -3681,6 +3704,19 @@ "@babel/runtime": "^7.13.10" } }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@react-dnd/asap": { "version": "5.0.2", "license": "MIT" @@ -5371,6 +5407,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chart.js": { + "version": "2.9.41", + "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz", + "integrity": "sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==", + "dev": true, + "dependencies": { + "moment": "^2.10.2" + } + }, + "node_modules/@types/command-line-args": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.0.tgz", + "integrity": "sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==" + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.2.tgz", + "integrity": "sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==" + }, "node_modules/@types/compression": { "version": "1.7.4", "dev": true, @@ -5717,6 +5772,11 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pad-left": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/pad-left/-/pad-left-2.1.1.tgz", + "integrity": "sha512-Xd22WCRBydkGSApl5Bw0PhAOHKSVjNL3E3AwzKaps96IMraPqy5BvZIsBVK6JLwdybUzjHnuWVwpDd0JjTfHXA==" + }, "node_modules/@types/papaparse": { "version": "5.3.10", "dev": true, @@ -6321,6 +6381,31 @@ "node": ">= 8" } }, + "node_modules/apache-arrow": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-13.0.0.tgz", + "integrity": "sha512-3gvCX0GDawWz6KFNC28p65U+zGh/LZ6ZNKWNu74N6CQlKzxeoWHpi4CgEQsgRSEMuyrIIXi1Ea2syja7dwcHvw==", + "dependencies": { + "@types/command-line-args": "5.2.0", + "@types/command-line-usage": "5.0.2", + "@types/node": "20.3.0", + "@types/pad-left": "2.1.1", + "command-line-args": "5.2.1", + "command-line-usage": "7.0.1", + "flatbuffers": "23.5.26", + "json-bignum": "^0.0.3", + "pad-left": "^2.1.0", + "tslib": "^2.5.3" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.js" + } + }, + "node_modules/apache-arrow/node_modules/@types/node": { + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.0.tgz", + "integrity": "sha512-cumHmIAf6On83X7yP+LrsEyUOf/YlociZelmpRYaGFydoaPdxdt80MAbu6vWerQT2COCp2nPvHdsbD7tHn/YlQ==" + }, "node_modules/app-root-dir": { "version": "1.0.2", "dev": true, @@ -6354,6 +6439,14 @@ "deep-equal": "^2.0.5" } }, + "node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.0", "dev": true, @@ -6959,6 +7052,20 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, "node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", "license": "MIT", @@ -7266,6 +7373,50 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/command-line-usage": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.1.tgz", + "integrity": "sha512-NCyznE//MuTjwi3y84QVUGEOT+P5oto1e1Pk/jFPVdPPfsG03qpTIl3yw6etR+v73d0lXsoojRpvbru2sqePxQ==", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^3.0.0", + "typical": "^7.1.1" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/command-line-usage/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/command-line-usage/node_modules/typical": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.1.1.tgz", + "integrity": "sha512-T+tKVNs6Wu7IWiAce5BgMd7OZfNYUndHwc5MknN+UHOudi7sGZzuHdCadllRuqJ3fPtgFtIH9+lt9qRv6lmpfA==", + "engines": { + "node": ">=12.17" + } + }, "node_modules/commander": { "version": "2.20.3", "devOptional": true, @@ -8668,6 +8819,17 @@ "semver": "bin/semver.js" } }, + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/find-root": { "version": "1.1.0", "license": "MIT" @@ -8698,6 +8860,11 @@ "node": ">=12.0.0" } }, + "node_modules/flatbuffers": { + "version": "23.5.26", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-23.5.26.tgz", + "integrity": "sha512-vE+SI9vrJDwi1oETtTIFldC/o9GsVKRM+s6EL0nQgxXlYV1Vc4Tk30hj4xGICftInKQKj1F3up2n8UbIVobISQ==" + }, "node_modules/flatted": { "version": "3.2.9", "license": "ISC" @@ -10774,6 +10941,14 @@ "node": ">=4" } }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "license": "MIT" @@ -10896,6 +11071,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.assignwith": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assignwith/-/lodash.assignwith-4.2.0.tgz", + "integrity": "sha512-ZznplvbvtjK2gMvnQ1BR/zqPFZmS6jbK4p+6Up4xcRYA7yMIwxHCfbTcrYxXKzzqLsQ05eJPVznEW3tuwV7k1g==" + }, + "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==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "dev": true, @@ -11950,6 +12135,15 @@ "dev": true, "license": "MIT" }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/mri": { "version": "1.2.0", "license": "MIT", @@ -12354,6 +12548,17 @@ "node": ">=6" } }, + "node_modules/pad-left": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pad-left/-/pad-left-2.1.0.tgz", + "integrity": "sha512-HJxs9K9AztdIQIAIa/OIazRAUW/L6B9hbQDxO4X07roW3eo9XqZc2ur9bn1StH9CnbbI9EgvejHQX7CBpCF1QA==", + "dependencies": { + "repeat-string": "^1.5.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pako": { "version": "0.2.9", "dev": true, @@ -12962,6 +13167,72 @@ "node": ">= 0.8" } }, + "node_modules/rc-resize-observer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.0.tgz", + "integrity": "sha512-PnMVyRid9JLxFavTjeDXEXo65HCRqbmLBw9xX9gfC4BZiSzbLXKzW3jPz+J0P71pLbD5tBMTT+mkstV5gD0c9Q==", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.38.0", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.36.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.36.0.tgz", + "integrity": "sha512-3xVcdCC5OLeOOhaCg+5Lps2oPreM/GWXmUXWTSX4p6vF7F76ABM4dfPpMJ9Dnf5yGRyh+8pe7FRyhRVnWw2H/w==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.37.0", + "rc-virtual-list": "^3.11.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.38.1.tgz", + "integrity": "sha512-e4ZMs7q9XqwTuhIK7zBIVFltUtMSjphuPPQXHoHlzRzNdOwUxDejo0Zls5HYaJfRKNURcsS/ceKVULlhjBrxng==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.11.3", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.11.3.tgz", + "integrity": "sha512-tu5UtrMk/AXonHwHxUogdXAWynaXsrx1i6dsgg+lOo/KJSF8oBAcprh1z5J3xgnPJD5hXxTL58F8s8onokdt0Q==", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/react": { "version": "18.2.0", "license": "MIT", @@ -13875,6 +14146,14 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -14324,6 +14603,14 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/stream-read-all": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/stream-read-all/-/stream-read-all-3.0.1.tgz", + "integrity": "sha512-EWZT9XOceBPlVJRrYcykW8jyRSZYbkb/0ZK36uLEmoWVO5gxBOnntNTseNzfREsqxqdfEGQrD8SXQ3QWbBmq8A==", + "engines": { + "node": ">=10" + } + }, "node_modules/stream-shift": { "version": "1.0.1", "dev": true, @@ -14536,6 +14823,42 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/table-layout": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-3.0.2.tgz", + "integrity": "sha512-rpyNZYRw+/C+dYkcQ3Pr+rLxW4CfHpXjPDnG7lYhdRoUcZTUt+KEsX+94RGp/aVp/MQU35JCITv2T/beY4m+hw==", + "dependencies": { + "@75lb/deep-merge": "^1.1.1", + "array-back": "^6.2.2", + "command-line-args": "^5.2.1", + "command-line-usage": "^7.0.0", + "stream-read-all": "^3.0.1", + "typical": "^7.1.1", + "wordwrapjs": "^5.1.0" + }, + "bin": { + "table-layout": "bin/cli.js" + }, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/table-layout/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/table-layout/node_modules/typical": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.1.1.tgz", + "integrity": "sha512-T+tKVNs6Wu7IWiAce5BgMd7OZfNYUndHwc5MknN+UHOudi7sGZzuHdCadllRuqJ3fPtgFtIH9+lt9qRv6lmpfA==", + "engines": { + "node": ">=12.17" + } + }, "node_modules/tar": { "version": "6.2.0", "dev": true, @@ -15034,6 +15357,14 @@ "node": ">=14.17" } }, + "node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "engines": { + "node": ">=8" + } + }, "node_modules/uglify-js": { "version": "3.17.4", "dev": true, @@ -15730,6 +16061,14 @@ "dev": true, "license": "MIT" }, + "node_modules/wordwrapjs": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.0.tgz", + "integrity": "sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==", + "engines": { + "node": ">=12.17" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index a2affff16e..122fed6e08 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", "@vitejs/plugin-react": "^4.1.1", + "apache-arrow": "^13.0.0", "axios": "^1.6.0", "chance": "^1.1.11", "chart.js": "^4.4.0", @@ -57,6 +58,7 @@ "mustache": "^4.2.0", "nodemon": "^3.0.1", "prettier-plugin-organize-imports": "^3.2.3", + "rc-table": "^7.35.2", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", "react-datepicker": "^4.21.0", @@ -99,6 +101,7 @@ "@testing-library/react": "^14.0.0", "@types/axios": "^0.14.0", "@types/chance": "^1.1.5", + "@types/chart.js": "^2.9.41", "@types/compression": "^1.7.4", "@types/cors": "^2.8.15", "@types/express": "^4.17.20", diff --git a/frontend/src/js/api/api.ts b/frontend/src/js/api/api.ts index 000ac1510d..04138e7c67 100644 --- a/frontend/src/js/api/api.ts +++ b/frontend/src/js/api/api.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useContext, useEffect, useRef } from "react"; import { EditorV2Query } from "../editor-v2/types"; import { EntityId } from "../entity-history/reducer"; @@ -11,6 +11,7 @@ import type { QueryToUploadT } from "../previous-queries/upload/CSVColumnPicker" import { StandardQueryStateT } from "../standard-query-editor/queryReducer"; import { ValidatedTimebasedQueryStateT } from "../timebased-query-editor/reducer"; +import { AuthTokenContext } from "../authorization/AuthTokenProvider"; import { transformQueryToApi } from "./apiHelper"; import type { ConceptIdT, @@ -34,6 +35,7 @@ import type { PostLoginResponseT, PostQueriesResponseT, PostResolveEntitiesResponse, + PreviewStatisticsResponse, QueryIdT, UploadQueryResponseT, } from "./types"; @@ -405,3 +407,42 @@ export const usePostResolveEntities = () => { [api], ); }; + +export const useGetResult = () => { + const { authToken } = useContext(AuthTokenContext); + const authTokenRef = useRef(authToken); + useEffect( + function updateRef() { + authTokenRef.current = authToken; + }, + [authToken], + ); + return useCallback( + (queryId: string, limit = 1000) => { + const url = + `/result/arrow/${queryId}.arrs?` + + new URLSearchParams({ limit: limit.toString() }); + const res = fetch(getProtectedUrl(url), { + headers: { + Authorization: `Bearer ${authTokenRef.current}`, + }, + }); + return res; + }, + [authTokenRef], + ); +}; + +export const usePreviewStatistics = () => { + const api = useApi(); + + return useCallback( + (queryId: string) => + api({ + url: getProtectedUrl(`/queries/${queryId}/statistics`), + method: "GET", + data: queryId, + }), + [api], + ); +}; diff --git a/frontend/src/js/api/types.ts b/frontend/src/js/api/types.ts index d7cf400fcd..01a3458a31 100644 --- a/frontend/src/js/api/types.ts +++ b/frontend/src/js/api/types.ts @@ -583,3 +583,37 @@ export type GetEntityHistoryResponse = { export type PostResolveEntitiesResponse = { [idKind: string]: string; // idKind is the key, the value is the resolved ID }[]; + +export type BaseStatistics = { + label: string; + description?: string; + count: number; + nullValues: number; +}; + +export type BarStatistics = BaseStatistics & { + chart: "HISTO"; + type: "INTEGER" | "DECIMAL" | "MONEY" | "STRING" | "REAL"; + entries: { label: string; value: number }[]; + extras: { [key: string]: string }; +}; + +export type DateStatistics = BaseStatistics & { + chart: "DATES"; + type: "DATE_RANGE" | "DATE"; + quarterCounts: Record; + monthCounts: Record; + span: { + min: string; // format "yyyy-MM-dd" + max: string; + }; +}; + +export type PreviewStatistics = BarStatistics | DateStatistics; + +export type PreviewStatisticsResponse = { + entities: number; + total: number; // Number of rows + statistics: PreviewStatistics[]; + dateRange: DateRangeT; +}; diff --git a/frontend/src/js/app/Content.tsx b/frontend/src/js/app/Content.tsx index ef82813c4d..633e9721fc 100644 --- a/frontend/src/js/app/Content.tsx +++ b/frontend/src/js/app/Content.tsx @@ -2,13 +2,13 @@ import styled from "@emotion/styled"; import { useSelector } from "react-redux"; import { History } from "../entity-history/History"; -import Preview from "../preview/Preview"; import ActivateTooltip from "../tooltip/ActivateTooltip"; import Tooltip from "../tooltip/Tooltip"; import { useMemo } from "react"; import { Panel, PanelGroup } from "react-resizable-panels"; import { ResizeHandle } from "../common/ResizeHandle"; +import Preview from "../preview/Preview"; import DndProvider from "./DndProvider"; import LeftPane from "./LeftPane"; import RightPane from "./RightPane"; @@ -33,6 +33,10 @@ const Content = () => { (state) => state.entityHistory.isOpen, ); + const disableDragHandles = useSelector( + (state) => state.panes.disableDragHandles, + ); + const collapsedStyles = useMemo(() => { if (displayTooltip) return {}; @@ -58,11 +62,11 @@ const Content = () => { > {displayTooltip ? : } - + {!disableDragHandles && } - + {!disableDragHandles && } diff --git a/frontend/src/js/app/actions.ts b/frontend/src/js/app/actions.ts index 612e4be010..e2e09dbca1 100644 --- a/frontend/src/js/app/actions.ts +++ b/frontend/src/js/app/actions.ts @@ -4,7 +4,7 @@ import type { DatasetActions } from "../dataset/actions"; import type { EntityHistoryActions } from "../entity-history/actions"; import type { ExternalFormActions } from "../external-forms/actions"; import type { PaneActions } from "../pane/actions"; -import type { PreviewActions } from "../preview/actions"; +import { PreviewActions } from "../preview/actions"; import type { ProjectItemsFilterActions } from "../previous-queries/filter/actions"; import type { FolderFilterActions } from "../previous-queries/folder-filter/actions"; import type { PreviousQueryListActions } from "../previous-queries/list/actions"; diff --git a/frontend/src/js/button/PreviewButton.tsx b/frontend/src/js/button/PreviewButton.tsx index 18cf5ae19e..7901075d80 100644 --- a/frontend/src/js/button/PreviewButton.tsx +++ b/frontend/src/js/button/PreviewButton.tsx @@ -1,39 +1,52 @@ import styled from "@emotion/styled"; import { useTranslation } from "react-i18next"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; -import type { ColumnDescription } from "../api/types"; -import { useGetAuthorizedUrl } from "../authorization/useAuthorizedUrl"; +import { + faMagnifyingGlass, + faSpinner, +} from "@fortawesome/free-solid-svg-icons"; +import { useMemo, useState } from "react"; +import { StateT } from "../app/reducers"; import { openPreview, useLoadPreviewData } from "../preview/actions"; +import IconButton, { IconButtonPropsT } from "./IconButton"; -import { TransparentButton } from "./TransparentButton"; - -const Button = styled(TransparentButton)` +const Button = styled(IconButton)` white-space: nowrap; height: 35px; + padding: 5px 12px; `; -const PreviewButton = ({ - url, - columns, - ...restProps -}: { - columns: ColumnDescription[]; - url: string; -}) => { +const PreviewButton = (buttonProps: Partial) => { const { t } = useTranslation(); const dispatch = useDispatch(); const loadPreviewData = useLoadPreviewData(); - const getAuthorizedUrl = useGetAuthorizedUrl(); + const queryId = useSelector( + (state) => state.preview.lastQuery, + ); + + const [isLoading, setLoading] = useState(false); + const icon = useMemo( + () => (isLoading ? faSpinner : faMagnifyingGlass), + [isLoading], + ); return ( diff --git a/frontend/src/js/button/QueryResultHistoryButton.tsx b/frontend/src/js/button/QueryResultHistoryButton.tsx index 7c0b650bf4..27de64b222 100644 --- a/frontend/src/js/button/QueryResultHistoryButton.tsx +++ b/frontend/src/js/button/QueryResultHistoryButton.tsx @@ -3,9 +3,7 @@ import { faListUl, faSpinner } from "@fortawesome/free-solid-svg-icons"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; -import type { ColumnDescription } from "../api/types"; import type { StateT } from "../app/reducers"; -import { useGetAuthorizedUrl } from "../authorization/useAuthorizedUrl"; import { openHistory, useNewHistorySession } from "../entity-history/actions"; import IconButton from "./IconButton"; @@ -16,19 +14,16 @@ const SxIconButton = styled(IconButton)` `; interface PropsT { - columns: ColumnDescription[]; label: string; - url: string; } -export const QueryResultHistoryButton = ({ url, label, columns }: PropsT) => { +export const QueryResultHistoryButton = ({ label }: PropsT) => { const { t } = useTranslation(); const dispatch = useDispatch(); const isLoading = useSelector( (state) => state.entityHistory.isLoading, ); - const getAuthorizedUrl = useGetAuthorizedUrl(); const newHistorySession = useNewHistorySession(); return ( @@ -36,7 +31,7 @@ export const QueryResultHistoryButton = ({ url, label, columns }: PropsT) => { icon={isLoading ? faSpinner : faListUl} frame onClick={async () => { - await newHistorySession(getAuthorizedUrl(url), columns, label); + await newHistorySession(label); dispatch(openHistory()); }} > diff --git a/frontend/src/js/entity-history/TimeStratifiedChart.tsx b/frontend/src/js/entity-history/TimeStratifiedChart.tsx index f56a918fbe..bcb5c9c8ce 100644 --- a/frontend/src/js/entity-history/TimeStratifiedChart.tsx +++ b/frontend/src/js/entity-history/TimeStratifiedChart.tsx @@ -5,7 +5,10 @@ import { CategoryScale, Chart as ChartJS, ChartOptions, + LineElement, LinearScale, + PointElement, + Title, Tooltip, } from "chart.js"; import { useMemo } from "react"; @@ -18,7 +21,17 @@ import { formatCurrency } from "./timeline/util"; const TRUNCATE_X_AXIS_LABELS_LEN = 18; -ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip); +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, +); const ChartContainer = styled("div")` height: 190px; @@ -27,7 +40,7 @@ const ChartContainer = styled("div")` justify-content: flex-end; `; -function hexToRgbA(hex: string) { +export function hexToRgbA(hex: string) { let c: string | string[]; if (/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) { c = hex.substring(1).split(""); @@ -41,7 +54,7 @@ function hexToRgbA(hex: string) { throw new Error("Bad Hex"); } -function interpolateDecreasingOpacity(index: number) { +export function interpolateDecreasingOpacity(index: number) { return Math.min(1, 1 / (index + 0.3)); } diff --git a/frontend/src/js/entity-history/actions.ts b/frontend/src/js/entity-history/actions.ts index 4856259def..6988b06e48 100644 --- a/frontend/src/js/entity-history/actions.ts +++ b/frontend/src/js/entity-history/actions.ts @@ -31,6 +31,7 @@ import { useLoadPreviewData } from "../preview/actions"; import { setMessage } from "../snack-message/actions"; import { SnackMessageType } from "../snack-message/reducer"; +import { Table } from "apache-arrow"; import { EntityEvent, EntityId } from "./reducer"; import { isDateColumn, isSourceColumn } from "./timeline/util"; @@ -101,18 +102,6 @@ export const loadHistoryData = createAsyncAction( export const PREFERRED_ID_KINDS = ["EGK", "PID"]; export const DEFAULT_ID_KIND = "EGK"; -function getPreferredIdColumns(columns: ColumnDescription[]) { - const findColumnIdxWithIdKind = (kind: string) => - columns.findIndex((col) => - col.semantics.some((s) => s.type === "ID" && s.kind === kind), - ); - - return PREFERRED_ID_KINDS.map((kind) => ({ - columnIdx: findColumnIdxWithIdKind(kind), - idKind: kind, - })); -} - // TODO: This starts a session with the current query results, // but there will be other ways of starting a history session // - from a dropped file with a list of entities @@ -120,12 +109,20 @@ function getPreferredIdColumns(columns: ColumnDescription[]) { export function useNewHistorySession() { const dispatch = useDispatch(); const loadPreviewData = useLoadPreviewData(); + const queryId = useSelector( + (state) => state.preview.lastQuery, + ); const { updateHistorySession } = useUpdateHistorySession(); - return async (url: string, columns: ColumnDescription[], label: string) => { + return async (label: string) => { + if (!queryId) { + dispatch(loadHistoryData.failure(new Error("Could not load query data"))); + return; + } + dispatch(loadHistoryData.request()); - const result = await loadPreviewData(url, columns, { + const result = await loadPreviewData(queryId, { noLoading: true, }); @@ -136,25 +133,17 @@ export function useNewHistorySession() { return; } - const preferredIdColumns = getPreferredIdColumns(columns); - if (preferredIdColumns.length === 0) { - dispatch(loadHistoryData.failure(new Error("No valid ID columns found"))); - return; - } - - const entityIds = result.csv - .slice(1) // remove header + const entityIds = new Table(result.initialTableData.value) + .toArray() .map((row) => { - for (const col of preferredIdColumns) { - // some values might be empty, search for defined values - if (row[col.columnIdx]) { + for (const [k, v] of Object.entries(row)) { + if (PREFERRED_ID_KINDS.includes(v as string)) { return { - id: row[col.columnIdx], - kind: col.idKind, + id: v as string, + kind: k, }; } } - return null; }) .filter(exists); diff --git a/frontend/src/js/pane/actions.ts b/frontend/src/js/pane/actions.ts index b86e534741..1af88466fd 100644 --- a/frontend/src/js/pane/actions.ts +++ b/frontend/src/js/pane/actions.ts @@ -1,8 +1,12 @@ import { ActionType, createAction } from "typesafe-actions"; -export type PaneActions = ActionType; +export type PaneActions = + | ActionType + | ActionType; export const clickPaneTab = createAction("pane/CLICK_PANE_TAB")<{ paneType: "left" | "right"; tab: string; }>(); + +export const toggleDragHandles = createAction("pane/TOGGLE_DRAG_HANDLES")(); diff --git a/frontend/src/js/pane/reducer.ts b/frontend/src/js/pane/reducer.ts index 27cf4d6a66..13d4769ecb 100644 --- a/frontend/src/js/pane/reducer.ts +++ b/frontend/src/js/pane/reducer.ts @@ -2,12 +2,13 @@ import { getType } from "typesafe-actions"; import type { Action } from "../app/actions"; -import { clickPaneTab } from "./actions"; +import { clickPaneTab, toggleDragHandles } from "./actions"; export type LeftPaneTab = "conceptTrees" | "previousQueries" | "formConfigs"; export interface PanesStateT { left: { activeTab: LeftPaneTab }; right: { activeTab: string | null }; + disableDragHandles: boolean; } const initialState: PanesStateT = { @@ -17,6 +18,7 @@ const initialState: PanesStateT = { right: { activeTab: "queryEditor", }, + disableDragHandles: false, }; const reducer = ( @@ -34,6 +36,11 @@ const reducer = ( activeTab: tab, }, }; + case getType(toggleDragHandles): + return { + ...state, + disableDragHandles: !state.disableDragHandles, + }; default: return state; } diff --git a/frontend/src/js/preview/Cell.ts b/frontend/src/js/preview/Cell.ts deleted file mode 100644 index a44f055318..0000000000 --- a/frontend/src/js/preview/Cell.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { css } from "@emotion/react"; -import styled from "@emotion/styled"; - -export const Cell = styled("code")<{ isDates?: boolean; isHeader?: boolean }>` - padding: 1px 5px; - font-size: ${({ theme }) => theme.font.xs}; - height: ${({ theme }) => theme.font.xs}; - min-width: ${({ isDates }) => (isDates ? "300px" : "100px")}; - width: ${({ isDates }) => (isDates ? "auto" : "100px")}; - flex-grow: ${({ isDates }) => (isDates ? "1" : "0")}; - flex-shrink: 0; - background-color: white; - margin: 0; - position: relative; - text-overflow: ellipsis; - white-space: nowrap; - display: ${({ isDates }) => (isDates ? "flex" : "block")}; - align-items: center; - overflow: hidden; - - ${({ isHeader }) => - isHeader && - css` - font-weight: 700; - overflow-wrap: break-word; - margin: 0 0 5px; - text-overflow: initial; - white-space: initial; - height: initial; - `}; -`; diff --git a/frontend/src/js/preview/Charts.tsx b/frontend/src/js/preview/Charts.tsx new file mode 100644 index 0000000000..44dabe3e73 --- /dev/null +++ b/frontend/src/js/preview/Charts.tsx @@ -0,0 +1,109 @@ +import styled from "@emotion/styled"; +import { faArrowLeft, faArrowRight } from "@fortawesome/free-solid-svg-icons"; +import { t } from "i18next"; + +import { PreviewStatistics } from "../api/types"; +import IconButton from "../button/IconButton"; + +import { useHotkeys } from "react-hotkeys-hook"; +import Diagram from "./Diagram"; + +const Root = styled("div")``; + +const SxDiagram = styled(Diagram)` + padding: 5px; + margin-right: 15px; + height: 27vh; +`; + +const DirectionSelector = styled("div")` + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + margin-bottom: 5px; + grid-column: 1 / 3; + grid-row: 3; + padding-left: 100px; + padding-right: 100px; +`; + +const SxIconButton = styled(IconButton)` + font-size: 24; +`; + +const DiagramContainer = styled("div")` + overflow-x: hidden; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-gap: 5px; +`; + +type ChartProps = { + statistics: PreviewStatistics[]; + className?: string; + showPopup: (statistic: PreviewStatistics) => void; + page: number; + setPage: (page: number) => void; +}; + +const DIAGRAMS_PER_PAGE = 4; + +export default function Charts({ + statistics, + className, + showPopup, + page, + setPage, +}: ChartProps) { + const diagramsOnPage = statistics.slice( + page * DIAGRAMS_PER_PAGE, + (page + 1) * DIAGRAMS_PER_PAGE, + ); + const maxPage = Math.ceil(statistics.length / DIAGRAMS_PER_PAGE); + + const updatePage = (change: number) => { + const newValue = page + change; + if (newValue >= 0 && newValue < maxPage) { + setPage(newValue); + } + }; + + useHotkeys("left", () => updatePage(-1), [page]); + useHotkeys("right", () => updatePage(1), [page]); + + return ( + <> + + + {diagramsOnPage.map((statistic) => { + return ( +
+ showPopup(statistic)} + /> +
+ ); + })} +
+ + updatePage(-1)} + disabled={page === 0} + /> + + {t("preview.page")} {page + 1}/ + {Math.ceil(statistics.length / DIAGRAMS_PER_PAGE)} + + updatePage(1)} + disabled={page === maxPage - 1} + /> + +
+ + ); +} diff --git a/frontend/src/js/preview/ColumnStats.tsx b/frontend/src/js/preview/ColumnStats.tsx deleted file mode 100644 index 451c8f4ceb..0000000000 --- a/frontend/src/js/preview/ColumnStats.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import styled from "@emotion/styled"; -import { FC } from "react"; -import { useTranslation } from "react-i18next"; - -import { formatCurrency } from "../entity-history/timeline/util"; - -import { ColumnDescriptionType } from "./Preview"; - -const Name = styled("code")` - display: block; - font-weight: 700; - font-size: ${({ theme }) => theme.font.xs}; - max-width: 200px; -`; -const Label = styled("span")` - font-style: italic; -`; -const Values = styled("div")` - display: grid; - grid-template-columns: auto 1fr; - gap: 0px 10px; - font-size: ${({ theme }) => theme.font.xs}; -`; -const Value = styled("span")` - font-weight: 700; - text-align: right; -`; -interface Props { - colName: string; - columnType: ColumnDescriptionType; - rawColumnData: string[]; -} - -// Might come in handy at some point -// function getVarianceFromAvg(arr: number[], avg: number) { -// const diffs = arr.map((val) => Math.abs(avg - val)); -// const sumDiffs = diffs.reduce((a, b) => a + b, 0); - -// return sumDiffs / arr.length; -// } - -function getStdDeviationFromAvg(arr: number[], avg: number) { - const squareDiffs = arr.map((val) => { - const diff = Math.abs(avg - val); - - return diff * diff; - }); - - const sumSquareDiffs = squareDiffs.reduce((a, b) => a + b, 0); - - return Math.sqrt(sumSquareDiffs / arr.length); -} - -function getMedian(sortedArr: number[]) { - if (sortedArr.length === 0) return 0; - - const half = Math.floor(sortedArr.length / 2); - - return sortedArr.length % 2 === 1 - ? sortedArr[half] - : (sortedArr[half - 1] + sortedArr[half]) / 2.0; -} - -function toRoundedDecimalsString(num: number, decimals: number) { - const factor = Math.pow(10, decimals); - const rounded = Math.round(num * factor) / factor; - - return rounded.toFixed(decimals).replace(".", ","); -} - -const ColumnStats: FC = ({ colName, columnType, rawColumnData }) => { - const { t } = useTranslation(); - - switch (columnType) { - case "NUMERIC": - case "MONEY": - case "INTEGER": { - const cleanSortedData = rawColumnData - .slice(1) - .map((x) => { - if (!x) return 0; - - switch (columnType) { - case "INTEGER": - return parseInt(x); - case "NUMERIC": - case "MONEY": - default: - return parseFloat(x); - } - }) - .sort((a, b) => a - b); - - const sum = cleanSortedData.reduce((a, b) => a + b, 0); - const median = getMedian(cleanSortedData); - const avg = sum / cleanSortedData.length; - const min = cleanSortedData[0]; - const max = cleanSortedData[cleanSortedData.length - 1]; - const std = getStdDeviationFromAvg(cleanSortedData, avg); - const decimals = 2; - // Might come in handy at some point - // const variance = getVarianceFromAvg(cleanSortedData, avg); - const formatValue = ( - num: number, - { alwaysDecimals }: { alwaysDecimals?: boolean } = {}, - ) => { - return columnType === "MONEY" - ? formatCurrency(num / 100, decimals) - : alwaysDecimals - ? toRoundedDecimalsString(num, decimals) - : num; - }; - - return ( - <> - {colName} - - - {formatValue(avg, { alwaysDecimals: true })} - - {formatValue(median)} - - {formatValue(min)} - - {formatValue(max)} - - {formatValue(std, { alwaysDecimals: true })} - - {formatValue(sum)} - - - ); - } - default: - return null; - } -}; - -export default ColumnStats; diff --git a/frontend/src/js/preview/DateCell.tsx b/frontend/src/js/preview/DateCell.tsx deleted file mode 100644 index 2f8b5b27d1..0000000000 --- a/frontend/src/js/preview/DateCell.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import styled from "@emotion/styled"; -import { FC } from "react"; - -import { getDiffInDays, parseStdDate } from "../common/helpers/dateHelper"; - -import { Cell } from "./Cell"; - -const Span = styled("div")` - position: absolute; - top: 0; - height: 10px; - background-color: ${({ theme }) => theme.col.blueGrayDark}; - margin-right: 10px; - color: white; - font-size: ${({ theme }) => theme.font.tiny}; - min-width: 1px; -`; - -interface PropsT { - cell: string; - minDate: Date; - dateDiffInDays: number; -} - -const DateCell: FC = ({ cell, minDate, dateDiffInDays }) => { - if (cell.length === 0 || cell === "{}") { - return ; - } - - return ( - - {cell - .slice(1, cell.length - 1) - .split(",") - .map((dateRange, k) => { - const s = dateRange.split("/"); - - const dateStr1 = s[0].trim(); - const date1 = parseStdDate(dateStr1); - - const dateStr2 = s[1].trim(); - const date2 = parseStdDate(dateStr2); - - const diffWidth = date1 && date2 ? getDiffInDays(date1, date2) : 0; - const diffLeft = date1 ? getDiffInDays(minDate, date1) : 0; - - const left = (diffLeft / dateDiffInDays) * 100; - const width = (diffWidth / dateDiffInDays) * 100; - - return ( - - {diffWidth} - - ); - })} - - ); -}; -export default DateCell; diff --git a/frontend/src/js/preview/Diagram.tsx b/frontend/src/js/preview/Diagram.tsx new file mode 100644 index 0000000000..36803001c4 --- /dev/null +++ b/frontend/src/js/preview/Diagram.tsx @@ -0,0 +1,215 @@ +import { ChartData, ChartOptions } from "chart.js"; +import { addMonths, format } from "date-fns"; +import { useMemo } from "react"; +import { Bar, Line } from "react-chartjs-2"; + +import { BarStatistics, DateStatistics, PreviewStatistics } from "../api/types"; +import { parseDate, parseStdDate } from "../common/helpers/dateHelper"; +import { hexToRgbA } from "../entity-history/TimeStratifiedChart"; + +import { Theme, useTheme } from "@emotion/react"; +import { + formatNumber, + previewStatsIsBarStats, + previewStatsIsDateStats, +} from "./util"; + +type DiagramProps = { + stat: PreviewStatistics; + className?: string; + onClick?: () => void; + height?: string | number; + width?: string | number; +}; +function transformBarStatsToData( + stats: BarStatistics, + theme: Theme, +): ChartData<"bar"> { + return { + labels: stats.entries.map((entry) => entry.label), + datasets: [ + { + data: stats.entries.map((entry) => entry.value), + backgroundColor: `rgba(${hexToRgbA(theme.col.blueGrayDark)}, 1)`, + borderWidth: 1, + }, + ], + }; +} + +function transformDateStatsToData( + stats: DateStatistics, + theme: Theme, +): ChartData<"line"> { + const labels: string[] = []; + const values: number[] = []; + const minDate = parseStdDate(stats.span.min); + const maxDate = parseStdDate(stats.span.max); + const start = parseStdDate(`${minDate?.getFullYear()}-01-01`); + const end = parseStdDate(`${maxDate?.getFullYear()}-12-01`); + + if (start === null || end === null) { + return { + labels, + datasets: [ + { + data: values, + borderColor: `rgba(${hexToRgbA(theme.col.blueGrayDark)}, 1)`, + borderWidth: 1, + fill: false, + }, + ], + }; + } + const { monthCounts } = stats; + let pointer = start; + while (pointer <= end) { + const month = format(pointer, "yyyy-MM"); + const monthLabel = format(pointer, "dd.MM.yyyy"); + + labels.push(monthLabel); + values.push(monthCounts[month] ?? 0); + + pointer = addMonths(pointer, 1); + } + return { + labels, + datasets: [ + { + data: values, + borderColor: `rgba(${hexToRgbA(theme.col.blueGrayDark)}, 1)`, + borderWidth: 1, + fill: false, + }, + ], + }; +} + +export default function Diagram({ + stat, + className, + onClick, + height, + width, +}: DiagramProps) { + const theme = useTheme(); + const data = useMemo(() => { + if (previewStatsIsBarStats(stat)) { + return transformBarStatsToData(stat, theme); + } + if (previewStatsIsDateStats(stat)) { + return transformDateStatsToData(stat, theme); + } + }, [stat, theme]); + + const options = useMemo(() => { + const baseOptions = { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: "index" as const, + intersect: false, + }, + layout: { + padding: 0, + }, + plugins: { + title: { + display: true, + text: stat.label, + }, + tooltip: { + usePointStyle: true, + backgroundColor: "rgba(255, 255, 255, 0.9)", + titleColor: "rgba(0, 0, 0, 1)", + bodyColor: "rgba(0, 0, 0, 1)", + borderColor: "rgba(0, 0, 0, 0.2)", + borderWidth: 0.5, + padding: 10, + callbacks: { + title: (title) => title[0].label, + label: (context) => formatNumber(context.raw as number), + }, + caretSize: 0, + caretPadding: 0, + }, + }, + } as Partial; + + if (previewStatsIsBarStats(stat)) { + return { + ...baseOptions, + type: "bar", + scales: { + y: { + beginAtZero: true, + }, + }, + } as ChartOptions<"bar">; + } + + if (previewStatsIsDateStats(stat)) { + return { + ...baseOptions, + type: "line", + elements: { + point: { + radius: 0, + }, + }, + scales: { + y: { + beginAtZero: true, + ticks: { + callback: (value: number) => { + return formatNumber(value); + }, + }, + }, + x: { + ticks: { + autoSkip: false, + callback: (valueIndex: number) => { + const label = data?.labels?.[valueIndex]; + if (label) { + const date = parseDate(label as string, "dd.MM.yyyy"); + if ( + date?.getMonth() !== undefined && + date.getMonth() % 3 === 0 + ) { + return label as string; + } + } + return ""; + }, + }, + }, + }, + } as ChartOptions<"line">; + } + + throw new Error("Unknown stats type"); + }, [data?.labels, stat]); + + return ( +
+ {previewStatsIsBarStats(stat) ? ( + } + data={data as ChartData<"bar">} + onClick={() => onClick && onClick()} + height={height} + width={width} + /> + ) : ( + } + data={data as ChartData<"line">} + onClick={() => onClick && onClick()} + height={height} + width={width} + /> + )} +
+ ); +} diff --git a/frontend/src/js/preview/DiagramModal.tsx b/frontend/src/js/preview/DiagramModal.tsx new file mode 100644 index 0000000000..51451c7051 --- /dev/null +++ b/frontend/src/js/preview/DiagramModal.tsx @@ -0,0 +1,69 @@ +import styled from "@emotion/styled"; +import { t } from "i18next"; +import RcTable from "rc-table"; +import { useHotkeys } from "react-hotkeys-hook"; +import { PreviewStatistics } from "../api/types"; +import Modal from "../modal/Modal"; +import Diagram from "./Diagram"; +import { StyledTable } from "./Table"; +import { previewStatsIsBarStats } from "./util"; + +interface DiagramModalProps { + statistic: PreviewStatistics; + onClose: () => void; +} + +const Horizontal = styled("div")` + display: inline-flex; +`; + +const SxDiagram = styled(Diagram)` + width: 70vw; + height: 70vh; + margin-right: 15px; +`; + +const StyledRcTable = styled(RcTable)` + margin: auto; +`; + +export default function DiagramModal({ + statistic, + onClose, +}: DiagramModalProps) { + const components = { + table: StyledTable, + }; + + useHotkeys("esc", () => onClose()); + + return ( + onClose()}> + + + {previewStatsIsBarStats(statistic) && + Object.keys(statistic.extras).length > 0 && ( + { + return { name, value }; + })} + rowKey={(_, index) => `row_${index}`} + components={components} + /> + )} + + + ); +} diff --git a/frontend/src/js/preview/HeadlineStats.tsx b/frontend/src/js/preview/HeadlineStats.tsx new file mode 100644 index 0000000000..45f4420a43 --- /dev/null +++ b/frontend/src/js/preview/HeadlineStats.tsx @@ -0,0 +1,32 @@ +import styled from "@emotion/styled"; +import { PreviewStatisticsResponse } from "../api/types"; +import TooltipEntries from "../tooltip/TooltipEntries"; + +const Root = styled("div")` + padding: 10px; + align-self: right; + margin-left: auto; +`; + +const SxTooltipEntries = styled(TooltipEntries)` + display: flex; + flex-direction: row; + gap: 12px 12px; + margin: auto; +`; + +export type HeadlineStatsProps = { + statistics: PreviewStatisticsResponse | null; +}; + +export default function HeadlineStats({ statistics }: HeadlineStatsProps) { + return ( + + + + ); +} diff --git a/frontend/src/js/preview/Preview.tsx b/frontend/src/js/preview/Preview.tsx index bc58104959..2cdc7db457 100644 --- a/frontend/src/js/preview/Preview.tsx +++ b/frontend/src/js/preview/Preview.tsx @@ -1,24 +1,25 @@ -import { css } from "@emotion/react"; import styled from "@emotion/styled"; -import { FC } from "react"; +import { useEffect, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; -import { useDispatch, useSelector } from "react-redux"; - -import type { ColumnDescription, ColumnDescriptionKind } from "../api/types"; -import type { StateT } from "../app/reducers"; -import { - getDiffInDays, - getFirstAndLastDateOfRange, -} from "../common/helpers/dateHelper"; - -import { Cell } from "./Cell"; -import DateCell from "./DateCell"; -import PreviewInfo from "./PreviewInfo"; -import { StatsHeadline } from "./StatsHeadline"; -import StatsSubline from "./StatsSubline"; +import { useDispatch, useSelector, useStore } from "react-redux"; + +import { StateT } from "../app/reducers"; + +import { PreviewStatistics } from "../api/types"; +import { TransparentButton } from "../button/TransparentButton"; +import FaIcon from "../icon/FaIcon"; + +import { faSpinner } from "@fortawesome/free-solid-svg-icons"; +import { toggleDragHandles } from "../pane/actions"; +import Charts from "./Charts"; +import DiagramModal from "./DiagramModal"; +import HeadlineStats from "./HeadlineStats"; +import ScrollBox from "./ScrollBox"; +import SelectBox from "./SelectBox"; +import Table from "./Table"; import { closePreview } from "./actions"; -import type { PreviewStateT } from "./reducer"; +import { PreviewStateT } from "./reducer"; const FullScreen = styled("div")` height: 100%; @@ -27,220 +28,128 @@ const FullScreen = styled("div")` top: 0; left: 0; background-color: ${({ theme }) => theme.col.bgAlt}; - padding: 60px 20px 20px; z-index: 2; display: flex; flex-direction: column; + gap: 15px; `; -const Line = styled("div")<{ isHeader?: boolean }>` +const Headline = styled("div")` display: flex; - width: 100%; + flex-direction: row; align-items: center; - line-height: 10px; + gap: 30px; +`; - ${({ isHeader }) => - isHeader && - css` - border-bottom: "1px solid #ccc"; - align-items: flex-end; - margin: "0 0 10px"; - `}; +const SxScrollBox = styled(ScrollBox)` + padding: 60px 20px 20px 20px; + display: flex; + flex-direction: column; + gap: 20px; `; -const CSVFrame = styled("div")` - flex-grow: 1; - overflow: hidden; - padding: 10px; - box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); +const SxCharts = styled(Charts)` + width: 100%; background-color: white; + padding: 10px; + box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.2); `; -const ScrollWrap = styled("div")` - overflow: auto; +const SxChartLoadingBlocker = styled("div")` + width: 100%; + background-color: white; + padding: 10px; + box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.2); + align-items: center; + height: 65vh; display: flex; - flex-direction: column; - height: 100%; + justify-content: center; + align-items: center; `; -const List = styled("div")` - position: relative; - height: 100%; - flex-grow: 1; +const SxFaIcon = styled(FaIcon)` + width: 30px; + height: 30px; `; -export type ColumnDescriptionType = ColumnDescriptionKind | "OTHER"; - -const SUPPORTED_COLUMN_DESCRIPTION_KINDS = new Set([ - "BOOLEAN", - "INTEGER", - "NUMERIC", - "MONEY", - "DATE", - "DATE_RANGE", - "LIST[DATE_RANGE]", - "STRING", -]); - -function detectColumnType( - cell: string, - resultColumns: ColumnDescription[], -): ColumnDescriptionType { - if (cell === "dates") return "DATE_RANGE"; - - const maybeColumn = resultColumns.find((column) => column.label === cell); - - if (maybeColumn && SUPPORTED_COLUMN_DESCRIPTION_KINDS.has(maybeColumn.type)) { - if (maybeColumn.type === "LIST[DATE_RANGE]") { - return "DATE_RANGE"; - } - return maybeColumn.type; - } - - return "OTHER"; -} - -function detectColumnTypesByHeader( - line: string[], - resultColumns: ColumnDescription[], -) { - return line.map((cell) => detectColumnType(cell, resultColumns)); -} - -function getMinMaxDates( - rows: string[][], - columns: string[], -): { - min: Date | null; - max: Date | null; - diff: number; -} { - let min = null; - let max = null; - - const dateColumn = columns.find((col) => col === "DATE_RANGE"); - const dateColumnIdx = dateColumn ? columns.indexOf(dateColumn) : -1; - - if (dateColumnIdx === -1) return { min: null, max: null, diff: 0 }; - - for (const row of rows) { - // To cut off '{' and '}' - const cell = row[dateColumnIdx]; - const { first, last } = getFirstAndLastDateOfRange(cell); - - if (!!first && (!min || first < min)) { - min = first; - } - if (!!last && (!max || last > max)) { - max = last; - } - } - - return { - min, - max, - diff: min && max ? getDiffInDays(min, max) : 0, - }; -} +const SxSelectBox = styled(SelectBox)` + box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.2); + background-color: white; + border-radius: ${({ theme }) => theme.borderRadius}; +`; -const Preview: FC = () => { +export default function Preview() { const preview = useSelector((state) => state.preview); const dispatch = useDispatch(); const { t } = useTranslation(); - + const [selectBoxOpen, setSelectBoxOpen] = useState(false); + const [page, setPage] = useState(0); + const [popOver, setPopOver] = useState(null); const onClose = () => dispatch(closePreview()); + const statistics = preview.statisticsData; useHotkeys("esc", () => { - onClose(); + if (!selectBoxOpen && !popOver) onClose(); }); - if (!preview.data.csv || !preview.data.resultColumns) return null; - - // Limit size: - const RENDER_ROWS_LIMIT = 500; - const previewData = preview.data.csv.slice(0, RENDER_ROWS_LIMIT + 1); // +1 Header row - - if (previewData.length < 2) return null; - - const columns = detectColumnTypesByHeader( - previewData[0], - preview.data.resultColumns, - ); - - const { min, max, diff } = getMinMaxDates(previewData.slice(1), columns); - - const Row = ({ index }: { index: number }) => ( - - {previewData[index + 1].map((cell, j) => { - if (columns[j] === "DATE_RANGE" && min && max) { - return ( - - ); - } - - if (columns[j] === "MONEY") { - const cellAsCents = parseInt(cell); - - return ( - - {isNaN(cellAsCents) - ? cell - : (cellAsCents / 100).toFixed(2).replace(".", ",")} - - ); - } - - return ( - - {cell} - - ); - })} - - ); + const store = useStore(); + useEffect(() => { + if (!(store.getState() as StateT).panes.disableDragHandles) { + dispatch(toggleDragHandles()); + return () => { + dispatch(toggleDragHandles()); + }; + } + }, [preview.statisticsData, dispatch, store]); return ( - - {t("preview.previewHeadline")} - - {t("preview.previewSubline", { count: RENDER_ROWS_LIMIT })} - - - - - {previewData[0].map((cell, k) => ( - - {cell} - - ))} - - - {previewData.slice(1).map((_, i) => ( - - ))} - - - + + + + {t("common.back")} + + Ergebnisvorschau + { + const stat = statistics?.statistics.find( + (stat) => stat.label === res.label, + ); + setPopOver(stat ?? null); + }} + isOpen={selectBoxOpen} + setIsOpen={setSelectBoxOpen} + /> + + + {statistics ? ( + { + setPopOver(statistic); + }} + page={page} + setPage={setPage} + /> + ) : ( + + + + )} + {popOver && ( + setPopOver(null)} /> + )} + {preview.arrowReader && + preview.initialTableData && + preview.queryData && ( + + )} + ); -}; - -export default Preview; +} diff --git a/frontend/src/js/preview/PreviewInfo.tsx b/frontend/src/js/preview/PreviewInfo.tsx deleted file mode 100644 index 1b7e9a4066..0000000000 --- a/frontend/src/js/preview/PreviewInfo.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import styled from "@emotion/styled"; -import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; -import { FC } from "react"; -import { useTranslation } from "react-i18next"; - -import IconButton from "../button/IconButton"; -import { - formatStdDate, - useFormatDateDistance, -} from "../common/helpers/dateHelper"; - -import ColumnStats from "./ColumnStats"; -import type { ColumnDescriptionType } from "./Preview"; -import { StatsHeadline } from "./StatsHeadline"; -import StatsSubline from "./StatsSubline"; - -const TopRow = styled("div")` - margin: 12px 0 20px; - width: 100%; - display: flex; - align-items: flex-start; - justify-content: space-between; -`; - -const StdRow = styled("div")` - display: flex; - align-items: center; -`; - -const Stat = styled("code")` - display: block; - margin: 0; - padding-right: 10px; - font-size: ${({ theme }) => theme.font.xs}; -`; - -const BStat = styled(Stat)` - font-weight: 700; -`; - -const Headline = styled("h2")` - font-size: ${({ theme }) => theme.font.md}; - margin: 0; -`; - -const HeadInfo = styled("div")` - margin: 0 20px; -`; - -const Tr = styled("tr")` - line-height: 1; -`; - -const SxIconButton = styled(IconButton)` - background-color: white; -`; - -const StatsCard = styled("div")` - padding: 10px; - box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); - background-color: white; -`; -const StatsContainer = styled("div")` - display: grid; - grid-template-columns: minmax(120px, 200px); - grid-template-rows: 1fr auto; - grid-auto-flow: column; - gap: 5px 20px; - overflow-x: auto; -`; - -const COLUMN_TYPES_WITH_SUPPORTED_STATS = new Set([ - "MONEY", - "NUMERIC", - "INTEGER", -]); - -interface PropsT { - columns: ColumnDescriptionType[]; - rawPreviewData: string[][]; - onClose: () => void; - minDate: Date | null; - maxDate: Date | null; -} - -const PreviewInfo: FC = ({ - rawPreviewData, - columns, - onClose, - minDate, - maxDate, -}) => { - const { t } = useTranslation(); - const formatDateDistance = useFormatDateDistance(); - - if (rawPreviewData.length < 2) return null; - - const showStats = columns.some((column) => - COLUMN_TYPES_WITH_SUPPORTED_STATS.has(column), - ); - - return ( -
- -
- - - {t("common.back")} - - - {t("preview.headline")} - - -
-
- - - - - - - - - - - - - - -
- {t("preview.total")}: - - {rawPreviewData.length - 1} - - {t("preview.min")}: - - {minDate ? formatStdDate(minDate) : "-"} -
- {t("preview.span")}: - - - {!!minDate && !!maxDate - ? formatDateDistance(minDate, maxDate) - : "-"} - - - {t("preview.max")}: - - {maxDate ? formatStdDate(maxDate) : "-"} -
- - {showStats && ( -
- {t("preview.statisticsHeadline")} - {t("preview.statisticsSubline")} - - - {rawPreviewData[0].map((col, j) => ( - row[j])} - /> - ))} - - -
- )} - - ); -}; - -export default PreviewInfo; diff --git a/frontend/src/js/preview/ScrollBox.tsx b/frontend/src/js/preview/ScrollBox.tsx new file mode 100644 index 0000000000..2d485373ba --- /dev/null +++ b/frontend/src/js/preview/ScrollBox.tsx @@ -0,0 +1,65 @@ +import styled from "@emotion/styled"; +import { faArrowUp } from "@fortawesome/free-solid-svg-icons"; +import { + HTMLAttributes, + PropsWithChildren, + useEffect, + useRef, + useState, +} from "react"; +import IconButton from "../button/IconButton"; + +const Root = styled("div")` + overflow: auto; +`; +const ScrollTopButton = styled(IconButton)` + position: absolute; + right: 30px; + bottom: 30px; + width: 50px; + height: 50px; + display: flex; + justify-content: center; + border-radius: 50%; + border: 1px solid ${({ theme }) => theme.col.gray}; + background: white; + box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.2); + z-index: 3; +`; + +export default function ScrollBox({ + threshold = 0, + children, + ...props +}: PropsWithChildren<{ threshold?: number }> & HTMLAttributes) { + const scrollBoxRef = useRef(null); + const [showButton, setShowButton] = useState(false); + + useEffect(() => { + const scrollHandler = (e: Event) => { + const target = e.target as HTMLDivElement; + setShowButton(target.scrollTop > threshold); + }; + + const scrollBox = scrollBoxRef.current; + scrollBox?.addEventListener("scroll", scrollHandler, { + passive: true, + }); + return () => scrollBox?.removeEventListener("scroll", scrollHandler); + }, [scrollBoxRef, threshold]); + + return ( + + {showButton && ( + + scrollBoxRef.current?.scrollTo({ top: 0, behavior: "smooth" }) + } + /> + )} + {children} + + ); +} diff --git a/frontend/src/js/preview/SelectBox.tsx b/frontend/src/js/preview/SelectBox.tsx new file mode 100644 index 0000000000..735fe2882d --- /dev/null +++ b/frontend/src/js/preview/SelectBox.tsx @@ -0,0 +1,128 @@ +import styled from "@emotion/styled"; +import { SetStateAction, useMemo, useRef, useState } from "react"; + +import { faCaretDown, faCaretUp } from "@fortawesome/free-solid-svg-icons"; +import { useClickOutside } from "../common/helpers/useClickOutside"; +import FaIcon from "../icon/FaIcon"; +import { Input } from "../ui-components/InputSelect/InputSelectComponents"; + +export interface SelectItem { + label: string; +} + +interface SelectBoxProps { + items: T[]; + onChange: (item: T) => void; + className?: string; + isOpen: boolean; + setIsOpen: (open: boolean) => void; +} + +const Root = styled("div")` + display: flex; + min-height: 30px; + flex-direction: column; + width: 20vw; +`; + +const InputContainer = styled("div")` + display: flex; + flex-direction: row; +`; + +const List = styled("div")` + position: absolute; + z-index: 1; + margin-top: 35px; + background-color: white; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); + border-radius: ${({ theme }) => theme.borderRadius}; + clip-path: inset(0px -8px -8px -8px); + display: flex; + flex-direction: column; + gap: 5px; + max-height: 40vh; + overflow-y: auto; + width: 20vw; +`; + +const ListItem = styled("div")` + padding: 0 5px; + cursor: pointer; + cursor: pointer; + &:hover { + background-color: ${({ theme }) => theme.col.grayVeryLight}; + } +`; + +const SxInput = styled(Input)` + margin-top: 5px; + width: 190px; +`; +const ArrowContainer = styled("div")` + margin-right: 5px; +`; +const SxArrow = styled(FaIcon)` + margin-top: 5px; + color: ${({ theme }) => theme.col.gray}; + font-size: 17px; + cursor: pointer; +`; + +export default function SelectBox({ + items, + onChange, + className, + isOpen, + setIsOpen, +}: SelectBoxProps) { + const [searchTerm, setSearchTerm] = useState(""); + const clickOutsideRef = useRef(null); + useClickOutside(clickOutsideRef, () => setIsOpen(false)); + + const displayedItems = useMemo(() => { + return items.filter((item) => { + if (searchTerm === "") { + return true; + } + if (item.label === null) { + return false; + } + return item.label.toLowerCase().includes(searchTerm.toLowerCase()); + }); + }, [items, searchTerm]); + + return ( + setIsOpen(!isOpen)}> + + } }) => + setSearchTerm(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + onChange(displayedItems[0]); + } + }} + spellCheck={false} + /> + + + + + + {isOpen && + displayedItems.map((item) => { + return ( + onChange(item)}> + {item.label} + + ); + })} + + + ); +} diff --git a/frontend/src/js/preview/StatsHeadline.ts b/frontend/src/js/preview/StatsHeadline.ts deleted file mode 100644 index 6553f30e51..0000000000 --- a/frontend/src/js/preview/StatsHeadline.ts +++ /dev/null @@ -1,8 +0,0 @@ -import styled from "@emotion/styled"; - -export const StatsHeadline = styled("h3")` - font-size: ${({ theme }) => theme.font.sm}; - color: ${({ theme }) => theme.col.black}; - font-weight: 400; - margin: 14px 0 0; -`; diff --git a/frontend/src/js/preview/StatsSubline.ts b/frontend/src/js/preview/StatsSubline.ts deleted file mode 100644 index 0d6e568e47..0000000000 --- a/frontend/src/js/preview/StatsSubline.ts +++ /dev/null @@ -1,10 +0,0 @@ -import styled from "@emotion/styled"; - -const StatsSubline = styled("h4")` - font-size: ${({ theme }) => theme.font.xs}; - color: ${({ theme }) => theme.col.black}; - font-weight: 400; - margin: 0 0 12px; -`; - -export default StatsSubline; diff --git a/frontend/src/js/preview/Table.tsx b/frontend/src/js/preview/Table.tsx new file mode 100644 index 0000000000..0355c64523 --- /dev/null +++ b/frontend/src/js/preview/Table.tsx @@ -0,0 +1,147 @@ +import styled from "@emotion/styled"; +import { + Table as ArrowTable, + AsyncRecordBatchStreamReader, + RecordBatch, + Vector, +} from "apache-arrow"; +import RcTable from "rc-table"; +import { DefaultRecordType } from "rc-table/lib/interface"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { GetQueryResponseDoneT, GetQueryResponseT } from "../api/types"; +import { useCustomTableRenderers } from "./tableUtils"; + +interface Props { + arrowReader: AsyncRecordBatchStreamReader; + initialTableData: IteratorResult; + queryData: GetQueryResponseT; +} + +const Root = styled("div")` + flex-grow: 1; + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); + transform: rotateX(180deg); + + table { + transform: rotateX(-180deg); + } +`; + +export const StyledTable = styled("table")` + width: 100%; + border-spacing: 0; + + th { + background: ${({ theme }) => theme.col.grayVeryLight}; + font-weight: normal; + text-align: left; + } + + td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 25ch; + } + + th, + td { + padding: 10px; + border-bottom: 1px solid ${({ theme }) => theme.col.grayMediumLight}; + border-right: 1px solid ${({ theme }) => theme.col.grayMediumLight}; + } + + th:last-of-type, + td:last-of-type { + border-right: none; + } +`; + +export default memo(function Table({ + arrowReader, + initialTableData, + queryData, +}: Props) { + const rootRef = useRef(null); + const { getRenderFunctionByFieldName } = useCustomTableRenderers( + queryData as GetQueryResponseDoneT, + ); + + const columns = useMemo( + () => + arrowReader.schema?.fields.map((field) => ({ + title: field.name.charAt(0).toUpperCase() + field.name.slice(1), + dataIndex: field.name, + key: field.name, + render: (value: string | Vector) => { + return typeof value === "string" ? ( + {value} + ) : ( + value + ); + }, + })), + [arrowReader.schema], + ); + + const parseTableRows = useCallback( + (data: Vector[]) => { + const nextRows = [] as DefaultRecordType[]; + data.forEach((dataEntry: Vector) => { + const parsedValues = Object.fromEntries( + Object.entries(dataEntry.toJSON()).map(([key, value]) => { + const parsedValue = + getRenderFunctionByFieldName(key)?.(value) ?? value; + return [key, parsedValue]; + }), + ); + nextRows.push(parsedValues); + }); + + return nextRows; + }, + [getRenderFunctionByFieldName], + ); + + const loadedTableData = useMemo( + () => parseTableRows(new ArrowTable(initialTableData.value).toArray()), + [initialTableData, parseTableRows], + ); + const [visibleTableRows, setVisibleTableRows] = useState(50); + + useEffect(() => { + const eventFunction = async () => { + const div = rootRef.current; + if (!div) { + return; + } + const maxScroll = + (div.parentElement?.scrollHeight || div.scrollHeight) - + window.innerHeight; + const thresholdTriggered = + (div.parentElement?.scrollTop || div.scrollTop) / maxScroll > 0.9; + if (thresholdTriggered) { + setVisibleTableRows((rowCount) => + Math.min(rowCount + 50, loadedTableData.length), + ); + } + }; + + window.addEventListener("scroll", eventFunction, true); + return () => window.removeEventListener("scroll", eventFunction, true); + }, [loadedTableData, visibleTableRows, arrowReader]); + + return ( + + `previewtable_row_${index}`} + components={{ + table: StyledTable, + }} + scroll={{ x: true }} + /> + + ); +}); diff --git a/frontend/src/js/preview/actions.ts b/frontend/src/js/preview/actions.ts index c196e82d2a..7ca717c07f 100644 --- a/frontend/src/js/preview/actions.ts +++ b/frontend/src/js/preview/actions.ts @@ -1,74 +1,115 @@ +import { AsyncRecordBatchStreamReader, RecordBatch } from "apache-arrow"; +import { t } from "i18next"; import { useDispatch, useSelector } from "react-redux"; import { ActionType, createAction, createAsyncAction } from "typesafe-actions"; - -import type { ColumnDescription } from "../api/types"; +import { useGetQuery, useGetResult, usePreviewStatistics } from "../api/api"; +import { GetQueryResponseT, PreviewStatisticsResponse } from "../api/types"; import { StateT } from "../app/reducers"; -import { ErrorObject, errorPayload } from "../common/actions/genericActions"; -import { loadCSV } from "../file/csv"; - +import { ErrorObject } from "../common/actions/genericActions"; +import { setMessage } from "../snack-message/actions"; +import { SnackMessageType } from "../snack-message/reducer"; import { PreviewStateT } from "./reducer"; export type PreviewActions = ActionType< - typeof loadCSVForPreview | typeof closePreview | typeof openPreview + | typeof loadPreview + | typeof closePreview + | typeof openPreview + | typeof updateQueryId >; -export const openPreview = createAction("preview/OPENk")(); -export const closePreview = createAction("preview/CLOSE")(); - interface PreviewData { - csv: string[][]; - columns: ColumnDescription[]; - resultUrl: string; + statisticsData: PreviewStatisticsResponse; + queryData: GetQueryResponseT; + arrowReader: AsyncRecordBatchStreamReader; + initialTableData: IteratorResult; + queryId: string; } -export const loadCSVForPreview = createAsyncAction( - "preview/LOAD_CSV_START", - "preview/LOAD_CSV_SUCCESS", - "preview/LOAD_CSV_ERROR", +export const loadPreview = createAsyncAction( + "preview/LOAD_START", + "preview/LOAD_SUCCESS", + "preview/LOAD_ERROR", )(); +export const openPreview = createAction("preview/OPEN")(); +export const closePreview = createAction("preview/CLOSE")(); + +export const updateQueryId = createAction("preview/UPDATE_LAST_QUERY_ID")<{ + queryId: string; +}>(); + export function useLoadPreviewData() { const dispatch = useDispatch(); - const { dataLoadedForResultUrl, data } = useSelector( - (state) => state.preview, - ); + const getQuery = useGetQuery(); + const getResult = useGetResult(); + const getStatistics = usePreviewStatistics(); + + const { + dataLoadedForQueryId, + arrowReader, + initialTableData, + queryData, + statisticsData, + } = useSelector((state) => state.preview); const currentPreviewData: PreviewData | null = - data.csv && data.resultColumns && dataLoadedForResultUrl + dataLoadedForQueryId && + arrowReader && + initialTableData && + queryData && + statisticsData ? { - csv: data.csv, - columns: data.resultColumns, - resultUrl: dataLoadedForResultUrl, + queryId: dataLoadedForQueryId, + statisticsData, + queryData, + arrowReader, + initialTableData, } : null; return async ( - url: string, - columns: ColumnDescription[], + queryId: string, { noLoading }: { noLoading: boolean } = { noLoading: false }, ): Promise => { - if (currentPreviewData && dataLoadedForResultUrl === url) { + if (currentPreviewData && dataLoadedForQueryId === queryId) { return currentPreviewData; } if (!noLoading) { - dispatch(loadCSVForPreview.request()); + dispatch(loadPreview.request()); } try { - const result = await loadCSV(url); - const payload = { - csv: result.data, - columns, - resultUrl: url, + const arrowReader = await AsyncRecordBatchStreamReader.from( + getResult(queryId), + ); + const loadInitialData = async () => { + await arrowReader.open(); + return await arrowReader.next(); }; - dispatch(loadCSVForPreview.success(payload)); - + const awaitedData = await Promise.all([ + getStatistics(queryId), + getQuery(queryId), + loadInitialData(), + ]); + const payload = { + statisticsData: awaitedData[0], + queryData: awaitedData[1], + arrowReader: arrowReader, + initialTableData: awaitedData[2], + queryId, + }; + dispatch(loadPreview.success(payload)); return payload; - } catch (e) { - dispatch(loadCSVForPreview.failure(errorPayload(e as Error, {}))); - - return null; + } catch (err) { + dispatch( + setMessage({ + message: t("preview.loadingError"), + type: SnackMessageType.ERROR, + }), + ); + dispatch(loadPreview.failure({})); } + return null; }; } diff --git a/frontend/src/js/preview/reducer.ts b/frontend/src/js/preview/reducer.ts index 9b2b40ed4f..926e630895 100644 --- a/frontend/src/js/preview/reducer.ts +++ b/frontend/src/js/preview/reducer.ts @@ -1,28 +1,36 @@ +import { AsyncRecordBatchStreamReader, RecordBatch } from "apache-arrow"; import { getType } from "typesafe-actions"; +import { GetQueryResponseT, PreviewStatisticsResponse } from "../api/types"; -import type { ColumnDescription } from "../api/types"; import { Action } from "../app/actions"; -import { closePreview, loadCSVForPreview, openPreview } from "./actions"; +import { + closePreview, + loadPreview, + openPreview, + updateQueryId, +} from "./actions"; export type PreviewStateT = { isOpen: boolean; isLoading: boolean; - dataLoadedForResultUrl: string | null; - data: { - csv: string[][] | null; - resultColumns: ColumnDescription[] | null; - }; + dataLoadedForQueryId: string | null; + statisticsData: PreviewStatisticsResponse | null; + queryData: GetQueryResponseT | null; + arrowReader: AsyncRecordBatchStreamReader | null; + initialTableData: IteratorResult | null; + lastQuery: string | null; }; const initialState: PreviewStateT = { isOpen: false, isLoading: false, - dataLoadedForResultUrl: null, - data: { - csv: null, - resultColumns: null, - }, + dataLoadedForQueryId: null, + statisticsData: null, + queryData: null, + arrowReader: null, + initialTableData: null, + lastQuery: null, }; export default function reducer( @@ -30,35 +38,44 @@ export default function reducer( action: Action, ): PreviewStateT { switch (action.type) { - case getType(loadCSVForPreview.request): + case getType(openPreview): return { ...state, - isLoading: true, + isOpen: true, }; - case getType(loadCSVForPreview.failure): + case getType(closePreview): return { ...state, - isLoading: false, + isOpen: false, }; - case getType(loadCSVForPreview.success): + case getType(loadPreview.request): return { ...state, + isLoading: true, + }; + case getType(loadPreview.failure): + return { + ...state, + dataLoadedForQueryId: null, + statisticsData: null, + arrowReader: null, + initialTableData: null, isLoading: false, - dataLoadedForResultUrl: action.payload.resultUrl, - data: { - csv: action.payload.csv, - resultColumns: action.payload.columns, - }, }; - case getType(openPreview): + case getType(loadPreview.success): return { ...state, - isOpen: true, + isLoading: false, + dataLoadedForQueryId: action.payload.queryId, + queryData: action.payload.queryData, + arrowReader: action.payload.arrowReader, + initialTableData: action.payload.initialTableData, + statisticsData: action.payload.statisticsData, }; - case getType(closePreview): + case getType(updateQueryId): return { ...state, - isOpen: false, + lastQuery: action.payload.queryId, }; default: return state; diff --git a/frontend/src/js/preview/tableUtils.ts b/frontend/src/js/preview/tableUtils.ts new file mode 100644 index 0000000000..ea727e13e9 --- /dev/null +++ b/frontend/src/js/preview/tableUtils.ts @@ -0,0 +1,91 @@ +import { Vector } from "apache-arrow"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; +import { CurrencyConfigT, GetQueryResponseDoneT } from "../api/types"; +import { StateT } from "../app/reducers"; +import { + NUMBER_TYPES, + formatDate, + formatNumber, + toFullLocaleDateString, +} from "./util"; + +export type CellValue = string | Vector; + +export function useCustomTableRenderers(queryData: GetQueryResponseDoneT) { + const { t } = useTranslation(); + const currencyConfig = useSelector( + (state) => state.startup.config.currency, + ); + + const getRenderFunction = useCallback( + (cellType: string): ((value: CellValue) => string) | undefined => { + if (cellType.indexOf("LIST") == 0) { + const listType = cellType.match(/LIST\[(?.*)\]/)?.groups?.[ + "listtype" + ]; + if (listType) { + const listTypeRenderFunction = getRenderFunction(listType); + return (value) => + value + ? (value as Vector) + .toArray() + .map((listItem: string) => + listTypeRenderFunction + ? listTypeRenderFunction(listItem) + : listItem, + ) + .join(", ") + : null; + } + } else if (NUMBER_TYPES.includes(cellType)) { + return (value) => { + const num = parseFloat(value as string); + return isNaN(num) ? "" : formatNumber(num); + }; + } else if (cellType == "DATE") { + return (value) => + value instanceof Date + ? toFullLocaleDateString(value) + : formatDate(value as string); + } else if (cellType == "DATE_RANGE") { + return (value) => { + const dateRange = (value as Vector).toJSON() as unknown as { + min: Date; + max: Date; + }; + const min = toFullLocaleDateString(dateRange.min); + const max = toFullLocaleDateString(dateRange.max); + return min == max ? min : `${min} - ${max}`; + }; + } else if (cellType == "MONEY") { + return (value) => { + const num = parseFloat(value as string) / 100; + return isNaN(num) + ? "" + : `${formatNumber(num, { forceFractionDigits: true })} ${ + currencyConfig.unit + }`; + }; + } else if (cellType == "BOOLEAN") { + return (value) => (value ? t("common.true") : t("common.false")); + } + }, + [currencyConfig.unit, t], + ); + + const getRenderFunctionByFieldName = useCallback( + (fieldName: string): ((value: CellValue) => string) | undefined => { + const cellType = ( + queryData as GetQueryResponseDoneT + ).columnDescriptions?.find((x) => x.label == fieldName)?.type; + if (cellType) { + return getRenderFunction(cellType); + } + }, + [getRenderFunction, queryData], + ); + + return { getRenderFunction, getRenderFunctionByFieldName }; +} diff --git a/frontend/src/js/preview/util.ts b/frontend/src/js/preview/util.ts new file mode 100644 index 0000000000..dad51022b4 --- /dev/null +++ b/frontend/src/js/preview/util.ts @@ -0,0 +1,48 @@ +import { t } from "i18next"; +import { BarStatistics, DateStatistics, PreviewStatistics } from "../api/types"; +import { parseDate } from "../common/helpers/dateHelper"; + +export const NUMBER_TYPES = ["NUMERIC", "INTEGER"]; + +export const NUMBER_STATISTICS_TYPES = [...NUMBER_TYPES, "MONEY"]; + +export function formatNumber( + num: number, + { + precision = 2, + forceFractionDigits, + }: { precision?: number; forceFractionDigits?: boolean } = {}, +): string { + return new Intl.NumberFormat(navigator.language, { + maximumFractionDigits: precision, + minimumFractionDigits: forceFractionDigits ? precision : undefined, + }).format(num); +} + +export function formatDate(date: string | undefined) { + if (date) { + const parsedDate = parseDate(date, "yyyy-MM-dd"); + return parsedDate ? toFullLocaleDateString(parsedDate) : date; + } + return t("preview.dateError"); +} + +export function toFullLocaleDateString(date: Date) { + return date.toLocaleDateString(navigator.language, { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +export function previewStatsIsBarStats( + stats: PreviewStatistics, +): stats is BarStatistics { + return stats.chart === "HISTO"; +} + +export function previewStatsIsDateStats( + stats: PreviewStatistics, +): stats is DateStatistics { + return stats.chart === "DATES"; +} diff --git a/frontend/src/js/query-runner/QueryResults.tsx b/frontend/src/js/query-runner/QueryResults.tsx index e3ebd0df85..0edc48da9b 100644 --- a/frontend/src/js/query-runner/QueryResults.tsx +++ b/frontend/src/js/query-runner/QueryResults.tsx @@ -9,7 +9,6 @@ import { StateT } from "../app/reducers"; import PreviewButton from "../button/PreviewButton"; import { QueryResultHistoryButton } from "../button/QueryResultHistoryButton"; import { isEmpty } from "../common/helpers/commonHelper"; -import { exists } from "../common/helpers/exists"; import FaIcon from "../icon/FaIcon"; import { canViewEntityPreview, canViewQueryPreview } from "../user/selectors"; @@ -49,11 +48,9 @@ const QueryResults: FC = ({ resultLabel, resultUrls, resultCount, - resultColumns, queryType, }) => { const { t } = useTranslation(); - const csvUrl = resultUrls.find((ru) => ru.url.endsWith("csv")); const canViewHistory = useSelector(canViewEntityPreview); const canViewPreview = useSelector(canViewQueryPreview); @@ -72,20 +69,8 @@ const QueryResults: FC = ({ : t("queryRunner.resultCount")} )} - {!!csvUrl && exists(resultColumns) && ( - <> - {canViewPreview && ( - - )} - {canViewHistory && ( - - )} - - )} + {canViewPreview && } + {canViewHistory && } {resultUrls.length > 0 && ( )} diff --git a/frontend/src/js/query-runner/actions.ts b/frontend/src/js/query-runner/actions.ts index f37ef44cb4..afe3446e85 100644 --- a/frontend/src/js/query-runner/actions.ts +++ b/frontend/src/js/query-runner/actions.ts @@ -31,6 +31,7 @@ import { import type { StandardQueryStateT } from "../standard-query-editor/queryReducer"; import type { ValidatedTimebasedQueryStateT } from "../timebased-query-editor/reducer"; +import { updateQueryId } from "../preview/actions"; import { QUERY_AGAIN_TIMEOUT } from "./constants"; export type QueryRunnerActions = ActionType< @@ -116,7 +117,7 @@ export const useStartQuery = (queryType: QueryTypeT) => { dispatch(startQuery.success(successPayload(r, { queryType }))); const queryId = r.id; - + dispatch(updateQueryId({ queryId })); return queryResult(datasetId, queryId); }, (e) => dispatch(startQuery.failure(errorPayload(e, { queryType }))), diff --git a/frontend/src/js/tooltip/Tooltip.tsx b/frontend/src/js/tooltip/Tooltip.tsx index 815e22f250..919daa53b3 100644 --- a/frontend/src/js/tooltip/Tooltip.tsx +++ b/frontend/src/js/tooltip/Tooltip.tsx @@ -114,6 +114,13 @@ const InfoHeadline = styled("h4")` line-height: 1.3; `; +const SxTooltipEntries = styled(TooltipEntries)` + display: grid; + grid-template-columns: auto 1fr; + gap: 12px 12px; + align-items: center; +`; + const HighlightedText = ({ text, words = [], @@ -211,7 +218,7 @@ const Tooltip = () => { - { matchingEntries?: number | null; matchingEntities?: number | null; dateRange?: DateRangeT; @@ -104,7 +100,7 @@ const TooltipEntries = (props: Props) => { : "- - - - - - -"; return ( - + diff --git a/frontend/src/localization/de.json b/frontend/src/localization/de.json index 08471c4a55..675e394acc 100644 --- a/frontend/src/localization/de.json +++ b/frontend/src/localization/de.json @@ -169,7 +169,9 @@ "missingLabel": "Unbenannt", "import": "Importieren", "openFileDialog": "Datei auswählen", - "shortcut": "Kurzbefehl" + "shortcut": "Kurzbefehl", + "true": "Wahr", + "false": "Falsch" }, "tooltip": { "headline": "Info", @@ -393,6 +395,7 @@ }, "preview": { "preview": "Vorschau", + "page": "Seite", "headline": "Ergebnisvorschau", "previewHeadline": "Ergebnis-CSV", "previewSubline": "Auszug der ersten {{count}} Zeilen", @@ -401,7 +404,12 @@ "total": "Zeilen", "min": "Min Datum", "max": "Max Datum", - "span": "Datumsbereich" + "span": "Datumsbereich", + "loadingError": "Da ist etwas schiefgelaufen, bitte versuche es nochmal.", + "dateError": "Datum unbekannt", + "name": "Name", + "value": "Wert", + "densityPlot": "Dichtediagramm" }, "login": { "headline": "Anfragen und Analyse", diff --git a/frontend/src/localization/en.json b/frontend/src/localization/en.json index c190ed82e1..b7da2348ab 100644 --- a/frontend/src/localization/en.json +++ b/frontend/src/localization/en.json @@ -169,7 +169,9 @@ "missingLabel": "Unknown", "import": "Import", "openFileDialog": "Select file", - "shortcut": "Key" + "shortcut": "Key", + "true": "True", + "false": "False" }, "tooltip": { "headline": "Info", @@ -393,6 +395,7 @@ }, "preview": { "preview": "Preview", + "page": "Page", "headline": "Results preview", "previewHeadline": "Result CSV", "previewSubline": "Preview of the first {{count}} lines", @@ -401,7 +404,12 @@ "total": "Total rows", "min": "Min date", "max": "Max date", - "span": "Date span" + "span": "Date span", + "loadingError": "An error occurred while loading the preview.", + "dateError": "Date unknown", + "name": "Name", + "value": "Value", + "densityPlot": "Density Plot" }, "login": { "headline": "Queries and Analyses",