diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a8dbdf68..11e16c3f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). +## [4.6.1] - 2025-11-15 +### Added +* *Nothing* + +### Changed +* [#802](https://github.com/shlinkio/shlink-web-client/issues/802) Improve dependency injection in components. +* Stop injecting redux state and actions. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* Fix small UI issues. + + ## [4.6.0] - 2025-11-12 ### Added * [shlink-web-component#839](https://github.com/shlinkio/shlink-web-component/issues/839) Allow filtering short URLs by excluded tags when using Shlink >=4.6.0 diff --git a/Dockerfile b/Dockerfile index 5a0a7dca6..a4a076f8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:25.1-alpine AS node +FROM node:25.2-alpine AS node COPY . /shlink-web-client ARG VERSION="latest" ENV VERSION=${VERSION} diff --git a/package-lock.json b/package-lock.json index e12820598..a3fb03077 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,10 @@ "@json2csv/plainjs": "^7.0.6", "@reduxjs/toolkit": "^2.10.1", "@shlinkio/data-manipulation": "^1.0.4", - "@shlinkio/shlink-frontend-kit": "^1.3.0", + "@shlinkio/shlink-frontend-kit": "^1.3.1", "@shlinkio/shlink-js-sdk": "^3.0.1", "@shlinkio/shlink-web-component": "^0.17.0", - "@vitest/browser-playwright": "^4.0.8", + "@vitest/browser-playwright": "^4.0.9", "bottlejs": "^2.0.1", "clsx": "^2.1.1", "compare-versions": "^6.1.1", @@ -29,7 +29,7 @@ "react-dom": "^19.2.0", "react-external-link": "^2.6.1", "react-redux": "^9.2.0", - "react-router": "^7.9.5", + "react-router": "^7.9.6", "redux-localstorage-simple": "^2.5.1", "workbox-core": "^7.3.0", "workbox-expiration": "^7.3.0", @@ -45,11 +45,11 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@total-typescript/shoehorn": "^0.1.2", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "@vitejs/plugin-react": "^5.1.0", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", "@vitest/browser": "^4.0.3", - "@vitest/coverage-v8": "^4.0.8", + "@vitest/coverage-v8": "^4.0.9", "adm-zip": "^0.5.16", "axe-core": "^4.11.0", "chalk": "^5.6.2", @@ -64,7 +64,7 @@ "playwright": "^1.56.1", "tailwindcss": "^4.1.3", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.3", + "typescript-eslint": "^8.46.4", "vite": "^7.2.2", "vite-plugin-pwa": "^1.1.0", "vitest": "^4.0.3" @@ -230,20 +230,20 @@ } }, "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -269,13 +269,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "dev": true, "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -1745,17 +1745,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "dev": true, "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/types": "^7.28.5", "debug": "^4.3.1" }, "engines": { @@ -2939,9 +2939,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.43", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", - "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", "dev": true }, "node_modules/@rollup/plugin-node-resolve": { @@ -3309,10 +3309,9 @@ } }, "node_modules/@shlinkio/shlink-frontend-kit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-1.3.0.tgz", - "integrity": "sha512-/ydi82RM/rbSqcMkyGJ4+zz1hAlatBhW8m5YQ1XvXrB7ZrU2VSCJB0VA4XZmsDHNz4WDeuktwaFUo7TxsGutxA==", - "license": "MIT", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-1.3.1.tgz", + "integrity": "sha512-hfKsUJdNYXQv9+ABubvgprnjA28l/lGCXzJ34GMgtXzTnvV5bexAb+CRhHYfRiYkmLyU5or7+rNpBixudHwUjw==", "dependencies": { "@floating-ui/react": "^0.27.16", "@vitest/browser-playwright": "^4.0.8", @@ -4073,18 +4072,18 @@ } }, "node_modules/@types/react": { - "version": "19.2.2", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", - "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", + "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", "devOptional": true, "dependencies": { "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.2", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", - "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "peerDependencies": { "@types/react": "^19.2.0" @@ -4108,16 +4107,16 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", - "integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", + "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/type-utils": "8.46.3", - "@typescript-eslint/utils": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/type-utils": "8.46.4", + "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -4131,7 +4130,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.3", + "@typescript-eslint/parser": "^8.46.4", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -4146,15 +4145,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", - "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", + "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "debug": "^4.3.4" }, "engines": { @@ -4170,13 +4169,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz", - "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", + "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", "dev": true, "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.3", - "@typescript-eslint/types": "^8.46.3", + "@typescript-eslint/tsconfig-utils": "^8.46.4", + "@typescript-eslint/types": "^8.46.4", "debug": "^4.3.4" }, "engines": { @@ -4191,13 +4190,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz", - "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", + "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3" + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4208,9 +4207,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz", - "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", + "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4224,14 +4223,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.3.tgz", - "integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", + "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/utils": "8.46.3", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -4248,9 +4247,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz", - "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", + "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4261,15 +4260,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz", - "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", + "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", "dev": true, "dependencies": { - "@typescript-eslint/project-service": "8.46.3", - "@typescript-eslint/tsconfig-utils": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/project-service": "8.46.4", + "@typescript-eslint/tsconfig-utils": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -4313,15 +4312,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz", - "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", + "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3" + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4336,12 +4335,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz", - "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", + "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/types": "8.46.4", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -4365,15 +4364,15 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz", - "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", + "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", "dev": true, "dependencies": { - "@babel/core": "^7.28.4", + "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.43", + "@rolldown/pluginutils": "1.0.0-beta.47", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -4385,12 +4384,12 @@ } }, "node_modules/@vitest/browser": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.8.tgz", - "integrity": "sha512-oG6QJAR0d7S5SDnIYZwjxCj/a5fhbp9ZE7GtMgZn+yCUf4CxtqbBV6aXyg0qmn8nbUWT+rGuXL2ZB6qDBUjv/A==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.9.tgz", + "integrity": "sha512-OWN4ZgOIV2+T9cR4qfoajtjZDFoxcLa6qUpgDkviXZFUNkZ7XTVKvL/16X+gz5dtpqdZwXf3m0qIj72Ge/vytw==", "dependencies": { - "@vitest/mocker": "4.0.8", - "@vitest/utils": "4.0.8", + "@vitest/mocker": "4.0.9", + "@vitest/utils": "4.0.9", "magic-string": "^0.30.21", "pixelmatch": "7.1.0", "pngjs": "^7.0.0", @@ -4402,16 +4401,16 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.0.8" + "vitest": "4.0.9" } }, "node_modules/@vitest/browser-playwright": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.8.tgz", - "integrity": "sha512-MUi0msIAPXcA2YAuVMcssrSYP/yylxLt347xyTC6+ODl0c4XQFs0d2AN3Pc3iTa0pxIGmogflUV6eogXpPbJeA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.9.tgz", + "integrity": "sha512-ayr0vCxvJIvodzfUTVzifFMT3bmcMeKzEWoPt7mtgrZsqJhMbYaftifuBZRQeF/glogsVr+jhtIePHw6g+0YRQ==", "dependencies": { - "@vitest/browser": "4.0.8", - "@vitest/mocker": "4.0.8", + "@vitest/browser": "4.0.9", + "@vitest/mocker": "4.0.9", "tinyrainbow": "^3.0.3" }, "funding": { @@ -4419,7 +4418,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "4.0.8" + "vitest": "4.0.9" }, "peerDependenciesMeta": { "playwright": { @@ -4428,13 +4427,13 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.8.tgz", - "integrity": "sha512-wQgmtW6FtPNn4lWUXi8ZSYLpOIb92j3QCujxX3sQ81NTfQ/ORnE0HtK7Kqf2+7J9jeveMGyGyc4NWc5qy3rC4A==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.9.tgz", + "integrity": "sha512-70oyhP+Q0HlWBIeGSP74YBw5KSjYhNgSCQjvmuQFciMqnyF36WL2cIkcT7XD85G4JPmBQitEMUsx+XMFv2AzQA==", "dev": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.8", + "@vitest/utils": "4.0.9", "ast-v8-to-istanbul": "^0.3.8", "debug": "^4.4.3", "istanbul-lib-coverage": "^3.2.2", @@ -4449,8 +4448,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.8", - "vitest": "4.0.8" + "@vitest/browser": "4.0.9", + "vitest": "4.0.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4459,14 +4458,14 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.8.tgz", - "integrity": "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.9.tgz", + "integrity": "sha512-C2vyXf5/Jfj1vl4DQYxjib3jzyuswMi/KHHVN2z+H4v16hdJ7jMZ0OGe3uOVIt6LyJsAofDdaJNIFEpQcrSTFw==", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.8", - "@vitest/utils": "4.0.8", + "@vitest/spy": "4.0.9", + "@vitest/utils": "4.0.9", "chai": "^6.2.0", "tinyrainbow": "^3.0.3" }, @@ -4475,11 +4474,11 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.8.tgz", - "integrity": "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.9.tgz", + "integrity": "sha512-PUyaowQFHW+9FKb4dsvvBM4o025rWMlEDXdWRxIOilGaHREYTi5Q2Rt9VCgXgPy/hHZu1LeuXtrA/GdzOatP2g==", "dependencies": { - "@vitest/spy": "4.0.8", + "@vitest/spy": "4.0.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4508,9 +4507,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz", - "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.9.tgz", + "integrity": "sha512-Hor0IBTwEi/uZqB7pvGepyElaM8J75pYjrrqbC8ZYMB9/4n5QA63KC15xhT+sqHpdGWfdnPo96E8lQUxs2YzSQ==", "dependencies": { "tinyrainbow": "^3.0.3" }, @@ -4519,11 +4518,11 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.8.tgz", - "integrity": "sha512-mdY8Sf1gsM8hKJUQfiPT3pn1n8RF4QBcJYFslgWh41JTfrK1cbqY8whpGCFzBl45LN028g0njLCYm0d7XxSaQQ==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.9.tgz", + "integrity": "sha512-aF77tsXdEvIJRkj9uJZnHtovsVIx22Ambft9HudC+XuG/on1NY/bf5dlDti1N35eJT+QZLb4RF/5dTIG18s98w==", "dependencies": { - "@vitest/utils": "4.0.8", + "@vitest/utils": "4.0.9", "pathe": "^2.0.3" }, "funding": { @@ -4531,11 +4530,11 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.8.tgz", - "integrity": "sha512-Nar9OTU03KGiubrIOFhcfHg8FYaRaNT+bh5VUlNz8stFhCZPNrJvmZkhsr1jtaYvuefYFwK2Hwrq026u4uPWCw==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.9.tgz", + "integrity": "sha512-r1qR4oYstPbnOjg0Vgd3E8ADJbi4ditCzqr+Z9foUrRhIy778BleNyZMeAJ2EjV+r4ASAaDsdciC9ryMy8xMMg==", "dependencies": { - "@vitest/pretty-format": "4.0.8", + "@vitest/pretty-format": "4.0.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4544,19 +4543,19 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.8.tgz", - "integrity": "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.9.tgz", + "integrity": "sha512-J9Ttsq0hDXmxmT8CUOWUr1cqqAj2FJRGTdyEjSR+NjoOGKEqkEWj+09yC0HhI8t1W6t4Ctqawl1onHgipJve1A==", "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz", - "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.9.tgz", + "integrity": "sha512-cEol6ygTzY4rUPvNZM19sDf7zGa35IYTm9wfzkHoT/f5jX10IOY7QleWSOh5T0e3I3WVozwK5Asom79qW8DiuQ==", "dependencies": { - "@vitest/pretty-format": "4.0.8", + "@vitest/pretty-format": "4.0.9", "tinyrainbow": "^3.0.3" }, "funding": { @@ -5113,9 +5112,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", - "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", "engines": { "node": ">=18" } @@ -7655,11 +7654,10 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -8862,9 +8860,9 @@ } }, "node_modules/react-router": { - "version": "7.9.5", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", - "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==", + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", + "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -10111,15 +10109,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.3.tgz", - "integrity": "sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz", + "integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==", "dev": true, "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.3", - "@typescript-eslint/parser": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/utils": "8.46.3" + "@typescript-eslint/eslint-plugin": "8.46.4", + "@typescript-eslint/parser": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10430,17 +10428,17 @@ } }, "node_modules/vitest": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.8.tgz", - "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==", - "dependencies": { - "@vitest/expect": "4.0.8", - "@vitest/mocker": "4.0.8", - "@vitest/pretty-format": "4.0.8", - "@vitest/runner": "4.0.8", - "@vitest/snapshot": "4.0.8", - "@vitest/spy": "4.0.8", - "@vitest/utils": "4.0.8", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.9.tgz", + "integrity": "sha512-E0Ja2AX4th+CG33yAFRC+d1wFx2pzU5r6HtG6LiPSE04flaE0qB6YyjSw9ZcpJAtVPfsvZGtJlKWZpuW7EHRxg==", + "dependencies": { + "@vitest/expect": "4.0.9", + "@vitest/mocker": "4.0.9", + "@vitest/pretty-format": "4.0.9", + "@vitest/runner": "4.0.9", + "@vitest/snapshot": "4.0.9", + "@vitest/spy": "4.0.9", + "@vitest/utils": "4.0.9", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", @@ -10468,10 +10466,10 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.8", - "@vitest/browser-preview": "4.0.8", - "@vitest/browser-webdriverio": "4.0.8", - "@vitest/ui": "4.0.8", + "@vitest/browser-playwright": "4.0.9", + "@vitest/browser-preview": "4.0.9", + "@vitest/browser-webdriverio": "4.0.9", + "@vitest/ui": "4.0.9", "happy-dom": "*", "jsdom": "*" }, @@ -11206,20 +11204,20 @@ "dev": true }, "@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "requires": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -11237,13 +11235,13 @@ } }, "@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "dev": true, "requires": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -12227,17 +12225,17 @@ } }, "@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "dev": true, "requires": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", + "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, @@ -12849,9 +12847,9 @@ } }, "@rolldown/pluginutils": { - "version": "1.0.0-beta.43", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", - "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", "dev": true }, "@rollup/plugin-node-resolve": { @@ -13036,9 +13034,9 @@ "requires": {} }, "@shlinkio/shlink-frontend-kit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-1.3.0.tgz", - "integrity": "sha512-/ydi82RM/rbSqcMkyGJ4+zz1hAlatBhW8m5YQ1XvXrB7ZrU2VSCJB0VA4XZmsDHNz4WDeuktwaFUo7TxsGutxA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@shlinkio/shlink-frontend-kit/-/shlink-frontend-kit-1.3.1.tgz", + "integrity": "sha512-hfKsUJdNYXQv9+ABubvgprnjA28l/lGCXzJ34GMgtXzTnvV5bexAb+CRhHYfRiYkmLyU5or7+rNpBixudHwUjw==", "requires": { "@floating-ui/react": "^0.27.16", "@vitest/browser-playwright": "^4.0.8", @@ -13567,18 +13565,18 @@ } }, "@types/react": { - "version": "19.2.2", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", - "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", + "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", "devOptional": true, "requires": { "csstype": "^3.0.2" } }, "@types/react-dom": { - "version": "19.2.2", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", - "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "requires": {} }, @@ -13600,16 +13598,16 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" }, "@typescript-eslint/eslint-plugin": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", - "integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", + "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/type-utils": "8.46.3", - "@typescript-eslint/utils": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/type-utils": "8.46.4", + "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -13625,75 +13623,75 @@ } }, "@typescript-eslint/parser": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", - "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", + "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "debug": "^4.3.4" } }, "@typescript-eslint/project-service": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz", - "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", + "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", "dev": true, "requires": { - "@typescript-eslint/tsconfig-utils": "^8.46.3", - "@typescript-eslint/types": "^8.46.3", + "@typescript-eslint/tsconfig-utils": "^8.46.4", + "@typescript-eslint/types": "^8.46.4", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz", - "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", + "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", "dev": true, "requires": { - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3" + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4" } }, "@typescript-eslint/tsconfig-utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz", - "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", + "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", "dev": true, "requires": {} }, "@typescript-eslint/type-utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.3.tgz", - "integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", + "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", "dev": true, "requires": { - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/utils": "8.46.3", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" } }, "@typescript-eslint/types": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz", - "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", + "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz", - "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", + "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", "dev": true, "requires": { - "@typescript-eslint/project-service": "8.46.3", - "@typescript-eslint/tsconfig-utils": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/project-service": "8.46.4", + "@typescript-eslint/tsconfig-utils": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -13723,24 +13721,24 @@ } }, "@typescript-eslint/utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz", - "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", + "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3" + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4" } }, "@typescript-eslint/visitor-keys": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz", - "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", + "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", "dev": true, "requires": { - "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/types": "8.46.4", "eslint-visitor-keys": "^4.2.1" }, "dependencies": { @@ -13753,26 +13751,26 @@ } }, "@vitejs/plugin-react": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz", - "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", + "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", "dev": true, "requires": { - "@babel/core": "^7.28.4", + "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.43", + "@rolldown/pluginutils": "1.0.0-beta.47", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" } }, "@vitest/browser": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.8.tgz", - "integrity": "sha512-oG6QJAR0d7S5SDnIYZwjxCj/a5fhbp9ZE7GtMgZn+yCUf4CxtqbBV6aXyg0qmn8nbUWT+rGuXL2ZB6qDBUjv/A==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.9.tgz", + "integrity": "sha512-OWN4ZgOIV2+T9cR4qfoajtjZDFoxcLa6qUpgDkviXZFUNkZ7XTVKvL/16X+gz5dtpqdZwXf3m0qIj72Ge/vytw==", "requires": { - "@vitest/mocker": "4.0.8", - "@vitest/utils": "4.0.8", + "@vitest/mocker": "4.0.9", + "@vitest/utils": "4.0.9", "magic-string": "^0.30.21", "pixelmatch": "7.1.0", "pngjs": "^7.0.0", @@ -13782,23 +13780,23 @@ } }, "@vitest/browser-playwright": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.8.tgz", - "integrity": "sha512-MUi0msIAPXcA2YAuVMcssrSYP/yylxLt347xyTC6+ODl0c4XQFs0d2AN3Pc3iTa0pxIGmogflUV6eogXpPbJeA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.9.tgz", + "integrity": "sha512-ayr0vCxvJIvodzfUTVzifFMT3bmcMeKzEWoPt7mtgrZsqJhMbYaftifuBZRQeF/glogsVr+jhtIePHw6g+0YRQ==", "requires": { - "@vitest/browser": "4.0.8", - "@vitest/mocker": "4.0.8", + "@vitest/browser": "4.0.9", + "@vitest/mocker": "4.0.9", "tinyrainbow": "^3.0.3" } }, "@vitest/coverage-v8": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.8.tgz", - "integrity": "sha512-wQgmtW6FtPNn4lWUXi8ZSYLpOIb92j3QCujxX3sQ81NTfQ/ORnE0HtK7Kqf2+7J9jeveMGyGyc4NWc5qy3rC4A==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.9.tgz", + "integrity": "sha512-70oyhP+Q0HlWBIeGSP74YBw5KSjYhNgSCQjvmuQFciMqnyF36WL2cIkcT7XD85G4JPmBQitEMUsx+XMFv2AzQA==", "dev": true, "requires": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.8", + "@vitest/utils": "4.0.9", "ast-v8-to-istanbul": "^0.3.8", "debug": "^4.4.3", "istanbul-lib-coverage": "^3.2.2", @@ -13811,24 +13809,24 @@ } }, "@vitest/expect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.8.tgz", - "integrity": "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.9.tgz", + "integrity": "sha512-C2vyXf5/Jfj1vl4DQYxjib3jzyuswMi/KHHVN2z+H4v16hdJ7jMZ0OGe3uOVIt6LyJsAofDdaJNIFEpQcrSTFw==", "requires": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.8", - "@vitest/utils": "4.0.8", + "@vitest/spy": "4.0.9", + "@vitest/utils": "4.0.9", "chai": "^6.2.0", "tinyrainbow": "^3.0.3" } }, "@vitest/mocker": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.8.tgz", - "integrity": "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.9.tgz", + "integrity": "sha512-PUyaowQFHW+9FKb4dsvvBM4o025rWMlEDXdWRxIOilGaHREYTi5Q2Rt9VCgXgPy/hHZu1LeuXtrA/GdzOatP2g==", "requires": { - "@vitest/spy": "4.0.8", + "@vitest/spy": "4.0.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -13844,43 +13842,43 @@ } }, "@vitest/pretty-format": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz", - "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.9.tgz", + "integrity": "sha512-Hor0IBTwEi/uZqB7pvGepyElaM8J75pYjrrqbC8ZYMB9/4n5QA63KC15xhT+sqHpdGWfdnPo96E8lQUxs2YzSQ==", "requires": { "tinyrainbow": "^3.0.3" } }, "@vitest/runner": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.8.tgz", - "integrity": "sha512-mdY8Sf1gsM8hKJUQfiPT3pn1n8RF4QBcJYFslgWh41JTfrK1cbqY8whpGCFzBl45LN028g0njLCYm0d7XxSaQQ==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.9.tgz", + "integrity": "sha512-aF77tsXdEvIJRkj9uJZnHtovsVIx22Ambft9HudC+XuG/on1NY/bf5dlDti1N35eJT+QZLb4RF/5dTIG18s98w==", "requires": { - "@vitest/utils": "4.0.8", + "@vitest/utils": "4.0.9", "pathe": "^2.0.3" } }, "@vitest/snapshot": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.8.tgz", - "integrity": "sha512-Nar9OTU03KGiubrIOFhcfHg8FYaRaNT+bh5VUlNz8stFhCZPNrJvmZkhsr1jtaYvuefYFwK2Hwrq026u4uPWCw==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.9.tgz", + "integrity": "sha512-r1qR4oYstPbnOjg0Vgd3E8ADJbi4ditCzqr+Z9foUrRhIy778BleNyZMeAJ2EjV+r4ASAaDsdciC9ryMy8xMMg==", "requires": { - "@vitest/pretty-format": "4.0.8", + "@vitest/pretty-format": "4.0.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "@vitest/spy": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.8.tgz", - "integrity": "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA==" + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.9.tgz", + "integrity": "sha512-J9Ttsq0hDXmxmT8CUOWUr1cqqAj2FJRGTdyEjSR+NjoOGKEqkEWj+09yC0HhI8t1W6t4Ctqawl1onHgipJve1A==" }, "@vitest/utils": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz", - "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.9.tgz", + "integrity": "sha512-cEol6ygTzY4rUPvNZM19sDf7zGa35IYTm9wfzkHoT/f5jX10IOY7QleWSOh5T0e3I3WVozwK5Asom79qW8DiuQ==", "requires": { - "@vitest/pretty-format": "4.0.8", + "@vitest/pretty-format": "4.0.9", "tinyrainbow": "^3.0.3" } }, @@ -14265,9 +14263,9 @@ "dev": true }, "chai": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", - "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==" + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==" }, "chalk": { "version": "5.6.2", @@ -16059,9 +16057,9 @@ "dev": true }, "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "requires": { "argparse": "^2.0.1" @@ -16798,9 +16796,9 @@ "dev": true }, "react-router": { - "version": "7.9.5", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", - "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==", + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", + "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", "requires": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -17666,15 +17664,15 @@ "dev": true }, "typescript-eslint": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.3.tgz", - "integrity": "sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz", + "integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==", "dev": true, "requires": { - "@typescript-eslint/eslint-plugin": "8.46.3", - "@typescript-eslint/parser": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/utils": "8.46.3" + "@typescript-eslint/eslint-plugin": "8.46.4", + "@typescript-eslint/parser": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4" } }, "unbox-primitive": { @@ -17832,17 +17830,17 @@ } }, "vitest": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.8.tgz", - "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==", - "requires": { - "@vitest/expect": "4.0.8", - "@vitest/mocker": "4.0.8", - "@vitest/pretty-format": "4.0.8", - "@vitest/runner": "4.0.8", - "@vitest/snapshot": "4.0.8", - "@vitest/spy": "4.0.8", - "@vitest/utils": "4.0.8", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.9.tgz", + "integrity": "sha512-E0Ja2AX4th+CG33yAFRC+d1wFx2pzU5r6HtG6LiPSE04flaE0qB6YyjSw9ZcpJAtVPfsvZGtJlKWZpuW7EHRxg==", + "requires": { + "@vitest/expect": "4.0.9", + "@vitest/mocker": "4.0.9", + "@vitest/pretty-format": "4.0.9", + "@vitest/runner": "4.0.9", + "@vitest/snapshot": "4.0.9", + "@vitest/spy": "4.0.9", + "@vitest/utils": "4.0.9", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", diff --git a/package.json b/package.json index c423d826c..777fb3556 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,10 @@ "@json2csv/plainjs": "^7.0.6", "@reduxjs/toolkit": "^2.10.1", "@shlinkio/data-manipulation": "^1.0.4", - "@shlinkio/shlink-frontend-kit": "^1.3.0", + "@shlinkio/shlink-frontend-kit": "^1.3.1", "@shlinkio/shlink-js-sdk": "^3.0.1", "@shlinkio/shlink-web-component": "^0.17.0", - "@vitest/browser-playwright": "^4.0.8", + "@vitest/browser-playwright": "^4.0.9", "bottlejs": "^2.0.1", "clsx": "^2.1.1", "compare-versions": "^6.1.1", @@ -42,7 +42,7 @@ "react-dom": "^19.2.0", "react-external-link": "^2.6.1", "react-redux": "^9.2.0", - "react-router": "^7.9.5", + "react-router": "^7.9.6", "redux-localstorage-simple": "^2.5.1", "workbox-core": "^7.3.0", "workbox-expiration": "^7.3.0", @@ -58,11 +58,11 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@total-typescript/shoehorn": "^0.1.2", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "@vitejs/plugin-react": "^5.1.0", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", "@vitest/browser": "^4.0.3", - "@vitest/coverage-v8": "^4.0.8", + "@vitest/coverage-v8": "^4.0.9", "adm-zip": "^0.5.16", "axe-core": "^4.11.0", "chalk": "^5.6.2", @@ -77,7 +77,7 @@ "playwright": "^1.56.1", "tailwindcss": "^4.1.3", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.3", + "typescript-eslint": "^8.46.4", "vite": "^7.2.2", "vite-plugin-pwa": "^1.1.0", "vitest": "^4.0.3" diff --git a/src/api/services/ShlinkApiClientBuilder.ts b/src/api/services/ShlinkApiClientBuilder.ts index 8e64c3eea..caf269f80 100644 --- a/src/api/services/ShlinkApiClientBuilder.ts +++ b/src/api/services/ShlinkApiClientBuilder.ts @@ -1,13 +1,11 @@ import type { HttpClient } from '@shlinkio/shlink-js-sdk'; import { ShlinkApiClient } from '@shlinkio/shlink-js-sdk'; -import type { GetState } from '../../container/types'; import type { ServerWithId } from '../../servers/data'; import { hasServerData } from '../../servers/data'; +import type { GetState } from '../../store'; const apiClients: Map = new Map(); -const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState => - typeof getStateOrSelectedServer === 'function'; const getSelectedServerFromState = (getState: GetState): ServerWithId => { const { selectedServer } = getState(); if (!hasServerData(selectedServer)) { @@ -18,7 +16,7 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => { }; export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => { - const { url: baseUrl, apiKey, forwardCredentials } = isGetState(getStateOrSelectedServer) + const { url: baseUrl, apiKey, forwardCredentials } = typeof getStateOrSelectedServer === 'function' ? getSelectedServerFromState(getStateOrSelectedServer) : getStateOrSelectedServer; const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`; @@ -34,6 +32,7 @@ export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelec { requestCredentials: forwardCredentials ? 'include' : undefined }, ); apiClients.set(serverKey, apiClient); + return apiClient; }; diff --git a/src/api/services/provideServices.ts b/src/api/services/provideServices.ts deleted file mode 100644 index a89e111bb..000000000 --- a/src/api/services/provideServices.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type Bottle from 'bottlejs'; -import { buildShlinkApiClient } from './ShlinkApiClientBuilder'; - -export const provideServices = (bottle: Bottle) => { - bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient'); -}; diff --git a/src/app/App.tsx b/src/app/App.tsx index 6ee98cadf..c295ba6c4 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,61 +1,32 @@ import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit'; -import type { Settings } from '@shlinkio/shlink-web-component/settings'; import { clsx } from 'clsx'; import type { FC } from 'react'; -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { Route, Routes, useLocation } from 'react-router'; import { AppUpdateBanner } from '../common/AppUpdateBanner'; +import { Home } from '../common/Home'; +import { MainHeader } from '../common/MainHeader'; import { NotFound } from '../common/NotFound'; -import type { FCWithDeps } from '../container/utils'; -import { componentFactory, useDependencies } from '../container/utils'; -import type { ServersMap } from '../servers/data'; +import { ShlinkVersionsContainer } from '../common/ShlinkVersionsContainer'; +import { ShlinkWebComponentContainer } from '../common/ShlinkWebComponentContainer'; +import { CreateServer } from '../servers/CreateServer'; +import { EditServer } from '../servers/EditServer'; +import { ManageServers } from '../servers/ManageServers'; +import { useLoadRemoteServers } from '../servers/reducers/remoteServers'; +import { useSettings } from '../settings/reducers/settings'; +import { Settings } from '../settings/Settings'; import { forceUpdate } from '../utils/helpers/sw'; +import { useAppUpdated } from './reducers/appUpdates'; -type AppProps = { - fetchServers: () => void; - servers: ServersMap; - settings: Settings; - resetAppUpdate: () => void; - appUpdated: boolean; -}; - -type AppDeps = { - MainHeader: FC; - Home: FC; - ShlinkWebComponentContainer: FC; - CreateServer: FC; - EditServer: FC; - Settings: FC; - ManageServers: FC; - ShlinkVersionsContainer: FC; -}; +export const App: FC = () => { + const { appUpdated, resetAppUpdate } = useAppUpdated(); -const App: FCWithDeps = ( - { fetchServers, servers, settings, appUpdated, resetAppUpdate }, -) => { - const { - MainHeader, - Home, - ShlinkWebComponentContainer, - CreateServer, - EditServer, - Settings, - ManageServers, - ShlinkVersionsContainer, - } = useDependencies(App); + useLoadRemoteServers(); const location = useLocation(); - const initialServers = useRef(servers); const isHome = location.pathname === '/'; - useEffect(() => { - // Try to fetch the remote servers if the list is empty during first render. - // We use a ref because we don't care if the servers list becomes empty later. - if (Object.keys(initialServers.current).length === 0) { - fetchServers(); - } - }, [fetchServers]); - + const { settings } = useSettings(); useEffect(() => { changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme()); }, [settings.ui?.theme]); @@ -98,14 +69,3 @@ const App: FCWithDeps = ( ); }; - -export const AppFactory = componentFactory(App, [ - 'MainHeader', - 'Home', - 'ShlinkWebComponentContainer', - 'CreateServer', - 'EditServer', - 'Settings', - 'ManageServers', - 'ShlinkVersionsContainer', -]); diff --git a/src/app/reducers/appUpdates.ts b/src/app/reducers/appUpdates.ts index 675b39598..2bf870d8b 100644 --- a/src/app/reducers/appUpdates.ts +++ b/src/app/reducers/appUpdates.ts @@ -1,4 +1,6 @@ import { createSlice } from '@reduxjs/toolkit'; +import { useCallback } from 'react'; +import { useAppDispatch, useAppSelector } from '../../store'; const { actions, reducer } = createSlice({ name: 'shlink/appUpdates', @@ -12,3 +14,12 @@ const { actions, reducer } = createSlice({ export const { appUpdateAvailable, resetAppUpdate } = actions; export const appUpdatesReducer = reducer; + +export const useAppUpdated = () => { + const dispatch = useAppDispatch(); + const appUpdateAvailable = useCallback(() => dispatch(actions.appUpdateAvailable()), [dispatch]); + const resetAppUpdate = useCallback(() => dispatch(actions.resetAppUpdate()), [dispatch]); + const appUpdated = useAppSelector((state) => state.appUpdated); + + return { appUpdated, appUpdateAvailable, resetAppUpdate }; +}; diff --git a/src/app/services/provideServices.ts b/src/app/services/provideServices.ts deleted file mode 100644 index ab9750373..000000000 --- a/src/app/services/provideServices.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type Bottle from 'bottlejs'; -import type { ConnectDecorator } from '../../container/types'; -import { AppFactory } from '../App'; -import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates'; - -export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { - // Components - bottle.factory('App', AppFactory); - bottle.decorator('App', connect(['servers', 'settings', 'appUpdated'], ['fetchServers', 'resetAppUpdate'])); - - // Actions - bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable); - bottle.serviceFactory('resetAppUpdate', () => resetAppUpdate); -}; diff --git a/src/common/Home.tsx b/src/common/Home.tsx index e1018489b..ba6288374 100644 --- a/src/common/Home.tsx +++ b/src/common/Home.tsx @@ -2,19 +2,18 @@ import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Button, Card } from '@shlinkio/shlink-frontend-kit'; import { clsx } from 'clsx'; +import type { FC } from 'react'; import { useEffect } from 'react'; import { ExternalLink } from 'react-external-link'; import { useNavigate } from 'react-router'; -import type { ServersMap } from '../servers/data'; +import { withoutSelectedServer } from '../servers/helpers/withoutSelectedServer'; +import { useServers } from '../servers/reducers/servers'; import { ServersListGroup } from '../servers/ServersListGroup'; import { ShlinkLogo } from './img/ShlinkLogo'; -export type HomeProps = { - servers: ServersMap; -}; - -export const Home = ({ servers }: HomeProps) => { +export const Home: FC = withoutSelectedServer(() => { const navigate = useNavigate(); + const { servers } = useServers(); const serversList = Object.values(servers); const hasServers = serversList.length > 0; @@ -68,4 +67,4 @@ export const Home = ({ servers }: HomeProps) => { ); -}; +}); diff --git a/src/common/MainHeader.tsx b/src/common/MainHeader.tsx index 45816f05b..6d5d52c18 100644 --- a/src/common/MainHeader.tsx +++ b/src/common/MainHeader.tsx @@ -3,16 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { NavBar } from '@shlinkio/shlink-frontend-kit'; import type { FC } from 'react'; import { Link, useLocation } from 'react-router'; -import type { FCWithDeps } from '../container/utils'; -import { componentFactory, useDependencies } from '../container/utils'; +import { ServersDropdown } from '../servers/ServersDropdown'; import { ShlinkLogo } from './img/ShlinkLogo'; -type MainHeaderDeps = { - ServersDropdown: FC; -}; - -const MainHeader: FCWithDeps = () => { - const { ServersDropdown } = useDependencies(MainHeader); +export const MainHeader: FC = () => { const { pathname } = useLocation(); const settingsPath = '/settings'; @@ -37,5 +31,3 @@ const MainHeader: FCWithDeps = () => { ); }; - -export const MainHeaderFactory = componentFactory(MainHeader, ['ServersDropdown']); diff --git a/src/common/ShlinkVersionsContainer.tsx b/src/common/ShlinkVersionsContainer.tsx index c80eb6984..ebba69667 100644 --- a/src/common/ShlinkVersionsContainer.tsx +++ b/src/common/ShlinkVersionsContainer.tsx @@ -1,16 +1,15 @@ import { clsx } from 'clsx'; -import type { SelectedServer } from '../servers/data'; import { isReachableServer } from '../servers/data'; +import { useSelectedServer } from '../servers/reducers/selectedServer'; import { ShlinkVersions } from './ShlinkVersions'; -export type ShlinkVersionsContainerProps = { - selectedServer: SelectedServer; +export const ShlinkVersionsContainer = () => { + const { selectedServer } = useSelectedServer(); + return ( +
+ +
+ ); }; - -export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => ( -
- -
-); diff --git a/src/common/ShlinkWebComponentContainer.tsx b/src/common/ShlinkWebComponentContainer.tsx index ab82c7d02..d07aba3d7 100644 --- a/src/common/ShlinkWebComponentContainer.tsx +++ b/src/common/ShlinkWebComponentContainer.tsx @@ -4,40 +4,34 @@ import { ShlinkSidebarVisibilityProvider, ShlinkWebComponent, } from '@shlinkio/shlink-web-component'; -import type { Settings } from '@shlinkio/shlink-web-component/settings'; import type { FC } from 'react'; import { memo } from 'react'; import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder'; -import type { FCWithDeps } from '../container/utils'; -import { componentFactory, useDependencies } from '../container/utils'; +import { withDependencies } from '../container/context'; import { isReachableServer } from '../servers/data'; -import type { WithSelectedServerProps } from '../servers/helpers/withSelectedServer'; +import { ServerError } from '../servers/helpers/ServerError'; import { withSelectedServer } from '../servers/helpers/withSelectedServer'; +import { useSelectedServer } from '../servers/reducers/selectedServer'; +import { useSettings } from '../settings/reducers/settings'; import { NotFound } from './NotFound'; -type ShlinkWebComponentContainerProps = WithSelectedServerProps & { - settings: Settings; +export type ShlinkWebComponentContainerProps = { + TagColorsStorage: TagColorsStorage; + buildShlinkApiClient: ShlinkApiClientBuilder; }; -type ShlinkWebComponentContainerDeps = { - buildShlinkApiClient: ShlinkApiClientBuilder, - TagColorsStorage: TagColorsStorage, - ServerError: FC, -}; - -const ShlinkWebComponentContainer: FCWithDeps< - ShlinkWebComponentContainerProps, - ShlinkWebComponentContainerDeps +const ShlinkWebComponentContainerBase: FC< + ShlinkWebComponentContainerProps // FIXME Using `memo` here to solve a flickering effect in charts. // memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the // extra rendering there. // This should be revisited at some point. -> = withSelectedServer(memo(({ selectedServer, settings }) => { - const { - buildShlinkApiClient, - TagColorsStorage: tagColorsStorage, - ServerError, - } = useDependencies(ShlinkWebComponentContainer); +> = withSelectedServer(memo(({ + buildShlinkApiClient, + TagColorsStorage: tagColorsStorage, +}) => { + const { selectedServer } = useSelectedServer(); + const { settings } = useSettings(); if (!isReachableServer(selectedServer)) { return ; @@ -62,8 +56,7 @@ const ShlinkWebComponentContainer: FCWithDeps< ); })); -export const ShlinkWebComponentContainerFactory = componentFactory(ShlinkWebComponentContainer, [ +export const ShlinkWebComponentContainer = withDependencies(ShlinkWebComponentContainerBase, [ 'buildShlinkApiClient', 'TagColorsStorage', - 'ServerError', ]); diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts deleted file mode 100644 index 7e3671375..000000000 --- a/src/common/services/provideServices.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch'; -import type Bottle from 'bottlejs'; -import type { ConnectDecorator } from '../../container/types'; -import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; -import { ErrorHandler } from '../ErrorHandler'; -import { Home } from '../Home'; -import { MainHeaderFactory } from '../MainHeader'; -import { ScrollToTop } from '../ScrollToTop'; -import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer'; -import { ShlinkWebComponentContainerFactory } from '../ShlinkWebComponentContainer'; - -export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { - // Services - bottle.constant('window', window); - bottle.constant('console', console); - bottle.constant('fetch', window.fetch.bind(window)); - bottle.service('HttpClient', FetchHttpClient, 'fetch'); - - // Components - bottle.serviceFactory('ScrollToTop', () => ScrollToTop); - - bottle.factory('MainHeader', MainHeaderFactory); - - bottle.serviceFactory('Home', () => Home); - bottle.decorator('Home', withoutSelectedServer); - bottle.decorator('Home', connect(['servers'], ['resetSelectedServer'])); - - bottle.factory('ShlinkWebComponentContainer', ShlinkWebComponentContainerFactory); - bottle.decorator('ShlinkWebComponentContainer', connect(['selectedServer', 'settings'], ['selectServer'])); - - bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer); - bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer'])); - - bottle.serviceFactory('ErrorHandler', () => ErrorHandler); -}; diff --git a/src/container/context.tsx b/src/container/context.tsx new file mode 100644 index 000000000..a11319430 --- /dev/null +++ b/src/container/context.tsx @@ -0,0 +1,60 @@ +import type { IContainer } from 'bottlejs'; +import { type ComponentType, createContext, useContext } from 'react'; + +const ContainerContext = createContext(null); + +export const ContainerProvider = ContainerContext.Provider; + +const useContainer = (wrapperName: string): IContainer => { + const container = useContext(ContainerContext); + if (!container) { + throw new Error(`You cannot use "${wrapperName}" outside of a ContainerProvider`); + } + + return container; +}; + +/** + * Hook used to extract dependencies from the container in other hooks. + */ +export const useDependencies = (...names: string[]): T => { + const container = useContainer('useDependencies'); + + return names.map((name) => { + const dependency = container[name]; + if (!dependency) { + throw new Error(`Dependency with name "${name}" not found in container`); + } + + return dependency; + }) as T; +}; + +/** + * Higher Order Component used to inject services into components as props. + */ +export function withDependencies< + Props extends Record, + DependencyName extends string & keyof Props, +>( + Component: ComponentType, + dependencyNames: DependencyName[], +): ComponentType> { + function Wrapper(props: Omit) { + const container = useContainer('withDependencies'); + + // Inject services, unless they have been overridden by props passed from + // the parent component. + const dependencies: Partial> = {}; + for (const dependency of dependencyNames) { + if (!(dependency in props)) { + dependencies[dependency] = container[dependency]; + } + } + + const propsWithServices = { ...dependencies, ...props } as Props; + return ; + } + + return Wrapper; +} diff --git a/src/container/index.ts b/src/container/index.ts index 4c5e6cb2b..c69ab7766 100644 --- a/src/container/index.ts +++ b/src/container/index.ts @@ -1,42 +1,32 @@ -import type { IContainer } from 'bottlejs'; +import { useTimeoutToggle } from '@shlinkio/shlink-frontend-kit'; +import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch'; import Bottle from 'bottlejs'; -import { connect as reduxConnect } from 'react-redux'; -import { provideServices as provideApiServices } from '../api/services/provideServices'; -import { provideServices as provideAppServices } from '../app/services/provideServices'; -import { provideServices as provideCommonServices } from '../common/services/provideServices'; -import { provideServices as provideServersServices } from '../servers/services/provideServices'; -import { provideServices as provideSettingsServices } from '../settings/services/provideServices'; -import { provideServices as provideUtilsServices } from '../utils/services/provideServices'; -import type { ConnectDecorator } from './types'; - -type LazyActionMap = Record unknown>; +import { buildShlinkApiClient } from '../api/services/ShlinkApiClientBuilder'; +import { ServersExporter } from '../servers/services/ServersExporter'; +import { ServersImporter } from '../servers/services/ServersImporter'; +import { csvToJson, jsonToCsv } from '../utils/helpers/csvjson'; +import { LocalStorage } from '../utils/services/LocalStorage'; +import { TagColorsStorage } from '../utils/services/TagColorsStorage'; const bottle = new Bottle(); export const { container } = bottle; -const lazyService = unknown, K>(cont: IContainer, serviceName: string) => - (...args: any[]) => (cont[serviceName] as T)(...args) as K; - -const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({ - ...map, - // Wrap actual action service in a function so that it is lazily created the first time it is called - [actionName]: lazyService(container, actionName), -}); - -const pickProps = (propsToPick: string[]) => (obj: any) => Object.fromEntries( - propsToPick.map((key) => [key, obj[key]]), -); - -const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) => - reduxConnect( - propsFromState ? pickProps(propsFromState) : null, - actionServiceNames.reduce(mapActionService, {}), - ); - -provideAppServices(bottle, connect); -provideCommonServices(bottle, connect); -provideApiServices(bottle); -provideServersServices(bottle, connect); -provideUtilsServices(bottle); -provideSettingsServices(bottle, connect); +bottle.constant('window', window); +bottle.constant('console', console); +bottle.constant('fetch', window.fetch.bind(window)); +bottle.service('HttpClient', FetchHttpClient, 'fetch'); + +bottle.constant('localStorage', window.localStorage); +bottle.service('Storage', LocalStorage, 'localStorage'); +bottle.service('TagColorsStorage', TagColorsStorage, 'Storage'); + +bottle.constant('csvToJson', csvToJson); +bottle.constant('jsonToCsv', jsonToCsv); + +bottle.serviceFactory('useTimeoutToggle', () => useTimeoutToggle); + +bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient'); + +bottle.service('ServersImporter', ServersImporter, 'csvToJson'); +bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv'); diff --git a/src/container/store.ts b/src/container/store.ts deleted file mode 100644 index 3ae1572bd..000000000 --- a/src/container/store.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit'; -import type { IContainer } from 'bottlejs'; -import type { RLSOptions } from 'redux-localstorage-simple'; -import { load, save } from 'redux-localstorage-simple'; -import { initReducers } from '../reducers'; -import { migrateDeprecatedSettings } from '../settings/helpers'; -import type { ShlinkState } from './types'; - -const isProduction = process.env.NODE_ENV === 'production'; -const localStorageConfig: RLSOptions = { - states: ['settings', 'servers'], - namespace: 'shlink', - namespaceSeparator: '.', - debounce: 300, -}; -const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState); - -export const setUpStore = (container: IContainer) => configureStore({ - devTools: !isProduction, - reducer: initReducers(container), - preloadedState, - middleware: (defaultMiddlewaresIncludingReduxThunk) => - defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these - .concat(save(localStorageConfig)), -}); diff --git a/src/container/types.ts b/src/container/types.ts deleted file mode 100644 index 487094db6..000000000 --- a/src/container/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Settings } from '@shlinkio/shlink-web-component/settings'; -import type { SelectedServer, ServersMap } from '../servers/data'; - -export interface ShlinkState { - servers: ServersMap; - selectedServer: SelectedServer; - settings: Settings; - appUpdated: boolean; -} - -export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any; - -export type GetState = () => ShlinkState; diff --git a/src/container/utils.ts b/src/container/utils.ts deleted file mode 100644 index 0da77ccb6..000000000 --- a/src/container/utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { IContainer } from 'bottlejs'; -import type { FC } from 'react'; -import { useMemo } from 'react'; - -export type FCWithDeps = FC & Partial; - -export function useDependencies(obj: Deps): Omit, keyof FC> { - return useMemo(() => obj as Omit, keyof FC>, [obj]); -} - -export function componentFactory, keyof FC>>( - Component: CompType, - deps: ReadonlyArray, -) { - return (container: IContainer, console = globalThis.console) => { - deps.forEach((dep) => { - const resolvedDependency = container[dep as string]; - if (!resolvedDependency && process.env.NODE_ENV !== 'production') { - console.error(`[Debug] Could not find "${dep as string}" dependency in container`); - } - - Component[dep] = resolvedDependency; - }); - - return Component; - }; -} diff --git a/src/index.tsx b/src/index.tsx index b4f69f5de..e3c853597 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,24 +2,30 @@ import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router'; import pack from '../package.json'; +import { App } from './app/App'; +import { appUpdateAvailable } from './app/reducers/appUpdates'; +import { ErrorHandler } from './common/ErrorHandler'; +import { ScrollToTop } from './common/ScrollToTop'; import { container } from './container'; -import { setUpStore } from './container/store'; +import { ContainerProvider } from './container/context'; import { register as registerServiceWorker } from './serviceWorkerRegistration'; +import { setUpStore } from './store'; import './tailwind.css'; -const store = setUpStore(container); -const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container; +const store = setUpStore(); createRoot(document.getElementById('root')!).render( - - - - - - - - - , + + + + + + + + + + + , ); // Learn more about service workers: https://cra.link/PWA diff --git a/src/servers/CreateServer.tsx b/src/servers/CreateServer.tsx index f3ab0cd49..332160ce3 100644 --- a/src/servers/CreateServer.tsx +++ b/src/servers/CreateServer.tsx @@ -1,27 +1,22 @@ -import type { ResultProps,TimeoutToggle } from '@shlinkio/shlink-frontend-kit'; -import { Button, Result,useToggle } from '@shlinkio/shlink-frontend-kit'; +import type { ResultProps, TimeoutToggle } from '@shlinkio/shlink-frontend-kit'; +import { Button, Result, useToggle } from '@shlinkio/shlink-frontend-kit'; import type { FC } from 'react'; import { useCallback, useState } from 'react'; import { useNavigate } from 'react-router'; import { NoMenuLayout } from '../common/NoMenuLayout'; -import type { FCWithDeps } from '../container/utils'; -import { componentFactory, useDependencies } from '../container/utils'; +import { withDependencies } from '../container/context'; import { useGoBack } from '../utils/helpers/hooks'; -import type { ServerData, ServersMap, ServerWithId } from './data'; +import type { ServerData } from './data'; import { ensureUniqueIds } from './helpers'; import { DuplicatedServersModal } from './helpers/DuplicatedServersModal'; -import type { ImportServersBtnProps } from './helpers/ImportServersBtn'; +import { ImportServersBtn } from './helpers/ImportServersBtn'; import { ServerForm } from './helpers/ServerForm'; +import { withoutSelectedServer } from './helpers/withoutSelectedServer'; +import { useServers } from './reducers/servers'; const SHOW_IMPORT_MSG_TIME = 4000; -type CreateServerProps = { - createServers: (servers: ServerWithId[]) => void; - servers: ServersMap; -}; - -type CreateServerDeps = { - ImportServersBtn: FC; +export type CreateServerProps = { useTimeoutToggle: TimeoutToggle; }; @@ -34,14 +29,12 @@ const ImportResult = ({ variant }: Pick) => ( ); -const CreateServer: FCWithDeps = ({ servers, createServers }) => { - const { ImportServersBtn, useTimeoutToggle } = useDependencies(CreateServer); +const CreateServerBase: FC = withoutSelectedServer(({ useTimeoutToggle }) => { + const { servers, createServers } = useServers(); const navigate = useNavigate(); const goBack = useGoBack(); const hasServers = !!Object.keys(servers).length; - // eslint-disable-next-line react-compiler/react-compiler const [serversImported, setServersImported] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME }); - // eslint-disable-next-line react-compiler/react-compiler const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME }); const { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle(); const [serverData, setServerData] = useState(); @@ -86,6 +79,6 @@ const CreateServer: FCWithDeps = ({ servers /> ); -}; +}); -export const CreateServerFactory = componentFactory(CreateServer, ['ImportServersBtn', 'useTimeoutToggle']); +export const CreateServer = withDependencies(CreateServerBase, ['useTimeoutToggle']); diff --git a/src/servers/DeleteServerButton.tsx b/src/servers/DeleteServerButton.tsx index b39a8a556..e05858b88 100644 --- a/src/servers/DeleteServerButton.tsx +++ b/src/servers/DeleteServerButton.tsx @@ -2,21 +2,14 @@ import { useToggle } from '@shlinkio/shlink-frontend-kit'; import type { FC, PropsWithChildren } from 'react'; import { useCallback } from 'react'; import { useNavigate } from 'react-router'; -import type { FCWithDeps } from '../container/utils'; -import { componentFactory, useDependencies } from '../container/utils'; import type { ServerWithId } from './data'; -import type { DeleteServerModalProps } from './DeleteServerModal'; +import { DeleteServerModal } from './DeleteServerModal'; export type DeleteServerButtonProps = PropsWithChildren<{ server: ServerWithId; }>; -type DeleteServerButtonDeps = { - DeleteServerModal: FC; -}; - -const DeleteServerButton: FCWithDeps = ({ server, children }) => { - const { DeleteServerModal } = useDependencies(DeleteServerButton); +export const DeleteServerButton: FC = ({ server, children }) => { const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle(); const navigate = useNavigate(); const onClose = useCallback((confirmed: boolean) => { @@ -35,5 +28,3 @@ const DeleteServerButton: FCWithDeps ); }; - -export const DeleteServerButtonFactory = componentFactory(DeleteServerButton, ['DeleteServerModal']); diff --git a/src/servers/DeleteServerModal.tsx b/src/servers/DeleteServerModal.tsx index b02187070..ce9c80368 100644 --- a/src/servers/DeleteServerModal.tsx +++ b/src/servers/DeleteServerModal.tsx @@ -3,6 +3,7 @@ import { CardModal } from '@shlinkio/shlink-frontend-kit'; import type { FC } from 'react'; import { useCallback } from 'react'; import type { ServerWithId } from './data'; +import { useServers } from './reducers/servers'; export type DeleteServerModalProps = { server: ServerWithId; @@ -10,11 +11,8 @@ export type DeleteServerModalProps = { open: boolean; }; -type DeleteServerModalConnectProps = DeleteServerModalProps & { - deleteServer: (server: ServerWithId) => void; -}; - -export const DeleteServerModal: FC = ({ server, onClose, open, deleteServer }) => { +export const DeleteServerModal: FC = ({ server, onClose, open }) => { + const { deleteServer } = useServers(); const onClosed = useCallback((exitAction: ExitAction) => { if (exitAction === 'confirm') { deleteServer(server); diff --git a/src/servers/EditServer.tsx b/src/servers/EditServer.tsx index 0c959964c..707ee7131 100644 --- a/src/servers/EditServer.tsx +++ b/src/servers/EditServer.tsx @@ -1,26 +1,17 @@ -import { Button,useParsedQuery } from '@shlinkio/shlink-frontend-kit'; +import { Button, useParsedQuery } from '@shlinkio/shlink-frontend-kit'; import type { FC } from 'react'; import { NoMenuLayout } from '../common/NoMenuLayout'; -import type { FCWithDeps } from '../container/utils'; -import { componentFactory } from '../container/utils'; import { useGoBack } from '../utils/helpers/hooks'; import type { ServerData } from './data'; import { isServerWithId } from './data'; import { ServerForm } from './helpers/ServerForm'; -import type { WithSelectedServerProps } from './helpers/withSelectedServer'; import { withSelectedServer } from './helpers/withSelectedServer'; +import { useSelectedServer } from './reducers/selectedServer'; +import { useServers } from './reducers/servers'; -type EditServerProps = WithSelectedServerProps & { - editServer: (serverId: string, serverData: ServerData) => void; -}; - -type EditServerDeps = { - ServerError: FC; -}; - -const EditServer: FCWithDeps = withSelectedServer(( - { editServer, selectedServer, selectServer }, -) => { +export const EditServer: FC = withSelectedServer(() => { + const { editServer } = useServers(); + const { selectServer, selectedServer } = useSelectedServer(); const goBack = useGoBack(); const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>(); @@ -49,5 +40,3 @@ const EditServer: FCWithDeps = withSelectedServ ); }); - -export const EditServerFactory = componentFactory(EditServer, ['ServerError']); diff --git a/src/servers/ManageServers.tsx b/src/servers/ManageServers.tsx index 9771cbcb2..130f405b3 100644 --- a/src/servers/ManageServers.tsx +++ b/src/servers/ManageServers.tsx @@ -5,33 +5,25 @@ import { Button, Result, SearchInput, SimpleCard, Table } from '@shlinkio/shlink import type { FC } from 'react'; import { useMemo, useState } from 'react'; import { NoMenuLayout } from '../common/NoMenuLayout'; -import type { FCWithDeps } from '../container/utils'; -import { componentFactory, useDependencies } from '../container/utils'; -import type { ServersMap } from './data'; -import type { ImportServersBtnProps } from './helpers/ImportServersBtn'; -import type { ManageServersRowProps } from './ManageServersRow'; +import { withDependencies } from '../container/context'; +import { ImportServersBtn } from './helpers/ImportServersBtn'; +import { withoutSelectedServer } from './helpers/withoutSelectedServer'; +import { ManageServersRow } from './ManageServersRow'; +import { useServers } from './reducers/servers'; import type { ServersExporter } from './services/ServersExporter'; -type ManageServersProps = { - servers: ServersMap; -}; - -type ManageServersDeps = { +export type ManageServersProps = { ServersExporter: ServersExporter; - ImportServersBtn: FC; useTimeoutToggle: TimeoutToggle; - ManageServersRow: FC; }; const SHOW_IMPORT_MSG_TIME = 4000; -const ManageServers: FCWithDeps = ({ servers }) => { - const { - ServersExporter: serversExporter, - ImportServersBtn, - useTimeoutToggle, - ManageServersRow, - } = useDependencies(ManageServers); +const ManageServersBase: FC = withoutSelectedServer(({ + ServersExporter: serversExporter, + useTimeoutToggle, +}) => { + const { servers } = useServers(); const [searchTerm, setSearchTerm] = useState(''); const allServers = useMemo(() => Object.values(servers), [servers]); const filteredServers = useMemo( @@ -39,7 +31,7 @@ const ManageServers: FCWithDeps = ({ serv [allServers, searchTerm], ); const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect); - // eslint-disable-next-line react-compiler/react-compiler + const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME }); return ( @@ -87,11 +79,6 @@ const ManageServers: FCWithDeps = ({ serv )} ); -}; +}); -export const ManageServersFactory = componentFactory(ManageServers, [ - 'ServersExporter', - 'ImportServersBtn', - 'useTimeoutToggle', - 'ManageServersRow', -]); +export const ManageServers = withDependencies(ManageServersBase, ['ServersExporter', 'useTimeoutToggle']); diff --git a/src/servers/ManageServersRow.tsx b/src/servers/ManageServersRow.tsx index 1181b4f33..f052b7291 100644 --- a/src/servers/ManageServersRow.tsx +++ b/src/servers/ManageServersRow.tsx @@ -3,22 +3,15 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Table, Tooltip, useTooltip } from '@shlinkio/shlink-frontend-kit'; import type { FC } from 'react'; import { Link } from 'react-router'; -import type { FCWithDeps } from '../container/utils'; -import { componentFactory, useDependencies } from '../container/utils'; import type { ServerWithId } from './data'; -import type { ManageServersRowDropdownProps } from './ManageServersRowDropdown'; +import { ManageServersRowDropdown } from './ManageServersRowDropdown'; export type ManageServersRowProps = { server: ServerWithId; hasAutoConnect: boolean; }; -type ManageServersRowDeps = { - ManageServersRowDropdown: FC; -}; - -const ManageServersRow: FCWithDeps = ({ server, hasAutoConnect }) => { - const { ManageServersRowDropdown } = useDependencies(ManageServersRow); +export const ManageServersRow: FC = ({ server, hasAutoConnect }) => { const { anchor, tooltip } = useTooltip(); return ( @@ -31,6 +24,7 @@ const ManageServersRow: FCWithDeps icon={checkIcon} className="text-lm-brand dark:text-dm-brand" {...anchor} + data-testid="auto-connect" /> Auto-connect to this server @@ -47,5 +41,3 @@ const ManageServersRow: FCWithDeps ); }; - -export const ManageServersRowFactory = componentFactory(ManageServersRow, ['ManageServersRowDropdown']); diff --git a/src/servers/ManageServersRowDropdown.tsx b/src/servers/ManageServersRowDropdown.tsx index e09779360..eeaa36e90 100644 --- a/src/servers/ManageServersRowDropdown.tsx +++ b/src/servers/ManageServersRowDropdown.tsx @@ -6,29 +6,18 @@ import { faPlug as connectIcon, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { RowDropdown,useToggle } from '@shlinkio/shlink-frontend-kit'; +import { RowDropdown, useToggle } from '@shlinkio/shlink-frontend-kit'; import type { FC } from 'react'; -import type { FCWithDeps } from '../container/utils'; -import { componentFactory, useDependencies } from '../container/utils'; import type { ServerWithId } from './data'; -import type { DeleteServerModalProps } from './DeleteServerModal'; +import { DeleteServerModal } from './DeleteServerModal'; +import { useServers } from './reducers/servers'; export type ManageServersRowDropdownProps = { server: ServerWithId; }; -type ManageServersRowDropdownConnectProps = ManageServersRowDropdownProps & { - setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void; -}; - -type ManageServersRowDropdownDeps = { - DeleteServerModal: FC -}; - -const ManageServersRowDropdown: FCWithDeps = ( - { server, setAutoConnect }, -) => { - const { DeleteServerModal } = useDependencies(ManageServersRowDropdown); +export const ManageServersRowDropdown: FC = ({ server }) => { + const { setAutoConnect } = useServers(); const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle(); const serverUrl = `/server/${server.id}`; const { autoConnect: isAutoConnect } = server; @@ -56,5 +45,3 @@ const ManageServersRowDropdown: FCWithDeps ); }; - -export const ManageServersRowDropdownFactory = componentFactory(ManageServersRowDropdown, ['DeleteServerModal']); diff --git a/src/servers/ServersDropdown.tsx b/src/servers/ServersDropdown.tsx index 7fbfa9fa8..1e232b502 100644 --- a/src/servers/ServersDropdown.tsx +++ b/src/servers/ServersDropdown.tsx @@ -1,16 +1,15 @@ import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit'; -import type { SelectedServer, ServersMap } from './data'; +import type { FC } from 'react'; import { getServerId } from './data'; +import { useSelectedServer } from './reducers/selectedServer'; +import { useServers } from './reducers/servers'; -export interface ServersDropdownProps { - servers: ServersMap; - selectedServer: SelectedServer; -} - -export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => { +export const ServersDropdown: FC = () => { + const { servers } = useServers(); const serversList = Object.values(servers); + const { selectedServer } = useSelectedServer(); return ( ( ); export const ServersListGroup: FC = ({ servers, borderless }) => ( - <> - {servers.length > 0 && ( -
- {servers.map(({ id, name }) => )} -
- )} - + servers.length > 0 && ( +
+ {servers.map(({ id, name }) => )} +
+ ) ); diff --git a/src/servers/helpers/DuplicatedServersModal.tsx b/src/servers/helpers/DuplicatedServersModal.tsx index 07708c855..f1e8f492a 100644 --- a/src/servers/helpers/DuplicatedServersModal.tsx +++ b/src/servers/helpers/DuplicatedServersModal.tsx @@ -26,7 +26,7 @@ export const DuplicatedServersModal: FC = ( cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'} >

{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}

-
    +
      {duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
    • URL: {url}
    • diff --git a/src/servers/helpers/ImportServersBtn.tsx b/src/servers/helpers/ImportServersBtn.tsx index 2468f3414..3b774e5ac 100644 --- a/src/servers/helpers/ImportServersBtn.tsx +++ b/src/servers/helpers/ImportServersBtn.tsx @@ -1,11 +1,11 @@ import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Button, Tooltip, useToggle , useTooltip } from '@shlinkio/shlink-frontend-kit'; -import type { ChangeEvent, PropsWithChildren } from 'react'; +import { Button, Tooltip, useToggle, useTooltip } from '@shlinkio/shlink-frontend-kit'; +import type { ChangeEvent, FC, PropsWithChildren } from 'react'; import { useCallback, useRef, useState } from 'react'; -import type { FCWithDeps } from '../../container/utils'; -import { componentFactory, useDependencies } from '../../container/utils'; -import type { ServerData, ServersMap, ServerWithId } from '../data'; +import { withDependencies } from '../../container/context'; +import type { ServerData } from '../data'; +import { useServers } from '../reducers/servers'; import type { ServersImporter } from '../services/ServersImporter'; import { DuplicatedServersModal } from './DuplicatedServersModal'; import { dedupServers, ensureUniqueIds } from './index'; @@ -15,27 +15,20 @@ export type ImportServersBtnProps = PropsWithChildren<{ onError?: (error: Error) => void; tooltipPlacement?: 'top' | 'bottom'; className?: string; -}>; - -type ImportServersBtnConnectProps = ImportServersBtnProps & { - createServers: (servers: ServerWithId[]) => void; - servers: ServersMap; -}; -type ImportServersBtnDeps = { + // Injected ServersImporter: ServersImporter -}; +}>; -const ImportServersBtn: FCWithDeps = ({ - createServers, - servers, +const ImportServersBtnBase: FC = ({ children, onImport, onError = () => {}, tooltipPlacement = 'bottom', className = '', + ServersImporter: serversImporter, }) => { - const { ServersImporter: serversImporter } = useDependencies(ImportServersBtn); + const { createServers, servers } = useServers(); const fileInputRef = useRef(null); const { anchor, tooltip } = useTooltip({ placement: tooltipPlacement }); const [duplicatedServers, setDuplicatedServers] = useState([]); @@ -111,4 +104,4 @@ const ImportServersBtn: FCWithDeps; -}; - -const ServerError: FCWithDeps = ({ servers, selectedServer }) => { - const { DeleteServerButton } = useDependencies(ServerError); +export const ServerError: FC = () => { + const { servers } = useServers(); + const { selectedServer } = useSelectedServer(); return ( @@ -54,5 +45,3 @@ const ServerError: FCWithDeps = ({ servers, s ); }; - -export const ServerErrorFactory = componentFactory(ServerError, ['DeleteServerButton']); diff --git a/src/servers/helpers/withSelectedServer.tsx b/src/servers/helpers/withSelectedServer.tsx index e1b41ba15..15b55a4e6 100644 --- a/src/servers/helpers/withSelectedServer.tsx +++ b/src/servers/helpers/withSelectedServer.tsx @@ -3,27 +3,14 @@ import type { FC } from 'react'; import { useEffect } from 'react'; import { useParams } from 'react-router'; import { NoMenuLayout } from '../../common/NoMenuLayout'; -import type { FCWithDeps } from '../../container/utils'; -import { useDependencies } from '../../container/utils'; -import type { SelectedServer } from '../data'; import { isNotFoundServer } from '../data'; +import { useSelectedServer } from '../reducers/selectedServer'; +import { ServerError } from './ServerError'; -export type WithSelectedServerProps = { - selectServer: (serverId: string) => void; - selectedServer: SelectedServer; -}; - -type WithSelectedServerPropsDeps = { - ServerError: FC; -}; - -export function withSelectedServer( - WrappedComponent: FCWithDeps, -) { - const ComponentWrapper: FCWithDeps = (props) => { - const { ServerError } = useDependencies(ComponentWrapper); +export function withSelectedServer(WrappedComponent: FC) { + const ComponentWrapper: FC = (props) => { const params = useParams<{ serverId: string }>(); - const { selectServer, selectedServer } = props; + const { selectServer, selectedServer } = useSelectedServer(); useEffect(() => { if (params.serverId) { diff --git a/src/servers/helpers/withoutSelectedServer.tsx b/src/servers/helpers/withoutSelectedServer.tsx index 5f533dc03..34c30a69b 100644 --- a/src/servers/helpers/withoutSelectedServer.tsx +++ b/src/servers/helpers/withoutSelectedServer.tsx @@ -1,13 +1,10 @@ import type { FC } from 'react'; import { useEffect } from 'react'; +import { useSelectedServer } from '../reducers/selectedServer'; -interface WithoutSelectedServerProps { - resetSelectedServer: () => unknown; -} - -export function withoutSelectedServer(WrappedComponent: FC) { - return (props: WithoutSelectedServerProps & T) => { - const { resetSelectedServer } = props; +export function withoutSelectedServer(WrappedComponent: FC) { + return (props: T) => { + const { resetSelectedServer } = useSelectedServer(); useEffect(() => { resetSelectedServer(); }, [resetSelectedServer]); diff --git a/src/servers/reducers/remoteServers.ts b/src/servers/reducers/remoteServers.ts index 37c437d0c..7d6f17ad4 100644 --- a/src/servers/reducers/remoteServers.ts +++ b/src/servers/reducers/remoteServers.ts @@ -1,21 +1,46 @@ import type { HttpClient } from '@shlinkio/shlink-js-sdk'; +import { useCallback, useEffect, useRef } from 'react'; import pack from '../../../package.json'; -import { createAsyncThunk } from '../../utils/helpers/redux'; +import { useDependencies } from '../../container/context'; +import { useAppDispatch } from '../../store'; +import { createAsyncThunk } from '../../store/helpers'; import { hasServerData } from '../data'; import { ensureUniqueIds } from '../helpers'; -import { createServers } from './servers'; +import { createServers, useServers } from './servers'; const responseToServersList = (data: any) => ensureUniqueIds( {}, (Array.isArray(data) ? data.filter(hasServerData) : []), ); -export const fetchServers = (httpClient: HttpClient) => createAsyncThunk( +export const fetchServers = createAsyncThunk( 'shlink/remoteServers/fetchServers', - async (_: void, { dispatch }): Promise => { + async (httpClient: HttpClient, { dispatch }): Promise => { const resp = await httpClient.jsonRequest(`${pack.homepage}/servers.json`); const result = responseToServersList(resp); dispatch(createServers(result)); }, ); + +export const useRemoteServers = () => { + const dispatch = useAppDispatch(); + const [httpClient] = useDependencies<[HttpClient]>('HttpClient'); + const dispatchFetchServer = useCallback(() => dispatch(fetchServers(httpClient)), [dispatch, httpClient]); + + return { fetchServers: dispatchFetchServer }; +}; + +export const useLoadRemoteServers = () => { + const { fetchServers } = useRemoteServers(); + const { servers } = useServers(); + const initialServers = useRef(servers); + + useEffect(() => { + // Try to fetch the remote servers if the list is empty during first render. + // We use a ref because we don't care if the servers list becomes empty later. + if (Object.keys(initialServers.current).length === 0) { + fetchServers(); + } + }, [fetchServers]); +}; diff --git a/src/servers/reducers/selectedServer.ts b/src/servers/reducers/selectedServer.ts index 512ddb4dc..b99cbcf4b 100644 --- a/src/servers/reducers/selectedServer.ts +++ b/src/servers/reducers/selectedServer.ts @@ -1,8 +1,11 @@ import { createAction, createSlice } from '@reduxjs/toolkit'; import { memoizeWith } from '@shlinkio/data-manipulation'; import type { ShlinkHealth } from '@shlinkio/shlink-web-component/api-contract'; +import { useCallback } from 'react'; import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { createAsyncThunk } from '../../utils/helpers/redux'; +import { useDependencies } from '../../container/context'; +import { useAppDispatch, useAppSelector } from '../../store'; +import { createAsyncThunk } from '../../store/helpers'; import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version'; import type { SelectedServer, ServerWithId } from '../data'; @@ -29,9 +32,14 @@ const initialState: SelectedServer = null; export const resetSelectedServer = createAction(`${REDUCER_PREFIX}/resetSelectedServer`); -export const selectServer = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( +export type SelectServerOptions = { + serverId: string; + buildShlinkApiClient: ShlinkApiClientBuilder; +}; + +export const selectServer = createAsyncThunk( `${REDUCER_PREFIX}/selectServer`, - async (serverId: string, { dispatch, getState }): Promise => { + async ({ serverId, buildShlinkApiClient }: SelectServerOptions, { dispatch, getState }): Promise => { dispatch(resetSelectedServer()); const { servers } = getState(); @@ -56,14 +64,29 @@ export const selectServer = (buildShlinkApiClient: ShlinkApiClientBuilder) => cr }, ); -type SelectServerThunk = ReturnType; - -export const selectedServerReducerCreator = (selectServerThunk: SelectServerThunk) => createSlice({ +export const { reducer: selectedServerReducer } = createSlice({ name: REDUCER_PREFIX, - initialState, + initialState: initialState as SelectedServer, reducers: {}, extraReducers: (builder) => { builder.addCase(resetSelectedServer, () => initialState); - builder.addCase(selectServerThunk.fulfilled, (_, { payload }) => payload as any); + builder.addCase(selectServer.fulfilled, (_, { payload }) => payload); }, }); + +export const useSelectedServer = () => { + const dispatch = useAppDispatch(); + const [buildShlinkApiClient] = useDependencies<[ShlinkApiClientBuilder]>('buildShlinkApiClient'); + const dispatchResetSelectedServer = useCallback(() => dispatch(resetSelectedServer()), [dispatch]); + const dispatchSelectServer = useCallback( + (serverId: string) => dispatch(selectServer({ serverId, buildShlinkApiClient })), + [buildShlinkApiClient, dispatch], + ); + const selectedServer = useAppSelector(({ selectedServer }) => selectedServer); + + return { + selectedServer, + resetSelectedServer: dispatchResetSelectedServer, + selectServer: dispatchSelectServer, + }; +}; diff --git a/src/servers/reducers/servers.ts b/src/servers/reducers/servers.ts index ed02fe681..683959085 100644 --- a/src/servers/reducers/servers.ts +++ b/src/servers/reducers/servers.ts @@ -1,21 +1,23 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; +import { useCallback } from 'react'; +import { useAppDispatch, useAppSelector } from '../../store'; import type { ServerData, ServersMap, ServerWithId } from '../data'; import { serversListToMap } from '../helpers'; -interface EditServer { +type EditServer = { serverId: string; serverData: Partial; -} +}; -interface SetAutoConnect { +type SetAutoConnect = { serverId: string; autoConnect: boolean; -} +}; const initialState: ServersMap = {}; -export const { actions, reducer } = createSlice({ +export const { actions, reducer: serversReducer } = createSlice({ name: 'shlink/servers', initialState, reducers: { @@ -65,4 +67,19 @@ export const { actions, reducer } = createSlice({ export const { editServer, deleteServer, setAutoConnect, createServers } = actions; -export const serversReducer = reducer; +export const useServers = () => { + const dispatch = useAppDispatch(); + const servers = useAppSelector((state) => state.servers); + const editServer = useCallback( + (serverId: string, serverData: Partial) => dispatch(actions.editServer(serverId, serverData)), + [dispatch], + ); + const deleteServer = useCallback((server: ServerWithId) => dispatch(actions.deleteServer(server)), [dispatch]); + const setAutoConnect = useCallback( + (serverData: ServerWithId, autoConnect: boolean) => dispatch(actions.setAutoConnect(serverData, autoConnect)), + [dispatch], + ); + const createServers = useCallback((servers: ServerWithId[]) => dispatch(actions.createServers(servers)), [dispatch]); + + return { servers, editServer, deleteServer, setAutoConnect, createServers }; +}; diff --git a/src/servers/services/provideServices.ts b/src/servers/services/provideServices.ts deleted file mode 100644 index 4a2d1ab39..000000000 --- a/src/servers/services/provideServices.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type Bottle from 'bottlejs'; -import type { ConnectDecorator } from '../../container/types'; -import { CreateServerFactory } from '../CreateServer'; -import { DeleteServerButtonFactory } from '../DeleteServerButton'; -import { DeleteServerModal } from '../DeleteServerModal'; -import { EditServerFactory } from '../EditServer'; -import { ImportServersBtnFactory } from '../helpers/ImportServersBtn'; -import { ServerErrorFactory } from '../helpers/ServerError'; -import { withoutSelectedServer } from '../helpers/withoutSelectedServer'; -import { ManageServersFactory } from '../ManageServers'; -import { ManageServersRowFactory } from '../ManageServersRow'; -import { ManageServersRowDropdownFactory } from '../ManageServersRowDropdown'; -import { fetchServers } from '../reducers/remoteServers'; -import { - resetSelectedServer, - selectedServerReducerCreator, - selectServer, -} from '../reducers/selectedServer'; -import { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers'; -import { ServersDropdown } from '../ServersDropdown'; -import { ServersExporter } from './ServersExporter'; -import { ServersImporter } from './ServersImporter'; - -export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { - // Components - bottle.factory('ManageServers', ManageServersFactory); - bottle.decorator('ManageServers', withoutSelectedServer); - bottle.decorator('ManageServers', connect(['selectedServer', 'servers'], ['resetSelectedServer'])); - - bottle.factory('ManageServersRow', ManageServersRowFactory); - - bottle.factory('ManageServersRowDropdown', ManageServersRowDropdownFactory); - bottle.decorator('ManageServersRowDropdown', connect(null, ['setAutoConnect'])); - - bottle.factory('CreateServer', CreateServerFactory); - bottle.decorator('CreateServer', withoutSelectedServer); - bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServers', 'resetSelectedServer'])); - - bottle.factory('EditServer', EditServerFactory); - bottle.decorator('EditServer', connect(['selectedServer'], ['editServer', 'selectServer', 'resetSelectedServer'])); - - bottle.serviceFactory('ServersDropdown', () => ServersDropdown); - bottle.decorator('ServersDropdown', connect(['servers', 'selectedServer'])); - - bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal); - bottle.decorator('DeleteServerModal', connect(null, ['deleteServer'])); - - bottle.factory('DeleteServerButton', DeleteServerButtonFactory); - - bottle.factory('ImportServersBtn', ImportServersBtnFactory); - bottle.decorator('ImportServersBtn', connect(['servers'], ['createServers'])); - - bottle.factory('ServerError', ServerErrorFactory); - bottle.decorator('ServerError', connect(['servers', 'selectedServer'])); - - // Services - bottle.service('ServersImporter', ServersImporter, 'csvToJson'); - bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv'); - - // Actions - bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo'); - bottle.serviceFactory('createServers', () => createServers); - bottle.serviceFactory('deleteServer', () => deleteServer); - bottle.serviceFactory('editServer', () => editServer); - bottle.serviceFactory('setAutoConnect', () => setAutoConnect); - bottle.serviceFactory('fetchServers', fetchServers, 'HttpClient'); - - bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer); - - // Reducers - bottle.serviceFactory('selectedServerReducerCreator', selectedServerReducerCreator, 'selectServer'); - bottle.serviceFactory('selectedServerReducer', (obj) => obj.reducer, 'selectedServerReducerCreator'); -}; diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 28a0a75bb..6a508fe3f 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -1,20 +1,18 @@ -import type { Settings as AppSettings } from '@shlinkio/shlink-web-component/settings'; import { ShlinkWebSettings } from '@shlinkio/shlink-web-component/settings'; import type { FC } from 'react'; import { NoMenuLayout } from '../common/NoMenuLayout'; -import { DEFAULT_SHORT_URLS_ORDERING } from './reducers/settings'; +import { DEFAULT_SHORT_URLS_ORDERING, useSettings } from './reducers/settings'; -export type SettingsProps = { - settings: AppSettings; - setSettings: (newSettings: AppSettings) => void; -}; +export const Settings: FC = () => { + const { settings, setSettings } = useSettings(); -export const Settings: FC = ({ settings, setSettings }) => ( - - - -); + return ( + + + + ); +}; diff --git a/src/settings/helpers/index.ts b/src/settings/helpers/index.ts index 509df6cd0..53044ad08 100644 --- a/src/settings/helpers/index.ts +++ b/src/settings/helpers/index.ts @@ -1,12 +1,6 @@ -import type { ShlinkState } from '../../container/types'; - -export const migrateDeprecatedSettings = (state: Partial): Partial => { - if (!state.settings) { - return state; - } - +export const migrateDeprecatedSettings = (state: any): any => { // The "last180Days" interval had a typo, with a lowercase d - if (state.settings.visits && (state.settings.visits.defaultInterval as any) === 'last180days') { + if (state.settings?.visits?.defaultInterval === 'last180days') { state.settings.visits.defaultInterval = 'last180Days'; } diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index 33d9162ca..f05240ec7 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -3,6 +3,8 @@ import { createSlice } from '@reduxjs/toolkit'; import { mergeDeepRight } from '@shlinkio/data-manipulation'; import { getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit'; import type { Settings, ShortUrlsListSettings } from '@shlinkio/shlink-web-component/settings'; +import { useCallback } from 'react'; +import { useAppDispatch, useAppSelector } from '../../store'; import type { Defined } from '../../utils/types'; type ShortUrlsOrder = Defined; @@ -41,3 +43,11 @@ const { reducer, actions } = createSlice({ export const { setSettings } = actions; export const settingsReducer = reducer; + +export const useSettings = () => { + const dispatch = useAppDispatch(); + const setSettings = useCallback((settings: Settings) => dispatch(actions.setSettings(settings)), [dispatch]); + const settings = useAppSelector((state) => state.settings); + + return { settings, setSettings }; +}; diff --git a/src/settings/services/provideServices.ts b/src/settings/services/provideServices.ts deleted file mode 100644 index 246bed83b..000000000 --- a/src/settings/services/provideServices.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type Bottle from 'bottlejs'; -import type { ConnectDecorator } from '../../container/types'; -import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer'; -import { setSettings } from '../reducers/settings'; -import { Settings } from '../Settings'; - -export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { - // Components - bottle.serviceFactory('Settings', () => Settings); - bottle.decorator('Settings', withoutSelectedServer); - bottle.decorator('Settings', connect(['settings'], ['setSettings', 'resetSelectedServer'])); - - // Actions - bottle.serviceFactory('setSettings', () => setSettings); -}; diff --git a/src/utils/helpers/redux.ts b/src/store/helpers.ts similarity index 77% rename from src/utils/helpers/redux.ts rename to src/store/helpers.ts index d2766cf39..4304ec760 100644 --- a/src/utils/helpers/redux.ts +++ b/src/store/helpers.ts @@ -1,10 +1,10 @@ import type { AsyncThunkPayloadCreator } from '@reduxjs/toolkit'; import { createAsyncThunk as baseCreateAsyncThunk } from '@reduxjs/toolkit'; -import type { ShlinkState } from '../../container/types'; +import type { RootState } from '.'; export const createAsyncThunk = ( typePrefix: string, - payloadCreator: AsyncThunkPayloadCreator, + payloadCreator: AsyncThunkPayloadCreator, ) => baseCreateAsyncThunk( typePrefix, payloadCreator, diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 000000000..9a122aebe --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,32 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { useDispatch, useSelector } from 'react-redux'; +import type { RLSOptions } from 'redux-localstorage-simple'; +import { load, save } from 'redux-localstorage-simple'; +import { migrateDeprecatedSettings } from '../settings/helpers'; +import { initReducers } from './reducers'; + +const localStorageConfig: RLSOptions = { + states: ['settings', 'servers'], + namespace: 'shlink', + namespaceSeparator: '.', + debounce: 300, +}; +const getStateFromLocalStorage = () => migrateDeprecatedSettings(load(localStorageConfig)); + +const isProduction = process.env.NODE_ENV === 'production'; +export const setUpStore = (preloadedState = getStateFromLocalStorage()) => configureStore({ + devTools: !isProduction, + reducer: initReducers(), + preloadedState, + middleware: (defaultMiddlewaresIncludingReduxThunk) => + defaultMiddlewaresIncludingReduxThunk().concat(save(localStorageConfig)), +}); + +export type StoreType = ReturnType; +export type AppDispatch = StoreType['dispatch']; +export type GetState = StoreType['getState']; +export type RootState = ReturnType; + +// Typed versions of useDispatch() and useSelector() +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/src/reducers/index.ts b/src/store/reducers.ts similarity index 66% rename from src/reducers/index.ts rename to src/store/reducers.ts index 1a88d01df..09f2218a9 100644 --- a/src/reducers/index.ts +++ b/src/store/reducers.ts @@ -1,12 +1,12 @@ import { combineReducers } from '@reduxjs/toolkit'; -import type { IContainer } from 'bottlejs'; import { appUpdatesReducer } from '../app/reducers/appUpdates'; +import { selectedServerReducer } from '../servers/reducers/selectedServer'; import { serversReducer } from '../servers/reducers/servers'; import { settingsReducer } from '../settings/reducers/settings'; -export const initReducers = (container: IContainer) => combineReducers({ +export const initReducers = () => combineReducers({ appUpdated: appUpdatesReducer, servers: serversReducer, - selectedServer: container.selectedServerReducer, + selectedServer: selectedServerReducer, settings: settingsReducer, }); diff --git a/src/tailwind.css b/src/tailwind.css index d1ffef4d2..fe52bc177 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -8,7 +8,5 @@ :root { --footer-height: 2.3rem; --footer-margin: .8rem; - /* FIXME Remove this once updated to shlink-web-component 0.15.1 */ - --header-height: 52px; } } diff --git a/src/utils/services/provideServices.ts b/src/utils/services/provideServices.ts deleted file mode 100644 index 7c3920efb..000000000 --- a/src/utils/services/provideServices.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useTimeoutToggle } from '@shlinkio/shlink-frontend-kit'; -import type Bottle from 'bottlejs'; -import { csvToJson, jsonToCsv } from '../helpers/csvjson'; -import { LocalStorage } from './LocalStorage'; -import { TagColorsStorage } from './TagColorsStorage'; - -export const provideServices = (bottle: Bottle) => { - bottle.constant('localStorage', window.localStorage); - bottle.service('Storage', LocalStorage, 'localStorage'); - bottle.service('TagColorsStorage', TagColorsStorage, 'Storage'); - - bottle.constant('csvToJson', csvToJson); - bottle.constant('jsonToCsv', jsonToCsv); - - bottle.serviceFactory('useTimeoutToggle', () => useTimeoutToggle); -}; diff --git a/test/__helpers__/MemoryRouterWithParams.tsx b/test/__helpers__/MemoryRouterWithParams.tsx deleted file mode 100644 index 76e12f16b..000000000 --- a/test/__helpers__/MemoryRouterWithParams.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { FC, PropsWithChildren } from 'react'; -import { useMemo } from 'react'; -import { MemoryRouter, Route, Routes } from 'react-router'; - -export type MemoryRouterWithParamsProps = PropsWithChildren<{ - params: Record; -}>; - -/** - * Wrap any component using useParams() with MemoryRouterWithParams, in order to determine wat the hook should return - */ -export const MemoryRouterWithParams: FC = ({ children, params }) => { - const pathname = useMemo(() => `/${Object.values(params).join('/')}`, [params]); - const pathPattern = useMemo(() => `/:${Object.keys(params).join('/:')}`, [params]); - - return ( - - - - - - ); -}; diff --git a/test/__helpers__/setUpTest.ts b/test/__helpers__/setUpTest.ts deleted file mode 100644 index 7fccbf985..000000000 --- a/test/__helpers__/setUpTest.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { ReactElement } from 'react'; - -export const renderWithEvents = (element: ReactElement) => ({ - user: userEvent.setup(), - ...render(element), -}); diff --git a/test/__helpers__/setUpTest.tsx b/test/__helpers__/setUpTest.tsx new file mode 100644 index 000000000..91763f86b --- /dev/null +++ b/test/__helpers__/setUpTest.tsx @@ -0,0 +1,29 @@ +import type { RenderOptions } from '@testing-library/react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { PropsWithChildren, ReactElement } from 'react'; +import { Provider } from 'react-redux'; +import type { RootState } from '../../src/store'; +import { setUpStore } from '../../src/store'; + +export const renderWithEvents = (element: ReactElement, options?: RenderOptions) => ({ + user: userEvent.setup(), + ...render(element, options), +}); + +export type RenderOptionsWithState = Omit & { + initialState?: Partial; +}; + +export const renderWithStore = ( + element: ReactElement, + { initialState = {}, ...options }: RenderOptionsWithState = {}, +) => { + const store = setUpStore(initialState); + const Wrapper = ({ children }: PropsWithChildren) => {children}; + + return { + store, + ...renderWithEvents(element, { ...options, wrapper: Wrapper }), + }; +}; diff --git a/test/app/App.test.tsx b/test/app/App.test.tsx index 51aef3aea..691f7de76 100644 --- a/test/app/App.test.tsx +++ b/test/app/App.test.tsx @@ -1,50 +1,51 @@ -import { act, render, screen } from '@testing-library/react'; +import type { HttpClient } from '@shlinkio/shlink-js-sdk'; +import { act, screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router'; -import { AppFactory } from '../../src/app/App'; +import { App } from '../../src/app/App'; +import { ContainerProvider } from '../../src/container/context'; +import type { ServerWithId } from '../../src/servers/data'; import { checkAccessibility } from '../__helpers__/accessibility'; +import { renderWithStore } from '../__helpers__/setUpTest'; + +vi.mock(import('../../src/common/ShlinkWebComponentContainer'), () => ({ + ShlinkWebComponentContainer: () => ShlinkWebComponentContainer, +})); describe('', () => { - const App = AppFactory( - fromPartial({ - MainHeader: () => <>MainHeader, - Home: () => <>Home, - ShlinkWebComponentContainer: () => <>ShlinkWebComponentContainer, - CreateServer: () => <>CreateServer, - EditServer: () => <>EditServer, - Settings: () => <>SettingsComp, - ManageServers: () => <>ManageServers, - ShlinkVersionsContainer: () => <>ShlinkVersions, - }), - ); - const setUp = async (activeRoute = '/') => act(() => render( + const setUp = async (activeRoute = '/') => act(() => renderWithStore( - {}} - servers={{}} - settings={fromPartial({})} - appUpdated={false} - resetAppUpdate={() => {}} - /> + ({}), + buildShlinkApiClient: vi.fn(), + useTimeoutToggle: vi.fn().mockReturnValue([false, vi.fn()]), + })} + > + + , + { + initialState: { + servers: { + abc123: fromPartial({ id: 'abc123', name: 'abc123 server' }), + def456: fromPartial({ id: 'def456', name: 'def456 server' }), + }, + settings: fromPartial({}), + appUpdated: false, + }, + }, )); it('passes a11y checks', () => checkAccessibility(setUp())); - it('renders children components', async () => { - await setUp(); - - expect(screen.getByText('MainHeader')).toBeInTheDocument(); - expect(screen.getByText('ShlinkVersions')).toBeInTheDocument(); - }); - it.each([ - ['/settings/foo', 'SettingsComp'], - ['/settings/bar', 'SettingsComp'], - ['/manage-servers', 'ManageServers'], - ['/server/create', 'CreateServer'], - ['/server/abc123/edit', 'EditServer'], - ['/server/def456/edit', 'EditServer'], + ['/settings/general', 'User interface'], + ['/settings/short-urls', 'Short URLs form'], + ['/manage-servers', 'Add a server'], + ['/server/create', 'Add new server'], + ['/server/abc123/edit', 'Edit "abc123 server"'], + ['/server/def456/edit', 'Edit "def456 server"'], ['/server/abc123/foo', 'ShlinkWebComponentContainer'], ['/server/def456/bar', 'ShlinkWebComponentContainer'], ['/other', 'Oops! We could not find requested route.'], diff --git a/test/common/Home.test.tsx b/test/common/Home.test.tsx index 912594d3a..e9521d201 100644 --- a/test/common/Home.test.tsx +++ b/test/common/Home.test.tsx @@ -1,15 +1,22 @@ -import { render, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router'; import { Home } from '../../src/common/Home'; +import { ContainerProvider } from '../../src/container/context'; import type { ServersMap, ServerWithId } from '../../src/servers/data'; import { checkAccessibility } from '../__helpers__/accessibility'; +import { renderWithStore } from '../__helpers__/setUpTest'; describe('', () => { - const setUp = (servers: ServersMap = {}) => render( + const setUp = (servers: ServersMap = {}) => renderWithStore( - + + + , + { + initialState: { servers }, + }, ); it('passes a11y checks', () => checkAccessibility( diff --git a/test/common/MainHeader.test.tsx b/test/common/MainHeader.test.tsx index 2a1025438..e9614c6a1 100644 --- a/test/common/MainHeader.test.tsx +++ b/test/common/MainHeader.test.tsx @@ -2,22 +2,21 @@ import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { createMemoryHistory } from 'history'; import { Router } from 'react-router'; -import { MainHeaderFactory } from '../../src/common/MainHeader'; +import { MainHeader } from '../../src/common/MainHeader'; +import { ContainerProvider } from '../../src/container/context'; import { checkAccessibility } from '../__helpers__/accessibility'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import { renderWithStore } from '../__helpers__/setUpTest'; describe('', () => { - const MainHeader = MainHeaderFactory(fromPartial({ - // Fake this component as a li[role="menuitem"], as it gets rendered inside a ul[role="menu"] - ServersDropdown: () =>
    • ServersDropdown
    • , - })); const setUp = (pathname = '') => { const history = createMemoryHistory(); history.push(pathname); - return renderWithEvents( + return renderWithStore( - + + + , ); }; @@ -26,7 +25,7 @@ describe('', () => { it('renders ServersDropdown', () => { setUp(); - expect(screen.getByText('ServersDropdown')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Servers' })).toBeInTheDocument(); }); it.each([ diff --git a/test/common/ShlinkVersionsContainer.test.tsx b/test/common/ShlinkVersionsContainer.test.tsx index 2b092d463..ba9c9dc2e 100644 --- a/test/common/ShlinkVersionsContainer.test.tsx +++ b/test/common/ShlinkVersionsContainer.test.tsx @@ -1,12 +1,18 @@ -import { render } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { ShlinkVersionsContainer } from '../../src/common/ShlinkVersionsContainer'; +import { ContainerProvider } from '../../src/container/context'; import type { ReachableServer, SelectedServer } from '../../src/servers/data'; import { checkAccessibility } from '../__helpers__/accessibility'; +import { renderWithStore } from '../__helpers__/setUpTest'; describe('', () => { - const setUp = (selectedServer: SelectedServer = null) => render( - , + const setUp = (selectedServer: SelectedServer = null) => renderWithStore( + + + , + { + initialState: { selectedServer }, + }, ); it.each([ diff --git a/test/common/ShlinkWebComponentContainer.test.tsx b/test/common/ShlinkWebComponentContainer.test.tsx index d186a5883..fb31f69d0 100644 --- a/test/common/ShlinkWebComponentContainer.test.tsx +++ b/test/common/ShlinkWebComponentContainer.test.tsx @@ -1,9 +1,11 @@ -import { render, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import { ShlinkWebComponentContainerFactory } from '../../src/common/ShlinkWebComponentContainer'; +import { MemoryRouter } from 'react-router'; +import { ShlinkWebComponentContainer } from '../../src/common/ShlinkWebComponentContainer'; +import { ContainerProvider } from '../../src/container/context'; import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../src/servers/data'; import { checkAccessibility } from '../__helpers__/accessibility'; -import { MemoryRouterWithParams } from '../__helpers__/MemoryRouterWithParams'; +import { renderWithStore } from '../__helpers__/setUpTest'; vi.mock('@shlinkio/shlink-web-component', () => ({ ShlinkSidebarVisibilityProvider: ({ children }: any) => children, @@ -12,15 +14,18 @@ vi.mock('@shlinkio/shlink-web-component', () => ({ })); describe('', () => { - const ShlinkWebComponentContainer = ShlinkWebComponentContainerFactory(fromPartial({ - buildShlinkApiClient: vi.fn().mockReturnValue(fromPartial({})), - TagColorsStorage: fromPartial({}), - ServerError: () => <>ServerError, - })); - const setUp = (selectedServer: SelectedServer) => render( - - - , + const setUp = (selectedServer: SelectedServer) => renderWithStore( + + + + + , + { + initialState: { selectedServer, servers: {}, settings: {} }, + }, ); it('passes a11y checks', () => checkAccessibility(setUp(fromPartial({ version: '3.0.0' })))); @@ -29,18 +34,20 @@ describe('', () => { setUp(null); expect(screen.getByText('Loading...')).toBeInTheDocument(); - expect(screen.queryByText('ServerError')).not.toBeInTheDocument(); expect(screen.queryByText('ShlinkWebComponent')).not.toBeInTheDocument(); }); it.each([ - [fromPartial({ serverNotFound: true })], - [fromPartial({ serverNotReachable: true })], - ])('shows error for non reachable servers', (selectedServer) => { + [fromPartial({ serverNotFound: true }), 'Could not find this Shlink server.'], + [ + fromPartial({ id: 'foo', serverNotReachable: true }), + /Could not connect to this Shlink server/, + ], + ])('shows error for non reachable servers', (selectedServer, expectedError) => { setUp(selectedServer); expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); - expect(screen.getByText('ServerError')).toBeInTheDocument(); + expect(screen.getByText(expectedError)).toBeInTheDocument(); expect(screen.queryByText('ShlinkWebComponent')).not.toBeInTheDocument(); }); @@ -48,7 +55,6 @@ describe('', () => { setUp(fromPartial({ version: '3.0.0' })); expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); - expect(screen.queryByText('ServerError')).not.toBeInTheDocument(); expect(screen.getByText('ShlinkWebComponent')).toBeInTheDocument(); }); }); diff --git a/test/container/context.test.tsx b/test/container/context.test.tsx new file mode 100644 index 000000000..06af2159b --- /dev/null +++ b/test/container/context.test.tsx @@ -0,0 +1,39 @@ +import { render } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import { ContainerProvider, useDependencies } from '../../src/container/context'; + +describe('context', () => { + describe('useDependencies', () => { + let lastDependencies: unknown[]; + + function TestComponent({ name}: { name: string }) { + // eslint-disable-next-line react-compiler/react-compiler + lastDependencies = useDependencies(name); + return null; + } + + it('throws when used outside of ContainerProvider', () => { + expect(() => render()).toThrowError( + 'You cannot use "useDependencies" outside of a ContainerProvider', + ); + }); + + it('throws when requested dependency is not found in container', () => { + expect(() => render( + + + , + )).toThrowError('Dependency with name "foo" not found in container'); + }); + + it('gets dependency from container', () => { + render( + + + , + ); + + expect(lastDependencies).toEqual(['the dependency']); + }); + }); +}); diff --git a/test/servers/CreateServer.test.tsx b/test/servers/CreateServer.test.tsx index 204d67486..2905b783d 100644 --- a/test/servers/CreateServer.test.tsx +++ b/test/servers/CreateServer.test.tsx @@ -2,10 +2,11 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { createMemoryHistory } from 'history'; import { Router } from 'react-router'; -import { CreateServerFactory } from '../../src/servers/CreateServer'; +import { ContainerProvider } from '../../src/container/context'; +import { CreateServer } from '../../src/servers/CreateServer'; import type { ServersMap } from '../../src/servers/data'; import { checkAccessibility } from '../__helpers__/accessibility'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import { renderWithStore } from '../__helpers__/setUpTest'; type SetUpOptions = { serversImported?: boolean; @@ -14,9 +15,8 @@ type SetUpOptions = { }; describe('', () => { - const createServersMock = vi.fn(); const defaultServers: ServersMap = { - foo: fromPartial({ url: 'https://existing_url.com', apiKey: 'existing_api_key' }), + foo: fromPartial({ url: 'https://existing_url.com', apiKey: 'existing_api_key', id: 'foo' }), }; const setUp = ({ serversImported = false, importFailed = false, servers = defaultServers }: SetUpOptions = {}) => { let callCount = 0; @@ -25,18 +25,23 @@ describe('', () => { callCount += 1; return result; }); - const CreateServer = CreateServerFactory(fromPartial({ - ImportServersBtn: () => <>ImportServersBtn, - useTimeoutToggle, - })); const history = createMemoryHistory({ initialEntries: ['/foo', '/bar'] }); return { history, - ...renderWithEvents( + ...renderWithStore( - + <>ImportServersBtn, + useTimeoutToggle, + buildShlinkApiClient: vi.fn(), + })}> + + , + { + initialState: { servers }, + }, ), }; }; @@ -62,27 +67,24 @@ describe('', () => { expect(screen.getByText('The servers could not be imported. Make sure the format is correct.')).toBeInTheDocument(); }); - it('shows import button when no servers exist yet', () => { - setUp({ servers: {} }); - expect(screen.queryByText('ImportServersBtn')).toBeInTheDocument(); - }); - it('creates server data when form is submitted', async () => { - const { user, history } = setUp(); - - expect(createServersMock).not.toHaveBeenCalled(); + const { user, history, store } = setUp(); + const expectedServerId = 'the_name-the_url.com'; await user.type(screen.getByLabelText(/^Name/), 'the_name'); await user.type(screen.getByLabelText(/^URL/), 'https://the_url.com'); await user.type(screen.getByLabelText(/^API key/), 'the_api_key'); - fireEvent.submit(screen.getByRole('form')); - expect(createServersMock).toHaveBeenCalledWith([expect.objectContaining({ + expect(store.getState().servers[expectedServerId]).not.toBeDefined(); + fireEvent.submit(screen.getByRole('form')); + expect(store.getState().servers[expectedServerId]).toEqual(expect.objectContaining({ + id: expectedServerId, name: 'the_name', url: 'https://the_url.com', apiKey: 'the_api_key', - })]); - expect(history.location.pathname).toEqual(expect.stringMatching(/^\/server\//)); + })); + + expect(history.location.pathname).toEqual(`/server/${expectedServerId}`); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); @@ -92,12 +94,12 @@ describe('', () => { await user.type(screen.getByLabelText(/^Name/), 'the_name'); await user.type(screen.getByLabelText(/^URL/), 'https://existing_url.com'); await user.type(screen.getByLabelText(/^API key/), 'existing_api_key'); + fireEvent.submit(screen.getByRole('form')); await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); await user.click(screen.getByRole('button', { name: 'Discard' })); - expect(createServersMock).not.toHaveBeenCalled(); expect(history.location.pathname).toEqual('/foo'); // Goes back to first route from history's initialEntries }); }); diff --git a/test/servers/DeleteServerButton.test.tsx b/test/servers/DeleteServerButton.test.tsx index fce035cb9..c82cb8949 100644 --- a/test/servers/DeleteServerButton.test.tsx +++ b/test/servers/DeleteServerButton.test.tsx @@ -3,19 +3,14 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { createMemoryHistory } from 'history'; import type { ReactNode } from 'react'; import { Router } from 'react-router'; -import { DeleteServerButtonFactory } from '../../src/servers/DeleteServerButton'; -import type { DeleteServerModalProps } from '../../src/servers/DeleteServerModal'; -import { DeleteServerModal } from '../../src/servers/DeleteServerModal'; +import { DeleteServerButton } from '../../src/servers/DeleteServerButton'; import { checkAccessibility } from '../__helpers__/accessibility'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import { renderWithStore } from '../__helpers__/setUpTest'; describe('', () => { - const DeleteServerButton = DeleteServerButtonFactory(fromPartial({ - DeleteServerModal: (props: DeleteServerModalProps) => , - })); const setUp = (children: ReactNode = 'Remove this server') => { const history = createMemoryHistory({ initialEntries: ['/foo'] }); - const result = renderWithEvents( + const result = renderWithStore( {children} , diff --git a/test/servers/DeleteServerModal.test.tsx b/test/servers/DeleteServerModal.test.tsx index c573b93b6..a377c60fe 100644 --- a/test/servers/DeleteServerModal.test.tsx +++ b/test/servers/DeleteServerModal.test.tsx @@ -1,23 +1,23 @@ import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; +import type { ServerWithId } from '../../src/servers/data'; import { DeleteServerModal } from '../../src/servers/DeleteServerModal'; import { checkAccessibility } from '../__helpers__/accessibility'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import { renderWithStore } from '../__helpers__/setUpTest'; import { TestModalWrapper } from '../__helpers__/TestModalWrapper'; describe('', () => { - const deleteServerMock = vi.fn(); const serverName = 'the_server_name'; - const setUp = () => renderWithEvents( + const server = fromPartial({ id: 'foo', name: serverName }); + const setUp = () => renderWithStore( ( - - )} + renderModal={(args) => } />, + { + initialState: { + servers: { foo: server }, + }, + }, ); it('passes a11y checks', () => checkAccessibility(setUp())); @@ -40,19 +40,21 @@ describe('', () => { [() => screen.getByRole('button', { name: 'Cancel' })], [() => screen.getByLabelText('Close dialog')], ])('closes dialog when clicking cancel button', async (getButton) => { - const { user } = setUp(); + const { user, store } = setUp(); expect(screen.getByRole('dialog')).toBeInTheDocument(); await user.click(getButton()); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(deleteServerMock).not.toHaveBeenCalled(); + + // No server has been deleted + expect(Object.keys(store.getState().servers)).toHaveLength(1); }); it('deletes server when clicking accept button', async () => { - const { user } = setUp(); + const { user, store } = setUp(); - expect(deleteServerMock).not.toHaveBeenCalled(); + expect(Object.keys(store.getState().servers)).toHaveLength(1); await user.click(screen.getByRole('button', { name: 'Delete' })); - expect(deleteServerMock).toHaveBeenCalledOnce(); + expect(Object.keys(store.getState().servers)).toHaveLength(0); }); }); diff --git a/test/servers/EditServer.test.tsx b/test/servers/EditServer.test.tsx index 144e681fe..62b46252c 100644 --- a/test/servers/EditServer.test.tsx +++ b/test/servers/EditServer.test.tsx @@ -1,30 +1,37 @@ -import { fireEvent, screen } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { createMemoryHistory } from 'history'; import { Router } from 'react-router'; +import { ContainerProvider } from '../../src/container/context'; import type { ReachableServer, SelectedServer } from '../../src/servers/data'; -import { EditServerFactory } from '../../src/servers/EditServer'; +import { isServerWithId } from '../../src/servers/data'; +import { EditServer } from '../../src/servers/EditServer'; import { checkAccessibility } from '../__helpers__/accessibility'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import { renderWithStore } from '../__helpers__/setUpTest'; describe('', () => { - const ServerError = vi.fn(); - const editServerMock = vi.fn(); const defaultSelectedServer = fromPartial({ id: 'abc123', name: 'the_name', url: 'the_url', apiKey: 'the_api_key', }); - const EditServer = EditServerFactory(fromPartial({ ServerError })); const setUp = (selectedServer: SelectedServer = defaultSelectedServer) => { const history = createMemoryHistory({ initialEntries: ['/foo', '/bar'] }); return { history, - ...renderWithEvents( + ...renderWithStore( - + + + , + { + initialState: { + selectedServer, + servers: isServerWithId(selectedServer) ? { [selectedServer.id]: selectedServer } : {}, + }, + }, ), }; }; @@ -53,7 +60,7 @@ describe('', () => { }); it('edits server and redirects to it when form is submitted', async () => { - const { user, history } = setUp(); + const { user, history, store } = setUp(); await user.type(screen.getByLabelText(/^Name/), ' edited'); await user.type(screen.getByLabelText(/^URL/), ' edited'); @@ -61,12 +68,10 @@ describe('', () => { // await user.click(screen.getByRole('button', { name: 'Save' })); fireEvent.submit(screen.getByRole('form')); - expect(editServerMock).toHaveBeenCalledWith('abc123', { + expect(store.getState().servers[defaultSelectedServer.id]).toEqual(expect.objectContaining({ name: 'the_name edited', url: 'the_url edited', - apiKey: 'the_api_key', - forwardCredentials: false, - }); + })); // After saving we go back, to the first route from history's initialEntries expect(history.location.pathname).toEqual('/foo'); @@ -75,16 +80,15 @@ describe('', () => { it.each([ { forwardCredentials: true }, { forwardCredentials: false }, - ])('edits advanced options - forward credentials', async (serverPartial) => { - const { user } = setUp({ ...defaultSelectedServer, ...serverPartial }); + ])('edits advanced options - forward credentials', async ({ forwardCredentials }) => { + const { user, store } = setUp({ ...defaultSelectedServer, forwardCredentials }); await user.click(screen.getByText('Advanced options')); await user.click(screen.getByLabelText('Forward credentials to this server on every request.')); - fireEvent.submit(screen.getByRole('form')); - expect(editServerMock).toHaveBeenCalledWith('abc123', expect.objectContaining({ - forwardCredentials: !serverPartial.forwardCredentials, - })); + await waitFor(() => expect(store.getState().servers[defaultSelectedServer.id]).toEqual(expect.objectContaining({ + forwardCredentials: !forwardCredentials, + }))); }); }); diff --git a/test/servers/ManageServers.test.tsx b/test/servers/ManageServers.test.tsx index 2340dd814..f9395816b 100644 --- a/test/servers/ManageServers.test.tsx +++ b/test/servers/ManageServers.test.tsx @@ -1,29 +1,34 @@ import { screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router'; +import { ContainerProvider } from '../../src/container/context'; import type { ServersMap, ServerWithId } from '../../src/servers/data'; -import { ManageServersFactory } from '../../src/servers/ManageServers'; +import { ManageServers } from '../../src/servers/ManageServers'; import type { ServersExporter } from '../../src/servers/services/ServersExporter'; import { checkAccessibility } from '../__helpers__/accessibility'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import { renderWithStore } from '../__helpers__/setUpTest'; describe('', () => { const exportServers = vi.fn(); const serversExporter = fromPartial({ exportServers }); const useTimeoutToggle = vi.fn().mockReturnValue([false, vi.fn()]); - const ManageServers = ManageServersFactory(fromPartial({ - ServersExporter: serversExporter, - ImportServersBtn: () => ImportServersBtn, - useTimeoutToggle, - ManageServersRow: ({ hasAutoConnect }: { hasAutoConnect: boolean }) => ( - ManageServersRow {hasAutoConnect ? '[YES]' : '[NO]'} - ), - })); const createServerMock = (value: string, autoConnect = false) => fromPartial( { id: value, name: value, url: value, autoConnect }, ); - const setUp = (servers: ServersMap = {}) => renderWithEvents( - , + const setUp = (servers: ServersMap = {}) => renderWithStore( + + ImportServersBtn, + useTimeoutToggle, + buildShlinkApiClient: vi.fn(), + })}> + + + , + { + initialState: { servers }, + }, ); it('passes a11y checks', () => checkAccessibility(setUp({ @@ -42,20 +47,22 @@ describe('', () => { await user.clear(screen.getByPlaceholderText('Search...')); await user.type(screen.getByPlaceholderText('Search...'), searchTerm); }; + // Add one for the header row + const expectRows = (amount: number) => expect(screen.getAllByRole('row')).toHaveLength(amount + 1); - expect(screen.getAllByText(/^ManageServersRow/)).toHaveLength(3); + expectRows(3); expect(screen.queryByText('No servers found.')).not.toBeInTheDocument(); await search('foo'); - await waitFor(() => expect(screen.getAllByText(/^ManageServersRow/)).toHaveLength(1)); + await waitFor(() => expectRows(1)); expect(screen.queryByText('No servers found.')).not.toBeInTheDocument(); await search('Ba'); - await waitFor(() => expect(screen.getAllByText(/^ManageServersRow/)).toHaveLength(2)); + await waitFor(() => expectRows(2)); expect(screen.queryByText('No servers found.')).not.toBeInTheDocument(); await search('invalid'); - await waitFor(() => expect(screen.queryByText(/^ManageServersRow/)).not.toBeInTheDocument()); + await waitFor(() => expectRows(1)); expect(screen.getByText('No servers found.')).toBeInTheDocument(); }); @@ -67,11 +74,9 @@ describe('', () => { expect(screen.getAllByRole('columnheader')).toHaveLength(expectedCols); if (server.autoConnect) { - expect(screen.getByText(/\[YES]/)).toBeInTheDocument(); - expect(screen.queryByText(/\[NO]/)).not.toBeInTheDocument(); + expect(screen.getByTestId('auto-connect')).toBeInTheDocument(); } else { - expect(screen.queryByText(/\[YES]/)).not.toBeInTheDocument(); - expect(screen.getByText(/\[NO]/)).toBeInTheDocument(); + expect(screen.queryByTestId('auto-connect')).not.toBeInTheDocument(); } }); diff --git a/test/servers/ManageServersRow.test.tsx b/test/servers/ManageServersRow.test.tsx index 9a97d4050..e625954b8 100644 --- a/test/servers/ManageServersRow.test.tsx +++ b/test/servers/ManageServersRow.test.tsx @@ -1,22 +1,19 @@ import { Table } from '@shlinkio/shlink-frontend-kit'; -import { render, screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; +import { screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import type { ServerWithId } from '../../src/servers/data'; -import { ManageServersRowFactory } from '../../src/servers/ManageServersRow'; +import { ManageServersRow } from '../../src/servers/ManageServersRow'; import { checkAccessibility } from '../__helpers__/accessibility'; +import { renderWithStore } from '../__helpers__/setUpTest'; describe('', () => { - const ManageServersRow = ManageServersRowFactory(fromPartial({ - ManageServersRowDropdown: () => ManageServersRowDropdown, - })); const server: ServerWithId = { name: 'My server', url: 'https://example.com', apiKey: '123', id: 'abc', }; - const setUp = (hasAutoConnect = false, autoConnect = false) => render( + const setUp = (hasAutoConnect = false, autoConnect = false) => renderWithStore( }> @@ -34,9 +31,9 @@ describe('', () => { expect(screen.getAllByRole('cell')).toHaveLength(expectedCols); }); - it('renders a dropdown', () => { + it('renders an options dropdown', () => { setUp(); - expect(screen.getByText('ManageServersRowDropdown')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Options' })).toBeInTheDocument(); }); it.each([ diff --git a/test/servers/ManageServersRowDropdown.test.tsx b/test/servers/ManageServersRowDropdown.test.tsx index cb6a96318..a97aeaf6d 100644 --- a/test/servers/ManageServersRowDropdown.test.tsx +++ b/test/servers/ManageServersRowDropdown.test.tsx @@ -3,23 +3,22 @@ import type { UserEvent } from '@testing-library/user-event'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router'; import type { ServerWithId } from '../../src/servers/data'; -import { ManageServersRowDropdownFactory } from '../../src/servers/ManageServersRowDropdown'; +import { ManageServersRowDropdown } from '../../src/servers/ManageServersRowDropdown'; import { checkAccessibility } from '../__helpers__/accessibility'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import { renderWithStore } from '../__helpers__/setUpTest'; describe('', () => { - const ManageServersRowDropdown = ManageServersRowDropdownFactory(fromPartial({ - DeleteServerModal: ({ open }: { open: boolean }) => ( - DeleteServerModal {open ? '[OPEN]' : '[CLOSED]'} - ), - })); - const setAutoConnect = vi.fn(); const setUp = (autoConnect = false) => { const server = fromPartial({ id: 'abc123', autoConnect }); - return renderWithEvents( + return renderWithStore( - + , + { + initialState: { + servers: { [server.id]: server }, + }, + }, ); }; const toggleDropdown = (user: UserEvent) => user.click(screen.getByRole('button')); @@ -44,26 +43,24 @@ describe('', () => { expect(screen.getByRole('menuitem', { name: 'Edit server' })).toHaveAttribute('href', '/server/abc123/edit'); }); - it('allows toggling auto-connect', async () => { - const { user } = setUp(); + it.each([true, false])('allows toggling auto-connect', async (autoConnect) => { + const { user, store } = setUp(autoConnect); - expect(setAutoConnect).not.toHaveBeenCalled(); await toggleDropdown(user); - await user.click(screen.getByRole('menuitem', { name: 'Auto-connect' })); - expect(setAutoConnect).toHaveBeenCalledWith(expect.objectContaining({ id: 'abc123' }), true); + await user.click(screen.getByRole('menuitem', { name: autoConnect ? 'Do not auto-connect' : 'Auto-connect' })); + + expect(Object.values(store.getState().servers)[0].autoConnect).toEqual(!autoConnect); }); it('renders deletion modal', async () => { const { user } = setUp(); - expect(screen.queryByText('DeleteServerModal [OPEN]')).not.toBeInTheDocument(); - expect(screen.getByText('DeleteServerModal [CLOSED]')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); await toggleDropdown(user); await user.click(screen.getByRole('menuitem', { name: 'Remove server' })); - expect(screen.getByText('DeleteServerModal [OPEN]')).toBeInTheDocument(); - expect(screen.queryByText('DeleteServerModal [CLOSED]')).not.toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); it.each([[true], [false]])('renders expected size and icon', (autoConnect) => { diff --git a/test/servers/ServersDropdown.test.tsx b/test/servers/ServersDropdown.test.tsx index 410c2443c..d98a3a6ff 100644 --- a/test/servers/ServersDropdown.test.tsx +++ b/test/servers/ServersDropdown.test.tsx @@ -1,10 +1,11 @@ import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router'; +import { ContainerProvider } from '../../src/container/context'; import type { ServersMap } from '../../src/servers/data'; import { ServersDropdown } from '../../src/servers/ServersDropdown'; import { checkAccessibility } from '../__helpers__/accessibility'; -import { renderWithEvents } from '../__helpers__/setUpTest'; +import { renderWithStore } from '../__helpers__/setUpTest'; describe('', () => { const fallbackServers: ServersMap = { @@ -12,12 +13,17 @@ describe('', () => { '2b': fromPartial({ name: 'bar', id: '2b' }), '3c': fromPartial({ name: 'baz', id: '3c' }), }; - const setUp = (servers: ServersMap = fallbackServers) => renderWithEvents( + const setUp = (servers: ServersMap = fallbackServers) => renderWithStore( -
        - -
      + +
        + +
      +
      , + { + initialState: { selectedServer: null, servers }, + }, ); it('passes a11y checks', async () => { diff --git a/test/servers/__snapshots__/ManageServersRow.test.tsx.snap b/test/servers/__snapshots__/ManageServersRow.test.tsx.snap index a0169cc22..95412da1a 100644 --- a/test/servers/__snapshots__/ManageServersRow.test.tsx.snap +++ b/test/servers/__snapshots__/ManageServersRow.test.tsx.snap @@ -27,6 +27,7 @@ exports[` > renders auto-connect icon only if server is auto class="svg-inline--fa fa-check text-lm-brand dark:text-dm-brand" data-icon="check" data-prefix="fas" + data-testid="auto-connect" role="img" viewBox="0 0 448 512" > @@ -56,9 +57,32 @@ exports[` > renders auto-connect icon only if server is auto
      @@ -108,9 +132,32 @@ exports[` > renders auto-connect icon only if server is auto diff --git a/test/servers/__snapshots__/ManageServersRowDropdown.test.tsx.snap b/test/servers/__snapshots__/ManageServersRowDropdown.test.tsx.snap index 25f76312f..261c3b77e 100644 --- a/test/servers/__snapshots__/ManageServersRowDropdown.test.tsx.snap +++ b/test/servers/__snapshots__/ManageServersRowDropdown.test.tsx.snap @@ -6,7 +6,7 @@ exports[` > renders expected size and icon 1`] = ` class="relative inline-block" > - - DeleteServerModal - [CLOSED] - `; @@ -41,7 +37,7 @@ exports[` > renders expected size and icon 2`] = ` class="relative inline-block" > - - DeleteServerModal - [CLOSED] - `; diff --git a/test/servers/helpers/ImportServersBtn.test.tsx b/test/servers/helpers/ImportServersBtn.test.tsx index 33c221da2..aa3a5cdd2 100644 --- a/test/servers/helpers/ImportServersBtn.test.tsx +++ b/test/servers/helpers/ImportServersBtn.test.tsx @@ -1,27 +1,25 @@ import { screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; +import { ContainerProvider } from '../../../src/container/context'; import type { ServerData, ServersMap, ServerWithId } from '../../../src/servers/data'; -import type { - ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn'; -import { ImportServersBtnFactory } from '../../../src/servers/helpers/ImportServersBtn'; +import type { ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn'; +import { ImportServersBtn } from '../../../src/servers/helpers/ImportServersBtn'; import type { ServersImporter } from '../../../src/servers/services/ServersImporter'; import { checkAccessibility } from '../../__helpers__/accessibility'; -import { renderWithEvents } from '../../__helpers__/setUpTest'; +import { renderWithStore } from '../../__helpers__/setUpTest'; describe('', () => { const csvFile = new File([''], 'servers.csv', { type: 'text/csv' }); const onImportMock = vi.fn(); - const createServersMock = vi.fn(); const importServersFromFile = vi.fn().mockResolvedValue([]); const serversImporterMock = fromPartial({ importServersFromFile }); - const ImportServersBtn = ImportServersBtnFactory(fromPartial({ ServersImporter: serversImporterMock })); - const setUp = (props: Partial = {}, servers: ServersMap = {}) => renderWithEvents( - , + const setUp = (props: Partial = {}, servers: ServersMap = {}) => renderWithStore( + + + , + { + initialState: { servers }, + }, ); it('passes a11y checks', () => checkAccessibility(setUp())); @@ -57,11 +55,8 @@ describe('', () => { it('imports servers when file input changes', async () => { const { user } = setUp(); - const input = screen.getByTestId('csv-file-input'); - await user.upload(input, csvFile); - + await user.upload(screen.getByTestId('csv-file-input'), csvFile); expect(importServersFromFile).toHaveBeenCalledTimes(1); - expect(createServersMock).toHaveBeenCalledTimes(1); }); it.each([ @@ -78,26 +73,27 @@ describe('', () => { id: 'existingserver-s.test', }; const newServer: ServerData = { name: 'newServer', url: 'http://s.test/newUrl', apiKey: 'newApiKey' }; - const { user } = setUp({}, { [existingServer.id]: existingServer }); + const { user, store } = setUp({}, { [existingServer.id]: existingServer }); - importServersFromFile.mockResolvedValue([existingServer, newServer]); + importServersFromFile.mockResolvedValue([existingServerData, newServer]); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); await user.upload(screen.getByTestId('csv-file-input'), csvFile); // Once the file is uploaded, non-duplicated servers are immediately created - expect(createServersMock).toHaveBeenCalledExactlyOnceWith([expect.objectContaining(newServer)]); + const { servers } = store.getState(); + expect(Object.keys(servers)).toHaveLength(2); expect(screen.getByRole('dialog')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: btnName })); - // If duplicated servers are saved, there's one extra call + // If duplicated servers are saved, there's one extra server creation if (savesDuplicatedServers) { - expect(createServersMock).toHaveBeenLastCalledWith([expect.objectContaining(existingServerData)]); + const { servers } = store.getState(); + expect(Object.keys(servers)).toHaveLength(3); } // On import is called only once, no matter what expect(onImportMock).toHaveBeenCalledOnce(); - expect(createServersMock).toHaveBeenCalledTimes(savesDuplicatedServers ? 2 : 1); }); }); diff --git a/test/servers/helpers/ServerError.test.tsx b/test/servers/helpers/ServerError.test.tsx index ec6d42fbf..95e6693a1 100644 --- a/test/servers/helpers/ServerError.test.tsx +++ b/test/servers/helpers/ServerError.test.tsx @@ -1,16 +1,22 @@ -import { render, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router'; +import { ContainerProvider } from '../../../src/container/context'; import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../../src/servers/data'; -import { ServerErrorFactory } from '../../../src/servers/helpers/ServerError'; +import { ServerError } from '../../../src/servers/helpers/ServerError'; import { checkAccessibility } from '../../__helpers__/accessibility'; +import { renderWithStore } from '../../__helpers__/setUpTest'; describe('', () => { - const ServerError = ServerErrorFactory(fromPartial({ DeleteServerButton: () => null })); - const setUp = (selectedServer: SelectedServer) => render( + const setUp = (selectedServer: SelectedServer) => renderWithStore( - + + + , + { + initialState: { selectedServer, servers: {} }, + }, ); it.each([ diff --git a/test/servers/reducers/remoteServers.test.ts b/test/servers/reducers/remoteServers.test.ts index 3c0cf580f..ca8eb9f21 100644 --- a/test/servers/reducers/remoteServers.test.ts +++ b/test/servers/reducers/remoteServers.test.ts @@ -79,9 +79,8 @@ describe('remoteServersReducer', () => { }, ])('tries to fetch servers from remote', async ({ serversArray, expectedNewServers }) => { jsonRequest.mockResolvedValue(serversArray); - const doFetchServers = fetchServers(httpClient); - await doFetchServers()(dispatch, vi.fn(), {}); + await fetchServers(httpClient)(dispatch, vi.fn(), {}); expect(dispatch).toHaveBeenCalledTimes(3); expect(dispatch).toHaveBeenNthCalledWith(2, expect.objectContaining({ payload: expectedNewServers })); diff --git a/test/servers/reducers/selectedServer.test.ts b/test/servers/reducers/selectedServer.test.ts index 8c6020526..a7358f2a2 100644 --- a/test/servers/reducers/selectedServer.test.ts +++ b/test/servers/reducers/selectedServer.test.ts @@ -1,21 +1,19 @@ import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkState } from '../../../src/container/types'; import type { NonReachableServer, NotFoundServer, RegularServer } from '../../../src/servers/data'; import { MAX_FALLBACK_VERSION, MIN_FALLBACK_VERSION, resetSelectedServer, - selectedServerReducerCreator, - selectServer as selectServerCreator, + selectedServerReducer as reducer, + selectServer, } from '../../../src/servers/reducers/selectedServer'; +import type { RootState } from '../../../src/store'; describe('selectedServerReducer', () => { const dispatch = vi.fn(); const health = vi.fn(); - const buildApiClient = vi.fn().mockReturnValue(fromPartial({ health })); - const selectServer = selectServerCreator(buildApiClient); - const { reducer } = selectedServerReducerCreator(selectServer); + const buildShlinkApiClient = vi.fn().mockReturnValue(fromPartial({ health })); describe('reducer', () => { it('returns default when action is RESET_SELECTED_SERVER', () => @@ -23,7 +21,7 @@ describe('selectedServerReducer', () => { it('returns selected server when action is SELECT_SERVER', () => { const payload = fromPartial({ id: 'abc123' }); - expect(reducer(null, selectServer.fulfilled(payload, '', ''))).toEqual(payload); + expect(reducer(null, selectServer.fulfilled(payload, '', { serverId: '', buildShlinkApiClient }))).toEqual(payload); }); }); @@ -50,10 +48,10 @@ describe('selectedServerReducer', () => { health.mockResolvedValue({ version: serverVersion }); - await selectServer(id)(dispatch, getState, {}); + await selectServer({ serverId: id, buildShlinkApiClient })(dispatch, getState, {}); expect(getState).toHaveBeenCalledTimes(1); - expect(buildApiClient).toHaveBeenCalledTimes(1); + expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(3); // "Pending", "reset" and "fulfilled" expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: expectedSelectedServer })); }); @@ -65,7 +63,7 @@ describe('selectedServerReducer', () => { health.mockRejectedValue({}); - await selectServer(id)(dispatch, getState, {}); + await selectServer({ serverId: id, buildShlinkApiClient })(dispatch, getState, {}); expect(health).toHaveBeenCalled(); expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: expectedSelectedServer })); @@ -73,10 +71,10 @@ describe('selectedServerReducer', () => { it('dispatches error when server is not found', async () => { const id = crypto.randomUUID(); - const getState = vi.fn(() => fromPartial({ servers: {} })); + const getState = vi.fn(() => fromPartial({ servers: {} })); const expectedSelectedServer: NotFoundServer = { serverNotFound: true }; - await selectServer(id)(dispatch, getState, {}); + await selectServer({ serverId: id, buildShlinkApiClient })(dispatch, getState, {}); expect(getState).toHaveBeenCalled(); expect(health).not.toHaveBeenCalled(); diff --git a/test/settings/Settings.test.tsx b/test/settings/Settings.test.tsx index a1af73f1e..2d786f023 100644 --- a/test/settings/Settings.test.tsx +++ b/test/settings/Settings.test.tsx @@ -1,12 +1,12 @@ -import { render } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import { Settings } from '../../src/settings/Settings'; import { checkAccessibility } from '../__helpers__/accessibility'; +import { renderWithStore } from '../__helpers__/setUpTest'; describe('', () => { - const setUp = () => render( + const setUp = () => renderWithStore( - + , ); diff --git a/test/settings/helpers/index.test.ts b/test/settings/helpers/index.test.ts index e69fb8995..5fcc9ffc6 100644 --- a/test/settings/helpers/index.test.ts +++ b/test/settings/helpers/index.test.ts @@ -1,6 +1,6 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { ShlinkState } from '../../../src/container/types'; import { migrateDeprecatedSettings } from '../../../src/settings/helpers'; +import type { RootState } from '../../../src/store'; describe('settings-helpers', () => { describe('migrateDeprecatedSettings', () => { @@ -9,7 +9,7 @@ describe('settings-helpers', () => { }); it('updates settings as expected', () => { - const state = fromPartial({ + const state = fromPartial({ settings: { visits: { defaultInterval: 'last180days' as any, diff --git a/vite.config.ts b/vite.config.ts index 701121423..7266de635 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -65,7 +65,7 @@ export default defineConfig({ thresholds: { statements: 95, branches: 89, // FIXME Increase to 95 again. It dropped after updating to vitest 4 - functions: 95, + functions: 93, lines: 95, }, },
      - - ManageServersRowDropdown - +
      + +
      - - ManageServersRowDropdown - +
      + +