diff --git a/vuu-ui/allow-list.json b/vuu-ui/allow-list.json index c4bd0f3c8..29ab3d3f1 100644 --- a/vuu-ui/allow-list.json +++ b/vuu-ui/allow-list.json @@ -7,6 +7,10 @@ { "id": "CVE-2024-4068", "reason": "Lib used only in dev tooling (ESLint) no possibility of attack" + }, + { + "id": "CVE-2024-47068", + "reason": "Lib used only in dev tooling (Rollup) no possibility of attack" } ] } diff --git a/vuu-ui/package-lock.json b/vuu-ui/package-lock.json index d825bbc6e..3660a67c9 100644 --- a/vuu-ui/package-lock.json +++ b/vuu-ui/package-lock.json @@ -69,7 +69,7 @@ "typescript": "5.4.3", "vite": "5.0.12", "vite-tsconfig-paths": "4.2.2", - "vitest": "1.5.0" + "vitest": "2.1.1" }, "engines": { "node": ">=16.0.0" @@ -1520,9 +1520,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "node_modules/@jridgewell/trace-mapping": { @@ -2940,141 +2940,112 @@ } }, "node_modules/@vitest/expect": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.5.0.tgz", - "integrity": "sha512-0pzuCI6KYi2SIC3LQezmxujU9RK/vwC1U9R0rLuGlNGcOuDWxqWKu6nUdFsX9tH1WU0SXtAxToOsEjeUn1s3hA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "dependencies": { - "@vitest/spy": "1.5.0", - "@vitest/utils": "1.5.0", - "chai": "^4.3.10" + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", + "chai": "^5.1.1", + "tinyrainbow": "^1.2.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.5.0.tgz", - "integrity": "sha512-7HWwdxXP5yDoe7DTpbif9l6ZmDwCzcSIK38kTSIt6CFEpMjX4EpCgT6wUmS0xTXqMI6E/ONmfgRKmaujpabjZQ==", + "node_modules/@vitest/mocker": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "dependencies": { - "@vitest/utils": "1.5.0", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" + "@vitest/spy": "^2.1.0-beta.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" }, "funding": { "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/p-limit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^1.0.0" }, - "engines": { - "node": ">=18" + "peerDependencies": { + "@vitest/spy": "2.1.1", + "msw": "^2.3.5", + "vite": "^5.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@vitest/runner/node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "node_modules/@vitest/pretty-format": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz", + "integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==", "dev": true, - "engines": { - "node": ">=12.20" + "dependencies": { + "tinyrainbow": "^1.2.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.5.0.tgz", - "integrity": "sha512-qpv3fSEuNrhAO3FpH6YYRdaECnnRjg9VxbhdtPwPRnzSfHVXnNzzrpX4cJxqiwgRMo7uRMWDFBlsBq4Cr+rO3A==", + "node_modules/@vitest/runner": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "dependencies": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" + "@vitest/utils": "2.1.1", + "pathe": "^1.1.2" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/@vitest/snapshot": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@vitest/pretty-format": "2.1.1", + "magic-string": "^0.30.11", + "pathe": "^1.1.2" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, "node_modules/@vitest/spy": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.5.0.tgz", - "integrity": "sha512-vu6vi6ew5N5MMHJjD5PoakMRKYdmIrNJmyfkhRpQt5d9Ewhw9nZ5Aqynbi3N61bvk9UvZ5UysMT6ayIrZ8GA9w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "dependencies": { - "tinyspy": "^2.2.0" + "tinyspy": "^3.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.5.0.tgz", - "integrity": "sha512-BDU0GNL8MWkRkSRdNFvCUCAVOeHaUlVJ9Tx0TYBZyXaaOTmGtUFObzchCivIBrIwKzvZA7A9sCejVhXM2aY98A==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" + "@vitest/pretty-format": "2.1.1", + "loupe": "^3.1.1", + "tinyrainbow": "^1.2.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@vitest/utils/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, "node_modules/@zeit/schemas": { "version": "2.29.0", "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.29.0.tgz", @@ -3121,15 +3092,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -3405,12 +3367,12 @@ } }, "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "engines": { - "node": "*" + "node": ">=12" } }, "node_modules/astral-regex": { @@ -3910,21 +3872,19 @@ } }, "node_modules/chai": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", - "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", + "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "dev": true, "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.0.8" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, "engines": { - "node": ">=4" + "node": ">=12" } }, "node_modules/chalk": { @@ -4056,15 +4016,12 @@ } }, "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, - "dependencies": { - "get-func-name": "^2.0.2" - }, "engines": { - "node": "*" + "node": ">= 16" } }, "node_modules/check-more-types": { @@ -4680,11 +4637,11 @@ "dev": true }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -4763,13 +4720,10 @@ } }, "node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, - "dependencies": { - "type-detect": "^4.0.0" - }, "engines": { "node": ">=6" } @@ -7912,12 +7866,6 @@ "node": ">=6" } }, - "node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", - "dev": true - }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -8029,22 +7977,6 @@ } } }, - "node_modules/local-pkg": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", - "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", - "dev": true, - "dependencies": { - "mlly": "^1.4.2", - "pkg-types": "^1.0.3" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -8263,9 +8195,9 @@ } }, "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", + "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", "dev": true, "dependencies": { "get-func-name": "^2.0.1" @@ -8290,15 +8222,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", - "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "dev": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/map-obj": { @@ -9286,22 +9215,10 @@ "node": ">=0.10.0" } }, - "node_modules/mlly": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.2.tgz", - "integrity": "sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==", - "dev": true, - "dependencies": { - "acorn": "^8.10.0", - "pathe": "^1.1.1", - "pkg-types": "^1.0.3", - "ufo": "^1.3.0" - } - }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/nanoid": { "version": "3.3.7", @@ -9754,18 +9671,18 @@ } }, "node_modules/pathe": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz", - "integrity": "sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "dev": true }, "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, "engines": { - "node": "*" + "node": ">= 14.16" } }, "node_modules/pend": { @@ -9817,17 +9734,6 @@ "node": ">=0.10.0" } }, - "node_modules/pkg-types": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", - "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==", - "dev": true, - "dependencies": { - "jsonc-parser": "^3.2.0", - "mlly": "^1.2.0", - "pathe": "^1.1.0" - } - }, "node_modules/playwright": { "version": "1.43.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", @@ -11092,9 +10998,9 @@ "link": true }, "node_modules/std-env": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.6.0.tgz", - "integrity": "sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", "dev": true }, "node_modules/stop-iteration-iterator": { @@ -11247,24 +11153,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.0.0.tgz", - "integrity": "sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==", - "dev": true, - "dependencies": { - "js-tokens": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz", - "integrity": "sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==", - "dev": true - }, "node_modules/style-mod": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.3.tgz", @@ -11535,9 +11423,9 @@ "dev": true }, "node_modules/tinybench": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.5.1.tgz", - "integrity": "sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true }, "node_modules/tinycolor2": { @@ -11549,19 +11437,34 @@ "node": "*" } }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "dev": true + }, "node_modules/tinypool": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", - "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz", + "integrity": "sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", "dev": true, "engines": { "node": ">=14.0.0" } }, "node_modules/tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, "engines": { "node": ">=14.0.0" @@ -11742,15 +11645,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", @@ -11840,12 +11734,6 @@ "node": ">=14.17" } }, - "node_modules/ufo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", - "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==", - "dev": true - }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -12249,15 +12137,14 @@ } }, "node_modules/vite-node": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.5.0.tgz", - "integrity": "sha512-tV8h6gMj6vPzVCa7l+VGq9lwoJjW8Y79vst8QZZGiuRAfijU+EEWuc0kFpmndQrWhMMhet1jdSF+40KSZUqIIw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", + "debug": "^4.3.6", + "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { @@ -12696,31 +12583,30 @@ } }, "node_modules/vitest": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.5.0.tgz", - "integrity": "sha512-d8UKgR0m2kjdxDWX6911uwxout6GHS0XaGH1cksSIVVG8kRlE7G7aBw7myKQCvDI5dT4j7ZMa+l706BIORMDLw==", - "dev": true, - "dependencies": { - "@vitest/expect": "1.5.0", - "@vitest/runner": "1.5.0", - "@vitest/snapshot": "1.5.0", - "@vitest/spy": "1.5.0", - "@vitest/utils": "1.5.0", - "acorn-walk": "^8.3.2", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.3", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", + "chai": "^5.1.1", + "debug": "^4.3.6", + "magic-string": "^0.30.11", + "pathe": "^1.1.2", + "std-env": "^3.7.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", + "tinypool": "^1.0.0", + "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "1.5.0", - "why-is-node-running": "^2.2.2" + "vite-node": "2.1.1", + "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" @@ -12734,8 +12620,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.5.0", - "@vitest/ui": "1.5.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, @@ -12760,140 +12646,6 @@ } } }, - "node_modules/vitest/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/vitest/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/vitest/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/vitest/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -13023,9 +12775,9 @@ } }, "node_modules/why-is-node-running": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", - "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "dependencies": { "siginfo": "^2.0.0", @@ -13360,6 +13112,7 @@ "dependencies": { "@finos/vuu-data-remote": "0.0.26", "@finos/vuu-filter-parser": "0.0.26", + "@finos/vuu-layout": "0.0.26", "@finos/vuu-popups": "0.0.26", "@finos/vuu-table": "0.0.26", "@finos/vuu-ui-controls": "0.0.26", @@ -14742,6 +14495,7 @@ "@finos/vuu-data-types": "0.0.26", "@finos/vuu-filter-parser": "0.0.26", "@finos/vuu-filter-types": "0.0.26", + "@finos/vuu-layout": "0.0.26", "@finos/vuu-popups": "0.0.26", "@finos/vuu-protocol-types": "0.0.26", "@finos/vuu-table": "0.0.26", @@ -15167,9 +14921,9 @@ "dev": true }, "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "@jridgewell/trace-mapping": { @@ -16188,112 +15942,76 @@ } }, "@vitest/expect": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.5.0.tgz", - "integrity": "sha512-0pzuCI6KYi2SIC3LQezmxujU9RK/vwC1U9R0rLuGlNGcOuDWxqWKu6nUdFsX9tH1WU0SXtAxToOsEjeUn1s3hA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "requires": { - "@vitest/spy": "1.5.0", - "@vitest/utils": "1.5.0", - "chai": "^4.3.10" + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", + "chai": "^5.1.1", + "tinyrainbow": "^1.2.0" + } + }, + "@vitest/mocker": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", + "dev": true, + "requires": { + "@vitest/spy": "^2.1.0-beta.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + } + }, + "@vitest/pretty-format": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz", + "integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==", + "dev": true, + "requires": { + "tinyrainbow": "^1.2.0" } }, "@vitest/runner": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.5.0.tgz", - "integrity": "sha512-7HWwdxXP5yDoe7DTpbif9l6ZmDwCzcSIK38kTSIt6CFEpMjX4EpCgT6wUmS0xTXqMI6E/ONmfgRKmaujpabjZQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "requires": { - "@vitest/utils": "1.5.0", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" - }, - "dependencies": { - "p-limit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", - "dev": true, - "requires": { - "yocto-queue": "^1.0.0" - } - }, - "yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", - "dev": true - } + "@vitest/utils": "2.1.1", + "pathe": "^1.1.2" } }, "@vitest/snapshot": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.5.0.tgz", - "integrity": "sha512-qpv3fSEuNrhAO3FpH6YYRdaECnnRjg9VxbhdtPwPRnzSfHVXnNzzrpX4cJxqiwgRMo7uRMWDFBlsBq4Cr+rO3A==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "requires": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" - }, - "dependencies": { - "pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "requires": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - } - }, - "react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - } + "@vitest/pretty-format": "2.1.1", + "magic-string": "^0.30.11", + "pathe": "^1.1.2" } }, "@vitest/spy": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.5.0.tgz", - "integrity": "sha512-vu6vi6ew5N5MMHJjD5PoakMRKYdmIrNJmyfkhRpQt5d9Ewhw9nZ5Aqynbi3N61bvk9UvZ5UysMT6ayIrZ8GA9w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "requires": { - "tinyspy": "^2.2.0" + "tinyspy": "^3.0.0" } }, "@vitest/utils": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.5.0.tgz", - "integrity": "sha512-BDU0GNL8MWkRkSRdNFvCUCAVOeHaUlVJ9Tx0TYBZyXaaOTmGtUFObzchCivIBrIwKzvZA7A9sCejVhXM2aY98A==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "requires": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" - }, - "dependencies": { - "pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "requires": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - } - }, - "react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - } + "@vitest/pretty-format": "2.1.1", + "loupe": "^3.1.1", + "tinyrainbow": "^1.2.0" } }, "@zeit/schemas": { @@ -16331,12 +16049,6 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "requires": {} }, - "acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", - "dev": true - }, "agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -16548,9 +16260,9 @@ "dev": true }, "assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true }, "astral-regex": { @@ -16864,18 +16576,16 @@ "dev": true }, "chai": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", - "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", + "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "dev": true, "requires": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.0.8" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" } }, "chalk": { @@ -16963,13 +16673,10 @@ "dev": true }, "check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, - "requires": { - "get-func-name": "^2.0.2" - } + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true }, "check-more-types": { "version": "2.24.0", @@ -17428,11 +17135,11 @@ "dev": true }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "requires": { - "ms": "2.1.2" + "ms": "^2.1.3" } }, "decamelize": { @@ -17483,13 +17190,10 @@ } }, "deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", - "dev": true, - "requires": { - "type-detect": "^4.0.0" - } + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true }, "deep-equal": { "version": "2.2.3", @@ -19876,12 +19580,6 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, - "jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", - "dev": true - }, "jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -19965,16 +19663,6 @@ "wrap-ansi": "^7.0.0" } }, - "local-pkg": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", - "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", - "dev": true, - "requires": { - "mlly": "^1.4.2", - "pkg-types": "^1.0.3" - } - }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -20136,9 +19824,9 @@ } }, "loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", + "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", "dev": true, "requires": { "get-func-name": "^2.0.1" @@ -20160,12 +19848,12 @@ "dev": true }, "magic-string": { - "version": "0.30.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", - "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "dev": true, "requires": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "map-obj": { @@ -20812,22 +20500,10 @@ } } }, - "mlly": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.2.tgz", - "integrity": "sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==", - "dev": true, - "requires": { - "acorn": "^8.10.0", - "pathe": "^1.1.1", - "pkg-types": "^1.0.3", - "ufo": "^1.3.0" - } - }, "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "nanoid": { "version": "3.3.7", @@ -21149,15 +20825,15 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" }, "pathe": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz", - "integrity": "sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "dev": true }, "pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true }, "pend": { @@ -21200,17 +20876,6 @@ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "dev": true }, - "pkg-types": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", - "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==", - "dev": true, - "requires": { - "jsonc-parser": "^3.2.0", - "mlly": "^1.2.0", - "pathe": "^1.1.0" - } - }, "playwright": { "version": "1.43.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", @@ -22115,9 +21780,9 @@ } }, "std-env": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.6.0.tgz", - "integrity": "sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", "dev": true }, "stop-iteration-iterator": { @@ -22227,23 +21892,6 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, - "strip-literal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.0.0.tgz", - "integrity": "sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==", - "dev": true, - "requires": { - "js-tokens": "^8.0.2" - }, - "dependencies": { - "js-tokens": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz", - "integrity": "sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==", - "dev": true - } - } - }, "style-mod": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.3.tgz", @@ -22467,9 +22115,9 @@ "dev": true }, "tinybench": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.5.1.tgz", - "integrity": "sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true }, "tinycolor2": { @@ -22478,16 +22126,28 @@ "integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==", "dev": true }, + "tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "dev": true + }, "tinypool": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", - "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz", + "integrity": "sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==", + "dev": true + }, + "tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", "dev": true }, "tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true }, "tmp": { @@ -22612,12 +22272,6 @@ "prelude-ls": "^1.2.1" } }, - "type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true - }, "type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", @@ -22676,12 +22330,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==" }, - "ufo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", - "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==", - "dev": true - }, "unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -23178,15 +22826,14 @@ } }, "vite-node": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.5.0.tgz", - "integrity": "sha512-tV8h6gMj6vPzVCa7l+VGq9lwoJjW8Y79vst8QZZGiuRAfijU+EEWuc0kFpmndQrWhMMhet1jdSF+40KSZUqIIw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "requires": { "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", + "debug": "^4.3.6", + "pathe": "^1.1.2", "vite": "^5.0.0" } }, @@ -23202,110 +22849,30 @@ } }, "vitest": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.5.0.tgz", - "integrity": "sha512-d8UKgR0m2kjdxDWX6911uwxout6GHS0XaGH1cksSIVVG8kRlE7G7aBw7myKQCvDI5dT4j7ZMa+l706BIORMDLw==", - "dev": true, - "requires": { - "@vitest/expect": "1.5.0", - "@vitest/runner": "1.5.0", - "@vitest/snapshot": "1.5.0", - "@vitest/spy": "1.5.0", - "@vitest/utils": "1.5.0", - "acorn-walk": "^8.3.2", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.3", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", + "dev": true, + "requires": { + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", + "chai": "^5.1.1", + "debug": "^4.3.6", + "magic-string": "^0.30.11", + "pathe": "^1.1.2", + "std-env": "^3.7.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", + "tinypool": "^1.0.0", + "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "1.5.0", - "why-is-node-running": "^2.2.2" - }, - "dependencies": { - "execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - } - }, - "get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true - }, - "human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true - }, - "is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true - }, - "mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true - }, - "npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", - "dev": true, - "requires": { - "path-key": "^4.0.0" - } - }, - "onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "requires": { - "mimic-fn": "^4.0.0" - } - }, - "path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true - }, - "signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true - }, - "strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true - } + "vite-node": "2.1.1", + "why-is-node-running": "^2.3.0" } }, "w3c-keyname": { @@ -23404,9 +22971,9 @@ } }, "why-is-node-running": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", - "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "requires": { "siginfo": "^2.0.0", diff --git a/vuu-ui/package.json b/vuu-ui/package.json index a214db7ff..aba8b33a1 100644 --- a/vuu-ui/package.json +++ b/vuu-ui/package.json @@ -98,7 +98,7 @@ "typescript": "5.4.3", "vite": "5.0.12", "vite-tsconfig-paths": "4.2.2", - "vitest": "1.5.0" + "vitest": "2.1.1" }, "engines": { "node": ">=16.0.0" diff --git a/vuu-ui/packages/vuu-data-react/src/datasource-provider/VuuDataSourceProvider.tsx b/vuu-ui/packages/vuu-data-react/src/datasource-provider/VuuDataSourceProvider.tsx index 056c90abc..2dc170679 100644 --- a/vuu-ui/packages/vuu-data-react/src/datasource-provider/VuuDataSourceProvider.tsx +++ b/vuu-ui/packages/vuu-data-react/src/datasource-provider/VuuDataSourceProvider.tsx @@ -1,7 +1,9 @@ -import { VuuDataSource, getServerAPI } from "@finos/vuu-data-remote"; +import { ConnectionManager, VuuDataSource } from "@finos/vuu-data-remote"; import { DataSourceProvider } from "@finos/vuu-utils"; import { ReactNode } from "react"; +const getServerAPI = () => ConnectionManager.serverAPI; + export const VuuDataSourceProvider = ({ children, }: { diff --git a/vuu-ui/packages/vuu-data-react/src/hooks/index.ts b/vuu-ui/packages/vuu-data-react/src/hooks/index.ts index 140dd5729..b8d797fb2 100644 --- a/vuu-ui/packages/vuu-data-react/src/hooks/index.ts +++ b/vuu-ui/packages/vuu-data-react/src/hooks/index.ts @@ -1,7 +1,7 @@ export * from "./useLookupValues"; +export * from "./useSessionDataSource"; export * from "./useVuuMenuActions"; export * from "./useVuuTables"; export * from "./useVisualLinks"; -export * from "./useServerConnectionStatus"; export * from "./useServerConnectionQuality"; export * from "./useTypeaheadSuggestions"; diff --git a/vuu-ui/packages/vuu-data-react/src/hooks/useServerConnectionStatus.ts b/vuu-ui/packages/vuu-data-react/src/hooks/useServerConnectionStatus.ts deleted file mode 100644 index 6f80c0862..000000000 --- a/vuu-ui/packages/vuu-data-react/src/hooks/useServerConnectionStatus.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { ConnectionManager } from "@finos/vuu-data-remote"; -import { ConnectionStatusMessage } from "@finos/vuu-data-types"; - -export const useServerConnectionStatus = () => { - const [connectionStatus, setConnectionStatus] = useState("disconnected"); - - const handleStatusChange = useCallback( - ({ status }: ConnectionStatusMessage) => { - setConnectionStatus(status); - }, - [] - ); - - useEffect(() => { - ConnectionManager.on("connection-status", handleStatusChange); - return () => { - ConnectionManager.removeListener("connection-status", handleStatusChange); - }; - }, [handleStatusChange]); - - return connectionStatus; -}; diff --git a/vuu-ui/sample-apps/feature-filter-table/src/useSessionDataSource.ts b/vuu-ui/packages/vuu-data-react/src/hooks/useSessionDataSource.ts similarity index 100% rename from vuu-ui/sample-apps/feature-filter-table/src/useSessionDataSource.ts rename to vuu-ui/packages/vuu-data-react/src/hooks/useSessionDataSource.ts diff --git a/vuu-ui/packages/vuu-data-react/src/hooks/useTypeaheadSuggestions.ts b/vuu-ui/packages/vuu-data-react/src/hooks/useTypeaheadSuggestions.ts index 9c35ffcd1..ae3ba2b53 100644 --- a/vuu-ui/packages/vuu-data-react/src/hooks/useTypeaheadSuggestions.ts +++ b/vuu-ui/packages/vuu-data-react/src/hooks/useTypeaheadSuggestions.ts @@ -1,4 +1,4 @@ -import { makeRpcCall } from "@finos/vuu-data-remote"; +import { ConnectionManager } from "@finos/vuu-data-remote"; import { SuggestionFetcher, TableSchemaTable } from "@finos/vuu-data-types"; import { VuuRpcServiceRequest, @@ -34,5 +34,5 @@ export const useTypeaheadSuggestions = () => method: "getUniqueFieldValuesStartingWith", params, }; - return makeRpcCall(rpcMessage); + return ConnectionManager.makeRpcCall(rpcMessage); }, []); diff --git a/vuu-ui/packages/vuu-data-react/src/hooks/useVuuTables.ts b/vuu-ui/packages/vuu-data-react/src/hooks/useVuuTables.ts index 6be2f5f2c..f61bc490f 100644 --- a/vuu-ui/packages/vuu-data-react/src/hooks/useVuuTables.ts +++ b/vuu-ui/packages/vuu-data-react/src/hooks/useVuuTables.ts @@ -28,7 +28,7 @@ export const useVuuTables = () => { setTables(tableSchemas); } catch (err) { console.warn( - `useVuuTables: error fetching table metedata ${String(err)}`, + `useVuuTables: error fetching table metadata ${String(err)}`, ); } } diff --git a/vuu-ui/packages/vuu-data-remote/src/ConnectionManager.ts b/vuu-ui/packages/vuu-data-remote/src/ConnectionManager.ts new file mode 100644 index 000000000..90b64003b --- /dev/null +++ b/vuu-ui/packages/vuu-data-remote/src/ConnectionManager.ts @@ -0,0 +1,243 @@ +import { + ConnectOptions, + DataSourceCallbackMessage, + ServerAPI, + ServerProxySubscribeMessage, + TableSchema, + VuuUIMessageIn, +} from "@finos/vuu-data-types"; +import { + VuuCreateVisualLink, + VuuRemoveVisualLink, + VuuRpcMenuRequest, + VuuRpcServiceRequest, + VuuRpcViewportRequest, + VuuTableList, + VuuTableListRequest, + VuuTableMetaRequest, +} from "@finos/vuu-protocol-types"; +import { + DeferredPromise, + EventEmitter, + isConnectionQualityMetrics, + isRequestResponse, + isTableSchemaMessage, + messageHasResult, + uuid, +} from "@finos/vuu-utils"; +import { + WebSocketConnectionEvents, + WebSocketConnectionState, + isWebSocketConnectionMessage, +} from "./WebSocketConnection"; +import { DedicatedWorker } from "./DedicatedWorker"; +import { shouldMessageBeRoutedToDataSource } from "./data-source"; + +import { ConnectionQualityMetrics } from "@finos/vuu-data-types"; + +export type PostMessageToClientCallback = ( + msg: DataSourceCallbackMessage, +) => void; + +export type ConnectionEvents = WebSocketConnectionEvents & { + "connection-metrics": (message: ConnectionQualityMetrics) => void; +}; + +type RegisteredViewport = { + postMessageToClientDataSource: PostMessageToClientCallback; + request: ServerProxySubscribeMessage; + status: "subscribing"; +}; + +class ConnectionManager extends EventEmitter { + #connectionState: WebSocketConnectionState = { + connectionPhase: "connecting", + connectionStatus: "closed", + retryAttemptsTotal: -1, + retryAttemptsRemaining: -1, + secondsToNextRetry: -1, + }; + static #instance: ConnectionManager; + #deferredServerAPI = new DeferredPromise(); + #pendingRequests = new Map(); + #viewports = new Map(); + // #worker?: Worker; + #worker: DedicatedWorker; + + private constructor() { + super(); + this.#worker = new DedicatedWorker(this.handleMessageFromWorker); + } + + public static get instance(): ConnectionManager { + if (!ConnectionManager.#instance) { + ConnectionManager.#instance = new ConnectionManager(); + } + return ConnectionManager.#instance; + } + + /** + * Open a connection to the VuuServer. This method opens the websocket connection + * and logs in. It can be called from whichever client code has access to the auth + * token (eg. the login page, or just a hardcoded login script in a sample). + * This will unblock any DataSources which may have already tried to subscribe to data, + * but lacked access to the auth token. + * + * @param serverUrl + * @param token + */ + async connect(options: ConnectOptions) { + const result = await this.#worker.connect(options); + if (result === "connected") { + this.#deferredServerAPI.resolve(this.connectedServerAPI); + } + return result; + } + + private handleMessageFromWorker = ( + message: VuuUIMessageIn | DataSourceCallbackMessage, + ) => { + if (shouldMessageBeRoutedToDataSource(message)) { + const viewport = this.#viewports.get(message.clientViewportId); + if (viewport) { + viewport.postMessageToClientDataSource(message); + } else { + console.error( + `[ConnectionManager] ${message.type} message received, viewport not found`, + ); + } + } else if (isWebSocketConnectionMessage(message)) { + this.#connectionState = message; + this.emit("connection-status", message); + } else if (isConnectionQualityMetrics(message)) { + this.emit("connection-metrics", message); + } else if (isRequestResponse(message)) { + const { requestId } = message; + if (this.#pendingRequests.has(requestId)) { + const { resolve } = this.#pendingRequests.get(requestId); + this.#pendingRequests.delete(requestId); + const { requestId: _, ...messageWithoutRequestId } = message; + + if (messageHasResult(message)) { + resolve(message.result); + } else if ( + message.type === "VP_EDIT_RPC_RESPONSE" || + message.type === "VP_EDIT_RPC_REJECT" + ) { + resolve(message); + } else if (isTableSchemaMessage(message)) { + resolve(message.tableSchema); + } else { + resolve(messageWithoutRequestId); + } + } else { + console.warn( + "%cConnectionManager Unexpected message from the worker", + "color:red;font-weight:bold;", + ); + } + } + }; + + get connectionStatus() { + return this.#connectionState.connectionStatus; + } + + get serverAPI() { + return this.#deferredServerAPI.promise; + } + + private connectedServerAPI: ServerAPI = { + subscribe: (message, callback) => { + if (this.#viewports.get(message.viewport)) { + throw Error( + `ConnectionManager attempting to subscribe with an existing viewport id`, + ); + } + // TODO we never use this status + this.#viewports.set(message.viewport, { + status: "subscribing", + request: message, + postMessageToClientDataSource: callback, + }); + this.#worker.send({ type: "subscribe", ...message }); + }, + + unsubscribe: (viewport) => { + this.#worker.send({ type: "unsubscribe", viewport }); + }, + + send: (message) => { + this.#worker.send(message); + }, + + destroy: (viewportId?: string) => { + if (viewportId && this.#viewports.has(viewportId)) { + this.#viewports.delete(viewportId); + } + }, + + rpcCall: async ( + message: + | VuuRpcServiceRequest + | VuuRpcMenuRequest + | VuuRpcViewportRequest + | VuuCreateVisualLink + | VuuRemoveVisualLink, + ) => this.asyncRequest(message), + + getTableList: async () => + this.asyncRequest({ type: "GET_TABLE_LIST" }), + + getTableSchema: async (table) => + this.asyncRequest({ + type: "GET_TABLE_META", + table, + }), + }; + + private asyncRequest = ( + msg: + | VuuRpcServiceRequest + | VuuRpcMenuRequest + | VuuTableListRequest + | VuuTableMetaRequest + | VuuRpcViewportRequest + | VuuCreateVisualLink + | VuuRemoveVisualLink, + ): Promise => { + const requestId = uuid(); + this.#worker.send({ + requestId, + ...msg, + }); + return new Promise((resolve, reject) => { + this.#pendingRequests.set(requestId, { resolve, reject }); + }); + }; + + async disconnect() { + try { + // should we await this ? + this.#worker.send({ type: "disconnect" }); + // how do we disable the serverAPI + return "disconnected"; + } catch (err: unknown) { + return "rejected"; + } + } + + destroy() { + this.#worker.terminate(); + } + + async makeRpcCall(rpcRequest: VuuRpcServiceRequest) { + try { + return this.asyncRequest(rpcRequest); + } catch (err) { + throw Error("Error accessing server api"); + } + } +} + +export default ConnectionManager.instance; diff --git a/vuu-ui/packages/vuu-data-remote/src/DedicatedWorker.ts b/vuu-ui/packages/vuu-data-remote/src/DedicatedWorker.ts new file mode 100644 index 000000000..ef1148a61 --- /dev/null +++ b/vuu-ui/packages/vuu-data-remote/src/DedicatedWorker.ts @@ -0,0 +1,82 @@ +import { + ConnectOptions, + VuuUIMessageIn, + VuuUIMessageOut, + WithRequestId, +} from "@finos/vuu-data-types"; +import { DeferredPromise, getLoggingConfigForWorker } from "@finos/vuu-utils"; + +// Note: inlined-worker is a generated file, it must be built +import { workerSourceCode } from "./inlined-worker"; +import { + VuuCreateVisualLink, + VuuRemoveVisualLink, + VuuRpcMenuRequest, + VuuRpcServiceRequest, + VuuRpcViewportRequest, +} from "@finos/vuu-protocol-types"; + +const workerBlob = new Blob([getLoggingConfigForWorker() + workerSourceCode], { + type: "text/javascript", +}); +const workerBlobUrl = URL.createObjectURL(workerBlob); + +export class DedicatedWorker { + #deferredConnection?: DeferredPromise< + "connected" | "reconnected" | "rejected" + >; + #worker: Promise; + + constructor(onMessage: (msg: VuuUIMessageIn) => void) { + const deferredWorker = new DeferredPromise(); + this.#worker = deferredWorker.promise; + const worker = new Worker(workerBlobUrl); + const timer: number | null = window.setTimeout(() => { + deferredWorker.reject(Error("timed out waiting for worker to load")); + }, 1000); + worker.onmessage = (msg: MessageEvent) => { + const { data: message } = msg; + if (message.type === "ready") { + window.clearTimeout(timer); + deferredWorker.resolve(worker); + } else if (message.type === "connected") { + // how do we detect reconnected + this.#deferredConnection?.resolve("connected"); + } else if (message.type === "connection-failed") { + this.#deferredConnection?.resolve("rejected"); + // this.#deferredConnection?.reject(message.reason); + } else { + onMessage(message); + } + }; + } + + async connect(options: ConnectOptions) { + this.#deferredConnection = new DeferredPromise< + "connected" | "reconnected" | "rejected" + >(); + this.send({ + ...options, + type: "connect", + }); + return this.#deferredConnection.promise; + } + + async send( + message: + | VuuUIMessageOut + | WithRequestId< + | VuuRpcViewportRequest + | VuuCreateVisualLink + | VuuRemoveVisualLink + | VuuRpcServiceRequest + | VuuRpcMenuRequest + >, + ) { + (await this.#worker).postMessage(message); + } + + async terminate() { + (await this.#worker).terminate(); + } +} diff --git a/vuu-ui/packages/vuu-data-remote/src/WebSocketConnection.ts b/vuu-ui/packages/vuu-data-remote/src/WebSocketConnection.ts new file mode 100644 index 000000000..449f9a72c --- /dev/null +++ b/vuu-ui/packages/vuu-data-remote/src/WebSocketConnection.ts @@ -0,0 +1,322 @@ +import { WebSocketProtocol } from "@finos/vuu-data-types"; +import { VuuClientMessage, VuuServerMessage } from "@finos/vuu-protocol-types"; +import { DeferredPromise, EventEmitter } from "@finos/vuu-utils"; + +export type ConnectingStatus = "connecting" | "reconnecting"; +export type RetryStatus = ConnectingStatus | "disconnected"; +export type ConnectedStatus = "connected" | "reconnected"; +export type ConnectionStatus = + | RetryStatus + | ConnectedStatus + | "closed" + | "connection-open-awaiting-session" + | "failed" + | "inactive"; + +type ReconnectAttempts = { + retryAttemptsTotal: number; + retryAttemptsRemaining: number; + secondsToNextRetry: number; +}; + +export interface WebSocketConnectionState extends ReconnectAttempts { + connectionPhase: ConnectingStatus; + connectionStatus: ConnectionStatus; +} + +export const isWebSocketConnectionMessage = ( + msg: object | WebSocketConnectionState, +): msg is WebSocketConnectionState => { + if ("connectionStatus" in msg) { + return [ + "connecting", + "connected", + "connection-open-awaiting-session", + "reconnecting", + "reconnected", + "disconnected", + "closed", + "failed", + ].includes(msg.connectionStatus); + } else { + return false; + } +}; + +export type VuuServerMessageCallback = (msg: VuuServerMessage) => void; + +export type RetryLimits = { + connect: number; + reconnect: number; +}; + +export type WebSocketConnectionConfig = { + url: string; + protocols: WebSocketProtocol; + callback: VuuServerMessageCallback; + connectionTimeout?: number; + retryLimits?: RetryLimits; +}; + +const DEFAULT_RETRY_LIMITS: RetryLimits = { + connect: 5, + reconnect: 8, +}; + +const DEFAULT_CONNECTION_TIMEOUT = 10000; + +const ConnectingEndState: Record = { + connecting: "connected", + reconnecting: "reconnected", +} as const; + +const parseWebSocketMessage = (message: string): VuuServerMessage => { + try { + return JSON.parse(message) as VuuServerMessage; + } catch (e) { + throw Error(`Error parsing JSON response from server ${message}`); + } +}; + +export type WebSocketConnectionCloseReason = "failure" | "shutdown"; +export type WebSocketConnectionEvents = { + closed: (reason: WebSocketConnectionCloseReason) => void; + connected: () => void; + "connection-status": (message: WebSocketConnectionState) => void; + reconnected: () => void; +}; + +export class WebSocketConnection extends EventEmitter { + #callback; + /** + We are not confirmedOpen until we receive the first message from the + server. If we get an unexpected close event before that, we consider + the reconnect attempts as still within the connection phase, not true + reconnection. This can happen e.g. when connecting to remote host via + a proxy. + */ + #confirmedOpen = false; + #connectionState: WebSocketConnectionState; + #connectionTimeout; + #deferredConnection?: DeferredPromise; + #protocols; + #reconnectAttempts: ReconnectAttempts; + #requiresLogin = true; + #url; + #ws?: WebSocket; + + constructor({ + callback, + connectionTimeout = DEFAULT_CONNECTION_TIMEOUT, + protocols, + retryLimits = DEFAULT_RETRY_LIMITS, + url, + }: WebSocketConnectionConfig) { + super(); + + this.#callback = callback; + this.#connectionTimeout = connectionTimeout; + this.#url = url; + this.#protocols = protocols; + + this.#reconnectAttempts = { + retryAttemptsTotal: retryLimits.reconnect, + retryAttemptsRemaining: retryLimits.reconnect, + secondsToNextRetry: 1, + }; + + /** + * Initial retryAttempts are for the 'connecting' phase. These will + * be replaced with 'reconnecting' phase retry attempts only once + * initial connection succeeds. + */ + this.#connectionState = { + connectionPhase: "connecting", + connectionStatus: "closed", + retryAttemptsTotal: retryLimits.connect, + retryAttemptsRemaining: retryLimits.connect, + secondsToNextRetry: 1, + }; + } + + get connectionTimeout() { + return this.#connectionTimeout; + } + + get protocols() { + return this.#protocols; + } + + get requiresLogin() { + return this.#requiresLogin; + } + + get isClosed() { + return this.status === "closed"; + } + get isDisconnected() { + return this.status === "disconnected"; + } + + get isConnecting() { + return this.#connectionState.connectionPhase === "connecting"; + } + + get status() { + return this.#connectionState.connectionStatus; + } + + private set status(connectionStatus: ConnectionStatus) { + this.#connectionState = { + ...this.#connectionState, + connectionStatus, + }; + this.emit("connection-status", this.#connectionState); + } + + get connectionState() { + return this.#connectionState; + } + + private get hasConnectionAttemptsRemaining() { + return this.#connectionState.retryAttemptsRemaining > 0; + } + + private get confirmedOpen() { + return this.#confirmedOpen; + } + + /** + * We are 'confirmedOpen' when we see the first message transmitted + * from the server. This ensures that even if we have one or more + * proxies in our route to the endPoint, all connections have been + * opened successfully. + * First time in here (on our initial successful connection) we switch + * from 'connect' phase to 'reconnect' phase. We may have different + * retry configurations for these two phases. + */ + private set confirmedOpen(confirmedOpen: boolean) { + this.#confirmedOpen = confirmedOpen; + + if (confirmedOpen && this.isConnecting) { + this.#connectionState = { + ...this.#connectionState, + connectionPhase: "reconnecting", + ...this.#reconnectAttempts, + }; + } else if (confirmedOpen) { + // we have successfully reconnected after a failure. + // Reset the retry attempts, ready for next failure + // Note: this retry is shared with 'disconnected' status + this.#connectionState = { + ...this.#connectionState, + ...this.#reconnectAttempts, + }; + } + } + + get url() { + return this.#url; + } + + async connect(clientCall = true) { + const state = this.#connectionState; + if (this.isConnecting && this.#deferredConnection === undefined) { + // We block on the first connecting call, this will be the + // initial connect call from app. Any other calls will be + // reconnect attempts. The initial connecting call returns a promise. + // This promise is resolved either on that initial call or on a + // subsequent successful retry attempt within nthat same initial + // connecting phase. + this.#deferredConnection = new DeferredPromise(); + } + const { connectionTimeout, protocols, url } = this; + this.status = state.connectionPhase; + const timer = setTimeout(() => { + throw Error( + `Failed to open WebSocket connection to ${url}, timed out after ${connectionTimeout}ms`, + ); + }, connectionTimeout); + + const ws = (this.#ws = new WebSocket(url, protocols)); + + ws.onopen = () => { + const connectedStatus = ConnectingEndState[state.connectionPhase]; + this.status = connectedStatus; + clearTimeout(timer); + if (this.#deferredConnection) { + this.#deferredConnection.resolve(undefined); + this.#deferredConnection = undefined; + } + if (this.isConnecting) { + this.emit("connected"); + } else { + this.emit("reconnected"); + } + }; + ws.onerror = () => { + clearTimeout(timer); + }; + + ws.onclose = () => { + if (!this.isClosed) { + this.confirmedOpen = false; + this.status = "disconnected"; + if (this.hasConnectionAttemptsRemaining) { + this.reconnect(); + } else { + this.close("failure"); + } + } + }; + + ws.onmessage = (evt) => { + if (!this.confirmedOpen) { + // Now that we are confirmedOpen any subsequent close events + // will be treated as part of a reconnection phase. + this.confirmedOpen = true; + } + this.receive(evt); + }; + + if (clientCall) { + return this.#deferredConnection?.promise; + } + } + + private reconnect() { + const { retryAttemptsRemaining, secondsToNextRetry } = + this.#connectionState; + setTimeout(() => { + this.#connectionState = { + ...this.#connectionState, + retryAttemptsRemaining: retryAttemptsRemaining - 1, + secondsToNextRetry: secondsToNextRetry * 2, + }; + this.connect(false); + }, secondsToNextRetry * 1000); + } + + private receive = (evt: MessageEvent) => { + const vuuMessageFromServer = parseWebSocketMessage(evt.data); + this.#callback(vuuMessageFromServer); + }; + + send = (msg: VuuClientMessage) => { + this.#ws?.send(JSON.stringify(msg)); + }; + + close(reason: WebSocketConnectionCloseReason = "shutdown") { + this.status = "closed"; + if (reason === "failure") { + if (this.#deferredConnection) { + this.#deferredConnection.reject(Error("connection failed")); + this.#deferredConnection = undefined; + } + } else { + this.#ws?.close(); + } + this.emit("closed", reason); + this.#ws = undefined; + } +} diff --git a/vuu-ui/packages/vuu-data-remote/src/connection-manager.ts b/vuu-ui/packages/vuu-data-remote/src/connection-manager.ts deleted file mode 100644 index 4ec4523d1..000000000 --- a/vuu-ui/packages/vuu-data-remote/src/connection-manager.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { - ConnectionStatusMessage, - DataSourceCallbackMessage, - ServerProxySubscribeMessage, - TableSchema, - VuuUIMessageIn, - VuuUIMessageOut, - WebSocketProtocol, -} from "@finos/vuu-data-types"; -import { - VuuRpcMenuRequest, - VuuTableListRequest, - VuuTableMetaRequest, - VuuRpcViewportRequest, - VuuRpcServiceRequest, - VuuTable, - VuuTableList, - VuuCreateVisualLink, - VuuRemoveVisualLink, -} from "@finos/vuu-protocol-types"; -import { - EventEmitter, - getLoggingConfigForWorker, - isConnectionQualityMetrics, - isConnectionStatusMessage, - isRequestResponse, - isTableSchemaMessage, - messageHasResult, - uuid, -} from "@finos/vuu-utils"; -import { shouldMessageBeRoutedToDataSource as messageShouldBeRoutedToDataSource } from "./data-source"; -import * as Message from "./server-proxy/messages"; - -// Note: inlined-worker is a generated file, it must be built -import { ConnectionQualityMetrics } from "@finos/vuu-data-types"; -import { workerSourceCode } from "./inlined-worker"; - -const workerBlob = new Blob([getLoggingConfigForWorker() + workerSourceCode], { - type: "text/javascript", -}); -const workerBlobUrl = URL.createObjectURL(workerBlob); - -type WorkerResolver = { - reject: (message: string | PromiseLike) => void; - resolve: (value: Worker | PromiseLike) => void; -}; - -let worker: Worker; -let pendingWorker: Promise; -const pendingWorkerNoToken: WorkerResolver[] = []; - -let resolveServer: (server: ServerAPI) => void; -let rejectServer: (err: unknown) => void; - -const serverAPI = new Promise((resolve, reject) => { - resolveServer = resolve; - rejectServer = reject; -}); - -/** - * returns a promise for serverApi. This will be resolved when the - * connectToServer call succeeds. If client never calls connectToServer - * serverAPI will never be resolved. - */ -export const getServerAPI = () => serverAPI; - -export type PostMessageToClientCallback = ( - msg: DataSourceCallbackMessage, -) => void; - -const viewports = new Map< - string, - { - postMessageToClientDataSource: PostMessageToClientCallback; - request: ServerProxySubscribeMessage; - status: "subscribing"; - } ->(); -const pendingRequests = new Map(); - -type WorkerOptions = { - protocol: WebSocketProtocol; - retryLimitDisconnect?: number; - retryLimitStartup?: number; - url: string; - token?: string; - username: string | undefined; - handleConnectionStatusChange: (msg: { - data: ConnectionStatusMessage; - }) => void; -}; - -// We do not resolve the worker until we have a connection, but we will get -// connection status messages before that, so we forward them to caller -// while they wait for worker. -const getWorker = async ({ - handleConnectionStatusChange, - protocol, - retryLimitDisconnect, - retryLimitStartup, - token = "", - username, - url, -}: WorkerOptions) => { - if (token === "" && pendingWorker === undefined) { - return new Promise((resolve, reject) => { - pendingWorkerNoToken.push({ resolve, reject }); - }); - } - //FIXME If we have a pending request already and a new request arrives with a DIFFERENT - // token, this would cause us to ignore the new request and ultimately resolve it with - // the original request. - return ( - pendingWorker || - // we get this far when we receive the first request with auth token - (pendingWorker = new Promise((resolve, reject) => { - const worker = new Worker(workerBlobUrl); - - const timer: number | null = window.setTimeout(() => { - reject(Error("timed out waiting for worker to load")); - }, 1000); - - // This is the inial message handler only, it processes messages whilst we are - // establishing a connection. When we resolve the worker, a runtime message - // handler will replace this (see below) - worker.onmessage = (msg: MessageEvent) => { - const { data: message } = msg; - if (message.type === "ready") { - window.clearTimeout(timer); - worker.postMessage({ - protocol, - retryLimitDisconnect, - retryLimitStartup, - token, - type: "connect", - url, - username, - }); - } else if (message.type === "connected") { - worker.onmessage = handleMessageFromWorker; - resolve(worker); - for (const pendingWorkerRequest of pendingWorkerNoToken) { - pendingWorkerRequest.resolve(worker); - } - pendingWorkerNoToken.length = 0; - } else if (isConnectionStatusMessage(message)) { - handleConnectionStatusChange({ data: message }); - } else if (message.type === "connection-failed") { - reject(message.reason); - for (const pendingWorkerRequest of pendingWorkerNoToken) { - pendingWorkerRequest.reject(message.reason); - } - pendingWorkerNoToken.length = 0; - } else { - console.warn("ConnectionManager: Unexpected message from the worker"); - } - }; - // TODO handle error - })) - ); -}; - -function handleMessageFromWorker({ - data: message, -}: MessageEvent) { - if (messageShouldBeRoutedToDataSource(message)) { - const viewport = viewports.get(message.clientViewportId); - if (viewport) { - viewport.postMessageToClientDataSource(message); - } else { - console.error( - `[ConnectionManager] ${message.type} message received, viewport not found`, - ); - } - } else if (isConnectionStatusMessage(message)) { - ConnectionManager.emit("connection-status", message); - } else if (isConnectionQualityMetrics(message)) { - ConnectionManager.emit("connection-metrics", message); - } else if (isRequestResponse(message)) { - const { requestId } = message; - if (pendingRequests.has(requestId)) { - const { resolve } = pendingRequests.get(requestId); - pendingRequests.delete(requestId); - const { requestId: _, ...messageWithoutRequestId } = message; - - if (messageHasResult(message)) { - resolve(message.result); - } else if ( - message.type === "VP_EDIT_RPC_RESPONSE" || - message.type === "VP_EDIT_RPC_REJECT" - ) { - resolve(message); - } else if (isTableSchemaMessage(message)) { - resolve(message.tableSchema); - } else { - resolve(messageWithoutRequestId); - } - } else { - console.warn( - "%cConnectionManager Unexpected message from the worker", - "color:red;font-weight:bold;", - ); - } - } -} - -const asyncRequest = ( - msg: - | VuuRpcServiceRequest - | VuuRpcMenuRequest - | VuuTableListRequest - | VuuTableMetaRequest - | VuuRpcViewportRequest - | VuuCreateVisualLink - | VuuRemoveVisualLink, -): Promise => { - const requestId = uuid(); - worker.postMessage({ - requestId, - ...msg, - }); - return new Promise((resolve, reject) => { - pendingRequests.set(requestId, { resolve, reject }); - }); -}; - -export interface ServerAPI { - destroy: (viewportId?: string) => void; - getTableSchema: (table: VuuTable) => Promise; - getTableList: (module?: string) => Promise; - // TODO its not really unknown - rpcCall: ( - msg: - | VuuRpcServiceRequest - | VuuRpcMenuRequest - | VuuRpcViewportRequest - | VuuCreateVisualLink - | VuuRemoveVisualLink, - ) => Promise; - send: (message: VuuUIMessageOut) => void; - subscribe: ( - message: ServerProxySubscribeMessage, - callback: PostMessageToClientCallback, - ) => void; - unsubscribe: (viewport: string) => void; -} - -const connectedServerAPI: ServerAPI = { - subscribe: (message, callback) => { - if (viewports.get(message.viewport)) { - throw Error( - `ConnectionManager attempting to subscribe with an existing viewport id`, - ); - } - // TODO we never use this status - viewports.set(message.viewport, { - status: "subscribing", - request: message, - postMessageToClientDataSource: callback, - }); - worker.postMessage({ type: "subscribe", ...message }); - }, - - unsubscribe: (viewport) => { - worker.postMessage({ type: "unsubscribe", viewport }); - }, - - send: (message) => { - worker.postMessage(message); - }, - - destroy: (viewportId?: string) => { - if (viewportId && viewports.has(viewportId)) { - viewports.delete(viewportId); - } - }, - - rpcCall: async ( - message: - | VuuRpcServiceRequest - | VuuRpcMenuRequest - | VuuRpcViewportRequest - | VuuCreateVisualLink - | VuuRemoveVisualLink, - ) => asyncRequest(message), - - getTableList: async () => - asyncRequest({ type: "GET_TABLE_LIST" }), - - getTableSchema: async (table) => - asyncRequest({ - type: Message.GET_TABLE_META, - table, - }), -}; - -export type ConnectionEvents = { - "connection-status": (message: ConnectionStatusMessage) => void; - "connection-metrics": (message: ConnectionQualityMetrics) => void; -}; - -export type ConnectOptions = { - url: string; - authToken?: string; - username?: string; - protocol?: WebSocketProtocol; - /** Max number of reconnect attempts in the event of unsuccessful websocket connection at startup */ - retryLimitStartup?: number; - /** Max number of reconnect attempts in the event of a disconnected websocket connection */ - retryLimitDisconnect?: number; -}; - -class _ConnectionManager extends EventEmitter { - // The first request must have the token. We can change this to block others until - // the request with token is received. - async connect({ - url, - authToken, - username, - protocol, - retryLimitDisconnect, - retryLimitStartup, - }: ConnectOptions): Promise { - // By passing handleMessageFromWorker here, we can get connection status - //messages while we wait for worker to resolve. - worker = await getWorker({ - protocol, - url, - token: authToken, - username, - retryLimitDisconnect, - retryLimitStartup, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - handleConnectionStatusChange: handleMessageFromWorker, - }); - return connectedServerAPI; - } - - destroy() { - worker.terminate(); - } -} - -export const ConnectionManager = new _ConnectionManager(); - -/** - * Open a connection to the VuuServer. This method opens the websocket connection - * and logs in. It can be called from whichever client code has access to the auth - * token (eg. the login page, or just a hardcoded login script in a sample). - * This will unblock any DataSources which may have already tried to subscribe to data, - * but lacked access to the auth token. - * - * @param serverUrl - * @param token - */ -export const connectToServer = async ({ - url, - protocol = undefined, - authToken, - username, - retryLimitDisconnect, - retryLimitStartup, -}: ConnectOptions): Promise<"connected" | "rejected"> => { - try { - const serverAPI = await ConnectionManager.connect({ - protocol, - url, - authToken, - username, - retryLimitDisconnect, - retryLimitStartup, - }); - resolveServer(serverAPI); - return "connected"; - } catch (err: unknown) { - rejectServer(err); - return "rejected"; - } -}; - -export const makeRpcCall = async ( - rpcRequest: VuuRpcServiceRequest, -) => { - try { - return (await serverAPI).rpcCall(rpcRequest); - } catch (err) { - throw Error("Error accessing server api"); - } -}; diff --git a/vuu-ui/packages/vuu-data-remote/src/connectionTypes.ts b/vuu-ui/packages/vuu-data-remote/src/connectionTypes.ts deleted file mode 100644 index 27acd3bca..000000000 --- a/vuu-ui/packages/vuu-data-remote/src/connectionTypes.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface Connection { - requiresLogin?: boolean; - send: (message: T) => void; - status: - | "closed" - | "ready" - | "connection-open-awaiting-session" - | "connected" - | "reconnected"; -} diff --git a/vuu-ui/packages/vuu-data-remote/src/data-source.ts b/vuu-ui/packages/vuu-data-remote/src/data-source.ts index 1e92e5f8e..9566d41e7 100644 --- a/vuu-ui/packages/vuu-data-remote/src/data-source.ts +++ b/vuu-ui/packages/vuu-data-remote/src/data-source.ts @@ -33,6 +33,7 @@ export const toDataSourceConfig = ( const datasourceMessages = [ "config", "aggregate", + "viewport-clear", "viewport-update", "columns", "debounce-begin", diff --git a/vuu-ui/packages/vuu-data-remote/src/index.ts b/vuu-ui/packages/vuu-data-remote/src/index.ts index ee1570327..c05716877 100644 --- a/vuu-ui/packages/vuu-data-remote/src/index.ts +++ b/vuu-ui/packages/vuu-data-remote/src/index.ts @@ -1,7 +1,8 @@ export * from "./authenticate"; -export * from "./connection-manager"; -export type { ServerAPI } from "./connection-manager"; +export * from "./ConnectionManager"; +export { default as ConnectionManager } from "./ConnectionManager"; export * from "./constants"; export * from "./data-source"; export * from "./message-utils"; export * from "./vuu-data-source"; +export type { WebSocketConnectionState } from "./WebSocketConnection"; diff --git a/vuu-ui/packages/vuu-data-remote/src/server-proxy/server-proxy.ts b/vuu-ui/packages/vuu-data-remote/src/server-proxy/server-proxy.ts index 138fc36ec..e5f1b98f3 100644 --- a/vuu-ui/packages/vuu-data-remote/src/server-proxy/server-proxy.ts +++ b/vuu-ui/packages/vuu-data-remote/src/server-proxy/server-proxy.ts @@ -36,6 +36,7 @@ import type { VuuRpcRequest, VuuCreateVisualLink, VuuRemoveVisualLink, + VuuViewportRangeRequest, } from "@finos/vuu-protocol-types"; import { isVuuMenuRpcRequest, @@ -47,7 +48,6 @@ import { isSessionTableActionMessage, isVisualLinkMessage, } from "@finos/vuu-utils"; -import type { Connection } from "../connectionTypes"; import { createSchemaFromTableMetadata, groupRowsByViewport, @@ -57,6 +57,7 @@ import { import * as Message from "./messages"; import { getRpcServiceModule } from "./rpc-services"; import { NO_DATA_UPDATE, Viewport } from "./viewport"; +import { WebSocketConnection } from "../WebSocketConnection"; export type PostMessageToClientCallback = ( message: VuuUIMessageIn | DataSourceCallbackMessage, @@ -118,12 +119,12 @@ interface PendingLogin { type QueuedRequest = { clientViewportId: string; - message: VuuClientMessage["body"]; + message: VuuViewportRangeRequest; requestId: string; }; export class ServerProxy { - private connection: Connection; + private connection: WebSocketConnection; private postMessageToClient: PostMessageToClientCallback; private viewports: Map; private mapClientToServerViewport: Map; @@ -138,14 +139,19 @@ export class ServerProxy { private cachedTableSchemas: Map = new Map(); private tableList: Promise | undefined; - constructor(connection: Connection, callback: PostMessageToClientCallback) { + constructor( + connection: WebSocketConnection, + callback: PostMessageToClientCallback, + ) { this.connection = connection; this.postMessageToClient = callback; this.viewports = new Map(); this.mapClientToServerViewport = new Map(); + + connection.on("reconnected", this.reconnect); } - public async reconnect() { + private reconnect = async () => { await this.login(this.authToken); // The "active" viewports are those the user has on their open layout @@ -161,9 +167,25 @@ export class ServerProxy { const reconnectViewports = (viewports: Viewport[]) => { viewports.forEach((viewport) => { const { clientViewportId } = viewport; - this.viewports.set(clientViewportId, viewport); - this.sendMessageToServer(viewport.subscribe(), clientViewportId); + + this.awaitResponseToMessage( + viewport.subscribe(), + clientViewportId, + ).then((msg) => { + if (msg.type === "CREATE_VP_SUCCESS") { + this.mapClientToServerViewport.set( + clientViewportId, + msg.viewPortId, + ); + this.viewports.set(msg.viewPortId, viewport); + // TODO should we just call viewport.reconnected() + viewport.status = "subscribed"; + viewport.serverViewportId = msg.viewPortId; + } + }); }); + + // this.sendMessageToServer(viewport.subscribe(), clientViewportId); }; reconnectViewports(activeViewports); @@ -171,7 +193,7 @@ export class ServerProxy { setTimeout(() => { reconnectViewports(inactiveViewports); }, 2000); - } + }; public async login( authToken?: string, @@ -192,6 +214,22 @@ export class ServerProxy { } } + public disconnect() { + this.viewports.forEach((viewport) => { + const { clientViewportId } = viewport; + // would it be better to await these calls ? + // Once ACKed, these will clear up entries in local viewport map + this.unsubscribe(clientViewportId); + this.postMessageToClient({ + clientViewportId, + type: "viewport-clear", + }); + }); + + // this.viewports.clear(); + // this.mapClientToServerViewport.clear(); + } + public subscribe(message: ServerProxySubscribeMessage) { // guard against subscribe message when a viewport is already subscribed if (!this.mapClientToServerViewport.has(message.viewport)) { @@ -277,31 +315,49 @@ export class ServerProxy { } } + /** + * Currently we only queue range requests, this may change + */ + private addRequestToQueue(queuedRequest: QueuedRequest) { + const isDifferentTypeViewport = (qr: QueuedRequest) => + qr.clientViewportId !== queuedRequest.clientViewportId || + queuedRequest.message.type !== qr.message.type; + + // Do not queue multiple requests of the same type for the same viewport. + // Latest takes priority + if (!this.queuedRequests.every(isDifferentTypeViewport)) { + this.queuedRequests = this.queuedRequests.filter(isDifferentTypeViewport); + } + + this.queuedRequests.push(queuedRequest); + } + private processQueuedRequests() { - const messageTypesProcessed: { [key: string]: true } = {}; - while (this.queuedRequests.length) { - const queuedRequest = this.queuedRequests.pop(); - if (queuedRequest) { - const { clientViewportId, message, requestId } = queuedRequest; - if (message.type === "CHANGE_VP_RANGE") { - if (messageTypesProcessed.CHANGE_VP_RANGE) { - continue; - } - messageTypesProcessed.CHANGE_VP_RANGE = true; - const serverViewportId = - this.mapClientToServerViewport.get(clientViewportId); - if (serverViewportId) { - this.sendMessageToServer( - { - ...message, - viewPortId: serverViewportId, - }, - requestId, - ); - } - } + const newQueue: QueuedRequest[] = []; + for (const queuedRequest of this.queuedRequests) { + const { clientViewportId, message, requestId } = queuedRequest; + const serverViewportId = + this.mapClientToServerViewport.get(clientViewportId); + if (serverViewportId) { + this.sendMessageToServer( + { + ...message, + viewPortId: serverViewportId, + }, + requestId, + ); + } else if (this.viewports.has(clientViewportId)) { + // If the clientViewportId is still used a a key in the viewport map, this + // viewport has not yet subscribed. Keep in the queue + newQueue.push(queuedRequest); + } else { + console.warn( + `ServerProxy processQueuedRequests, ${message.type} request not found ${clientViewportId}`, + ); } } + + this.queuedRequests = newQueue; } public unsubscribe(clientViewportId: string) { @@ -359,44 +415,43 @@ export class ServerProxy { /**********************************************************************/ private setViewRange(viewport: Viewport, message: VuuUIMessageOutViewRange) { const requestId = nextRequestId(); + const [serverRequest, rows, debounceRequest] = viewport.rangeRequest( requestId, message.range, ); - info?.(`setViewRange ${message.range.from} - ${message.range.to}`); + if (viewport.status === "subscribed") { + info?.(`setViewRange ${message.range.from} - ${message.range.to}`); - if (serverRequest) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (process.env.NODE_ENV === "development") { - info?.( - `CHANGE_VP_RANGE [${message.range.from}-${message.range.to}] => [${serverRequest.from}-${serverRequest.to}]`, - ); + if (serverRequest) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (process.env.NODE_ENV === "development") { + info?.( + `CHANGE_VP_RANGE [${message.range.from}-${message.range.to}] => [${serverRequest.from}-${serverRequest.to}]`, + ); + } + this.sendMessageToServer(serverRequest, requestId); } - const sentToServer = this.sendIfReady( - serverRequest, - requestId, - viewport.status === "subscribed", - ); - if (!sentToServer) { - this.queuedRequests.push({ - clientViewportId: message.viewport, - message: serverRequest, - requestId, + + if (rows) { + info?.(`setViewRange ${rows.length} rows returned from cache`); + this.postMessageToClient({ + mode: "batch", + type: "viewport-update", + clientViewportId: viewport.clientViewportId, + rows, }); + } else if (debounceRequest) { + this.postMessageToClient(debounceRequest); } - } - if (rows) { - info?.(`setViewRange ${rows.length} rows returned from cache`); - this.postMessageToClient({ - mode: "batch", - type: "viewport-update", - clientViewportId: viewport.clientViewportId, - rows, + } else if (serverRequest) { + this.addRequestToQueue({ + clientViewportId: message.viewport, + message: serverRequest, + requestId, }); - } else if (debounceRequest) { - this.postMessageToClient(debounceRequest); } } @@ -622,7 +677,6 @@ export class ServerProxy { const viewport = isVisualLinkMessage(message) ? this.getViewportForClient(message.childVpId) : this.getViewportForClient(message.viewport); - switch (message.type) { case "setViewRange": return this.setViewRange(viewport, message); @@ -661,6 +715,8 @@ export class ServerProxy { ); } else if (isVuuMenuRpcRequest(message as VuuRpcRequest)) { return this.menuRpcCall(message as WithRequestId); + } else if (message.type === "disconnect") { + return this.disconnect(); } else { const { type, requestId } = message; switch (type) { @@ -752,19 +808,6 @@ export class ServerProxy { ) { const { module = "CORE" } = options; if (this.authToken) { - // if (body.type === "HB_RESP") { - // // do nothing; - // } else if (body.type === "CREATE_VP" || body.type === "CHANGE_VP_RANGE") { - // console.log( - // `%c >>> ${JSON.stringify(body, null, 2)}`, - // "background-color:green;color:white;font-weight:bold;" - // ); - // } else { - // console.log( - // `%c >>> ${body.type}`, - // "background-color:green;color:white;font-weight:bold;" - // ); - // } this.connection.send({ requestId, sessionId: this.sessionId, @@ -779,15 +822,6 @@ export class ServerProxy { public handleMessageFromServer(message: VuuServerMessage) { const { body, requestId, sessionId } = message; - // if (message.body.type !== "HB") { - // console.log( - // `%c<<< [${new Date().toISOString().slice(11, 23)}] (ServerProxy) ${ - // message.body.type || JSON.stringify(message) - // }`, - // "color:white;background-color:blue;font-weight:bold;" - // ); - // } - const pendingRequest = this.pendingRequests.get(requestId); if (pendingRequest) { const { resolve } = pendingRequest; @@ -1043,7 +1077,6 @@ export class ServerProxy { break; case "VIEW_PORT_MENU_REJ": { - console.log(`send menu error back to client`); const { error, rpcName, vpId } = body; const viewport = this.viewports.get(vpId); if (viewport) { diff --git a/vuu-ui/packages/vuu-data-remote/src/server-proxy/viewport.ts b/vuu-ui/packages/vuu-data-remote/src/server-proxy/viewport.ts index 7b3ed67ad..fd3a0b792 100644 --- a/vuu-ui/packages/vuu-data-remote/src/server-proxy/viewport.ts +++ b/vuu-ui/packages/vuu-data-remote/src/server-proxy/viewport.ts @@ -35,7 +35,7 @@ import { ClientToServerOpenTreeNode, VuuRemoveVisualLink, ClientToServerSelection, - ClientToServerViewPortRange, + VuuViewportRangeRequest, LinkDescriptorWithLabel, VuuViewportCreateResponse, VuuAggregation, @@ -59,6 +59,12 @@ import { getFirstAndLastRows } from "../message-utils"; import { ArrayBackedMovingWindow } from "./array-backed-moving-window"; import * as Message from "./messages"; +export type ViewportStatus = + | "" + | "subscribing" + | "resubscribing" + | "subscribed"; + const { debug, debugEnabled, error, info, infoEnabled, warn } = logger("viewport"); @@ -123,7 +129,7 @@ type AsyncOperation = | VuuRemoveVisualLink; type RangeRequestTuple = [ - ClientToServerViewPortRange | null, + VuuViewportRangeRequest | null, DataSourceRow[]?, DataSourceDebounceRequest?, ]; @@ -156,6 +162,8 @@ const NO_UPDATE_STATUS: LastUpdateStatus = { }; export class Viewport { + #status: ViewportStatus = ""; + private aggregations: VuuAggregation[]; /** batchMode is irrelevant for Vuu Table, it was introduced to try and improve rendering performance of AgGrid */ private batchMode = true; @@ -175,7 +183,7 @@ export class Viewport { private keys: KeySet; private pendingLinkedParent?: LinkDescriptorWithLabel; private pendingOperations = new Map(); - private pendingRangeRequests: (ClientToServerViewPortRange & { + private pendingRangeRequests: (VuuViewportRangeRequest & { acked?: boolean; requestId: string; })[] = []; @@ -195,7 +203,6 @@ export class Viewport { public linkedParent?: LinkedParent; public serverViewportId?: string; // TODO roll disabled/suspended into status - public status: "" | "subscribing" | "resubscribing" | "subscribed" = ""; public suspended = false; public suspendTimer: number | null = null; public table: VuuTable; @@ -282,10 +289,18 @@ export class Viewport { return this.dataWindow.rowCount ?? 0; } + get status() { + return this.#status; + } + + set status(status: ViewportStatus) { + this.#status = status; + } + subscribe() { const { filter } = this.filter; this.status = - this.status === "subscribed" ? "resubscribing" : "subscribing"; + this.#status === "subscribed" ? "resubscribing" : "subscribing"; return { type: Message.CREATE_VP, table: this.table, @@ -490,6 +505,7 @@ export class Viewport { if (debugEnabled) { this.rangeMonitor.set(range); } + // If we can satisfy the range request from the buffer, we will. // May or may not need to make a server request, depending on status of buffer const type = "CHANGE_VP_RANGE"; @@ -515,7 +531,7 @@ export class Viewport { type, viewPortId: this.serverViewportId, ...getFullRange(range, this.bufferSize, maxRange), - } as ClientToServerViewPortRange) + } as VuuViewportRangeRequest) : null; if (serverRequest) { debugEnabled && @@ -523,6 +539,8 @@ export class Viewport { `create CHANGE_VP_RANGE: [${serverRequest.from} - ${serverRequest.to}]`, ); // TODO check that there is not already a pending server request for more data + // were we await an operation that might not be sent (if still subscribing) + this.awaitOperation(requestId, { type }); const pendingRequest = this.pendingRangeRequests.at(-1); if (pendingRequest) { diff --git a/vuu-ui/packages/vuu-data-remote/src/vuu-data-source.ts b/vuu-ui/packages/vuu-data-remote/src/vuu-data-source.ts index 7dcd65b0b..b915e031b 100644 --- a/vuu-ui/packages/vuu-data-remote/src/vuu-data-source.ts +++ b/vuu-ui/packages/vuu-data-remote/src/vuu-data-source.ts @@ -9,6 +9,7 @@ import { DataSourceVisualLinkCreatedMessage, OptimizeStrategy, Selection, + ServerAPI, SubscribeCallback, SubscribeProps, TableSchema, @@ -50,7 +51,7 @@ import { vuuDeleteRowRequest, vuuEditCellRequest, } from "@finos/vuu-utils"; -import { getServerAPI, ServerAPI } from "./connection-manager"; +import ConnectionManager from "./ConnectionManager"; import { isDataSourceConfigMessage } from "./data-source"; import { MenuRpcResponse } from "@finos/vuu-data-types"; @@ -122,7 +123,8 @@ export class VuuDataSource this.#pendingVisualLink = visualLink; this.#title = title; - this.rangeRequest = this.throttleRangeRequest; + // this.rangeRequest = this.throttleRangeRequest; + this.rangeRequest = this.rawRangeRequest; } async subscribe( @@ -173,10 +175,11 @@ export class VuuDataSource this.#status = "subscribing"; this.viewport = viewport; - this.server = await getServerAPI(); + this.server = await ConnectionManager.serverAPI; const { bufferSize } = this; + // TODO make this async and await response here this.server?.subscribe( { ...this.#config, @@ -219,6 +222,9 @@ export class VuuDataSource ) { this.#size = message.size; this.emit("resize", message.size); + } else if (message.type === "viewport-clear") { + this.#size = 0; + this.emit("resize", 0); } // This is used to remove any progress indication from the UI. We wait for actual data rather than // just the CHANGE_VP_SUCCESS ack as there is often a delay between receiving the ack and the data. @@ -272,10 +278,13 @@ export class VuuDataSource } } - resume() { + resume(callback?: SubscribeCallback) { const isDisabled = this.#status.startsWith("disabl"); const isSuspended = this.#status === "suspended"; info?.(`resume #${this.viewport}, current status ${this.#status}`); + if (callback) { + this.clientCallback = callback; + } if (this.viewport) { if (isDisabled) { this.enable(); @@ -584,23 +593,13 @@ export class VuuDataSource set groupBy(groupBy: VuuGroupBy) { if (itemsOrOrderChanged(this.groupBy, groupBy)) { - const wasGrouped = this.#groupBy.length > 0; + const wasGrouped = this.groupBy.length > 0; this.config = { ...this.#config, groupBy, }; - // if (this.viewport) { - // const message = { - // viewport: this.viewport, - // type: "groupBy", - // groupBy: this.#config.groupBy, - // } as const; - - // if (this.server) { - // this.server.send(message); - // } - // } + if (!wasGrouped && groupBy.length > 0 && this.viewport) { this.clientCallback?.({ clientViewportId: this.viewport, @@ -610,10 +609,6 @@ export class VuuDataSource rows: [], }); } - // this.emit("config", this.#config, undefined, { - // ...NO_CONFIG_CHANGES, - // groupByChanged: true, - // }); this.setConfigPending({ groupBy }); } } diff --git a/vuu-ui/packages/vuu-data-remote/src/websocket-connection.ts b/vuu-ui/packages/vuu-data-remote/src/websocket-connection.ts deleted file mode 100644 index 2210a1a0a..000000000 --- a/vuu-ui/packages/vuu-data-remote/src/websocket-connection.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { VuuServerMessage, VuuClientMessage } from "@finos/vuu-protocol-types"; -import { Connection } from "./connectionTypes"; -import { logger } from "@finos/vuu-utils"; -import { - ConnectionQualityMetrics, - ConnectionStatus, - ConnectionStatusMessage, - WebSocketProtocol, -} from "@finos/vuu-data-types"; - -export type ConnectionMessage = - | VuuServerMessage - | ConnectionStatusMessage - | ConnectionQualityMetrics; -export type ConnectionCallback = (msg: ConnectionMessage) => void; - -const { debug, debugEnabled, error, info, infoEnabled, warn } = logger( - "websocket-connection", -); - -type ConnectionTracking = { - [key: string]: { - connect: { - allowed: number; - remaining: number; - }; - reconnect: { - allowed: number; - remaining: number; - }; - status: ConnectionStatus; - }; -}; - -const connectionAttemptStatus: ConnectionTracking = {}; - -const setWebsocket = Symbol("setWebsocket"); -const connectionCallback = Symbol("connectionCallback"); - -export async function connect( - connectionString: string, - protocol: WebSocketProtocol, - callback: ConnectionCallback, - retryLimitDisconnect = 10, - retryLimitStartup = 5, -): Promise { - connectionAttemptStatus[connectionString] = { - status: "connecting", - connect: { - allowed: retryLimitStartup, - remaining: retryLimitStartup, - }, - reconnect: { - allowed: retryLimitDisconnect, - remaining: retryLimitDisconnect, - }, - }; - return makeConnection(connectionString, protocol, callback); -} - -async function reconnect(_: WebsocketConnection) { - //TODO it's not enough to reconnect with a new websocket, we have to log back in as well - // Temp don't try to reconnect at all until better interop with a proxy is implemented - // makeConnection( - // connection.url, - // connection.protocol, - // connection[connectionCallback], - // connection - // ); - throw Error("connection broken"); -} - -async function makeConnection( - url: string, - protocol: WebSocketProtocol, - callback: ConnectionCallback, - connection?: WebsocketConnection, -): Promise { - const { - status: currentStatus, - connect: connectStatus, - reconnect: reconnectStatus, - } = connectionAttemptStatus[url]; - - const trackedStatus = - currentStatus === "connecting" ? connectStatus : reconnectStatus; - - try { - callback({ type: "connection-status", status: "connecting" }); - const reconnecting = typeof connection !== "undefined"; - const ws = await createWebsocket(url, protocol); - - console.info( - "%câš¡ %cconnected", - "font-size: 24px;color: green;font-weight: bold;", - "color:green; font-size: 14px;", - ); - - if (connection !== undefined) { - connection[setWebsocket](ws); - } - - const websocketConnection = - connection ?? new WebsocketConnection(ws, url, protocol, callback); - - const status = reconnecting - ? "reconnected" - : "connection-open-awaiting-session"; - callback({ type: "connection-status", status }); - websocketConnection.status = status; - - // reset the retry attempts for subsequent disconnections - trackedStatus.remaining = trackedStatus.allowed; - - return websocketConnection as Connection; - } catch (err) { - const retry = --trackedStatus.remaining > 0; - callback({ - type: "connection-status", - status: "disconnected", - reason: "failed to connect", - retry, - }); - if (retry) { - return makeConnectionIn(url, protocol, callback, connection, 2000); - } else { - callback({ - type: "connection-status", - status: "failed", - reason: "unable to connect", - retry, - }); - throw Error("Failed to establish connection"); - } - } -} - -const makeConnectionIn = ( - url: string, - protocol: WebSocketProtocol, - callback: ConnectionCallback, - connection?: WebsocketConnection, - delay?: number, -): Promise => - new Promise((resolve) => { - setTimeout(() => { - resolve(makeConnection(url, protocol, callback, connection)); - }, delay); - }); - -const createWebsocket = ( - websocketUrl: string, - protocol: WebSocketProtocol, -): Promise => - new Promise((resolve, reject) => { - //TODO add timeout - if (infoEnabled && protocol !== undefined) { - info(`WebSocket Protocol ${protocol?.toString()}`); - } - - const ws = new WebSocket(websocketUrl, protocol); - ws.onopen = () => resolve(ws); - ws.onerror = (evt) => reject(evt); - }); - -const closeWarn = () => { - warn?.(`Connection cannot be closed, socket not yet opened`); -}; - -const sendWarn = (msg: VuuClientMessage) => { - warn?.(`Message cannot be sent, socket closed ${msg.body.type}`); -}; - -const parseMessage = (message: string): VuuServerMessage => { - try { - return JSON.parse(message) as VuuServerMessage; - } catch (e) { - throw Error(`Error parsing JSON response from server ${message}`); - } -}; - -export class WebsocketConnection implements Connection { - [connectionCallback]: ConnectionCallback; - close: () => void = closeWarn; - requiresLogin = true; - send: (msg: VuuClientMessage) => void = sendWarn; - status: - | "closed" - | "ready" - | "connection-open-awaiting-session" - | "connected" - | "reconnected" = "ready"; - - public protocol: WebSocketProtocol; - public url: string; - public messagesCount = 0; - - private connectionMetricsInterval: ReturnType | null = - null; - - constructor( - ws: WebSocket, - url: string, - protocol: WebSocketProtocol, - callback: ConnectionCallback, - ) { - this.url = url; - this.protocol = protocol; - this[connectionCallback] = callback; - this[setWebsocket](ws); - } - - reconnect() { - reconnect(this); - } - - [setWebsocket](ws: WebSocket) { - const callback = this[connectionCallback]; - ws.onmessage = (evt) => { - this.status = "connected"; - ws.onmessage = this.handleWebsocketMessage; - this.handleWebsocketMessage(evt); - }; - - this.connectionMetricsInterval = setInterval(() => { - callback({ - type: "connection-metrics", - messagesLength: this.messagesCount, - }); - this.messagesCount = 0; - }, 2000); - - ws.onerror = () => { - error(`âš¡ connection error`); - callback({ - type: "connection-status", - status: "disconnected", - reason: "error", - }); - - if (this.connectionMetricsInterval) { - clearInterval(this.connectionMetricsInterval); - this.connectionMetricsInterval = null; - } - - if (this.status === "connection-open-awaiting-session") { - // our connection has errored before first server message has been received. This - // is not a normal reconnect, more likely a websocket configuration issue - error( - `Websocket connection lost before Vuu session established, check websocket configuration`, - ); - } else if (this.status !== "closed") { - reconnect(this); - this.send = queue; - } - }; - - ws.onclose = () => { - info?.(`âš¡ connection close`); - callback({ - type: "connection-status", - status: "disconnected", - reason: "close", - }); - - if (this.connectionMetricsInterval) { - clearInterval(this.connectionMetricsInterval); - this.connectionMetricsInterval = null; - } - - if (this.status !== "closed") { - reconnect(this); - this.send = queue; - } - }; - - const send = (msg: VuuClientMessage) => { - if (process.env.NODE_ENV === "development") { - if (debugEnabled && msg.body.type !== "HB_RESP") { - debug?.(`>>> ${msg.body.type}`); - } - } - ws.send(JSON.stringify(msg)); - }; - - const queue = (msg: VuuClientMessage) => { - info?.(`TODO queue message until websocket reconnected ${msg.body.type}`); - }; - - this.send = send; - - this.close = () => { - this.status = "closed"; - ws.close(); - this.close = closeWarn; - this.send = sendWarn; - info?.("close websocket"); - }; - } - - handleWebsocketMessage = (evt: MessageEvent) => { - const vuuMessageFromServer = parseMessage(evt.data); - this.messagesCount += 1; - if (process.env.NODE_ENV === "development") { - if (debugEnabled && vuuMessageFromServer.body.type !== "HB") { - debug?.(`<<< ${vuuMessageFromServer.body.type}`); - } - } - this[connectionCallback](vuuMessageFromServer); - }; -} diff --git a/vuu-ui/packages/vuu-data-remote/src/worker.ts b/vuu-ui/packages/vuu-data-remote/src/worker.ts index 7f7880e18..56d72adba 100644 --- a/vuu-ui/packages/vuu-data-remote/src/worker.ts +++ b/vuu-ui/packages/vuu-data-remote/src/worker.ts @@ -1,5 +1,6 @@ import { - ConnectionStatusMessage, + DataSourceCallbackMessage, + VuuUIMessageIn, VuuUIMessageOut, WebSocketProtocol, WithRequestId, @@ -8,61 +9,87 @@ import { VuuRpcMenuRequest, VuuRpcServiceRequest, } from "@finos/vuu-protocol-types"; -import { - isConnectionQualityMetrics, - isConnectionStatusMessage, - logger, -} from "@finos/vuu-utils"; +import { isConnectionQualityMetrics, logger } from "@finos/vuu-utils"; import { ServerProxy } from "./server-proxy/server-proxy"; -import { connect as connectWebsocket } from "./websocket-connection"; +// import { createWebSocketConnection } from "./websocket-connection"; +import { + type RetryLimits, + WebSocketConnection, + isWebSocketConnectionMessage, +} from "./WebSocketConnection"; let server: ServerProxy; const { info, infoEnabled } = logger("worker"); +const getRetryLimits = ( + retryLimitDisconnect?: number, + retryLimitStartup?: number, +): RetryLimits | undefined => { + if (retryLimitDisconnect !== undefined && retryLimitStartup !== undefined) { + return { + connect: retryLimitStartup, + reconnect: retryLimitDisconnect, + }; + } else if (retryLimitDisconnect !== undefined) { + return { + connect: retryLimitDisconnect, + reconnect: retryLimitDisconnect, + }; + } else if (retryLimitStartup !== undefined) { + return { + connect: retryLimitStartup, + reconnect: retryLimitStartup, + }; + } +}; + +let ws: WebSocketConnection; + +const sendMessageToClient = ( + message: DataSourceCallbackMessage | VuuUIMessageIn, +) => { + postMessage(message); +}; + async function connectToServer( url: string, - protocol: WebSocketProtocol, + protocols: WebSocketProtocol, token: string, username: string | undefined, - onConnectionStatusChange: (msg: ConnectionStatusMessage) => void, retryLimitDisconnect?: number, retryLimitStartup?: number, ) { - const connection = await connectWebsocket( - url, - protocol, - // if this was called during connect, we would get a ReferenceError, but it will - // never be called until subscriptions have been made, so this is safe. - //TODO do we need to listen in to the connection messages here so we can lock back in, in the event of a reconnenct ? - (msg) => { + const websocketConnection = (ws = new WebSocketConnection({ + callback: (msg) => { if (isConnectionQualityMetrics(msg)) { // console.log("post connection metrics"); postMessage({ type: "connection-metrics", messages: msg }); - } else if (isConnectionStatusMessage(msg)) { - onConnectionStatusChange(msg); - if (msg.status === "reconnected") { - server.reconnect(); - } + } else if (isWebSocketConnectionMessage(msg)) { + postMessage(msg); } else { server.handleMessageFromServer(msg); } }, - retryLimitDisconnect, - retryLimitStartup, - ); + protocols, + retryLimits: getRetryLimits(retryLimitStartup, retryLimitDisconnect), + url, + })); - server = new ServerProxy(connection, (msg) => sendMessageToClient(msg)); - if (connection.requiresLogin) { + websocketConnection.on("connection-status", postMessage); + + // This will not resolve until the websocket has been successfully opened, + // i.e. we get an open event... + await websocketConnection.connect(); + // ... at which point we will attempt to LOGIN, this will send the + // first message over the WebSocket connection. + server = new ServerProxy(websocketConnection, sendMessageToClient); + if (websocketConnection.requiresLogin) { // no handling for failed login await server.login(token, username); } } -function sendMessageToClient(message: any) { - postMessage(message); -} - const handleMessageFromClient = async ({ data: message, }: MessageEvent< @@ -78,7 +105,6 @@ const handleMessageFromClient = async ({ message.protocol, message.token, message.username, - postMessage, message.retryLimitDisconnect, message.retryLimitStartup, ); @@ -89,7 +115,10 @@ const handleMessageFromClient = async ({ break; // If any of the messages below are received BEFORE we have connected and created // the server - handle accordingly - + case "disconnect": + server.disconnect(); + ws?.close(); + break; case "subscribe": infoEnabled && info(`client subscribe: ${JSON.stringify(message)}`); server.subscribe(message); diff --git a/vuu-ui/packages/vuu-data-remote/test/WebSocketConnection.test.ts b/vuu-ui/packages/vuu-data-remote/test/WebSocketConnection.test.ts new file mode 100644 index 000000000..c5bcdc684 --- /dev/null +++ b/vuu-ui/packages/vuu-data-remote/test/WebSocketConnection.test.ts @@ -0,0 +1,339 @@ +import "./global-mocks"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearMessagesFromWebSocketEndPoint, + mockMessageFromWebSocketEndpoint, + MockWebSocketOpenFirstTime, + MockWebSocketConnectsOnSecondAttempt, + MockWebSocketConnectsOnThirdAttempt, + MockWebSocketAlwaysFails, + MockWebSocketAlwaysFailsLikeProxy, + MockWebSocketOpenFirstTimeLosesConnectionLater, +} from "./mock-websockets"; + +import { + VuuServerMessageCallback, + WebSocketConnection, + WebSocketConnectionState, +} from "../src/WebSocketConnection"; +import { VuuServerMessage } from "@finos/vuu-protocol-types"; + +describe("WebSocketConnection", () => { + beforeEach(() => { + vi.useFakeTimers(); + clearMessagesFromWebSocketEndPoint(); + }); + afterEach(() => { + // vi.useRealTimers(); + }); + + describe("initial connection", () => { + it("status moves from connecting to connected when initial connection succeeds", async () => { + vi.stubGlobal("WebSocket", MockWebSocketOpenFirstTime); + const vuuServerMessages: VuuServerMessage[] = []; + const callback: VuuServerMessageCallback = ( + message: VuuServerMessage, + ) => { + vuuServerMessages.push(message); + }; + + const connectionStatusMessages: WebSocketConnectionState[] = []; + const connectionCallback = (message: WebSocketConnectionState) => { + connectionStatusMessages.push(message); + }; + + const websocketConnection = new WebSocketConnection({ + callback, + protocols: "", + url: "wss://test", + }); + websocketConnection.on("connection-status", connectionCallback); + + await websocketConnection.connect(); + + expect(websocketConnection.connectionState).toEqual({ + connectionPhase: "connecting", + connectionStatus: "connected", + retryAttemptsRemaining: 5, + retryAttemptsTotal: 5, + secondsToNextRetry: 1, + }); + + expect(connectionStatusMessages.length).toEqual(2); + + const [msg1, msg2] = connectionStatusMessages; + expect(msg1.connectionStatus).toEqual("connecting"); + expect(msg2.connectionStatus).toEqual("connected"); + }); + + it("sets confirmedOpen and installs reconnect retry limits when first message received", async () => { + vi.stubGlobal("WebSocket", MockWebSocketOpenFirstTime); + const callback = vi.fn(); + const connectionCallback = vi.fn(); + + const websocketConnection = new WebSocketConnection({ + callback, + protocols: "", + url: "wss://test", + }); + websocketConnection.on("connection-status", connectionCallback); + + await websocketConnection.connect(); + + mockMessageFromWebSocketEndpoint("test", { + body: { type: "LOGIN_SUCCESS" }, + }); + + expect(websocketConnection.connectionState).toEqual({ + connectionPhase: "reconnecting", + connectionStatus: "connected", + retryAttemptsRemaining: 8, + retryAttemptsTotal: 8, + secondsToNextRetry: 1, + }); + }); + + it("retries connect in case of failure, single failure", async () => { + vi.stubGlobal("WebSocket", MockWebSocketConnectsOnSecondAttempt); + const vuuServerMessages: VuuServerMessage[] = []; + const callback: VuuServerMessageCallback = ( + message: VuuServerMessage, + ) => { + vuuServerMessages.push(message); + }; + + const connectionStatusMessages: WebSocketConnectionState[] = []; + const connectionCallback = (message: WebSocketConnectionState) => { + connectionStatusMessages.push(message); + }; + + const websocketConnection = new WebSocketConnection({ + callback, + protocols: "", + url: "wss://test", + }); + websocketConnection.on("connection-status", connectionCallback); + + await websocketConnection.connect(); + + expect(websocketConnection.connectionState).toEqual({ + connectionPhase: "connecting", + connectionStatus: "connected", + retryAttemptsRemaining: 4, + retryAttemptsTotal: 5, + secondsToNextRetry: 2, + }); + + expect(connectionStatusMessages.length).toEqual(4); + + const [msg1, msg2, msg3, msg4] = connectionStatusMessages; + expect(msg1.connectionStatus).toEqual("connecting"); + expect(msg2.connectionStatus).toEqual("disconnected"); + expect(msg3.connectionStatus).toEqual("connecting"); + expect(msg4.connectionStatus).toEqual("connected"); + }); + + it("retries connect in case of failure, two failures", async () => { + vi.stubGlobal("WebSocket", MockWebSocketConnectsOnThirdAttempt); + const vuuServerMessages: VuuServerMessage[] = []; + const callback: VuuServerMessageCallback = ( + message: VuuServerMessage, + ) => { + vuuServerMessages.push(message); + }; + + const connectionStatusMessages: WebSocketConnectionState[] = []; + const connectionCallback = (message: WebSocketConnectionState) => { + connectionStatusMessages.push(message); + }; + + const websocketConnection = new WebSocketConnection({ + callback, + protocols: "", + url: "wss://test", + }); + websocketConnection.on("connection-status", connectionCallback); + + await websocketConnection.connect(); + + expect(websocketConnection.connectionState).toEqual({ + connectionPhase: "connecting", + connectionStatus: "connected", + retryAttemptsRemaining: 3, + retryAttemptsTotal: 5, + secondsToNextRetry: 4, + }); + + expect(connectionStatusMessages.length).toEqual(6); + + const [msg1, msg2, msg3, msg4, msg5, msg6] = connectionStatusMessages; + expect(msg1.connectionStatus).toEqual("connecting"); + expect(msg2.connectionStatus).toEqual("disconnected"); + expect(msg3.connectionStatus).toEqual("connecting"); + expect(msg4.connectionStatus).toEqual("disconnected"); + expect(msg5.connectionStatus).toEqual("connecting"); + expect(msg6.connectionStatus).toEqual("connected"); + }); + + it("connect fails after maximum retries", async () => { + vi.stubGlobal("WebSocket", MockWebSocketAlwaysFails); + const vuuServerMessages: VuuServerMessage[] = []; + const callback: VuuServerMessageCallback = ( + message: VuuServerMessage, + ) => { + vuuServerMessages.push(message); + }; + + const connectionStatusMessages: WebSocketConnectionState[] = []; + const connectionCallback = (message: WebSocketConnectionState) => { + connectionStatusMessages.push(message); + }; + + const websocketConnection = new WebSocketConnection({ + callback, + protocols: "", + url: "wss://test", + }); + websocketConnection.on("connection-status", connectionCallback); + + await expect(() => websocketConnection.connect()).rejects.toThrowError( + "connection failed", + ); + + expect(websocketConnection.connectionState).toEqual({ + connectionPhase: "connecting", + connectionStatus: "closed", + retryAttemptsRemaining: 0, + retryAttemptsTotal: 5, + secondsToNextRetry: 32, + }); + + expect(connectionStatusMessages.length).toEqual(13); + + const [msg1, msg2, msg3, msg4, msg5, msg6, , , , , , , msg13] = + connectionStatusMessages; + expect(msg1.connectionStatus).toEqual("connecting"); + expect(msg2.connectionStatus).toEqual("disconnected"); + expect(msg3.connectionStatus).toEqual("connecting"); + expect(msg4.connectionStatus).toEqual("disconnected"); + expect(msg5.connectionStatus).toEqual("connecting"); + expect(msg6.connectionStatus).toEqual("disconnected"); + expect(msg13.connectionStatus).toEqual("closed"); + }); + + it("Simulating Proxy. opens but closes before message received. Fails after maximum retries", async () => { + vi.stubGlobal("WebSocket", MockWebSocketAlwaysFailsLikeProxy); + + const vuuServerMessages: VuuServerMessage[] = []; + const callback: VuuServerMessageCallback = ( + message: VuuServerMessage, + ) => { + vuuServerMessages.push(message); + }; + + const connectionStatusMessages: WebSocketConnectionState[] = []; + const connectionCallback = (message: WebSocketConnectionState) => { + connectionStatusMessages.push(message); + }; + + const websocketConnection = new WebSocketConnection({ + callback, + protocols: "", + url: "wss://test", + }); + websocketConnection.on("connection-status", connectionCallback); + + await websocketConnection.connect(); + + expect(websocketConnection.connectionState).toEqual({ + connectionPhase: "connecting", + connectionStatus: "connected", + retryAttemptsRemaining: 5, + retryAttemptsTotal: 5, + secondsToNextRetry: 1, + }); + + expect(connectionStatusMessages.length).toEqual(2); + const [msg1, msg2] = connectionStatusMessages; + expect(msg1.connectionStatus).toEqual("connecting"); + expect(msg2.connectionStatus).toEqual("connected"); + + let reconnectAttempts = 0; + while (vi.getTimerCount() > 0) { + reconnectAttempts += 1; + vi.advanceTimersToNextTimer(); + } + // 3 (timeouts) * 5 (retry attempts) + 1 + expect(reconnectAttempts).toEqual(16); + + const lastMessage = connectionStatusMessages.at(-1); + expect(lastMessage?.connectionStatus).toEqual("closed"); + + expect(websocketConnection.connectionState).toEqual({ + connectionPhase: "connecting", + connectionStatus: "closed", + retryAttemptsRemaining: 0, + retryAttemptsTotal: 5, + secondsToNextRetry: 32, + }); + }); + }); + + describe("disconnect following successful connection", () => { + it("attempts to reconnect, succeeds first time", async () => { + vi.stubGlobal( + "WebSocket", + MockWebSocketOpenFirstTimeLosesConnectionLater, + ); + const callback = vi.fn(); + const connectionCallback = vi.fn(); + + const websocketConnection = new WebSocketConnection({ + callback, + protocols: "", + url: "wss://test", + }); + websocketConnection.on("connection-status", connectionCallback); + + await websocketConnection.connect(); + + mockMessageFromWebSocketEndpoint("test", { + body: { type: "LOGIN_SUCCESS" }, + }); + + // There is a timeout pending which will kill the connection ... + vi.advanceTimersToNextTimer(); + + // swap back in the Success WebSocket + vi.stubGlobal("WebSocket", MockWebSocketOpenFirstTime); + + expect(websocketConnection.connectionState).toEqual({ + connectionPhase: "reconnecting", + connectionStatus: "disconnected", + retryAttemptsRemaining: 8, + retryAttemptsTotal: 8, + secondsToNextRetry: 1, + }); + + // Next timeout will trigger the first retry attempt, connecting + vi.advanceTimersToNextTimer(); + expect(websocketConnection.connectionState).toEqual({ + connectionPhase: "reconnecting", + connectionStatus: "reconnecting", + retryAttemptsRemaining: 7, + retryAttemptsTotal: 8, + secondsToNextRetry: 2, + }); + + // Next timeout will trigger the connection success, connected + vi.advanceTimersToNextTimer(); + expect(websocketConnection.connectionState).toEqual({ + connectionPhase: "reconnecting", + connectionStatus: "reconnected", + retryAttemptsRemaining: 7, + retryAttemptsTotal: 8, + secondsToNextRetry: 2, + }); + }); + }); +}); diff --git a/vuu-ui/packages/vuu-data-remote/test/mock-websockets.ts b/vuu-ui/packages/vuu-data-remote/test/mock-websockets.ts new file mode 100644 index 000000000..196c78600 --- /dev/null +++ b/vuu-ui/packages/vuu-data-remote/test/mock-websockets.ts @@ -0,0 +1,154 @@ +import { vi } from "vitest"; +import { VuuServerMessageCallback } from "../src/WebSocketConnection"; +import { EventEmitter } from "@finos/vuu-utils"; + +const websocketMessageEmitter = new EventEmitter(); + +export const clearMessagesFromWebSocketEndPoint = () => { + websocketMessageEmitter.removeAllListeners(); +}; +export const mockMessageFromWebSocketEndpoint = ( + messageName: string, + message: unknown, +) => { + websocketMessageEmitter.emit(messageName, message); +}; + +class BaseWebSocket { + protected messageHandler: any; + protected openHandler: any; + protected errorHandler: any; + protected closeHandler: any; + + callback: VuuServerMessageCallback; + + constructor() { + websocketMessageEmitter.on("test", this.receiveMessage); + } + + private receiveMessage = (message: unknown) => { + console.log(`BaseWebSocket receives message`); + this.messageHandler({ data: JSON.stringify(message) }); + }; + + set onopen(callback) { + this.openHandler = callback; + } + set onclose(callback) { + this.closeHandler = callback; + } + set onerror(callback) { + this.errorHandler = callback; + } + set onmessage(callback) { + this.messageHandler = callback; + } + send(msg: string) { + console.log(`===> ${msg}`); + } +} + +export class MockWebSocketOpenFirstTime extends BaseWebSocket { + constructor() { + super(); + console.log(`MockWebSocketOpenFirstTime`); + setTimeout(() => { + console.log(`call openHandler`); + this?.openHandler(); + }, 0); + vi.advanceTimersByTimeAsync(1); + } +} + +export class MockWebSocketConnectsOnSecondAttempt extends BaseWebSocket { + private static connectionCount = 0; + constructor() { + super(); + MockWebSocketConnectsOnSecondAttempt.connectionCount += 1; + setTimeout(() => { + if (MockWebSocketConnectsOnSecondAttempt.connectionCount === 2) { + MockWebSocketConnectsOnSecondAttempt.connectionCount = 0; + this?.openHandler(); + } else { + this?.errorHandler({ message: "test error" }); + this?.closeHandler(); + vi.advanceTimersByTimeAsync(1000); + } + }, 0); + vi.advanceTimersByTimeAsync(1); + } +} + +export class MockWebSocketConnectsOnThirdAttempt extends BaseWebSocket { + private static connectionCount = 0; + private static secondsToNextRetry = 1; + constructor() { + super(); + MockWebSocketConnectsOnThirdAttempt.connectionCount += 1; + setTimeout(() => { + if (MockWebSocketConnectsOnThirdAttempt.connectionCount === 3) { + MockWebSocketConnectsOnThirdAttempt.connectionCount = 0; + this?.openHandler(); + } else { + const { secondsToNextRetry } = MockWebSocketConnectsOnThirdAttempt; + MockWebSocketConnectsOnThirdAttempt.secondsToNextRetry *= 2; + this?.errorHandler({ message: "test error" }); + this?.closeHandler(); + vi.advanceTimersByTimeAsync(secondsToNextRetry * 1000); + } + }, 0); + vi.advanceTimersByTimeAsync(1); + } +} + +export class MockWebSocketAlwaysFails extends BaseWebSocket { + private static connectionCount = 0; + private static secondsToNextRetry = 1; + constructor() { + super(); + MockWebSocketAlwaysFails.connectionCount += 1; + setTimeout(() => { + const { secondsToNextRetry } = MockWebSocketAlwaysFails; + MockWebSocketAlwaysFails.secondsToNextRetry *= 2; + this?.errorHandler({ message: "test error" }); + this?.closeHandler(); + vi.advanceTimersByTimeAsync(secondsToNextRetry * 1000); + }, 0); + vi.advanceTimersByTimeAsync(1); + } +} + +export class MockWebSocketAlwaysFailsLikeProxy extends BaseWebSocket { + private static connectionCount = 0; + private static secondsToNextRetry = 1; + constructor() { + super(); + MockWebSocketAlwaysFailsLikeProxy.connectionCount += 1; + setTimeout(() => { + MockWebSocketAlwaysFailsLikeProxy.secondsToNextRetry *= 2; + this?.openHandler(); + setTimeout(() => { + this?.errorHandler({ message: "test error" }); + this?.closeHandler(); + }, 0); + }, 0); + vi.advanceTimersByTimeAsync(1); + } +} + +export class MockWebSocketOpenFirstTimeLosesConnectionLater extends BaseWebSocket { + constructor() { + super(); + console.log(`MockWebSocketOpenFirstTimeLosesConnectionLater`); + setTimeout(() => { + console.log(`call openHandler`); + this?.openHandler(); + }, 0); + vi.advanceTimersByTimeAsync(1); + setTimeout(() => { + console.log(`KILL connection`); + this.errorHandler(); + this.closeHandler(); + }, 100); + } +} diff --git a/vuu-ui/packages/vuu-data-remote/test/server-proxy-throttle.test.ts b/vuu-ui/packages/vuu-data-remote/test/server-proxy-throttle.test.ts index 00f7a2720..2b9dcafca 100644 --- a/vuu-ui/packages/vuu-data-remote/test/server-proxy-throttle.test.ts +++ b/vuu-ui/packages/vuu-data-remote/test/server-proxy-throttle.test.ts @@ -4,24 +4,20 @@ import { TEST_setRequestId } from "../src/server-proxy/server-proxy"; import { COMMON_ATTRS, COMMON_TABLE_ROW_ATTRS, + createConnection, createServerProxyAndSubscribeToViewport, createTableRows, sizeRow, } from "./test-utils"; -const mockConnection = { - send: vi.fn(), - status: "ready" as const, -}; - describe("ServerProxy 'size-only throttling'", () => { it("passes a size only message through to UI client", async () => { const postMessageToClient = vi.fn(); const serverProxy = await createServerProxyAndSubscribeToViewport( postMessageToClient, { - connection: mockConnection, - } + connection: createConnection(), + }, ); // prettier-ignore serverProxy.handleMessageFromServer({ diff --git a/vuu-ui/packages/vuu-data-remote/test/server-proxy.test.ts b/vuu-ui/packages/vuu-data-remote/test/server-proxy.test.ts index e51d84943..f298817da 100644 --- a/vuu-ui/packages/vuu-data-remote/test/server-proxy.test.ts +++ b/vuu-ui/packages/vuu-data-remote/test/server-proxy.test.ts @@ -17,6 +17,7 @@ import { testSchema, updateTableRow, createSubscription, + createConnection, } from "./test-utils"; import { VuuRow } from "@finos/vuu-protocol-types"; import { @@ -3746,7 +3747,7 @@ describe("ServerProxy", () => { }); it("queues range requests sent before subscription completes, sends to server after subscription completes", async () => { - const connection = { send: vi.fn(), status: "ready" as const }; + const connection = createConnection(); const postMessageToClient = vi.fn(); const serverProxy = new ServerProxy(connection, postMessageToClient); serverProxy["authToken"] = "test"; @@ -3755,6 +3756,7 @@ describe("ServerProxy", () => { const [clientSubscription, serverSubscriptionAck, tableMetaResponse] = createSubscription(); serverProxy.subscribe(clientSubscription); + // send a range request before the subscription is ACKed serverProxy.handleMessageFromClient({ type: "setViewRange", viewport: "client-vp-1", @@ -3767,8 +3769,7 @@ describe("ServerProxy", () => { serverProxy.handleMessageFromServer(tableMetaResponse); // allow the promises pending for the subscription and metadata to resolve await new Promise((resolve) => window.setTimeout(resolve, 0)); - // expect(serverProxy["queuedRequests"].length).toEqual(0); - console.log(`test messages sent`); + expect(serverProxy["queuedRequests"].length).toEqual(0); expect(connection.send).toHaveBeenCalledTimes(3); expect(connection.send).toHaveBeenNthCalledWith(1, { body: { diff --git a/vuu-ui/packages/vuu-data-remote/test/test-utils.ts b/vuu-ui/packages/vuu-data-remote/test/test-utils.ts index 572fd6feb..d4ea27a47 100644 --- a/vuu-ui/packages/vuu-data-remote/test/test-utils.ts +++ b/vuu-ui/packages/vuu-data-remote/test/test-utils.ts @@ -1,19 +1,19 @@ import { vi } from "vitest"; import { - ServerToClientCreateViewPortSuccess, - ServerToClientMessage, - ServerToClientTableMeta, + VuuViewportCreateResponse, ServerToClientTableRows, VuuRow, + VuuServerMessage, + VuuTableMetaResponse, } from "@finos/vuu-protocol-types"; import { ServerProxy } from "../src/server-proxy/server-proxy"; -import { Connection } from "../src/connectionTypes"; +import { PostMessageToClientCallback } from "../src"; import { - PostMessageToClientCallback, ServerProxySubscribeMessage, -} from "../src"; -import { TableSchema } from "@finos/vuu-data-types"; + TableSchema, +} from "@finos/vuu-data-types"; +import { WebSocketConnection } from "../src/WebSocketConnection"; export const COMMON_ATTRS = { module: "TEST", @@ -44,7 +44,7 @@ export const sizeRow = (viewPortId = "server-vp-1", vpSize = 100) => rowIndex: -1, rowKey: "SIZE", updateType: "SIZE", - } as VuuRow); + }) as VuuRow; export const createTableRows = ( viewPortId, @@ -53,7 +53,7 @@ export const createTableRows = ( vpSize = 100, ts = 1, sel: 0 | 1 = 0, - numericValue = 1000 + numericValue = 1000, ): VuuRow[] => { const results: VuuRow[] = []; for (let rowIndex = from; rowIndex < to; rowIndex++) { @@ -75,10 +75,10 @@ export const createTableRows = ( }; export const createTableGroupRows = ( - includeSizeRow = true -): ServerToClientMessage => { + includeSizeRow = true, +): VuuServerMessage => { // prettier-ignore - const message: ServerToClientMessage = { + const message: VuuServerMessage = { ...COMMON_ATTRS, requestId: '1', body: { @@ -146,7 +146,7 @@ export const updateTableRow = ( viewPortId, rowIndex, updatedVal, - { vpSize = 100, ts = 2 } = {} + { vpSize = 100, ts = 2 } = {}, ): VuuRow => { const key = ("0" + rowIndex).slice(-2); const rowKey = `key-${key}`; @@ -187,8 +187,8 @@ export const createSubscription = ({ viewport = `client-vp-${key}` } = {}): [ ServerProxySubscribeMessage, - ServerToClientMessage, - ServerToClientMessage + VuuServerMessage, + VuuServerMessage ] => [ { aggregations, @@ -233,14 +233,9 @@ export const createSubscription = ({ } ]; -const mockConnection = { - send: vi.fn(), - status: "ready" as const, -}; - export const subscribe = async ( serverProxy: ServerProxy, - { bufferSize = 0, key = "1", to = 10 }: SubscriptionDetails + { bufferSize = 0, key = "1", to = 10 }: SubscriptionDetails, ) => { const [clientSubscription, serverSubscriptionAck, tableMetaResponse] = createSubscription({ bufferSize, key, to }); @@ -258,8 +253,22 @@ export type SubscriptionDetails = { }; export type Mock = { mockClear: () => void }; -export type MockedConnection = Omit & { - send: Connection["send"] & Mock; +export type MockedConnection = Omit< + WebSocketConnection, + "on" | "protocols" | "send" +> & { + on: WebSocketConnection["on"] & Mock; + send: WebSocketConnection["send"] & Mock; +}; + +export const createConnection = () => { + return { + connectionTimeout: 0, + on: vi.fn(), + requiresLogin: true, + send: vi.fn(), + status: "ready" as const, + }; }; export const createFixtures = async ( @@ -268,22 +277,18 @@ export const createFixtures = async ( connection?: MockedConnection; key?: string; to?: number; - } = {} + } = {}, ): Promise< [ServerProxy, PostMessageToClientCallback & Mock, MockedConnection] > => { const postMessageToClient = vi.fn(); - const connection = { - send: vi.fn(), - status: "ready" as const, - }; - + const connection = createConnection(); const serverProxy = await createServerProxyAndSubscribeToViewport( postMessageToClient, { ...proxyParams, connection, - } + }, ); return [serverProxy, postMessageToClient, connection]; @@ -293,10 +298,10 @@ export const createServerProxyAndSubscribeToViewport = async ( postMessageToClient: any, { bufferSize = 0, - connection = mockConnection, + connection = createConnection(), key = "1", to = 10, - }: { bufferSize?: number; connection?: any; key?: string; to?: number } = {} + }: { bufferSize?: number; connection?: any; key?: string; to?: number } = {}, ) => { const serverProxy = new ServerProxy(connection, postMessageToClient); //TODO we shouldn't be able to bypass checks like this diff --git a/vuu-ui/packages/vuu-data-remote/test/vuu-data-source.test.ts b/vuu-ui/packages/vuu-data-remote/test/vuu-data-source.test.ts index 615b1fecd..604d1eb1b 100644 --- a/vuu-ui/packages/vuu-data-remote/test/vuu-data-source.test.ts +++ b/vuu-ui/packages/vuu-data-remote/test/vuu-data-source.test.ts @@ -2,11 +2,23 @@ // Important: This import must come before RemoteDataSource import import "./global-mocks"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as connectionExports from "../src/connection-manager"; //---------------------------------------------------- -import { DataSourceConfig } from "@finos/vuu-data-types"; +import { DataSourceConfig, ServerAPI } from "@finos/vuu-data-types"; import { LinkDescriptorWithLabel, VuuSortCol } from "@finos/vuu-protocol-types"; import { VuuDataSource } from "../src/vuu-data-source"; +import ConnectionManager from "../src/ConnectionManager"; + +vi.mock("../src/ConnectionManager", () => ({ + default: { + serverAPI: new Promise((resolve) => { + // @ts-ignore + resolve({ + send: vi.fn(), + subscribe: vi.fn(), + }); + }), + }, +})); const defaultSubscribeOptions = { aggregations: [], @@ -92,73 +104,11 @@ describe("RemoteDataSource", () => { const callback = () => undefined; it("assigns viewport id if not passed, defaults all other options, server resolved immediately", async () => { - const serverSubscribe = vi.fn(); - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - () => - new Promise((resolve) => { - // @ts-ignore - resolve({ - subscribe: serverSubscribe, - }); - }), - ); - const dataSource = new VuuDataSource({ table }); - await dataSource.subscribe({}, callback); - - expect(serverSubscribe).toHaveBeenCalledWith( - { - ...defaultSubscribeOptions, - table: { - module: "SIMUL", - table: "instruments", - }, - viewport: "uuid-1", - }, - expect.any(Function), - ); - }); - - it("assigns viewport id if not passed, defaults all other options, server resolved previously", async () => { - const serverSubscribe = vi.fn(); - const resolvedPromise = Promise.resolve({ subscribe: serverSubscribe }); - - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - // @ts-ignore - () => resolvedPromise, - ); - const dataSource = new VuuDataSource({ table }); - - await dataSource.subscribe({}, callback); - - expect(serverSubscribe).toHaveBeenCalledWith( - { - ...defaultSubscribeOptions, - table: { - module: "SIMUL", - table: "instruments", - }, - viewport: "uuid-1", - }, - expect.any(Function), - ); - }); - - it("assigns viewport id if not passed, defaults all other options, server resolved later", async () => { - const serverSubscribe = vi.fn(); - let resolvePromise; - - const pr = new Promise((resolve) => { - resolvePromise = resolve; - }); - - vi.spyOn(connectionExports, "getServerAPI").mockImplementation(() => pr); const dataSource = new VuuDataSource({ table }); - - setTimeout(() => resolvePromise({ subscribe: serverSubscribe }), 50); - await dataSource.subscribe({}, callback); - expect(serverSubscribe).toHaveBeenCalledWith( + const serverAPI = await ConnectionManager.serverAPI; + expect(serverAPI.subscribe).toHaveBeenCalledWith( { ...defaultSubscribeOptions, table: { @@ -172,8 +122,6 @@ describe("RemoteDataSource", () => { }); it("uses options supplied at creation, if not passed with subscription", async () => { - const serverSubscribe = vi.fn(); - const resolvedPromise = Promise.resolve({ subscribe: serverSubscribe }); const aggregations = [{ column: "test", aggType: 1 } as const]; const columns = ["test"]; const filterSpec = { filter: 'ccy="EUR"' }; @@ -191,10 +139,6 @@ describe("RemoteDataSource", () => { parentVpId: "test", }; - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - // @ts-ignore - () => resolvedPromise, - ); const dataSource = new VuuDataSource({ aggregations, bufferSize: 200, @@ -206,9 +150,11 @@ describe("RemoteDataSource", () => { visualLink, }); + const serverAPI = await ConnectionManager.serverAPI; + await dataSource.subscribe({}, callback); - expect(serverSubscribe).toHaveBeenCalledWith( + expect(serverAPI.subscribe).toHaveBeenCalledWith( { aggregations, bufferSize: 200, @@ -227,9 +173,6 @@ describe("RemoteDataSource", () => { ); }); it("uses options passed with subscription, in preference to objects passed at creation", async () => { - const serverSubscribe = vi.fn(); - const resolvedPromise = Promise.resolve({ subscribe: serverSubscribe }); - const aggregations = [{ column: "test", aggType: 1 } as const]; const columns = ["test"]; const filterSpec = { filter: 'ccy="EUR"' }; @@ -242,10 +185,6 @@ describe("RemoteDataSource", () => { const groupBy2 = ["test"]; const sort2 = { sortDefs: [{ column: "test", sortType: "A" } as const] }; - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - // @ts-ignore - () => resolvedPromise, - ); const dataSource = new VuuDataSource({ aggregations, columns, @@ -256,6 +195,8 @@ describe("RemoteDataSource", () => { viewport: "test-1", }); + const serverAPI = await ConnectionManager.serverAPI; + await dataSource.subscribe( { aggregations: aggregations2, @@ -268,7 +209,7 @@ describe("RemoteDataSource", () => { callback, ); - expect(serverSubscribe).toHaveBeenCalledWith( + expect(serverAPI.subscribe).toHaveBeenCalledWith( { aggregations, bufferSize: 100, @@ -289,29 +230,22 @@ describe("RemoteDataSource", () => { }); it("subscribes with latest version of attributes, including when there are set whilst awaiting server", async () => { - const serverSubscribe = vi.fn(); - let resolvePromise; - - const pr = new Promise((resolve) => { - resolvePromise = resolve; - }); + const serverAPI = await ConnectionManager.serverAPI; - vi.spyOn(connectionExports, "getServerAPI").mockImplementation(() => pr); const dataSource = new VuuDataSource({ table }); - setTimeout(() => { - // dataSource is blocked inside subscribe function, awaiting server ... - dataSource.groupBy = ["test2"]; - dataSource.range = { from: 0, to: 50 }; - resolvePromise({ subscribe: serverSubscribe }); - }, 50); - - await dataSource.subscribe( + const pendingSubscribe = dataSource.subscribe( { range: { from: 0, to: 20 }, groupBy: ["test1"] }, callback, ); - expect(serverSubscribe).toHaveBeenCalledWith( + // dataSource is blocked inside subscribe function, awaiting server ... + dataSource.groupBy = ["test2"]; + dataSource.range = { from: 0, to: 50 }; + + await pendingSubscribe; + + expect(serverAPI.subscribe).toHaveBeenCalledWith( { ...defaultSubscribeOptions, groupBy: ["test2"], @@ -330,36 +264,30 @@ describe("RemoteDataSource", () => { describe("prop setters", () => { const callback = () => undefined; it("calls server when range set", async () => { - const serverSend = vi.fn(); - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - // @ts-ignore - () => Promise.resolve({ send: serverSend, subscribe: callback }), - ); + const serverAPI = await ConnectionManager.serverAPI; + const dataSource = new VuuDataSource({ table, viewport: "vp1" }); await dataSource.subscribe({}, callback); const range = { from: 0, to: 20 }; dataSource.range = range; - expect(serverSend).toHaveBeenCalledWith({ + expect(serverAPI.send).toHaveBeenCalledWith({ type: "setViewRange", range, viewport: "vp1", }); }); it("calls server when aggregations set", async () => { - const serverSend = vi.fn(); - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - // @ts-ignore - () => Promise.resolve({ send: serverSend, subscribe: callback }), - ); + const serverAPI = await ConnectionManager.serverAPI; + const dataSource = new VuuDataSource({ table, viewport: "vp1" }); await dataSource.subscribe({}, callback); const aggregations = [{ column: "col1", aggType: 1 } as const]; dataSource.aggregations = aggregations; - expect(serverSend).toHaveBeenCalledWith({ + expect(serverAPI.send).toHaveBeenCalledWith({ type: "aggregate", aggregations, viewport: "vp1", @@ -367,36 +295,28 @@ describe("RemoteDataSource", () => { }); it("calls server when columns set", async () => { - const serverSend = vi.fn(); - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - // @ts-ignore - () => Promise.resolve({ send: serverSend, subscribe: callback }), - ); + const serverAPI = await ConnectionManager.serverAPI; const dataSource = new VuuDataSource({ table, viewport: "vp1" }); await dataSource.subscribe({}, callback); const columns = ["col1", "col2"]; dataSource.columns = columns; - expect(serverSend).toHaveBeenCalledWith({ + expect(serverAPI.send).toHaveBeenCalledWith({ type: "setColumns", columns, viewport: "vp1", }); }); it("calls server when filter set", async () => { - const serverSend = vi.fn(); - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - // @ts-ignore - () => Promise.resolve({ send: serverSend, subscribe: callback }), - ); + const { send } = await ConnectionManager.serverAPI; const dataSource = new VuuDataSource({ table, viewport: "vp1" }); await dataSource.subscribe({}, callback); const filterSpec = { filter: 'exchange="SETS"' }; dataSource.filter = filterSpec; - expect(serverSend).toHaveBeenCalledWith({ + expect(send).toHaveBeenCalledWith({ type: "config", config: { aggregations: [], @@ -415,19 +335,15 @@ describe("RemoteDataSource", () => { viewport: "vp1", }); }); - it("calls server when groupBy set", async () => { - const serverSend = vi.fn(); - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - // @ts-ignore - () => Promise.resolve({ send: serverSend, subscribe: callback }), - ); + it("calls server when groupBy set, using config message", async () => { + const { send } = await ConnectionManager.serverAPI; const dataSource = new VuuDataSource({ table, viewport: "vp1" }); await dataSource.subscribe({}, callback); const groupBy = ["col1", "col2"]; dataSource.groupBy = groupBy; - expect(serverSend).toHaveBeenCalledWith({ + expect(send).toHaveBeenCalledWith({ type: "config", config: { aggregations: [], @@ -443,11 +359,7 @@ describe("RemoteDataSource", () => { }); it("calls server when config set, if config has changed", async () => { - const serverSend = vi.fn(); - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - // @ts-ignore - () => Promise.resolve({ send: serverSend, subscribe: callback }), - ); + const { send } = await ConnectionManager.serverAPI; const dataSource = new VuuDataSource({ table, viewport: "vp1" }); await dataSource.subscribe({}, callback); @@ -457,7 +369,7 @@ describe("RemoteDataSource", () => { dataSource.config = config; - expect(serverSend).toHaveBeenCalledWith({ + expect(send).toHaveBeenCalledWith({ type: "config", config: { aggregations: [], @@ -475,7 +387,7 @@ describe("RemoteDataSource", () => { dataSource.config = config; - expect(serverSend).toHaveBeenCalledWith({ + expect(send).toHaveBeenCalledWith({ type: "config", config: { aggregations: [], @@ -489,11 +401,7 @@ describe("RemoteDataSource", () => { }); it("parses filterStruct, if filterQuery only is provided", async () => { - const serverSend = vi.fn(); - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - // @ts-ignore - () => Promise.resolve({ send: serverSend, subscribe: callback }), - ); + const { send } = await ConnectionManager.serverAPI; const dataSource = new VuuDataSource({ table, viewport: "vp1" }); await dataSource.subscribe({}, callback); @@ -503,7 +411,7 @@ describe("RemoteDataSource", () => { dataSource.config = config; - expect(serverSend).toHaveBeenCalledWith({ + expect(send).toHaveBeenCalledWith({ type: "config", config: { aggregations: [], @@ -524,11 +432,7 @@ describe("RemoteDataSource", () => { }); it("does not call server when config set, if config has not changed", async () => { - const serverSend = vi.fn(); - vi.spyOn(connectionExports, "getServerAPI").mockImplementation( - // @ts-ignore - () => Promise.resolve({ send: serverSend, subscribe: callback }), - ); + const { send } = await ConnectionManager.serverAPI; const dataSource = new VuuDataSource({ table, viewport: "vp1" }); await dataSource.subscribe({}, callback); @@ -537,11 +441,11 @@ describe("RemoteDataSource", () => { }; dataSource.config = config; - serverSend.mockClear(); + send.mockClear(); dataSource.config = config; - expect(serverSend).toHaveBeenCalledTimes(0); + expect(send).toHaveBeenCalledTimes(0); }); }); }); diff --git a/vuu-ui/packages/vuu-data-remote/test/websocket-connection.test.ts b/vuu-ui/packages/vuu-data-remote/test/websocket-connection.test.ts deleted file mode 100644 index b4d31854b..000000000 --- a/vuu-ui/packages/vuu-data-remote/test/websocket-connection.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import "./global-mocks"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - connect as connectWebsocket, - ConnectionMessage, -} from "../src/websocket-connection"; - -describe("websocket-connection", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - it("tries to connect by default a maximum of 5 times before throwing Exception", async () => { - const statusMessages: ConnectionMessage[] = []; - const callback = async (message: ConnectionMessage) => { - statusMessages.push(message); - await vi.advanceTimersByTimeAsync(2000); - }; - - try { - await connectWebsocket("tst/url", "", callback); - } catch (e) { - expect(e.message).toEqual("Failed to establish connection"); - } - - expect(statusMessages.length).toEqual(11); - expect(statusMessages).toEqual([ - { type: "connection-status", status: "connecting" }, - { - type: "connection-status", - status: "disconnected", - reason: "failed to connect", - retry: true, - }, - { type: "connection-status", status: "connecting" }, - { - type: "connection-status", - status: "disconnected", - reason: "failed to connect", - retry: true, - }, - { type: "connection-status", status: "connecting" }, - { - type: "connection-status", - status: "disconnected", - reason: "failed to connect", - retry: true, - }, - { type: "connection-status", status: "connecting" }, - { - type: "connection-status", - status: "disconnected", - reason: "failed to connect", - retry: true, - }, - { type: "connection-status", status: "connecting" }, - { - type: "connection-status", - status: "disconnected", - reason: "failed to connect", - retry: false, - }, - { - type: "connection-status", - status: "failed", - reason: "unable to connect", - retry: false, - }, - ]); - }); - - it("fires connection-status messages when connecting/connected", async () => { - class MockWebSocket { - private openHandler: any; - private errorHandler: any; - constructor() { - setTimeout(() => { - this?.openHandler(); - }, 0); - } - set onopen(callback) { - this.openHandler = callback; - } - set onerror(callback) { - this.errorHandler = callback; - } - } - vi.stubGlobal("WebSocket", MockWebSocket); - - const statusMessages: ConnectionMessage[] = []; - const callback = async (message: ConnectionMessage) => { - statusMessages.push(message); - await vi.advanceTimersByTimeAsync(10); - }; - - try { - await connectWebsocket("tst/url", "", callback); - } catch (e) { - expect(e.message).toEqual("Failed to establish connection"); - } - - expect(statusMessages.length).toEqual(2); - expect(statusMessages).toEqual([ - { type: "connection-status", status: "connecting" }, - { - type: "connection-status", - status: "connection-open-awaiting-session", - }, - ]); - }); -}); diff --git a/vuu-ui/packages/vuu-data-types/index.d.ts b/vuu-ui/packages/vuu-data-types/index.d.ts index 2d858e41b..2642a2626 100644 --- a/vuu-ui/packages/vuu-data-types/index.d.ts +++ b/vuu-ui/packages/vuu-data-types/index.d.ts @@ -15,6 +15,12 @@ import type { VuuSort, VuuTable, VuuRpcRequest, + VuuRpcServiceRequest, + VuuRpcMenuRequest, + VuuRpcViewportRequest, + VuuCreateVisualLink, + VuuRemoveVisualLink, + VuuTableList, } from "@finos/vuu-protocol-types"; import type { DataSourceConfigChanges, IEventEmitter } from "@finos/vuu-utils"; import type { @@ -32,6 +38,7 @@ import type { VuuRange, } from "@finos/vuu-protocol-types"; import { DataValueTypeDescriptor } from "@finos/vuu-table-types"; +import { PostMessageToClientCallback } from "@finos/vuu-data-remote"; export declare type DataValueValidationSuccessResult = { ok: true; @@ -211,6 +218,9 @@ export interface DataSourceAggregateMessage export type DataUpdateMode = "batch" | "update" | "size-only"; +export interface DataSourceClearMessage extends MessageWithClientViewportId { + type: "viewport-clear"; +} export interface DataSourceDataMessage extends MessageWithClientViewportId { mode: DataUpdateMode; rows?: DataSourceRow[]; @@ -315,6 +325,7 @@ export type DataSourceCallbackMessage = | DataSourceConfigMessage | DataSourceColumnsMessage | DataSourceDataMessage + | DataSourceClearMessage | DataSourceDebounceRequest | DataSourceDisabledMessage | DataSourceEnabledMessage @@ -548,7 +559,7 @@ export interface DataSource * If an suspend is requested and not resumed within 3 seconds, it will automatically be promoted to a disable., */ suspend?: () => void; - resume?: () => void; + resume?: (callback?: SubscribeCallback) => void; deleteRow?: DataSourceDeleteHandler; /** @@ -648,21 +659,6 @@ export declare type MenuRpcAction = | NoAction | ShowToastAction; -export type ConnectionStatus = - | "connecting" - | "connection-open-awaiting-session" - | "connected" - | "disconnected" - | "failed" - | "reconnected"; - -export interface ConnectionStatusMessage { - type: "connection-status"; - reason?: string; - retry?: boolean; - status: ConnectionStatus; -} - export interface ConnectionQualityMetrics { type: "connection-metrics"; messagesLength: number; @@ -773,7 +769,7 @@ export type VuuUIMessageIn = export type WebSocketProtocol = string | string[] | undefined; export interface VuuUIMessageOutConnect { - protocol: WebSocketProtocol; + protocol?: WebSocketProtocol; type: "connect"; token: string; url: string; @@ -782,6 +778,10 @@ export interface VuuUIMessageOutConnect { retryLimitStartup?: number; } +export interface VuuUIMessageOutDisconnect { + type: "disconnect"; +} + export interface VuuUIMessageOutSubscribe extends ServerProxySubscribeMessage { type: "subscribe"; } @@ -900,8 +900,40 @@ export type WithRequestId = T & { requestId: string }; export type VuuUIMessageOut = | VuuUIMessageOutConnect + | VuuUIMessageOutDisconnect | VuuUIMessageOutSubscribe | VuuUIMessageOutUnsubscribe | VuuUIMessageOutViewport - | WithRequestId - | WithRequestId; + | WithRequestId; + +export type ConnectOptions = { + url: string; + token: string; + username: string; + protocol?: WebSocketProtocol; + /** Max number of reconnect attempts in the event of unsuccessful websocket connection at startup */ + retryLimitStartup?: number; + /** Max number of reconnect attempts in the event of a disconnected websocket connection */ + retryLimitDisconnect?: number; +}; + +export interface ServerAPI { + destroy: (viewportId?: string) => void; + getTableSchema: (table: VuuTable) => Promise; + getTableList: (module?: string) => Promise; + // TODO its not really unknown + rpcCall: ( + msg: + | VuuRpcServiceRequest + | VuuRpcMenuRequest + | VuuRpcViewportRequest + | VuuCreateVisualLink + | VuuRemoveVisualLink, + ) => Promise; + send: (message: VuuUIMessageOut) => void; + subscribe: ( + message: ServerProxySubscribeMessage, + callback: PostMessageToClientCallback, + ) => void; + unsubscribe: (viewport: string) => void; +} diff --git a/vuu-ui/packages/vuu-datatable/src/filter-table/FilterTable.css b/vuu-ui/packages/vuu-datatable/src/filter-table/FilterTable.css index 9becb11be..79eeac01f 100644 --- a/vuu-ui/packages/vuu-datatable/src/filter-table/FilterTable.css +++ b/vuu-ui/packages/vuu-datatable/src/filter-table/FilterTable.css @@ -1,9 +1,9 @@ .vuuFilterTable { - --vuuFilterBar-flex: 0 0 33px; + /* --vuuFilterBar-flex: 0 0 33px; --vuuMeasuredContainer-flex: 1 1 auto; --vuuMeasuredContainer-height: auto; display: flex; - flex-direction: column; + flex-direction: column; */ .vuuFilterBar { flex: 0 0 auto; diff --git a/vuu-ui/packages/vuu-datatable/src/filter-table/FilterTable.tsx b/vuu-ui/packages/vuu-datatable/src/filter-table/FilterTable.tsx index 4531f8036..f7a27e316 100644 --- a/vuu-ui/packages/vuu-datatable/src/filter-table/FilterTable.tsx +++ b/vuu-ui/packages/vuu-datatable/src/filter-table/FilterTable.tsx @@ -3,7 +3,7 @@ import { Table, TableProps } from "@finos/vuu-table"; import { useComponentCssInjection } from "@salt-ds/styles"; import { useWindow } from "@salt-ds/window"; import cx from "clsx"; -import { HTMLAttributes } from "react"; +import { CSSProperties, HTMLAttributes } from "react"; import { useFilterTable } from "./useFilterTable"; import filterTableCss from "./FilterTable.css"; @@ -15,9 +15,19 @@ export interface FilterTableProps extends HTMLAttributes { TableProps: TableProps; } +// Using inline styles here as Salt style injection happens too late for the +// measurements that we have to take on first render +const style = { + "--vuuMeasuredContainer-flex": "1 1 auto", + "--vuuMeasuredContainer-height": "auto", + display: "flex", + flexDirection: "column", +} as CSSProperties; + export const FilterTable = ({ FilterBarProps, TableProps, + style: styleProps, ...htmlAttributes }: FilterTableProps) => { const targetWindow = useWindow(); @@ -33,8 +43,12 @@ export const FilterTable = ({ }); return ( -
- +
+ ); diff --git a/vuu-ui/packages/vuu-layout/test/global-mocks.ts b/vuu-ui/packages/vuu-layout/test/global-mocks.ts index 982d9c488..6c3d3e74d 100644 --- a/vuu-ui/packages/vuu-layout/test/global-mocks.ts +++ b/vuu-ui/packages/vuu-layout/test/global-mocks.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { vi } from "vitest"; +const WorkerMock = vi.fn(() => ({})); const BlobMock = vi.fn(() => ({})); const URLMock = { createObjectURL: () => ({}), }; +vi.stubGlobal("Worker", WorkerMock); vi.stubGlobal("Blob", BlobMock); vi.stubGlobal("URL", URLMock); vi.stubGlobal("loggingSettings", { loggingLevel: "error" }); diff --git a/vuu-ui/packages/vuu-layout/test/typeOf.test.ts b/vuu-ui/packages/vuu-layout/test/isLayoutJSON.test.ts similarity index 96% rename from vuu-ui/packages/vuu-layout/test/typeOf.test.ts rename to vuu-ui/packages/vuu-layout/test/isLayoutJSON.test.ts index d71ec1ff6..1a9176379 100644 --- a/vuu-ui/packages/vuu-layout/test/typeOf.test.ts +++ b/vuu-ui/packages/vuu-layout/test/isLayoutJSON.test.ts @@ -1,3 +1,4 @@ +import "./global-mocks"; import { LayoutJSON } from "@finos/vuu-utils"; import { describe, expect, it } from "vitest"; import { isLayoutJSON } from "../src"; diff --git a/vuu-ui/packages/vuu-protocol-types/index.d.ts b/vuu-ui/packages/vuu-protocol-types/index.d.ts index 97a51977c..3950e10fe 100644 --- a/vuu-ui/packages/vuu-protocol-types/index.d.ts +++ b/vuu-ui/packages/vuu-protocol-types/index.d.ts @@ -156,10 +156,17 @@ export interface VuuViewportChangeResponse { } export interface VuuViewportRangeRequest { - type: "CHANGE_VP_RANGE_SUCCESS"; + from: number; + to: number; + type: "CHANGE_VP_RANGE"; viewPortId: string; +} + +export interface VuuViewportRangeResponse { from: number; to: number; + type: "CHANGE_VP_RANGE_SUCCESS"; + viewPortId: string; } export interface ServerToClientDisableViewPortSuccess { type: "DISABLE_VP_SUCCESS"; @@ -169,7 +176,13 @@ export interface ServerToClientEnableViewPortSuccess { type: "ENABLE_VP_SUCCESS"; viewPortId: string; } -export interface ServerToClientRemoveViewPortSuccess { + +export interface VuuViewportRemoveRequest { + type: "REMOVE_VP"; + viewPortId: string; +} + +export interface VuuViewportRemoveResponse { type: "REMOVE_VP_SUCCESS"; viewPortId: string; } @@ -200,10 +213,10 @@ export declare type ServerMessageBody = | ServerToClientLoginSuccess | VuuViewportCreateResponse | VuuViewportChangeResponse - | VuuViewportRangeRequest + | VuuViewportRangeResponse | ServerToClientDisableViewPortSuccess | ServerToClientEnableViewPortSuccess - | ServerToClientRemoveViewPortSuccess + | VuuViewportRemoveResponse | ServerToClientSelectSuccess | VuuTableMetaResponse | VuuTableListResponse @@ -245,21 +258,11 @@ export interface ClientToServerEnable { type: "ENABLE_VP"; viewPortId: string; } -export interface ClientToServerRemoveViewPort { - type: "REMOVE_VP"; - viewPortId: string; -} export interface ClientToServerSelection { type: "SET_SELECTION"; selection: number[]; vpId: string; } -export interface ClientToServerViewPortRange { - from: number; - to: number; - type: "CHANGE_VP_RANGE"; - viewPortId: string; -} export interface ClientToServerOpenTreeNode { type: "OPEN_TREE_NODE"; vpId: string; @@ -303,9 +306,9 @@ export declare type ClientMessageBody = | VuuTableMetaRequest | VuuViewportCreateRequest | VuuViewportChangeRequest - | ClientToServerRemoveViewPort + | VuuViewportRemoveRequest | ClientToServerSelection - | ClientToServerViewPortRange + | VuuViewportRangeRequest | VuuViewportVisualLinksRequest | VuuViewportMenusRequest | ClientToServerOpenTreeNode diff --git a/vuu-ui/packages/vuu-shell/src/app-status-bar/AppStatusBar.css b/vuu-ui/packages/vuu-shell/src/app-status-bar/AppStatusBar.css new file mode 100644 index 000000000..509c317ff --- /dev/null +++ b/vuu-ui/packages/vuu-shell/src/app-status-bar/AppStatusBar.css @@ -0,0 +1,11 @@ +.vuuAppStatusBar { + align-items: center; + background-color: var(--salt-container-secondary-background); + display: flex; + height: 36px; + justify-content: flex-end; + padding-top: var(--salt-spacing-200); +} +.vuuAppStatusBar-hidden { + display: none; +} diff --git a/vuu-ui/packages/vuu-shell/src/app-status-bar/AppStatusBar.tsx b/vuu-ui/packages/vuu-shell/src/app-status-bar/AppStatusBar.tsx new file mode 100644 index 000000000..3024b48cd --- /dev/null +++ b/vuu-ui/packages/vuu-shell/src/app-status-bar/AppStatusBar.tsx @@ -0,0 +1,65 @@ +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import cx from "clsx"; +import { ConnectionManager } from "@finos/vuu-data-remote"; + +import appStatusBarCss from "./AppStatusBar.css"; +import { useUserSetting } from "../application-provider"; +import { Settings } from "@finos/vuu-utils"; +import { ConnectionStateDisplay } from "../connection-status"; +import { useEffect, useState } from "react"; + +const classBase = "vuuAppStatusBar"; + +const shouldShowStatusBar = (connected: boolean, settings?: Settings) => { + if (settings && "showAppStatusBar" in settings) { + return settings.showAppStatusBar === true || connected === false; + } else { + return connected === false; + } +}; + +export const ApplicationStatusBar = () => { + const targetWindow = useWindow(); + useComponentCssInjection({ + testId: "vuu-settings-form", + css: appStatusBarCss, + window: targetWindow, + }); + + const [connected, setConnected] = useState(true); + const settings = useUserSetting(); + + useEffect(() => { + ConnectionManager.on("connection-status", ({ connectionStatus }) => { + if (connectionStatus === "disconnected") { + setConnected(false); + } else if (connectionStatus.endsWith("connected")) { + setConnected(true); + } + }); + }, []); + + if (!shouldShowStatusBar(connected, settings)) { + return
; + } + + // const connect = () => { + // ConnectionManager.connect({ + // token: "blah", + // url: "ws://localhost:8090/websocket", + // username: "steve", + // }); + // }; + // const disconnect = () => { + // ConnectionManager.disconnect(); + // }; + + return ( +
+ {/* + */} + +
+ ); +}; diff --git a/vuu-ui/packages/vuu-shell/src/app-status-bar/index.ts b/vuu-ui/packages/vuu-shell/src/app-status-bar/index.ts new file mode 100644 index 000000000..55f8c2d74 --- /dev/null +++ b/vuu-ui/packages/vuu-shell/src/app-status-bar/index.ts @@ -0,0 +1 @@ +export * from "./AppStatusBar"; diff --git a/vuu-ui/packages/vuu-shell/src/application-provider/ApplicationProvider.tsx b/vuu-ui/packages/vuu-shell/src/application-provider/ApplicationProvider.tsx index f6c7355ac..6c535312f 100644 --- a/vuu-ui/packages/vuu-shell/src/application-provider/ApplicationProvider.tsx +++ b/vuu-ui/packages/vuu-shell/src/application-provider/ApplicationProvider.tsx @@ -5,7 +5,7 @@ import { SaltProvider, ThemeContextProps, useDensity, - useTheme + useTheme, } from "@salt-ds/core"; import { ReactElement, @@ -13,11 +13,11 @@ import { useCallback, useContext, useMemo, - useState + useState, } from "react"; import { ApplicationContext, - ApplicationContextProps + ApplicationContextProps, } from "./ApplicationContext"; import { usePersistenceManager } from "../persistence-manager"; @@ -30,7 +30,7 @@ export interface ApplicationProviderProps const getThemeMode = ( mode: Mode = "light", - userSettings?: Record + userSettings?: Record, ) => { const themeMode = userSettings?.themeMode; if (themeMode === "light" || themeMode === "dark") { @@ -46,7 +46,7 @@ export const ApplicationProvider = ({ mode, theme, userSettingsSchema: userSettingsSchema, - user + user, }: ApplicationProviderProps): ReactElement | null => { const { mode: inheritedMode, theme: inheritedTheme } = useTheme(); const density = useDensity(densityProp); @@ -72,7 +72,7 @@ export const ApplicationProvider = ({ return newSettings; }); }, - [persistenceManager] + [persistenceManager], ); return userSettings ? ( @@ -83,7 +83,7 @@ export const ApplicationProvider = ({ onUserSettingChanged, userSettings, userSettingsSchema, - user: user ?? context.user + user: user ?? context.user, }} > { return { onUserSettingChanged, userSettings, - userSettingsSchema + userSettingsSchema, }; }; //Getter method (read only access to applicationSetting) export const useUserSetting = () => { const { userSettings } = useContext(ApplicationContext); - return { userSettings }; + return userSettings; }; diff --git a/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionRetryCountdown.tsx b/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionRetryCountdown.tsx new file mode 100644 index 000000000..966102b56 --- /dev/null +++ b/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionRetryCountdown.tsx @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const classBase = "vuuConnectionRetryCountdown"; + +export const ConnectionRetryCountdown = ({ + seconds: secondsProp, +}: { + seconds: number; +}) => { + const secondsRemainingRef = useRef(secondsProp); + const [seconds, setSeconds] = useState(secondsRemainingRef.current); + + const countDown = useCallback(() => { + secondsRemainingRef.current -= 1; + setSeconds(secondsRemainingRef.current); + if (secondsRemainingRef.current > 0) { + setTimeout(countDown, 1000); + } + }, []); + + useEffect(() => { + if (secondsProp !== secondsRemainingRef.current) { + secondsRemainingRef.current = secondsProp; + countDown(); + } + }, [countDown, secondsProp]); + + useMemo(() => { + setTimeout(countDown, 1000); + }, [countDown]); + + return seconds === 0 ? ( +
+ connecting +
+ ) : ( +
+ retry in {seconds} seconds +
+ ); +}; diff --git a/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStateDisplay.css b/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStateDisplay.css new file mode 100644 index 000000000..c9fcdfc29 --- /dev/null +++ b/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStateDisplay.css @@ -0,0 +1,32 @@ +.vuuConnectionStateDisplay { + --ballbox-height: 40px; + --message-height: 16px; + --row-gap: 3px; + + align-items: center; + column-gap: var(--salt-spacing-200); + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: var(--ballbox-height) var(--message-height); + height: calc(var(--ballbox-height) + var(--message-height) + var(--row-gap)); + padding: 0 var(--salt-spacing-200); + row-gap: var(--row-gap); + + .vuuTrafficLightControl { + grid-area: 1/2/2/3; + padding-left: 20px; + } + + .vuuConnectionRetryCountdown { + color: var(--salt-content-secondary-foreground); + grid-area: 2/2/3/3; + font-size: 11px; + text-align: end; + width: 100%; + } +} + +.vuuConnectionStateDisplay-text { + grid-area: 1/1/2/2; + text-transform: capitalize; +} diff --git a/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStateDisplay.tsx b/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStateDisplay.tsx new file mode 100644 index 000000000..3fd4d8357 --- /dev/null +++ b/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStateDisplay.tsx @@ -0,0 +1,79 @@ +import { ConnectionManager } from "@finos/vuu-data-remote"; +import type { WebSocketConnectionState } from "@finos/vuu-data-remote/src/WebSocketConnection"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import { HTMLAttributes, useMemo, useState } from "react"; +import { ConnectionRetryCountdown } from "./ConnectionRetryCountdown"; +import { ConnectionStatusIndicator } from "./ConnectionStatusIndicator"; + +import connectionStateDisplayCss from "./ConnectionStateDisplay.css"; + +const classBase = "vuuConnectionStateDisplay"; + +const DefaultConnectionState: WebSocketConnectionState = { + connectionPhase: "connecting", + connectionStatus: "closed", + secondsToNextRetry: -1, + retryAttemptsRemaining: 0, + retryAttemptsTotal: 1, +}; + +interface ConnectionStateDisplayProps extends HTMLAttributes { + className?: string; + connectionState?: WebSocketConnectionState; + showText?: boolean; +} + +const getDisplayText = (connectionState: WebSocketConnectionState) => { + switch (connectionState.connectionStatus) { + case "closed": + return "Closed"; + case "failed": + return connectionState.connectionPhase === "connecting" + ? "Failed to connect" + : "Failed to re-connect"; + + case "disconnected": + return connectionState.connectionPhase === "connecting" + ? "Connecting" + : "Reconnecting"; + } +}; + +export const ConnectionStateDisplay = ({ + connectionState: connectionStateProp, + showText = true, + ...htmlAttributes +}: ConnectionStateDisplayProps) => { + const targetWindow = useWindow(); + useComponentCssInjection({ + testId: "vuu-connection-status-indicator", + css: connectionStateDisplayCss, + window: targetWindow, + }); + const [connectionState, setConnectionState] = + useState(DefaultConnectionState); + + useMemo(() => { + ConnectionManager.on("connection-status", setConnectionState); + if (connectionStateProp) { + setConnectionState(connectionStateProp); + } + }, [connectionStateProp]); + + const { connectionStatus, secondsToNextRetry } = connectionState; + + return ( +
+ {showText ? ( +
+ {getDisplayText(connectionState)} +
+ ) : null} + {connectionStatus === "disconnected" && secondsToNextRetry > 0 ? ( + + ) : null} + +
+ ); +}; diff --git a/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStatusIndicator.css b/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStatusIndicator.css index 597067096..5229dc294 100644 --- a/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStatusIndicator.css +++ b/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStatusIndicator.css @@ -1,68 +1,85 @@ -.vuuStatus-container { - display: flex; -} +@keyframes squeeze { + 0% { + transform: scale(1); + } + + 50% { + transform: scale(0.85); + } -.vuuStatus-text { - align-self: center; + 100% { + transform: scale(1); + } } +.ConnectionStatusIndicator { + --ball-size-large: 20px; + --ball-size-small: 14px; + --ballbox-padding: 4px; -.vuuStatus { - --vuu-icon-height: 18px; - --vuu-icon-padding: var(--vuuStatus-padding, 6px); - --vuu-icon-width: var(--vuuStatus-width, auto); - --vuu-icon-min-width: var(--vuuStatus-min-width, 20px); - align-items: center; - display: inline-flex; - height: var(--vuu-icon-height); - justify-content: center; - min-width: var(--vuu-icon-min-width); - padding: 0 var(--vuu-icon-padding); - width: var(--vuu-icon-width); - position: relative; + height: var(--ballbox-height); + padding: 0 4px; + position: relative; + + &.expanded { + .Ball.large { + animation-duration: 0.8s; + animation-name: squeeze; + transform-origin: center; + } + .Ball.small { + transform: translateX(calc(var(--ball-size-large) * var(--i))); + } + } } -.vuuStatus[data-icon]::after { - inset: 0 0 0 0; - content: ''; - box-shadow: 0 0 0 0 black; - position: absolute; - mask: var(--vuu-icon-svg) center center/20px 20px no-repeat; - -webkit-mask: var(--vuu-icon-svg) center center/20px 20px no-repeat; +.ConnectionStatusIndicator-inactive { + --ball-color: lightgray; } -.vuuActiveStatus::after { - --vuu-icon-svg: var(--svg-active-status); - background-color: rgb(0, 255, 0); +.ConnectionStatusIndicator-closed { + --ball-color: lightgray; + .Ball.large { + transition: background-color 1s linear; + } } -.vuuConnectingStatus::after { - --vuu-icon-svg: var(--svg-connecting-status); - background-color: orange; - transform: scale(1); - animation: infinite pulse 1s; +.ConnectionStatusIndicator-connected { + --ball-color: green; } -.vuuDisconnectedStatus::after { - --vuu-icon-svg: var(--svg-disconnected-status); - background-color: red; - transform: scale(1); - animation: infinite pulse 0.5s; +.ConnectionStatusIndicator-connecting { + --ball-color: red; +} +.ConnectionStatusIndicator-disconnected { + --ball-color: red; } -@keyframes pulse { - 0% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.7); - } +.Ball.large { + background-color: var(--ball-color); + border-radius: calc(var(--ball-size-large) / 2); + height: var(--ball-size-large); + right: var(--ballbox-padding); + top: calc(var(--ballbox-height) / 2 - (var(--ball-size-large) / 2)); + position: absolute; + transition: transform 0.8s linear; - 70% { - transform: scale(1); - box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); - } + width: var(--ball-size-large); + z-index: 1; +} + +.Ball.small { + background-color: var(--ball-color); + border-radius: calc(var(--ball-size-small) / 2); + height: var(--ball-size-small); + position: absolute; + right: calc( + var(--ballbox-padding) + (var(--ball-size-large) - var(--ball-size-small)) / + 2 + ); + top: calc(var(--ballbox-height) / 2 - (var(--ball-size-small) / 2)); + width: var(--ball-size-small); - 100% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); - } -} \ No newline at end of file + transition: transform 0.4s ease-out; + transition-delay: 0.1s; +} diff --git a/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStatusIndicator.tsx b/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStatusIndicator.tsx index 46df7d3f2..9902b078e 100644 --- a/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStatusIndicator.tsx +++ b/vuu-ui/packages/vuu-shell/src/connection-status/ConnectionStatusIndicator.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useState } from "react"; +import { CSSProperties, memo, useMemo, useRef } from "react"; +import type { WebSocketConnectionState } from "@finos/vuu-data-remote"; import { useComponentCssInjection } from "@salt-ds/styles"; import { useWindow } from "@salt-ds/window"; @@ -6,25 +7,35 @@ import cx from "clsx"; import connectionStatusIndicatorCss from "./ConnectionStatusIndicator.css"; -type connectionStatus = - | "connected" - | "reconnected" - | "connecting" - | "disconnected"; +const classBase = "ConnectionStatusIndicator"; -interface ConnectionStatusProps { - connectionStatus: connectionStatus; - className?: string; - props?: unknown; - element?: string; +interface BallProps { + background?: string; + i?: number; + large?: boolean; +} +const Ball = memo(({ background, i = 0, large = false }: BallProps) => { + if (large) { + return
; + } else { + return ( +
+ ); + } +}); +Ball.displayName = "Ball"; + +interface ConnectionStatusIndicatorProps { + connectionState: WebSocketConnectionState; } export const ConnectionStatusIndicator = ({ - connectionStatus, - className, - element = "span", - ...props -}: ConnectionStatusProps) => { + connectionState, +}: ConnectionStatusIndicatorProps) => { const targetWindow = useWindow(); useComponentCssInjection({ testId: "vuu-connection-status-indicator", @@ -32,37 +43,55 @@ export const ConnectionStatusIndicator = ({ window: targetWindow, }); - const [classBase, setClassBase] = useState("vuuConnectingStatus"); - useEffect(() => { - switch (connectionStatus) { - case "connected": - case "reconnected": - setClassBase("vuuActiveStatus"); - break; - case "connecting": - setClassBase("vuuConnectingStatus"); - break; - case "disconnected": - setClassBase("vuuDisconnectedStatus"); - break; - default: - break; + const ballbox = useRef(null); + const expandedRef = useRef(false); + const { connectionStatus, retryAttemptsRemaining, retryAttemptsTotal } = + connectionState; + + if (connectionStatus === "disconnected") { + // one way switch + expandedRef.current = true; + } + const finalState = + connectionStatus === "connected" || connectionStatus === "closed"; + + useMemo(() => { + if (finalState) { + expandedRef.current = false; } - }, [connectionStatus]); + }, [finalState]); - const statusIcon = React.createElement(element, { - ...props, - className: cx("vuuStatus vuuIcon", classBase, className), - }); + const getSmallBalls = () => { + const colors = Array(retryAttemptsTotal).fill("lightgray"); + const index = retryAttemptsTotal - retryAttemptsRemaining; + if (retryAttemptsRemaining) { + colors[index] = "orange"; + for (let i = 0; i < index; i++) { + colors[i] = "red"; + } + } else { + colors.fill("red"); + } + colors.reverse(); + return colors.map((background, i) => ( + + )); + }; + + const balls = getSmallBalls(); + + // const displayState = balls.length > 0 ? "disconnected" : connectionStatus; + const displayState = connectionStatus; return ( - <> -
- {statusIcon} -
- Status: {connectionStatus.toUpperCase()} -
-
- +
+ + {balls} +
); }; diff --git a/vuu-ui/packages/vuu-shell/src/connection-status/index.ts b/vuu-ui/packages/vuu-shell/src/connection-status/index.ts index 1b493192c..ffcd06b38 100644 --- a/vuu-ui/packages/vuu-shell/src/connection-status/index.ts +++ b/vuu-ui/packages/vuu-shell/src/connection-status/index.ts @@ -1 +1,2 @@ +export * from "./ConnectionStateDisplay"; export * from "./ConnectionStatusIndicator"; diff --git a/vuu-ui/packages/vuu-shell/src/shell-layout-templates/full-height-left-panel/useFullHeightLeftPanel.tsx b/vuu-ui/packages/vuu-shell/src/shell-layout-templates/full-height-left-panel/useFullHeightLeftPanel.tsx index c75b8cf21..8527a7179 100644 --- a/vuu-ui/packages/vuu-shell/src/shell-layout-templates/full-height-left-panel/useFullHeightLeftPanel.tsx +++ b/vuu-ui/packages/vuu-shell/src/shell-layout-templates/full-height-left-panel/useFullHeightLeftPanel.tsx @@ -4,6 +4,7 @@ import { ContextPanel } from "../context-panel"; import { SidePanel } from "../side-panel"; import { ShellLayoutTemplateHook } from "../useShellLayout"; import { useMemo } from "react"; +import { ApplicationStatusBar } from "../../app-status-bar"; export const useFullHeightLeftPanel: ShellLayoutTemplateHook = ({ appHeader, @@ -30,6 +31,7 @@ export const useFullHeightLeftPanel: ShellLayoutTemplateHook = ({ key="main-content" style={{ flex: 1 }} /> + diff --git a/vuu-ui/packages/vuu-shell/src/shell.tsx b/vuu-ui/packages/vuu-shell/src/shell.tsx index 844cc7c8c..ff9b365ab 100644 --- a/vuu-ui/packages/vuu-shell/src/shell.tsx +++ b/vuu-ui/packages/vuu-shell/src/shell.tsx @@ -1,11 +1,11 @@ -import { connectToServer } from "@finos/vuu-data-remote"; +import { ConnectionManager } from "@finos/vuu-data-remote"; import type { LayoutChangeHandler } from "@finos/vuu-layout"; import { LayoutProvider, StackLayout } from "@finos/vuu-layout"; import { ContextMenuProvider, DialogProvider, NotificationsProvider, - useNotifications + useNotifications, } from "@finos/vuu-popups"; import { VuuUser, logger, registerComponent } from "@finos/vuu-utils"; import { useComponentCssInjection } from "@salt-ds/styles"; @@ -17,7 +17,7 @@ import { IPersistenceManager, LocalPersistenceManager, PersistenceProvider, - usePersistenceManager + usePersistenceManager, } from "./persistence-manager"; import { ShellLayoutProps, useShellLayout } from "./shell-layout-templates"; import { SettingsSchema, UserSettingsPanel } from "./user-settings"; @@ -25,7 +25,7 @@ import { WorkspaceProps, WorkspaceProvider, useWorkspace, - useWorkspaceContextMenuItems + useWorkspaceContextMenuItems, } from "./workspace-management"; import shellCss from "./shell.css"; @@ -39,7 +39,7 @@ if (process.env.NODE_ENV === "production") { // to avoif tree shaking the Stack away. Causes a runtime issue in dev. if (typeof StackLayout !== "function") { console.warn( - "StackLayout module not loaded, will be unable to deserialize from layout JSON" + "StackLayout module not loaded, will be unable to deserialize from layout JSON", ); } } @@ -65,14 +65,14 @@ const getAppHeader = (shellLayoutProps?: ShellLayoutProps) => shellLayoutProps?.appHeader ?? defaultAppHeader; const defaultHTMLAttributes: HTMLAttributes = { - className: "vuuShell" + className: "vuuShell", }; const getHTMLAttributes = (props?: ShellLayoutProps) => { if (props?.htmlAttributes) { return { ...defaultHTMLAttributes, - ...props.htmlAttributes + ...props.htmlAttributes, }; } else { return defaultHTMLAttributes; @@ -84,7 +84,7 @@ const VuuApplication = ({ children, // loginUrl, // need to make this available to app header serverUrl, - user + user, }: Omit< ShellProps, "ContentLayoutProps" | "loginUrl" | "userSettingsSchema" | "workspaceProps" @@ -93,7 +93,7 @@ const VuuApplication = ({ useComponentCssInjection({ testId: "vuu-shell", css: shellCss, - window: targetWindow + window: targetWindow, }); const notify = useNotifications(); @@ -109,21 +109,21 @@ const VuuApplication = ({ error?.("Failed to save layout"); } }, - [saveApplicationLayout] + [saveApplicationLayout], ); useMemo(async () => { if (serverUrl && user.token) { - const connectionStatus = await connectToServer({ - authToken: user.token, + const connectionResult = await ConnectionManager.connect({ + token: user.token, url: serverUrl, - username: user.username + username: user.username, }); - if (connectionStatus === "rejected") { + if (connectionResult === "rejected") { notify({ type: "error", body: "Unable to connect to VUU Server", - header: "Error" + header: "Error", }); } } else { @@ -131,7 +131,7 @@ const VuuApplication = ({ `Shell: serverUrl: '${serverUrl}', token: '${Array(user.token.length) .fill("#") .join("")}' - ` + `, ); } }, [notify, serverUrl, user.token, user.username]); @@ -141,7 +141,7 @@ const VuuApplication = ({ const initialLayout = useShellLayout({ ...ShellLayoutProps, appHeader: getAppHeader(ShellLayoutProps), - htmlAttributes: getHTMLAttributes(ShellLayoutProps) + htmlAttributes: getHTMLAttributes(ShellLayoutProps), }); return isLayoutLoading ? null : ( @@ -178,7 +178,7 @@ export const Shell = ({ return undefined; } console.log( - `No Persistence Manager, configuration data will be persisted to Local Storage, key: 'vuu/${user.username}'` + `No Persistence Manager, configuration data will be persisted to Local Storage, key: 'vuu/${user.username}'`, ); return new LocalPersistenceManager(`vuu/${user.username}`); }, [persistenceManager, user.username]); diff --git a/vuu-ui/packages/vuu-shell/src/user-settings/SettingsForm.css b/vuu-ui/packages/vuu-shell/src/user-settings/SettingsForm.css new file mode 100644 index 000000000..7223927fc --- /dev/null +++ b/vuu-ui/packages/vuu-shell/src/user-settings/SettingsForm.css @@ -0,0 +1,5 @@ +.vuuSettingsForm { + display: flex; + flex-direction: column; + gap: var(--salt-spacing-200); +} diff --git a/vuu-ui/packages/vuu-shell/src/user-settings/SettingsForm.tsx b/vuu-ui/packages/vuu-shell/src/user-settings/SettingsForm.tsx index 9d17996db..c7d864a27 100644 --- a/vuu-ui/packages/vuu-shell/src/user-settings/SettingsForm.tsx +++ b/vuu-ui/packages/vuu-shell/src/user-settings/SettingsForm.tsx @@ -1,5 +1,6 @@ import { VuuRowDataItemType } from "@finos/vuu-protocol-types"; -import { queryClosest, Settings } from "@finos/vuu-utils"; +import { VuuInput } from "@finos/vuu-ui-controls"; +import { getFieldName, Settings } from "@finos/vuu-utils"; import { Dropdown, DropdownProps, @@ -11,7 +12,9 @@ import { ToggleButtonGroup, ToggleButtonGroupProps, } from "@salt-ds/core"; -import { VuuInput } from "@finos/vuu-ui-controls"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import cx from "clsx"; import { FormEventHandler, HTMLAttributes, @@ -19,19 +22,13 @@ import { useCallback, useState, } from "react"; + +import settingsFormCss from "./SettingsForm.css"; + export interface SettingsSchema { properties: SettingsProperty[]; } -export interface SettingsProps { - settingsSchema: SettingsSchema; - settings: Settings; - onSettingChanged: ( - propertyName: string, - value: string | number | boolean, - ) => void; -} - export type Option = { label: string; value: T }; export const isOption = ( @@ -83,6 +80,8 @@ const defaultPropertyValue: Record< string: "", }; +const classBase = "vuuSettingsForm"; + // Determine the form control type to be displayed export function FormControl({ property, @@ -189,28 +188,34 @@ function getTooltipContent(type: string, valid: string | undefined) { } } -export type SettingsFormProps = SettingsProps & HTMLAttributes; +export interface SettingsFormProps extends HTMLAttributes { + settingsSchema: SettingsSchema; + settings: Settings; + onSettingChanged: ( + propertyName: string, + value: string | number | boolean, + ) => void; +} // Generates application settings form component export const SettingsForm = ({ + className, settingsSchema, settings, onSettingChanged, ...htmlAttributes }: SettingsFormProps) => { - const getFieldNameFromEventTarget = (evt: SyntheticEvent) => { - const fieldElement = queryClosest(evt.target, "[data-field]"); - if (fieldElement && fieldElement.dataset.field) { - return fieldElement.dataset.field; - } else { - throw Error("data-field attribute not defined"); - } - }; + const targetWindow = useWindow(); + useComponentCssInjection({ + testId: "vuu-settings-form", + css: settingsFormCss, + window: targetWindow, + }); // Change Handler for toggle and switch buttons const changeHandler = useCallback( (event) => { - const fieldName = getFieldNameFromEventTarget(event); + const fieldName = getFieldName(event.target); const { checked, value } = event.target as HTMLInputElement; onSettingChanged(fieldName, checked ?? value); }, @@ -220,7 +225,7 @@ export const SettingsForm = ({ // Change handler for selection form controls const selectHandler = useCallback( (event: SyntheticEvent, [selected]: string[]) => { - const fieldName = getFieldNameFromEventTarget(event); + const fieldName = getFieldName(event.target); onSettingChanged(fieldName, selected); }, [onSettingChanged], @@ -229,7 +234,7 @@ export const SettingsForm = ({ // Change Handler for input boxes const inputHandler = useCallback( (event) => { - const fieldName = getFieldNameFromEventTarget(event); + const fieldName = getFieldName(event.target); const { value } = event.target as HTMLInputElement; if (!Number.isNaN(Number(value)) && value != "") { const numValue = Number(value); @@ -241,7 +246,7 @@ export const SettingsForm = ({ [onSettingChanged], ); return ( -
+
{settingsSchema.properties.map((property) => ( {property.label} diff --git a/vuu-ui/packages/vuu-shell/src/user-settings/UserSettingsPanel.css b/vuu-ui/packages/vuu-shell/src/user-settings/UserSettingsPanel.css index a65369c4f..8abc19cf1 100644 --- a/vuu-ui/packages/vuu-shell/src/user-settings/UserSettingsPanel.css +++ b/vuu-ui/packages/vuu-shell/src/user-settings/UserSettingsPanel.css @@ -1,4 +1,4 @@ .vuuUserSettingsPanel { height: 100%; overflow: auto; -} \ No newline at end of file +} diff --git a/vuu-ui/packages/vuu-shell/src/user-settings/UserSettingsPanel.tsx b/vuu-ui/packages/vuu-shell/src/user-settings/UserSettingsPanel.tsx index a29f79d86..70dbe2ea7 100644 --- a/vuu-ui/packages/vuu-shell/src/user-settings/UserSettingsPanel.tsx +++ b/vuu-ui/packages/vuu-shell/src/user-settings/UserSettingsPanel.tsx @@ -15,6 +15,11 @@ export const UserSettingsPanel = ({ ...htmlAttributes }: UserSettingsPanelProps) => { const targetWindow = useWindow(); + useComponentCssInjection({ + testId: "vuu-user-settings-panel", + css: userSettingsPanelCss, + window: targetWindow, + }); const { onUserSettingChanged, @@ -22,12 +27,6 @@ export const UserSettingsPanel = ({ userSettingsSchema, } = useApplicationSettings(); - useComponentCssInjection({ - testId: "vuu-user-settings-panel", - css: userSettingsPanelCss, - window: targetWindow, - }); - // Without a schema, we can't render a form // We could render a list of input boxes but lets require a schema for now. if (userSettingsSchema) { diff --git a/vuu-ui/packages/vuu-shell/test/layout-persistence/LocalLayoutPersistenceManager.test.ts b/vuu-ui/packages/vuu-shell/test/layout-persistence/LocalLayoutPersistenceManager.test.ts index 4fe10ff64..710bbc0a0 100644 --- a/vuu-ui/packages/vuu-shell/test/layout-persistence/LocalLayoutPersistenceManager.test.ts +++ b/vuu-ui/packages/vuu-shell/test/layout-persistence/LocalLayoutPersistenceManager.test.ts @@ -9,11 +9,11 @@ import { saveLocalEntity, } from "@finos/vuu-utils"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { LocalPersistenceManager } from "../../src/persistence-manager"; +import { LocalPersistenceManager } from "../../src/persistence-manager/LocalPersistenceManager"; const expectPromiseRejectsWithError = ( f: () => Promise, - message: string + message: string, ) => { expect(f).rejects.toStrictEqual(new Error(message)); }; @@ -79,7 +79,7 @@ describe("createLayout", () => { it("persists to local storage with a unique ID and current date", async () => { const { id, created } = await persistenceManager.createLayout( metadataToAdd, - layoutToAdd + layoutToAdd, ); const persistedMetadata = @@ -108,7 +108,7 @@ describe("createLayout", () => { const { id, created } = await persistenceManager.createLayout( metadataToAdd, - layoutToAdd + layoutToAdd, ); expect(id).not.toEqual(existingId); @@ -140,7 +140,7 @@ describe("updateLayout", () => { await persistenceManager.updateLayout( existingId, metadataToUpdate, - layoutToAdd + layoutToAdd, ); const persistedMetadata = @@ -169,9 +169,9 @@ describe("updateLayout", () => { persistenceManager.updateLayout( existingId, metadataToUpdate, - layoutToAdd + layoutToAdd, ), - `No metadata with ID ${existingId}` + `No metadata with ID ${existingId}`, ); }); @@ -183,9 +183,9 @@ describe("updateLayout", () => { persistenceManager.updateLayout( existingId, metadataToUpdate, - layoutToAdd + layoutToAdd, ), - `No layout with ID ${existingId}` + `No layout with ID ${existingId}`, ); }); @@ -197,9 +197,9 @@ describe("updateLayout", () => { persistenceManager.updateLayout( requestedId, metadataToUpdate, - layoutToAdd + layoutToAdd, ), - `No metadata with ID ${requestedId}; No layout with ID ${requestedId}` + `No metadata with ID ${requestedId}; No layout with ID ${requestedId}`, ); }); @@ -212,9 +212,9 @@ describe("updateLayout", () => { persistenceManager.updateLayout( existingId, metadataToUpdate, - layoutToAdd + layoutToAdd, ), - `Non-unique metadata with ID ${existingId}` + `Non-unique metadata with ID ${existingId}`, ); }); @@ -227,9 +227,9 @@ describe("updateLayout", () => { persistenceManager.updateLayout( existingId, metadataToUpdate, - layoutToAdd + layoutToAdd, ), - `Non-unique layout with ID ${existingId}` + `Non-unique layout with ID ${existingId}`, ); }); @@ -242,9 +242,9 @@ describe("updateLayout", () => { persistenceManager.updateLayout( existingId, metadataToUpdate, - layoutToAdd + layoutToAdd, ), - `Non-unique metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` + `Non-unique metadata with ID ${existingId}; Non-unique layout with ID ${existingId}`, ); }); @@ -256,9 +256,9 @@ describe("updateLayout", () => { persistenceManager.updateLayout( existingId, metadataToUpdate, - layoutToAdd + layoutToAdd, ), - `Non-unique metadata with ID ${existingId}; No layout with ID ${existingId}` + `Non-unique metadata with ID ${existingId}; No layout with ID ${existingId}`, ); }); @@ -270,9 +270,9 @@ describe("updateLayout", () => { persistenceManager.updateLayout( existingId, metadataToUpdate, - layoutToAdd + layoutToAdd, ), - `No metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` + `No metadata with ID ${existingId}; Non-unique layout with ID ${existingId}`, ); }); }); @@ -297,7 +297,7 @@ describe("deleteLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), - `No metadata with ID ${existingId}` + `No metadata with ID ${existingId}`, ); }); @@ -306,7 +306,7 @@ describe("deleteLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), - `No layout with ID ${existingId}` + `No layout with ID ${existingId}`, ); }); @@ -315,7 +315,7 @@ describe("deleteLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(requestedId), - `No metadata with ID ${requestedId}; No layout with ID ${requestedId}` + `No metadata with ID ${requestedId}; No layout with ID ${requestedId}`, ); }); @@ -325,7 +325,7 @@ describe("deleteLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), - `Non-unique metadata with ID ${existingId}` + `Non-unique metadata with ID ${existingId}`, ); }); @@ -335,7 +335,7 @@ describe("deleteLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), - `Non-unique layout with ID ${existingId}` + `Non-unique layout with ID ${existingId}`, ); }); @@ -345,7 +345,7 @@ describe("deleteLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), - `Non-unique metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` + `Non-unique metadata with ID ${existingId}; Non-unique layout with ID ${existingId}`, ); }); @@ -354,7 +354,7 @@ describe("deleteLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), - `Non-unique metadata with ID ${existingId}; No layout with ID ${existingId}` + `Non-unique metadata with ID ${existingId}; No layout with ID ${existingId}`, ); }); @@ -363,7 +363,7 @@ describe("deleteLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), - `No metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` + `No metadata with ID ${existingId}; Non-unique layout with ID ${existingId}`, ); }); }); @@ -391,7 +391,7 @@ describe("loadLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), - `No layout with ID ${existingId}` + `No layout with ID ${existingId}`, ); }); @@ -400,7 +400,7 @@ describe("loadLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.loadLayout(requestedId), - `No layout with ID ${requestedId}` + `No layout with ID ${requestedId}`, ); }); @@ -419,7 +419,7 @@ describe("loadLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), - `Non-unique layout with ID ${existingId}` + `Non-unique layout with ID ${existingId}`, ); }); @@ -429,7 +429,7 @@ describe("loadLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), - `Non-unique layout with ID ${existingId}` + `Non-unique layout with ID ${existingId}`, ); }); @@ -438,7 +438,7 @@ describe("loadLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), - `No layout with ID ${existingId}` + `No layout with ID ${existingId}`, ); }); @@ -447,7 +447,7 @@ describe("loadLayout", () => { expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), - `Non-unique layout with ID ${existingId}` + `Non-unique layout with ID ${existingId}`, ); }); }); diff --git a/vuu-ui/packages/vuu-shell/test/layout-persistence/RemoteLayoutPersistenceManager.test.ts b/vuu-ui/packages/vuu-shell/test/layout-persistence/RemoteLayoutPersistenceManager.test.ts index efc6d0120..34806078e 100644 --- a/vuu-ui/packages/vuu-shell/test/layout-persistence/RemoteLayoutPersistenceManager.test.ts +++ b/vuu-ui/packages/vuu-shell/test/layout-persistence/RemoteLayoutPersistenceManager.test.ts @@ -1,3 +1,4 @@ +import "@finos/vuu-layout/test/global-mocks"; import { LayoutJSON, LayoutMetadata, @@ -98,7 +99,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.createLayout(metadata, layout), - errorMessage + errorMessage, ); }); @@ -112,7 +113,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.createLayout(metadata, layout), - "Response did not contain valid metadata" + "Response did not contain valid metadata", ); }); @@ -121,7 +122,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.createLayout(metadata, layout), - fetchError.message + fetchError.message, ); }); }); @@ -151,7 +152,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.updateLayout(uniqueId, metadata, layout), - errorMessage + errorMessage, ); }); @@ -160,7 +161,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.updateLayout(uniqueId, metadata, layout), - fetchError.message + fetchError.message, ); }); }); @@ -190,7 +191,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.deleteLayout(uniqueId), - errorMessage + errorMessage, ); }); @@ -199,7 +200,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.deleteLayout(uniqueId), - fetchError.message + fetchError.message, ); }); }); @@ -233,7 +234,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.loadMetadata(), - errorMessage + errorMessage, ); }); @@ -247,7 +248,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.loadMetadata(), - "Response did not contain valid metadata" + "Response did not contain valid metadata", ); }); @@ -256,7 +257,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.loadMetadata(), - fetchError.message + fetchError.message, ); }); }); @@ -288,7 +289,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.loadLayout(uniqueId), - errorMessage + errorMessage, ); }); @@ -302,7 +303,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.loadLayout(uniqueId), - "Response did not contain a valid layout" + "Response did not contain a valid layout", ); }); @@ -311,7 +312,7 @@ describe("RemotePersistenceManager", () => { expectPromiseRejectsWithError( () => persistence.loadLayout(uniqueId), - fetchError.message + fetchError.message, ); }); }); diff --git a/vuu-ui/packages/vuu-table-extras/src/datasource-stats/DatasourceStats.tsx b/vuu-ui/packages/vuu-table-extras/src/datasource-stats/DatasourceStats.tsx index efe5e86f2..575178ed2 100644 --- a/vuu-ui/packages/vuu-table-extras/src/datasource-stats/DatasourceStats.tsx +++ b/vuu-ui/packages/vuu-table-extras/src/datasource-stats/DatasourceStats.tsx @@ -42,14 +42,22 @@ export const DataSourceStats = ({ const from = numberFormatter.format(range.from + 1); const to = numberFormatter.format(Math.min(range.to, size)); const value = numberFormatter.format(size); - return ( -
- Rows - {from} - - - {to} - of - {value} -
- ); + if (size === 0) { + return ( +
+ No Rows to display +
+ ); + } else { + return ( +
+ Rows + {from} + - + {to} + of + {value} +
+ ); + } }; diff --git a/vuu-ui/packages/vuu-table/src/useDataSource.ts b/vuu-ui/packages/vuu-table/src/useDataSource.ts index 3f09d2def..40e5e59c8 100644 --- a/vuu-ui/packages/vuu-table/src/useDataSource.ts +++ b/vuu-ui/packages/vuu-table/src/useDataSource.ts @@ -13,7 +13,6 @@ export interface DataSourceHookProps { dataSource: DataSource; onSizeChange: (size: number) => void; onSubscribed: (subscription: DataSourceSubscribedMessage) => void; - range?: VuuRange; renderBufferSize?: number; } @@ -21,17 +20,16 @@ export const useDataSource = ({ dataSource, onSizeChange, onSubscribed, - range = NULL_RANGE, renderBufferSize = 0, }: DataSourceHookProps) => { const [, forceUpdate] = useState(null); const data = useRef([]); const isMounted = useRef(true); const hasUpdated = useRef(false); - const rangeRef = useRef(range); + const rangeRef = useRef(NULL_RANGE); const dataWindow = useMemo( - () => new MovingWindow(getFullRange(range, renderBufferSize)), + () => new MovingWindow(getFullRange(NULL_RANGE, renderBufferSize)), // eslint-disable-next-line react-hooks/exhaustive-deps [], ); @@ -45,8 +43,6 @@ export const useDataSource = ({ if (isMounted.current) { // TODO do we ever need to worry about missing updates here ? forceUpdate({}); - } else { - // do nothing } }, [dataWindow], @@ -73,6 +69,11 @@ export const useDataSource = ({ data.current = dataWindow.data; hasUpdated.current = true; } + } else if (message.type === "viewport-clear") { + onSizeChange?.(0); + dataWindow.setRowCount(0); + setData([]); + forceUpdate({}); } else { console.log(`useDataSource unexpected message ${message.type}`); } @@ -86,39 +87,41 @@ export const useDataSource = ({ useEffect(() => { isMounted.current = true; - dataSource.resume?.(); + if (dataSource.status !== "initialising") { + dataSource.resume?.(datasourceMessageHandler); + } return () => { isMounted.current = false; dataSource.suspend?.(); }; - }, [dataSource]); + }, [dataSource, datasourceMessageHandler]); useEffect(() => { if (dataSource.status === "disabled") { dataSource.enable?.(datasourceMessageHandler); - } else { - //TODO could we improve this by using a ref for range ? - dataSource?.subscribe( - { range: getFullRange(range, renderBufferSize) }, - datasourceMessageHandler, - ); } - }, [dataSource, datasourceMessageHandler, range, renderBufferSize]); + }, [dataSource, datasourceMessageHandler, renderBufferSize]); const setRange = useCallback( (range: VuuRange) => { if (!rangesAreSame(range, rangeRef.current)) { const fullRange = getFullRange(range, renderBufferSize); dataWindow.setRange(fullRange); - dataSource.range = rangeRef.current = fullRange; + + if (dataSource.status !== "subscribed") { + dataSource?.subscribe({ range: fullRange }, datasourceMessageHandler); + } else { + dataSource.range = rangeRef.current = fullRange; + } // emit a range event omitting the renderBufferSize // This isn't great, we're using the dataSource as a conduit to emit a - // message that has nothing to do with the dataSource itself. CLient + // message that has nothing to do with the dataSource itself. Client // is the DataSourceState component. + // WHY CANT THIS BE DONE WITHIN DataSOurce ? dataSource.emit("range", range); } }, - [dataSource, dataWindow, renderBufferSize], + [dataSource, dataWindow, datasourceMessageHandler, renderBufferSize], ); return { diff --git a/vuu-ui/packages/vuu-table/src/useTable.ts b/vuu-ui/packages/vuu-table/src/useTable.ts index 06f010f11..a4a43875e 100644 --- a/vuu-ui/packages/vuu-table/src/useTable.ts +++ b/vuu-ui/packages/vuu-table/src/useTable.ts @@ -14,7 +14,7 @@ import { TableSelectionModel, TableRowSelectHandlerInternal, } from "@finos/vuu-table-types"; -import { VuuRange, VuuSortType } from "@finos/vuu-protocol-types"; +import { VuuSortType } from "@finos/vuu-protocol-types"; import { DragStartHandler, MeasuredProps, @@ -51,7 +51,6 @@ import { TableProps } from "./Table"; import { updateTableConfig } from "./table-config"; import { useCellEditing } from "./useCellEditing"; import { useDataSource } from "./useDataSource"; -import { useInitialValue } from "./useInitialValue"; import { useKeyboardNavigation } from "./useKeyboardNavigation"; import { useSelection } from "./useSelection"; import { useTableContextMenu } from "./useTableContextMenu"; @@ -243,18 +242,12 @@ export const useTable = ({ size: size, }); - const initialRange = useInitialValue({ - from: 0, - to: viewportMeasurements.rowCount, - }); - const { data, dataRef, getSelectedRows, range, setRange } = useDataSource({ dataSource, // We need to factor this out of Table renderBufferSize, onSizeChange: onDataRowcountChange, onSubscribed, - range: initialRange, }); const { requestScroll, ...scrollProps } = useTableScroll({ diff --git a/vuu-ui/packages/vuu-theme/css/components/table.css b/vuu-ui/packages/vuu-theme/css/components/table.css index efb37400c..ae59c8a75 100644 --- a/vuu-ui/packages/vuu-theme/css/components/table.css +++ b/vuu-ui/packages/vuu-theme/css/components/table.css @@ -1,29 +1,30 @@ .salt-theme.vuu-theme { - --vuuTableRow-selectionBlock-borderColor: var(--vuu-color-purple-10); + --vuuTableRow-selectionBlock-borderColor: var(--vuu-color-purple-10); - &.salt-density-high { - --vuu-table-row-height: 20px; - } - &.salt-density-medium { - --vuu-table-row-height: 28px; - } - &.salt-density-low { - --vuu-table-row-height: 36px; - } - &.salt-density-touch { - --vuu-table-row-height: 48px; - } + &.salt-density-high { + --vuu-table-row-height: 20px; + } + &.salt-density-medium { + --vuu-table-row-height: 28px; + } + &.salt-density-low { + --vuu-table-row-height: 36px; + } + &.salt-density-touch { + --vuu-table-row-height: 48px; + } - .vuuTableRow-selected:not(.vuuTableRow-selectedStart, .vuuTableRow-selectedEnd) { - .vuuTableRow-selectionDecorator { - background: var(--vuuTableRow-selectionBlock-borderColor); - } - } - - .vuuTableRow-selectedStart .vuuTableRow-selectionDecorator:before, - .vuuTableRow-selectedEnd .vuuTableRow-selectionDecorator:before { + .vuuTableRow-selected:not( + .vuuTableRow-selectedStart, + .vuuTableRow-selectedEnd + ) { + .vuuTableRow-selectionDecorator { background: var(--vuuTableRow-selectionBlock-borderColor); } + } + .vuuTableRow-selectedStart .vuuTableRow-selectionDecorator:before, + .vuuTableRow-selectedEnd .vuuTableRow-selectionDecorator:before { + background: var(--vuuTableRow-selectionBlock-borderColor); + } } - diff --git a/vuu-ui/packages/vuu-ui-controls/src/measured-container/useMeasuredContainer.ts b/vuu-ui/packages/vuu-ui-controls/src/measured-container/useMeasuredContainer.ts index 09c45eeeb..3f3c6f6b1 100644 --- a/vuu-ui/packages/vuu-ui-controls/src/measured-container/useMeasuredContainer.ts +++ b/vuu-ui/packages/vuu-ui-controls/src/measured-container/useMeasuredContainer.ts @@ -57,7 +57,7 @@ export interface MeasuredContainerHookResult { // inner values will be updated once measured. const getInitialCssSize = ( height?: number | string, - width?: number | string + width?: number | string, ): CssSize => { if (isValidNumber(height) && isValidNumber(width)) { return { @@ -76,7 +76,7 @@ const getInitialCssSize = ( const getInitialInnerSize = ( height: unknown, - width: unknown + width: unknown, ): MeasuredSize | undefined => { if (isValidNumber(height) && isValidNumber(width)) { return { @@ -109,12 +109,15 @@ export const useMeasuredContainer = ({ fixedHeight && fixedWidth ? NO_MEASUREMENT : fixedHeight - ? WidthOnly - : fixedWidth - ? HeightOnly - : ClientWidthHeight; + ? WidthOnly + : fixedWidth + ? HeightOnly + : ClientWidthHeight; useMemo(() => { + // TODO why call state from memo. + // Why not calculate size first inline, then assign that to state + // on first pass setSize((currentSize) => { const { inner, outer } = currentSize; if (isValidNumber(height) && isValidNumber(width) && inner && outer) { @@ -189,7 +192,7 @@ export const useMeasuredContainer = ({ setSize(newState); } }, - [defaultHeight, defaultWidth, fixedHeight, fixedWidth, height, size, width] + [defaultHeight, defaultWidth, fixedHeight, fixedWidth, height, size, width], ); useEffect(() => { diff --git a/vuu-ui/packages/vuu-ui-controls/src/measured-container/useResizeObserver.ts b/vuu-ui/packages/vuu-ui-controls/src/measured-container/useResizeObserver.ts index a5105fbee..8ba06a859 100644 --- a/vuu-ui/packages/vuu-ui-controls/src/measured-container/useResizeObserver.ts +++ b/vuu-ui/packages/vuu-ui-controls/src/measured-container/useResizeObserver.ts @@ -31,7 +31,7 @@ const getTargetSize = ( contentHeight: number; contentWidth: number; }, - dimension: measuredDimension + dimension: measuredDimension, ): number => { switch (dimension) { case "height": @@ -70,7 +70,7 @@ const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => { const newSize = getTargetSize( target as HTMLElement, { height, width, contentHeight, contentWidth }, - dimension as measuredDimension + dimension as measuredDimension, ); if (newSize !== size) { @@ -92,7 +92,7 @@ export function useResizeObserver( ref: RefObject, dimensions: readonly string[], onResize: ResizeHandler, - reportInitialSize = false + reportInitialSize = false, ) { const dimensionsRef = useRef(dimensions); @@ -112,11 +112,11 @@ export function useResizeObserver( contentHeight, contentWidth, }, - dim as measuredDimension + dim as measuredDimension, ); return map; }, - {} + {}, ); }, []); @@ -146,7 +146,7 @@ export function useResizeObserver( } else { console.log( `%cuseResizeObserver an target expected to be under observation wa snot found. This warrants investigation`, - "font-weight:bold; color:red;" + "font-weight:bold; color:red;", ); } } @@ -158,7 +158,7 @@ export function useResizeObserver( `useResizeObserver attemping to observe same element twice`, { target, - } + }, ); // throw Error( // "useResizeObserver attemping to observe same element twice" diff --git a/vuu-ui/packages/vuu-utils/src/context-definitions/DataSourceContext.tsx b/vuu-ui/packages/vuu-utils/src/context-definitions/DataSourceContext.tsx index 3584a1904..72ab44aee 100644 --- a/vuu-ui/packages/vuu-utils/src/context-definitions/DataSourceContext.tsx +++ b/vuu-ui/packages/vuu-utils/src/context-definitions/DataSourceContext.tsx @@ -1,7 +1,7 @@ -import type { ServerAPI } from "@finos/vuu-data-remote"; import type { DataSource, DataSourceConstructorProps, + ServerAPI, } from "@finos/vuu-data-types"; import { createContext } from "react"; diff --git a/vuu-ui/packages/vuu-utils/src/datasource-utils.ts b/vuu-ui/packages/vuu-utils/src/datasource-utils.ts index fec91a403..7855ff06c 100644 --- a/vuu-ui/packages/vuu-utils/src/datasource-utils.ts +++ b/vuu-ui/packages/vuu-utils/src/datasource-utils.ts @@ -1,6 +1,5 @@ import { ConnectionQualityMetrics, - ConnectionStatusMessage, DataSourceCallbackMessage, DataSourceConfig, DataSourceDataMessage, @@ -254,11 +253,6 @@ export const isTableSchemaMessage = ( message: VuuUIMessageIn, ): message is VuuUIMessageInTableMeta => message.type === "TABLE_META_RESP"; -export const isConnectionStatusMessage = ( - msg: object | ConnectionStatusMessage, -): msg is ConnectionStatusMessage => - (msg as ConnectionStatusMessage).type === "connection-status"; - export const isConnectionQualityMetrics = ( msg: object, ): msg is ConnectionQualityMetrics => diff --git a/vuu-ui/packages/vuu-utils/src/index.ts b/vuu-ui/packages/vuu-utils/src/index.ts index bd94c8ab6..91115c113 100644 --- a/vuu-ui/packages/vuu-utils/src/index.ts +++ b/vuu-ui/packages/vuu-utils/src/index.ts @@ -37,6 +37,7 @@ export * from "./nanoid"; export * from "./react-utils"; export * from "./round-decimal"; export * from "./perf-utils"; +export * from "./promise-utils"; export * from "./protocol-message-utils"; export * from "./range-utils"; export * from "./row-utils"; diff --git a/vuu-ui/packages/vuu-utils/src/promise-utils.ts b/vuu-ui/packages/vuu-utils/src/promise-utils.ts new file mode 100644 index 000000000..f71e6a7df --- /dev/null +++ b/vuu-ui/packages/vuu-utils/src/promise-utils.ts @@ -0,0 +1,30 @@ +export class DeferredPromise { + #promise: Promise; + #resolve: (value: T) => void = () => console.log("resolve was not set"); + #reject: (err: unknown) => void = () => console.log("reject was not set"); + #resolved = false; + + constructor() { + this.#promise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + }); + } + + get promise() { + return this.#promise; + } + + get isResolved() { + return this.#resolved; + } + + resolve(value: T) { + this.#resolved = true; + return this.#resolve(value); + } + + get reject() { + return this.#reject; + } +} diff --git a/vuu-ui/sample-apps/app-vuu-example/src/App.tsx b/vuu-ui/sample-apps/app-vuu-example/src/App.tsx index 2cdba9fd5..c7e1213e8 100644 --- a/vuu-ui/sample-apps/app-vuu-example/src/App.tsx +++ b/vuu-ui/sample-apps/app-vuu-example/src/App.tsx @@ -22,7 +22,7 @@ import { } from "@finos/vuu-utils"; import { useMemo } from "react"; import { getDefaultColumnConfig } from "./columnMetaData"; -import { useRpcResponseHandler } from "./useRpcResponseHandler"; +// import { useRpcResponseHandler } from "./useRpcResponseHandler"; import "./App.css"; @@ -43,6 +43,12 @@ const userSettingsSchema: SettingsSchema = { defaultValue: "light", type: "string", }, + { + name: "showAppStatusBar", + label: "Show Application Status Bar", + defaultValue: false, + type: "boolean", + }, ], }; @@ -61,7 +67,7 @@ const dynamicFeatures = Object.values(features); export const App = ({ user }: { user: VuuUser }) => { // this is causing full app re-render when tables are loaded - const { handleRpcResponse } = useRpcResponseHandler(); + // const { handleRpcResponse } = useRpcResponseHandler(); const dragSource = useMemo( () => ({ @@ -84,9 +90,7 @@ export const App = ({ user }: { user: VuuUser }) => { return ( - + =17.0.2", - "react-dom": ">=17.0.2" - }, - "engines": { - "node": ">=16.0.0" - }, - "vuu": { - "featureProps": { - "schema": "instrumentPrices" - } - } -} diff --git a/vuu-ui/sample-apps/feature-template/src/VuuTemplateFeature.css b/vuu-ui/sample-apps/feature-template/src/VuuTemplateFeature.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/vuu-ui/sample-apps/feature-template/src/VuuTemplateFeature.tsx b/vuu-ui/sample-apps/feature-template/src/VuuTemplateFeature.tsx deleted file mode 100644 index 6d1dfc368..000000000 --- a/vuu-ui/sample-apps/feature-template/src/VuuTemplateFeature.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { VuuDataSource } from "@finos/vuu-data-remote"; -import { - DataSource, - DataSourceConfig, - TableSchema, -} from "@finos/vuu-data-types"; -import { useViewContext } from "@finos/vuu-layout"; -import { useCallback, useEffect, useMemo } from "react"; - -import "./VuuTemplateFeature.css"; - -const classBase = "VuuTemplateFeature"; - -export interface FilterTableFeatureProps { - tableSchema: TableSchema; -} - -const VuuTemplateFeature = ({ tableSchema }: FilterTableFeatureProps) => { - const { id, save, loadSession, saveSession, title } = useViewContext(); - - console.log("Instrument Prices", { - tableSchema, - }); - - const handleDataSourceConfigChange = useCallback( - (config: DataSourceConfig | undefined, confirmed?: boolean) => { - // confirmed / unconfirmed messages are used for UI updates, not state saving - if (confirmed === undefined) { - save?.(config, "datasource-config"); - } - }, - [save] - ); - - const dataSource: DataSource = useMemo(() => { - let ds = loadSession?.("data-source") as VuuDataSource; - if (ds) { - return ds; - } - - ds = new VuuDataSource({ - bufferSize: 200, - viewport: id, - table: tableSchema.table, - columns: tableSchema.columns.map((col) => col.name), - title, - }); - ds.on("config", handleDataSourceConfigChange); - saveSession?.(ds, "data-source"); - return ds; - }, [ - handleDataSourceConfigChange, - id, - loadSession, - saveSession, - tableSchema.columns, - tableSchema.table, - title, - ]); - - useEffect(() => { - dataSource.resume?.(); - return () => { - dataSource.suspend?.(); - }; - }, [dataSource]); - - return
Instrument Tiles
; -}; - -export default VuuTemplateFeature; diff --git a/vuu-ui/showcase/src/examples/Apps/SampleApp.examples.tsx b/vuu-ui/showcase/src/examples/Apps/SampleApp.examples.tsx index 53a7c1f7f..34192cb13 100644 --- a/vuu-ui/showcase/src/examples/Apps/SampleApp.examples.tsx +++ b/vuu-ui/showcase/src/examples/Apps/SampleApp.examples.tsx @@ -118,6 +118,12 @@ const userSettingsSchema: SettingsSchema = { defaultValue: "light", type: "string", }, + { + name: "showAppStatusBar", + label: "Show Application Status Bar", + defaultValue: false, + type: "boolean", + }, ], }; diff --git a/vuu-ui/showcase/src/examples/Shell/ConnectionStatus.examples.tsx b/vuu-ui/showcase/src/examples/Shell/ConnectionStatus.examples.tsx index e07c880ff..b7885f7b0 100644 --- a/vuu-ui/showcase/src/examples/Shell/ConnectionStatus.examples.tsx +++ b/vuu-ui/showcase/src/examples/Shell/ConnectionStatus.examples.tsx @@ -1,28 +1,231 @@ -import { ConnectionStatus } from "@finos/vuu-data-types"; -import { ConnectionStatusIndicator } from "@finos/vuu-shell"; +import { + ConnectionStatus, + WebSocketConnectionState, +} from "@finos/vuu-data-remote/src/WebSocketConnection"; +import { + ConnectionStateDisplay, + ConnectionStatusIndicator, +} from "@finos/vuu-shell"; +import { ConnectionManager } from "@finos/vuu-data-remote"; +import { useLayoutEffect, useRef, useState } from "react"; +import { Button } from "@salt-ds/core"; let displaySequence = 1; -export const ActiveStatus = () => { - const connectionStatus: ConnectionStatus = "connected"; +export const ConnectionStatusIndicatorConnected = () => { + const connectionState: WebSocketConnectionState = { + connectionPhase: "connecting", + connectionStatus: "connected", + retryAttemptsTotal: 5, + retryAttemptsRemaining: 5, + secondsToNextRetry: 1, + }; + return ; +}; +ConnectionStatusIndicatorConnected.displaySequence = displaySequence++; + +export const ConnectionStatusIndicatorDisconnectedNoRetryUsed = () => { + const connectionState: WebSocketConnectionState = { + connectionPhase: "connecting", + connectionStatus: "disconnected", + retryAttemptsTotal: 5, + retryAttemptsRemaining: 5, + secondsToNextRetry: 1, + }; + return ; +}; +ConnectionStatusIndicatorDisconnectedNoRetryUsed.displaySequence = + displaySequence++; + +export const ConnectionStatusIndicatorDisconnectedOneRetryUsed = () => { + const connectionState: WebSocketConnectionState = { + connectionPhase: "connecting", + connectionStatus: "disconnected", + retryAttemptsTotal: 5, + retryAttemptsRemaining: 4, + secondsToNextRetry: 2, + }; + return ; +}; +ConnectionStatusIndicatorDisconnectedOneRetryUsed.displaySequence = + displaySequence++; + +export const ConnectionStatusIndicatorDisconnectedTwoRetryUsed = () => { + const connectionState: WebSocketConnectionState = { + connectionPhase: "connecting", + connectionStatus: "disconnected", + retryAttemptsTotal: 5, + retryAttemptsRemaining: 3, + secondsToNextRetry: 4, + }; + return ; +}; +ConnectionStatusIndicatorDisconnectedTwoRetryUsed.displaySequence = + displaySequence++; + +export const ConnectionStatusIndicatorDisconnectedThreeRetryUsed = () => { + const connectionStatusMessage: WebSocketConnectionState = { + connectionPhase: "connecting", + connectionStatus: "disconnected", + retryAttemptsTotal: 5, + retryAttemptsRemaining: 2, + secondsToNextRetry: 8, + }; return ( - + ); }; -ActiveStatus.displaySequence = displaySequence++; +ConnectionStatusIndicatorDisconnectedThreeRetryUsed.displaySequence = + displaySequence++; -export const ConnectingStatus = () => { - const connectionStatus: ConnectionStatus = "connecting"; +export const ConnectionStatusIndicatorDisconnectedFourRetryUsed = () => { + const connectionStatusMessage: WebSocketConnectionState = { + connectionPhase: "connecting", + connectionStatus: "disconnected", + retryAttemptsTotal: 5, + retryAttemptsRemaining: 1, + secondsToNextRetry: 16, + }; return ( - + ); }; -ConnectingStatus.displaySequence = displaySequence++; +ConnectionStatusIndicatorDisconnectedFourRetryUsed.displaySequence = + displaySequence++; + +export const ConnectionStatusIndicatorDisconnectedAllRetryUsed = () => { + const connectionStatusMessage: WebSocketConnectionState = { + connectionPhase: "connecting", + connectionStatus: "disconnected", + retryAttemptsTotal: 5, + retryAttemptsRemaining: 0, + secondsToNextRetry: 32, + }; + return ( + + ); +}; +ConnectionStatusIndicatorDisconnectedAllRetryUsed.displaySequence = + displaySequence++; + +export const ConnectionStatusIndicatorFailed = () => { + const connectionStatusMessage: WebSocketConnectionState = { + connectionPhase: "connecting", + connectionStatus: "failed", + retryAttemptsTotal: 5, + retryAttemptsRemaining: 0, + secondsToNextRetry: -1, + }; + return ( + + ); +}; +ConnectionStatusIndicatorFailed.displaySequence = displaySequence++; + +export const ConnectionStateDisplayConnected = () => { + useLayoutEffect(() => { + ConnectionManager.emit("connection-status", { + connectionPhase: "reconnecting", + connectionStatus: "connected", + secondsToNextRetry: 1, + retryAttemptsRemaining: 8, + retryAttemptsTotal: 8, + }); + }, []); + return ; +}; +ConnectionStateDisplayConnected.displaySequence = displaySequence++; + +export const ConnectionStateDisplayConnecting = () => { + useLayoutEffect(() => { + ConnectionManager.emit("connection-status", { + connectionPhase: "connecting", + connectionStatus: "disconnected", + secondsToNextRetry: 4, + retryAttemptsRemaining: 3, + retryAttemptsTotal: 5, + }); + }, []); + return ; +}; +ConnectionStateDisplayConnecting.displaySequence = displaySequence++; + +const initialConnectionState: WebSocketConnectionState = { + connectionPhase: "connecting", + connectionStatus: "inactive", + retryAttemptsTotal: 5, + retryAttemptsRemaining: 5, + secondsToNextRetry: 5, +}; + +export const InteractiveStateDisplay = () => { + const ref = useRef(initialConnectionState); + const [, forceUpdate] = useState({}); + + const setStatus = async ( + connectionStatus: ConnectionStatus, + initialConnection = false, + ) => + new Promise((resolve) => { + let { retryAttemptsRemaining, secondsToNextRetry } = ref.current; + let delay = 0; + if (connectionStatus === "disconnected") { + if (!initialConnection) { + retryAttemptsRemaining -= 1; + secondsToNextRetry *= 2; + } + ref.current = { + ...ref.current, + connectionStatus, + retryAttemptsRemaining, + secondsToNextRetry, + }; + if (retryAttemptsRemaining) { + delay = Math.min(secondsToNextRetry * 1000, 5000); + } + } else { + ref.current = { + ...ref.current, + connectionStatus, + }; + } + forceUpdate({}); + setTimeout(resolve, delay); + }); + + const connectFail = async () => { + // initial connection + await setStatus("connecting"); + await setStatus("disconnected", true); + + while (ref.current.retryAttemptsRemaining > 0) { + console.log( + `${ref.current.retryAttemptsRemaining} attempts remaining (next delay ${ref.current.secondsToNextRetry}s)`, + ); + await setStatus("connecting"); + await setStatus("disconnected"); + } + + await setStatus("closed"); + }; -export const DisconnectedStatus = () => { - const connectionStatus: ConnectionStatus = "disconnected"; return ( - +
+ +
+ + + + + +
+
); }; -DisconnectedStatus.displaySequence = displaySequence++; +InteractiveStateDisplay.displaySequence = displaySequence++; diff --git a/vuu-ui/showcase/src/examples/utils/useAutoLoginToVuuServer.tsx b/vuu-ui/showcase/src/examples/utils/useAutoLoginToVuuServer.tsx index b9943cd11..e0db9096f 100644 --- a/vuu-ui/showcase/src/examples/utils/useAutoLoginToVuuServer.tsx +++ b/vuu-ui/showcase/src/examples/utils/useAutoLoginToVuuServer.tsx @@ -1,6 +1,6 @@ import { authenticate as vuuAuthenticate, - connectToServer, + ConnectionManager, } from "@finos/vuu-data-remote"; import { useEffect, useState } from "react"; @@ -9,12 +9,16 @@ export const useAutoLoginToVuuServer = (autoLogin = true) => { useEffect(() => { const connect = async () => { try { - const authToken = (await vuuAuthenticate( + const token = (await vuuAuthenticate( "steve", "xyz", - "/api/authn" + "/api/authn", )) as string; - connectToServer({ url: "wss://localhost:8090/websocket", authToken }); + ConnectionManager.connect({ + url: "wss://localhost:8090/websocket", + token, + username: "steve", + }); } catch (e: unknown) { if (e instanceof Error) { console.error(e.message); diff --git a/vuu-ui/tsconfig.json b/vuu-ui/tsconfig.json index 0c9dd3aaa..79037fc14 100644 --- a/vuu-ui/tsconfig.json +++ b/vuu-ui/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "noImplicitAny": true, - "target": "es2017", + "target": "esnext", "downlevelIteration": true, "lib": ["dom", "dom.iterable", "esnext", "WebWorker"], "allowJs": true, diff --git a/vuu-ui/vitest.config.js b/vuu-ui/vitest.config.js index cb50f74e4..fcb73eef9 100644 --- a/vuu-ui/vitest.config.js +++ b/vuu-ui/vitest.config.js @@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + dangerouslyIgnoreUnhandledErrors: true, include: ["packages/**/test/**/**.test.(js|ts|tsx)"], environment: "happy-dom", },