diff --git a/package-lock.json b/package-lock.json index aa60f3b5a..6dedcbf0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2994,6 +2994,42 @@ "resolved": "packages/pxweb2-ui", "link": true }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -3658,7 +3694,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@storybook/addon-a11y": { @@ -4002,6 +4043,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -4134,6 +4238,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", @@ -5819,6 +5929,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5924,6 +6155,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -6351,6 +6588,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -7061,6 +7308,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -7712,6 +7965,12 @@ "void-elements": "3.1.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -7956,6 +8215,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", @@ -8036,6 +8305,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -10696,7 +10974,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT", "peer": true }, @@ -10727,6 +11004,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -10798,6 +11098,36 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -10825,6 +11155,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10948,6 +11293,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -11884,7 +12235,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, "license": "MIT" }, "node_modules/tinybench": { @@ -12477,6 +12827,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", @@ -13730,7 +14102,9 @@ "@vitejs/plugin-react": "^5.2.0", "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", + "html-to-image": "^1.11.13", "motion": "^12.38.0", + "recharts": "^3.8.1", "vite-plugin-dts": "^4.5.4" }, "devDependencies": { diff --git a/packages/pxweb2-ui/package.json b/packages/pxweb2-ui/package.json index 9a9b6f147..27a6a6ca6 100644 --- a/packages/pxweb2-ui/package.json +++ b/packages/pxweb2-ui/package.json @@ -19,7 +19,9 @@ "@vitejs/plugin-react": "^5.2.0", "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", + "html-to-image": "^1.11.13", "motion": "^12.38.0", + "recharts": "^3.8.1", "vite-plugin-dts": "^4.5.4" }, "devDependencies": { diff --git a/packages/pxweb2-ui/src/index.ts b/packages/pxweb2-ui/src/index.ts index 3c8afc3f1..2275e039b 100644 --- a/packages/pxweb2-ui/src/index.ts +++ b/packages/pxweb2-ui/src/index.ts @@ -4,6 +4,7 @@ export * from './lib/components/ActionItem/ActionItem'; export * from './lib/components/BottomSheet/BottomSheet'; export * from './lib/components/Breadcrumbs/Breadcrumbs'; export * from './lib/components/Button/Button'; +export * from './lib/components/Chart/Chart'; export * from './lib/components/Checkbox/Checkbox'; export * from './lib/components/CheckCircle/CheckCircleIcon'; export * from './lib/components/CheckCircle/CheckCircleToggle'; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx new file mode 100644 index 000000000..c7872382f --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; + +import { LineChart } from './Charts/LineChart'; +import { BarChart } from './Charts/BarChart'; +import { PopulationPyramid } from './Charts/PopulationPyramid'; +import { ExportableChart } from './ExportableChart'; +import { mapPxTableToChart } from './chartDataMapper'; +import type { PxTable } from '../../shared-types/pxTable'; + +interface ChartProps { + readonly pxtable: PxTable; +} + +export function Chart({ pxtable }: ChartProps) { + const chartConfig = useMemo(() => mapPxTableToChart(pxtable), [pxtable]); + + return ( +
+ + + + + + + + + + + + + + + +
+ ); +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx new file mode 100644 index 000000000..899d096a7 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -0,0 +1,59 @@ +import { + CartesianGrid, + Legend, + Bar, + BarChart as RechartsBarChart, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import type { ChartDataPoint, ChartSeries } from '../chartTypes'; + +const seriesColors = ['#5f3dc4', '#1864ab', '#0b7285', '#2b8a3e', '#e67700']; + +interface BarChartProps { + readonly data: ChartDataPoint[]; + readonly series: ChartSeries[]; + readonly isHorizontal?: boolean; +} + +export function BarChart({ + data, + series, + isHorizontal = false, +}: BarChartProps) { + const chartSeries = + series.length > 0 ? series : [{ key: 'value', name: 'Value' }]; + + return ( + + + + + + + + {chartSeries.map((serie, index) => ( + + ))} + + ); +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx new file mode 100644 index 000000000..b413814a5 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx @@ -0,0 +1,49 @@ +import { + CartesianGrid, + Legend, + Line, + LineChart as RechartsLineChart, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import type { ChartDataPoint, ChartSeries } from '../chartTypes'; + +interface LineChartProps { + readonly data: ChartDataPoint[]; + readonly series: ChartSeries[]; +} + +const seriesColors = ['#5f3dc4', '#1864ab', '#0b7285', '#2b8a3e', '#e67700']; + +export function LineChart({ data, series }: LineChartProps) { + const chartSeries = + series.length > 0 ? series : [{ key: 'value', name: 'Value' }]; + + return ( + + + + + + + + {chartSeries.map((serie, index) => ( + + ))} + + ); +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx new file mode 100644 index 000000000..bb8cdeb96 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx @@ -0,0 +1,70 @@ +import { useMemo } from 'react'; +import { Bar, BarChart, Legend, Tooltip, XAxis, YAxis } from 'recharts'; + +import type { PxTable } from '../../../shared-types/pxTable'; +import { + mapPopulationPyramidData, + validatePopulationPyramidData, +} from '../populationPyramidData'; + +interface PopulationPyramidProps { + readonly pxtable: PxTable; +} + +function formatAbsolute(value: number | string | null): string { + if (typeof value !== 'number') { + return '-'; + } + + return Math.abs(value).toString(); +} + +export function PopulationPyramid({ pxtable }: PopulationPyramidProps) { + const validation = useMemo( + () => validatePopulationPyramidData(pxtable), + [pxtable], + ); + const chartConfig = useMemo( + () => mapPopulationPyramidData(pxtable), + [pxtable], + ); + + if (!validation.isValid || !chartConfig) { + return ( +
+ Population pyramid is not available for this dataset. +
{validation.reason}
+
+ ); + } + + return ( + + formatAbsolute(value)} /> + + formatAbsolute(value as number | string | null)} + /> + + + + + ); +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/ExportableChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/ExportableChart.tsx new file mode 100644 index 000000000..d64714281 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/ExportableChart.tsx @@ -0,0 +1,92 @@ +import { useRef, type ReactNode } from 'react'; +import { toPng, toSvg } from 'html-to-image'; + +interface ExportableChartProps { + readonly title: string; + readonly fileName: string; + readonly children: ReactNode; +} + +function downloadDataUrl(dataUrl: string, fileName: string): void { + const link = document.createElement('a'); + link.href = dataUrl; + link.download = fileName; + link.click(); +} + +async function exportSvg( + container: HTMLDivElement | null, + fileName: string, +): Promise { + if (!container) { + return; + } + + const dataUrl = await toSvg(container, { + cacheBust: true, + backgroundColor: '#ffffff', + }); + + downloadDataUrl(dataUrl, `${fileName}.svg`); +} + +async function exportPng( + container: HTMLDivElement | null, + fileName: string, +): Promise { + if (!container) { + return; + } + + const dataUrl = await toPng(container, { + cacheBust: true, + backgroundColor: '#ffffff', + pixelRatio: 2, + }); + + downloadDataUrl(dataUrl, `${fileName}.png`); +} + +export function ExportableChart({ + title, + fileName, + children, +}: ExportableChartProps) { + const chartContainerRef = useRef(null); + + return ( +
+
+

{title}

+
+ + +
+
+
{children}
+
+ ); +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts new file mode 100644 index 000000000..eb9d81873 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts @@ -0,0 +1,109 @@ +import { getPxTableData } from '../Table/cubeHelper'; +import type { PxTable } from '../../shared-types/pxTable'; +import type { DataCell } from '../../shared-types/pxTableData'; + +import type { ChartDataPoint, ChartSeries } from './chartTypes'; + +interface CombinationItem { + readonly variableId: string; + readonly code: string; + readonly label: string; +} + +interface Combination { + readonly items: CombinationItem[]; +} + +function buildCombinations( + variables: PxTable['metadata']['variables'], +): Combination[] { + if (variables.length === 0) { + return [{ items: [] }]; + } + + return variables.reduce( + (acc, variable) => { + const next: Combination[] = []; + const values = variable.values.length > 0 ? variable.values : []; + + for (const combo of acc) { + for (const value of values) { + next.push({ + items: [ + ...combo.items, + { + variableId: variable.id, + code: value.code, + label: value.label, + }, + ], + }); + } + } + + return next; + }, + [{ items: [] }], + ); +} + +function toCodeMap(items: CombinationItem[]): Record { + return Object.fromEntries(items.map((item) => [item.variableId, item.code])); +} + +function getLabel(items: CombinationItem[], fallback: string): string { + if (items.length === 0) { + return fallback; + } + + return items.map((item) => item.label).join(' / '); +} + +export function mapPxTableToChart(pxtable: PxTable): { + data: ChartDataPoint[]; + series: ChartSeries[]; +} { + const rowCombinations = buildCombinations(pxtable.stub); + const seriesCombinations = buildCombinations(pxtable.heading); + + const series: ChartSeries[] = seriesCombinations.map( + (combination, index) => ({ + key: + combination.items.map((item) => item.code).join('|') || + `series-${index}`, + name: getLabel(combination.items, 'Value'), + }), + ); + + const data = rowCombinations.map((rowCombination) => { + const rowMap = toCodeMap(rowCombination.items); + const point: Record = { + name: getLabel(rowCombination.items, 'Value'), + }; + + seriesCombinations.forEach((seriesCombination, seriesIndex) => { + const seriesKey = series[seriesIndex].key; + const allCodes = { + ...rowMap, + ...toCodeMap(seriesCombination.items), + }; + + const dimensions = pxtable.data.variableOrder.map( + (variableId) => allCodes[variableId], + ); + + if (dimensions.some((dimension) => !dimension)) { + point[seriesKey] = null; + return; + } + + const dataCell = getPxTableData(pxtable.data.cube, dimensions); + + point[seriesKey] = dataCell?.value ?? null; + }); + + return point as ChartDataPoint; + }); + + return { data, series }; +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts new file mode 100644 index 000000000..1aaf457ef --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts @@ -0,0 +1,28 @@ +export interface ChartDataPoint { + readonly name: string; + readonly [seriesKey: string]: number | string | null; +} + +export interface ChartSeries { + readonly key: string; + readonly name: string; +} + +export interface PopulationPyramidDataPoint { + readonly name: string; + readonly left: number | null; + readonly right: number | null; +} + +export interface PopulationPyramidConfig { + readonly data: PopulationPyramidDataPoint[]; + readonly leftSeriesName: string; + readonly rightSeriesName: string; +} + +export interface PopulationPyramidValidationResult { + readonly isValid: boolean; + readonly reason?: string; + readonly twoValueDimensionId?: string; + readonly multiValueDimensionId?: string; +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/populationPyramidData.ts b/packages/pxweb2-ui/src/lib/components/Chart/populationPyramidData.ts new file mode 100644 index 000000000..e6930ec00 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/populationPyramidData.ts @@ -0,0 +1,151 @@ +import { getPxTableData } from '../Table/cubeHelper'; +import type { PxTable } from '../../shared-types/pxTable'; +import type { DataCell } from '../../shared-types/pxTableData'; +import type { Variable } from '../../shared-types/variable'; + +import { + type PopulationPyramidConfig, + type PopulationPyramidValidationResult, +} from './chartTypes'; + +function allDimensions(pxtable: PxTable): Variable[] { + return [...pxtable.stub, ...pxtable.heading]; +} + +export function validatePopulationPyramidData( + pxtable: PxTable, +): PopulationPyramidValidationResult { + const dimensions = allDimensions(pxtable); + + const twoValueDimensions = dimensions.filter( + (dimension) => dimension.values.length === 2, + ); + const multiValueDimensions = dimensions.filter( + (dimension) => dimension.values.length > 2, + ); + + if (twoValueDimensions.length !== 1) { + return { + isValid: false, + reason: + 'Population pyramid requires exactly one dimension with 2 values.', + }; + } + + if (multiValueDimensions.length !== 1) { + return { + isValid: false, + reason: + 'Population pyramid requires exactly one dimension with more than 2 values.', + }; + } + + const twoValueDimension = twoValueDimensions[0]; + const multiValueDimension = multiValueDimensions[0]; + + const restDimensions = dimensions.filter( + (dimension) => + dimension.id !== twoValueDimension.id && + dimension.id !== multiValueDimension.id, + ); + + if (restDimensions.some((dimension) => dimension.values.length !== 1)) { + return { + isValid: false, + reason: + 'Population pyramid requires all remaining dimensions to have exactly one value.', + }; + } + + return { + isValid: true, + twoValueDimensionId: twoValueDimension.id, + multiValueDimensionId: multiValueDimension.id, + }; +} + +export function mapPopulationPyramidData( + pxtable: PxTable, +): PopulationPyramidConfig | null { + const validation = validatePopulationPyramidData(pxtable); + + if ( + !validation.isValid || + !validation.twoValueDimensionId || + !validation.multiValueDimensionId + ) { + return null; + } + + const dimensions = allDimensions(pxtable); + const splitDimension = dimensions.find( + (dimension) => dimension.id === validation.twoValueDimensionId, + ); + const categoryDimension = dimensions.find( + (dimension) => dimension.id === validation.multiValueDimensionId, + ); + + if (!splitDimension || !categoryDimension) { + return null; + } + + const [leftValue, rightValue] = splitDimension.values; + + const fixedCodes = Object.fromEntries( + dimensions + .filter( + (dimension) => + dimension.id !== splitDimension.id && + dimension.id !== categoryDimension.id && + dimension.values.length === 1, + ) + .map((dimension) => [dimension.id, dimension.values[0].code]), + ); + + const data = categoryDimension.values.map((categoryValue) => { + const leftCodes: Record = { + ...fixedCodes, + [categoryDimension.id]: categoryValue.code, + [splitDimension.id]: leftValue.code, + }; + + const rightCodes: Record = { + ...fixedCodes, + [categoryDimension.id]: categoryValue.code, + [splitDimension.id]: rightValue.code, + }; + + const leftDimensions = pxtable.data.variableOrder.map( + (variableId) => leftCodes[variableId], + ); + const rightDimensions = pxtable.data.variableOrder.map( + (variableId) => rightCodes[variableId], + ); + + const leftCell = leftDimensions.some((dimension) => !dimension) + ? undefined + : getPxTableData(pxtable.data.cube, leftDimensions); + const rightCell = rightDimensions.some((dimension) => !dimension) + ? undefined + : getPxTableData(pxtable.data.cube, rightDimensions); + + const leftValueNumber = leftCell?.value; + const rightValueNumber = rightCell?.value; + + return { + name: categoryValue.label, + left: + typeof leftValueNumber === 'number' ? -Math.abs(leftValueNumber) : null, + right: + typeof rightValueNumber === 'number' + ? Math.abs(rightValueNumber) + : null, + }; + }); + + return { + data, + leftSeriesName: leftValue.label, + rightSeriesName: rightValue.label, + }; +} diff --git a/packages/pxweb2/public/_headers b/packages/pxweb2/public/_headers index dc4d3611d..b2793216b 100644 --- a/packages/pxweb2/public/_headers +++ b/packages/pxweb2/public/_headers @@ -8,4 +8,4 @@ Cache-Control: no-cache, must-revalidate Pragma: no-cache Expires: 0 - Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net ajax.googleapis.com; + Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self'; script-src 'self'; img-src 'self' data:; diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.module.scss b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.module.scss index 0aa3c1701..a3162055f 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.module.scss +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.module.scss @@ -1,3 +1,8 @@ -.alert { - margin: 8px; +.operationList { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; } diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.spec.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.spec.tsx index 245bcbf94..ae646654f 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.spec.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.spec.tsx @@ -1,12 +1,17 @@ import { describe, it, expect } from 'vitest'; import { render } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; +import { MemoryRouter } from 'react-router'; import { DrawerView } from './DrawerView'; describe('DrawerView', () => { it('renders successfully', () => { - const element = render(); + const element = render( + + + , + ); expect(element).toBeTruthy(); }); diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.tsx index f9a0810aa..e19e9b3da 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.tsx @@ -1,16 +1,48 @@ import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router'; -import { ContentBox, LocalAlert } from '@pxweb2/pxweb2-ui'; +import { ActionItem, ContentBox } from '@pxweb2/pxweb2-ui'; import classes from './DrawerView.module.scss'; +type ViewMode = 'table' | 'graph'; + +function getViewMode(searchParams: URLSearchParams): ViewMode { + return searchParams.get('view') === 'graph' ? 'graph' : 'table'; +} + export function DrawerView() { const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedViewMode = getViewMode(searchParams); + + function setViewMode(viewMode: ViewMode) { + const nextSearchParams = new URLSearchParams(searchParams); + nextSearchParams.set('view', viewMode); + setSearchParams(nextSearchParams); + } return ( - - {t('common.status_messages.drawer_view')} - +
    +
  • + setViewMode('table')} + toggleState={selectedViewMode === 'table'} + /> +
  • +
  • + setViewMode('graph')} + toggleState={selectedViewMode === 'graph'} + /> +
  • +
); } diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.spec.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.spec.tsx index ddc5ac0c5..98971e658 100644 --- a/packages/pxweb2/src/app/components/Presentation/Presentation.spec.tsx +++ b/packages/pxweb2/src/app/components/Presentation/Presentation.spec.tsx @@ -1,4 +1,5 @@ import { renderWithProviders } from '../../util/testing-utils'; +import { MemoryRouter } from 'react-router'; import Presentation from './Presentation'; describe('Presentation', () => { @@ -20,11 +21,13 @@ describe('Presentation', () => { it('should render successfully', () => { const { baseElement } = renderWithProviders( - , + + + , ); expect(baseElement).toBeTruthy(); diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx index 3f48d6014..d8ace49ec 100644 --- a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx +++ b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx @@ -1,12 +1,25 @@ import cl from 'clsx'; import { useTranslation } from 'react-i18next'; -import React, { useRef, useEffect, useState, useLayoutEffect } from 'react'; +import React, { + Activity, + useRef, + useEffect, + useState, + useLayoutEffect, +} from 'react'; +import { useSearchParams } from 'react-router'; import isEqual from 'lodash/isEqual'; import classes from './Presentation.module.scss'; import useApp from '../../context/useApp'; import { ContentTop } from '../ContentTop/ContentTop'; -import { Table, EmptyState, PxTable, LocalAlert } from '@pxweb2/pxweb2-ui'; +import { + Chart, + Table, + EmptyState, + PxTable, + LocalAlert, +} from '@pxweb2/pxweb2-ui'; import useTableData from '../../context/useTableData'; import useVariables from '../../context/useVariables'; import { useDebounce } from '@uidotdev/usehooks'; @@ -33,6 +46,7 @@ export function Presentation({ isExpanded, setIsExpanded, }: Readonly) { + const [searchParams] = useSearchParams(); const { isMobile, getSavedQueryId } = useApp(); const config = getConfig(); const { i18n, t } = useTranslation(); @@ -52,6 +66,7 @@ export function Presentation({ const { isFadingTable, setIsFadingTable } = tableData; const [isMandatoryNotSelectedFirst, setIsMandatoryNotSelectedFirst] = useState(true); + const isGraphView = searchParams.get('view') === 'graph'; const tableContainerRef = useRef(null); const gradientContainerRef = useRef(null); @@ -269,9 +284,16 @@ export function Presentation({ className={classes.gradientContainer} ref={gradientContainerRef} > -
- -
+ +
+ +
+
+ +
+ +
+
)} {isMissingMandatoryVariables && @@ -281,9 +303,22 @@ export function Presentation({ className={classes.gradientContainer} ref={gradientContainerRef} > -
- -
+ +
+ +
+
+ +
+ +
+
)} {!isLoadingMetadata && diff --git a/packages/pxweb2/src/app/savedQueryRouteLoader.spec.ts b/packages/pxweb2/src/app/savedQueryRouteLoader.spec.ts index 41de68035..9fd2ad2ed 100644 --- a/packages/pxweb2/src/app/savedQueryRouteLoader.spec.ts +++ b/packages/pxweb2/src/app/savedQueryRouteLoader.spec.ts @@ -17,6 +17,16 @@ const makeArgs = (sqId?: string): LoaderFunctionArgs => ({ unstable_pattern: '', }); +const makeArgsWithSearch = ( + sqId: string, + search: string, +): LoaderFunctionArgs => ({ + params: { sqId }, + request: new Request(`http://localhost/sq/${sqId}${search}`), + context: undefined, + unstable_pattern: '', +}); + vi.mock('./util/config/getConfig', () => ({ getConfig: vi.fn(), })); @@ -86,6 +96,38 @@ describe('savedQueryRouteLoader', () => { expect(i18n.changeLanguage).toHaveBeenCalledWith('en'); }); + it('preserves valid view query parameter on redirect', async () => { + vi.mocked(getConfig).mockReturnValue({ + apiUrl: 'https://api.example', + baseApplicationPath: '', + language: { + defaultLanguage: 'en', + showDefaultLanguageInPath: true, + supportedLanguages: [ + { shorthand: 'en', languageName: 'English' }, + { shorthand: 'sv', languageName: 'Swedish' }, + ], + }, + } as unknown as AppConfig); + vi.mocked(SavedQueriesService.getSaveQuery).mockResolvedValue({ + language: 'en', + id: '123', + savedQuery: { + tableId: 'T1', + language: 'en', + selection: { selection: [] }, + }, + links: [], + } as SavedQueryResponse); + + const res = await savedQueryRouteLoader( + makeArgsWithSearch('123', '?view=graph'), + ); + + expect(res.status).toBe(302); + expect(res.headers.get('Location')).toBe('/en/table/T1?sq=123&view=graph'); + }); + it('redirects to /table when lang is default and showDefaultLanguageInPath=false', async () => { vi.mocked(getConfig).mockReturnValue({ apiUrl: 'https://api.example', diff --git a/packages/pxweb2/src/app/savedQueryRouteLoader.ts b/packages/pxweb2/src/app/savedQueryRouteLoader.ts index cb806d4ab..ca1387c56 100644 --- a/packages/pxweb2/src/app/savedQueryRouteLoader.ts +++ b/packages/pxweb2/src/app/savedQueryRouteLoader.ts @@ -6,9 +6,14 @@ import { getLanguagePath } from './util/language/getLanguagePath'; // Handles URL:s for saved queries containing only the saved query id and redirects to the proper table URL. // Example: https://www.yourpxweb.com/sq/123456 -> https://www.yourpxweb.com/{lang}/table/{tableId}?sq=123456 -export async function savedQueryRouteLoader({ params }: LoaderFunctionArgs) { +export async function savedQueryRouteLoader({ + params, + request, +}: LoaderFunctionArgs) { const config = getConfig(); const { sqId } = params as { sqId?: string }; + const requestUrl = new URL(request.url); + const incomingView = requestUrl.searchParams.get('view'); if (!sqId) { throw new Response('Missing saved query id', { status: 400 }); @@ -34,7 +39,12 @@ export async function savedQueryRouteLoader({ params }: LoaderFunctionArgs) { config.language.positionInPath, ); - const search = `?${new URLSearchParams({ sq: sqId }).toString()}`; + const redirectParams = new URLSearchParams({ sq: sqId }); + if (incomingView === 'table' || incomingView === 'graph') { + redirectParams.set('view', incomingView); + } + + const search = `?${redirectParams.toString()}`; return redirect(`${path}${search}`); } catch (e: unknown) {