diff --git a/package-lock.json b/package-lock.json index 93aa9055e22ddae368df2d8f59f6dbbc92ef3785..6f42bd0976df8e01c2764dd4c37253b89ed17558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "react-dom": "18.2.0", "react-dropzone": "14.2.3", "react-redux": "8.1.3", + "react-select": "5.9.0", "sonner": "1.4.3", "tailwind-merge": "1.14.0", "tailwindcss": "3.4.13", @@ -136,7 +137,6 @@ "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", - "dev": true, "dependencies": { "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" @@ -149,7 +149,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -161,7 +160,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -175,7 +173,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -183,14 +180,12 @@ "node_modules/@babel/code-frame/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/@babel/code-frame/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, "engines": { "node": ">=4" } @@ -199,7 +194,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -360,7 +354,6 @@ "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", - "dev": true, "dependencies": { "@babel/types": "^7.22.15" }, @@ -424,7 +417,6 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -433,7 +425,6 @@ "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -465,7 +456,6 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -479,7 +469,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -491,7 +480,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -505,7 +493,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -513,14 +500,12 @@ "node_modules/@babel/highlight/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/@babel/highlight/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, "engines": { "node": ">=4" } @@ -529,7 +514,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -785,7 +769,6 @@ "version": "7.23.6", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", - "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", @@ -1217,6 +1200,133 @@ "ms": "^2.1.1" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1289,6 +1399,28 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -2435,6 +2567,11 @@ "resolved": "https://registry.npmjs.org/@types/openlayers/-/openlayers-4.6.23.tgz", "integrity": "sha512-7jCPIa4D4LV03Rttae1AEqvkIN0+nc6Snz4IgA/IjsJD5O3ONxpscqIOdp1qAGuAsikR/ZC9vrPF9np8JRc6ig==" }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -2484,6 +2621,14 @@ "redux": "^4.0.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/redux-mock-store": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.6.tgz", @@ -3383,6 +3528,43 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-macros/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", @@ -5044,6 +5226,15 @@ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -5211,7 +5402,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -5363,7 +5553,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, "engines": { "node": ">=0.8.0" } @@ -6569,8 +6758,7 @@ "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "dev": true + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" }, "node_modules/find-up": { "version": "4.1.0", @@ -7553,8 +7741,7 @@ "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, "node_modules/is-async-function": { "version": "2.0.0", @@ -9292,8 +9479,7 @@ "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema": { "version": "0.4.0", @@ -10294,6 +10480,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -10984,7 +11175,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -11811,6 +12001,26 @@ } } }, + "node_modules/react-select": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz", + "integrity": "sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-themeable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz", @@ -11827,6 +12037,21 @@ "node": ">=0.10.0" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -12908,6 +13133,11 @@ } } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -13173,7 +13403,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, "engines": { "node": ">=4" } @@ -13568,6 +13797,19 @@ "react": ">=16.8.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz", + "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -14177,7 +14419,6 @@ "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", - "dev": true, "requires": { "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" @@ -14187,7 +14428,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -14196,7 +14436,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -14207,7 +14446,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "requires": { "color-name": "1.1.3" } @@ -14215,20 +14453,17 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -14355,7 +14590,6 @@ "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", - "dev": true, "requires": { "@babel/types": "^7.22.15" } @@ -14400,14 +14634,12 @@ "@babel/helper-string-parser": { "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", - "dev": true + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==" }, "@babel/helper-validator-identifier": { "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "dev": true + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==" }, "@babel/helper-validator-option": { "version": "7.23.5", @@ -14430,7 +14662,6 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dev": true, "requires": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -14441,7 +14672,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -14450,7 +14680,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -14461,7 +14690,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "requires": { "color-name": "1.1.3" } @@ -14469,20 +14697,17 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -14670,7 +14895,6 @@ "version": "7.23.6", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", - "dev": true, "requires": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", @@ -15016,6 +15240,116 @@ } } }, + "@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "requires": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + }, + "dependencies": { + "convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" + } + } + }, + "@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "requires": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "requires": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + } + }, + "@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "requires": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "requires": {} + }, + "@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, "@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -15068,6 +15402,28 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==" }, + "@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "requires": { + "@floating-ui/utils": "^0.2.8" + } + }, + "@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "requires": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + }, "@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -15925,6 +16281,11 @@ "resolved": "https://registry.npmjs.org/@types/openlayers/-/openlayers-4.6.23.tgz", "integrity": "sha512-7jCPIa4D4LV03Rttae1AEqvkIN0+nc6Snz4IgA/IjsJD5O3ONxpscqIOdp1qAGuAsikR/ZC9vrPF9np8JRc6ig==" }, + "@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, "@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -15974,6 +16335,12 @@ "redux": "^4.0.0" } }, + "@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "requires": {} + }, "@types/redux-mock-store": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.6.tgz", @@ -16606,6 +16973,35 @@ "@types/babel__traverse": "^7.0.6" } }, + "babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "requires": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "dependencies": { + "cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" + } + } + }, "babel-preset-current-node-syntax": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", @@ -17840,6 +18236,15 @@ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -17972,7 +18377,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "requires": { "is-arrayish": "^0.2.1" } @@ -18102,8 +18506,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" }, "escodegen": { "version": "2.1.0", @@ -18933,8 +19336,7 @@ "find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "dev": true + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" }, "find-up": { "version": "4.1.0", @@ -19631,8 +20033,7 @@ "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, "is-async-function": { "version": "2.0.0", @@ -20872,8 +21273,7 @@ "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "json-schema": { "version": "0.4.0", @@ -21615,6 +22015,11 @@ "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", "dev": true }, + "memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -22111,7 +22516,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "requires": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -22602,6 +23006,22 @@ "use-sync-external-store": "^1.0.0" } }, + "react-select": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz", + "integrity": "sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw==", + "requires": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + } + }, "react-themeable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz", @@ -22617,6 +23037,17 @@ } } }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -23430,6 +23861,11 @@ "client-only": "0.0.1" } }, + "stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -23629,8 +24065,7 @@ "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" }, "to-regex-range": { "version": "5.0.1", @@ -23903,6 +24338,12 @@ "integrity": "sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==", "requires": {} }, + "use-isomorphic-layout-effect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz", + "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + "requires": {} + }, "use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", diff --git a/package.json b/package.json index 213f3cd7aeefd37073e42e98a791e68064c412c5..52f25ca960a70a3e6c7eb204f2519242adfd512c 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "react-dom": "18.2.0", "react-dropzone": "14.2.3", "react-redux": "8.1.3", + "react-select": "5.9.0", "sonner": "1.4.3", "tailwind-merge": "1.14.0", "tailwindcss": "3.4.13", diff --git a/src/components/AppWrapper/AppWrapper.component.tsx b/src/components/AppWrapper/AppWrapper.component.tsx index 2bae3997dd16e426802fd547b3235057ce22aa8c..39a65352f62dfc4a51e0c0a198333ef4f7b8ab17 100644 --- a/src/components/AppWrapper/AppWrapper.component.tsx +++ b/src/components/AppWrapper/AppWrapper.component.tsx @@ -3,6 +3,7 @@ import { MapInstanceProvider } from '@/utils/context/mapInstanceContext'; import { ReactNode } from 'react'; import { Provider } from 'react-redux'; import { Toaster } from 'sonner'; +import { Modal } from '@/components/FunctionalArea/Modal'; interface AppWrapperProps { children: ReactNode; @@ -13,6 +14,7 @@ export const AppWrapper = ({ children }: AppWrapperProps): JSX.Element => { <MapInstanceProvider> <Provider store={store}> <> + <Modal /> <Toaster position="top-center" visibleToasts={1} diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx index eab74a7e4fd30da041120e5731c0987b8720dc3e..5e0b2ec58fb1783beee5480196e935a6f0ed932a 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx @@ -50,6 +50,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -69,6 +70,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -100,6 +102,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -136,6 +139,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -173,6 +177,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -218,6 +223,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -248,6 +254,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts index 16f4307190235d6e03c4175b168b4b68fbc46e50..0079d31f1d11e8720a32c335826d0c86987d4ca3 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts @@ -25,6 +25,7 @@ describe('useEditOverlay', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -62,6 +63,7 @@ describe('useEditOverlay', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -102,6 +104,7 @@ describe('useEditOverlay', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -138,6 +141,7 @@ describe('useEditOverlay', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -175,6 +179,7 @@ describe('useEditOverlay', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.test.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5806621f6ee2e401190e84d6516adce5d64c5ea1 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.test.tsx @@ -0,0 +1,141 @@ +/* eslint-disable no-magic-numbers */ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { StoreType } from '@/redux/store'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; +import { GLYPHS_STATE_INITIAL_MOCK } from '@/redux/glyphs/glyphs.mock'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { layerImageFixture } from '@/models/fixtures/layerImageFixture'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { + LAYER_STATE_DEFAULT_DATA, + LAYERS_STATE_INITIAL_LAYER_MOCK, +} from '@/redux/layers/layers.mock'; +import { MODELS_DATA_MOCK_WITH_MAIN_MAP } from '@/redux/models/models.mock'; +import { overlayFixture } from '@/models/fixtures/overlaysFixture'; +import { showToast } from '@/utils/showToast'; +import { LayerImageObjectFactoryModal } from './LayerImageObjectFactoryModal.component'; + +const mockedAxiosNewClient = mockNetworkNewAPIResponse(); + +const glyph = { id: 1, file: 23, filename: 'Glyph1.png' }; + +jest.mock('../../../../utils/showToast'); + +const renderComponent = (): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore({ + ...INITIAL_STORE_STATE_MOCK, + glyphs: { + ...GLYPHS_STATE_INITIAL_MOCK, + data: [glyph], + }, + layers: { + 0: { + ...LAYERS_STATE_INITIAL_LAYER_MOCK, + data: { + ...LAYER_STATE_DEFAULT_DATA, + activeLayer: 1, + }, + }, + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + errorReportState: {}, + layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: { + x: 1, + y: 1, + width: 1, + height: 1, + }, + }, + models: { + ...MODELS_DATA_MOCK_WITH_MAIN_MAP, + }, + }); + + return { + store, + ...render( + <Wrapper> + <LayerImageObjectFactoryModal /> + </Wrapper>, + ), + }; +}; + +describe('LayerImageObjectFactoryModal - component', () => { + it('should render LayerImageObjectFactoryModal component with initial state', () => { + renderComponent(); + + expect(screen.getByText(/Glyph:/i)).toBeInTheDocument(); + expect(screen.getByText(/File:/i)).toBeInTheDocument(); + expect(screen.getByText(/Submit/i)).toBeInTheDocument(); + expect(screen.getByText(/No Image/i)).toBeInTheDocument(); + }); + + it('should display a list of glyphs in the dropdown', async () => { + renderComponent(); + + const dropdown = screen.getByTestId('autocomplete'); + if (!dropdown.firstChild) { + throw new Error('Dropdown does not have a firstChild'); + } + fireEvent.keyDown(dropdown.firstChild, { key: 'ArrowDown' }); + await waitFor(() => expect(screen.getByText(glyph.filename)).toBeInTheDocument()); + fireEvent.click(screen.getByText(glyph.filename)); + }); + + it('should update the selected glyph on dropdown change', async () => { + renderComponent(); + + const dropdown = screen.getByTestId('autocomplete'); + if (!dropdown.firstChild) { + throw new Error('Dropdown does not have a firstChild'); + } + fireEvent.keyDown(dropdown.firstChild, { key: 'ArrowDown' }); + await waitFor(() => expect(screen.getByText(glyph.filename)).toBeInTheDocument()); + fireEvent.click(screen.getByText(glyph.filename)); + + await waitFor(() => { + const imgPreview: HTMLImageElement = screen.getByTestId('layer-image-preview'); + const decodedSrc = decodeURIComponent(imgPreview.src); + expect(decodedSrc).toContain(`glyphs/${glyph.id}/fileContent`); + }); + }); + + it('should handle form submission correctly', async () => { + mockedAxiosNewClient + .onPost(apiPath.addLayerImageObject(0, 1)) + .reply(HttpStatusCode.Ok, layerImageFixture); + renderComponent(); + + const submitButton = screen.getByText(/Submit/i); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(showToast).toHaveBeenCalledWith({ + message: 'A new image object has been successfully added', + type: 'success', + }); + }); + + it('should display "No Image" when there is no image file', () => { + const { store } = renderComponent(); + + store.dispatch({ + type: 'glyphs/clearGlyphData', + }); + + expect(screen.getByText(/No Image/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b750d0618794a63ed1219c7c3aa35c8bf471f450 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx @@ -0,0 +1,186 @@ +/* eslint-disable no-magic-numbers */ +import React, { useState, useRef } from 'react'; +import { glyphsDataSelector } from '@/redux/glyphs/glyphs.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { layerImageObjectFactoryStateSelector } from '@/redux/modal/modal.selector'; +import { Button } from '@/shared/Button'; +import { BASE_NEW_API_URL } from '@/constants'; +import { apiPath } from '@/redux/apiPath'; +import { Input } from '@/shared/Input'; +import Image from 'next/image'; +import { Glyph } from '@/types/models'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { highestZIndexSelector, layersActiveLayerSelector } from '@/redux/layers/layers.selectors'; +import { addLayerImageObject } from '@/redux/layers/layers.thunks'; +import { addGlyph } from '@/redux/glyphs/glyphs.thunks'; +import { SerializedError } from '@reduxjs/toolkit'; +import { showToast } from '@/utils/showToast'; +import { closeModal } from '@/redux/modal/modal.slice'; +import { LoadingIndicator } from '@/shared/LoadingIndicator'; +import './LayerImageObjectFactoryModal.styles.css'; +import { useMapInstance } from '@/utils/context/mapInstanceContext'; +import { layerAddImage } from '@/redux/layers/layers.slice'; +import { Autocomplete } from '@/shared/Autocomplete'; + +export const LayerImageObjectFactoryModal: React.FC = () => { + const glyphs: Glyph[] = useAppSelector(glyphsDataSelector); + const currentModelId = useAppSelector(currentModelIdSelector); + const activeLayer = useAppSelector(layersActiveLayerSelector); + const layerImageObjectFactoryState = useAppSelector(layerImageObjectFactoryStateSelector); + const dispatch = useAppDispatch(); + const fileInputRef = useRef<HTMLInputElement>(null); + const highestZIndex = useAppSelector(highestZIndexSelector); + const { mapInstance } = useMapInstance(); + + const [selectedGlyph, setSelectedGlyph] = useState<number | null>(null); + const [file, setFile] = useState<File | null>(null); + const [isSending, setIsSending] = useState<boolean>(false); + const [previewUrl, setPreviewUrl] = useState<string | null>(null); + + const handleGlyphChange = (glyph: Glyph | null): void => { + const glyphId = glyph?.id || null; + setSelectedGlyph(glyphId); + if (!glyphId) { + return; + } + setFile(null); + setPreviewUrl(`${BASE_NEW_API_URL}${apiPath.getGlyphImage(glyphId)}`); + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => { + const uploadedFile = e.target.files?.[0] || null; + + setFile(uploadedFile); + if (!uploadedFile) { + return; + } + + setSelectedGlyph(null); + if (uploadedFile) { + const url = URL.createObjectURL(uploadedFile); + setPreviewUrl(url); + } else { + setPreviewUrl(null); + } + }; + + const handleSubmit = async (): Promise<void> => { + if (!layerImageObjectFactoryState || !activeLayer) { + return; + } + setIsSending(true); + try { + let glyphId = selectedGlyph; + if (file) { + const data = await dispatch(addGlyph(file)).unwrap(); + if (!data) { + return; + } + glyphId = data.id; + } + const imageData = await dispatch( + addLayerImageObject({ + modelId: currentModelId, + layerId: activeLayer, + x: layerImageObjectFactoryState.x, + y: layerImageObjectFactoryState.y, + z: highestZIndex + 1, + width: layerImageObjectFactoryState.width, + height: layerImageObjectFactoryState.height, + glyph: glyphId, + }), + ).unwrap(); + if (!imageData) { + showToast({ + type: 'error', + message: 'Error during adding layer image object', + }); + return; + } + dispatch( + layerAddImage({ modelId: currentModelId, layerId: activeLayer, layerImage: imageData }), + ); + mapInstance?.getAllLayers().forEach(layer => { + if (layer.get('id') === activeLayer && layer.get('drawImage')) { + const drawImage = layer.get('drawImage'); + if (drawImage instanceof Function) { + drawImage(imageData); + } + } + }); + showToast({ + type: 'success', + message: 'A new image object has been successfully added', + }); + dispatch(closeModal()); + } catch (error) { + const typedError = error as SerializedError; + showToast({ + type: 'error', + message: typedError.message || 'An error occurred while adding a new image object', + }); + } finally { + setIsSending(false); + } + }; + + return ( + <div className="relative w-[800px] border border-t-[#E1E0E6] bg-white p-[24px]"> + {isSending && ( + <div className="c-layer-image-object-factory-loader"> + <LoadingIndicator width={44} height={44} /> + </div> + )} + <div className="grid grid-cols-2 gap-2"> + <div className="mb-4 flex flex-col gap-2"> + <span>Glyph:</span> + <Autocomplete<Glyph> + options={glyphs} + valueKey="id" + labelKey="filename" + onChange={handleGlyphChange} + /> + </div> + <div className="mb-4 flex flex-col gap-2"> + <span>File:</span> + <Input + ref={fileInputRef} + type="file" + accept="image/*" + onChange={handleFileChange} + data-testid="image-file-input" + className="w-full border border-[#ccc] bg-white p-2" + /> + </div> + </div> + + <div className="relative mb-4 flex h-[350px] w-full items-center justify-center overflow-hidden rounded border"> + {previewUrl ? ( + <Image + src={previewUrl} + alt="image preview" + fill + style={{ objectFit: 'contain' }} + className="rounded" + data-testid="layer-image-preview" + /> + ) : ( + <div className="text-gray-500">No Image</div> + )} + </div> + + <Button + type="button" + onClick={handleSubmit} + className="w-full justify-center text-base font-medium" + > + Submit + </Button> + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.styles.css b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.styles.css new file mode 100644 index 0000000000000000000000000000000000000000..db49e44351fd2a7f31f6ea29a7c1e0409e11b2ba --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.styles.css @@ -0,0 +1,12 @@ +.c-layer-image-object-factory-loader { + width: 100%; + height: 100%; + margin-left: -24px; + margin-top: -24px; + background: #f9f9f980; + z-index: 1; + position: absolute; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/index.ts b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..119478065cc2155d8a9123fa0ecf696cc4bc9e9d --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/index.ts @@ -0,0 +1 @@ +export { LayerImageObjectFactoryModal } from './LayerImageObjectFactoryModal.component'; diff --git a/src/components/FunctionalArea/Modal/Modal.component.tsx b/src/components/FunctionalArea/Modal/Modal.component.tsx index 8419791dee19ccf04d34e3c87eb731862356838d..feec78d941d8fb4fcee1bd192539a001907a46af 100644 --- a/src/components/FunctionalArea/Modal/Modal.component.tsx +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -5,6 +5,7 @@ import { AccessDeniedModal } from '@/components/FunctionalArea/Modal/AccessDenie import { AddCommentModal } from '@/components/FunctionalArea/Modal/AddCommentModal/AddCommentModal.component'; import { LicenseModal } from '@/components/FunctionalArea/Modal/LicenseModal'; import { ToSModal } from '@/components/FunctionalArea/Modal/ToSModal/ToSModal.component'; +import { LayerImageObjectFactoryModal } from '@/components/FunctionalArea/Modal/LayerImageObjectFactoryModal'; import { EditOverlayModal } from './EditOverlayModal'; import { LoginModal } from './LoginModal'; import { ErrorReportModal } from './ErrorReportModal'; @@ -85,6 +86,11 @@ export const Modal = (): React.ReactNode => { <LayerFactoryModal /> </ModalLayout> )} + {isOpen && modalName === 'layer-image-object-factory' && ( + <ModalLayout> + <LayerImageObjectFactoryModal /> + </ModalLayout> + )} </> ); }; diff --git a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx index 493f2bc7a4be1a57cf5218ca7505075893746d29..64816f0bda36c4261fc2735a9c397ba215b16590 100644 --- a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx +++ b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx @@ -34,6 +34,7 @@ export const ModalLayout = ({ children }: ModalLayoutProps): JSX.Element => { modalName === 'add-comment' && 'h-auto w-[400px]', modalName === 'error-report' && 'h-auto w-[800px]', modalName === 'layer-factory' && 'h-auto w-[400px]', + modalName === 'layer-image-object-factory' && 'h-auto w-[800px]', ['edit-overlay', 'logged-in-menu'].includes(modalName) && 'h-auto w-[432px]', )} > diff --git a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx index b431590cad9fd0aca469b6b22e6d1345a69fe8b5..bbed3ad9ffe89dbdd02ad611b6b23ae56ce81208 100644 --- a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx +++ b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx @@ -5,7 +5,6 @@ import { layersForCurrentModelSelector, layersVisibilityForCurrentModelSelector, } from '@/redux/layers/layers.selectors'; -import { Switch } from '@/shared/Switch'; import { setLayerVisibility } from '@/redux/layers/layers.slice'; import { currentModelIdSelector } from '@/redux/models/models.selectors'; import { Button } from '@/shared/Button'; @@ -15,6 +14,7 @@ import { useState } from 'react'; import { getLayersForModel, removeLayer } from '@/redux/layers/layers.thunks'; import { showToast } from '@/utils/showToast'; import { SerializedError } from '@reduxjs/toolkit'; +import { LayersDrawerLayerActions } from '@/components/Map/Drawer/LayersDrawer/LayersDrawerLayerActions.component'; export const LayersDrawer = (): JSX.Element => { const layersForCurrentModel = useAppSelector(layersForCurrentModelSelector); @@ -83,28 +83,20 @@ export const LayersDrawer = (): JSX.Element => { className="flex items-center justify-between gap-3 border-b py-4" > <h1 className="truncate">{layer.details.name}</h1> - <div className="flex items-center gap-2"> - <Switch - isChecked={layersVisibilityForCurrentModel[layer.details.id]} - onToggle={value => - dispatch( - setLayerVisibility({ - modelId: currentModelId, - visible: value, - layerId: layer.details.id, - }), - ) - } - /> - <Button onClick={() => editLayer(layer.details.id)}>Edit</Button> - <Button - onClick={() => onRemoveLayer(layer.details.id)} - color="error" - variantStyles="remove" - > - Remove - </Button> - </div> + <LayersDrawerLayerActions + isChecked={layersVisibilityForCurrentModel[layer.details.id]} + editLayer={() => editLayer(layer.details.id)} + removeLayer={() => onRemoveLayer(layer.details.id)} + toggleVisibility={value => + dispatch( + setLayerVisibility({ + modelId: currentModelId, + visible: value, + layerId: layer.details.id, + }), + ) + } + /> </div> ))} </div> diff --git a/src/components/Map/Drawer/LayersDrawer/LayersDrawerLayerActions.component.tsx b/src/components/Map/Drawer/LayersDrawer/LayersDrawerLayerActions.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..02ea7ed9246cde4aa02b163288a561fedd8b4a5e --- /dev/null +++ b/src/components/Map/Drawer/LayersDrawer/LayersDrawerLayerActions.component.tsx @@ -0,0 +1,26 @@ +import { Button } from '@/shared/Button'; +import { Switch } from '@/shared/Switch'; + +type LayersDrawerLayerActionsProps = { + editLayer: () => void; + removeLayer: () => void; + isChecked: boolean; + toggleVisibility: (value: boolean) => void; +}; + +export const LayersDrawerLayerActions = ({ + editLayer, + removeLayer, + isChecked, + toggleVisibility, +}: LayersDrawerLayerActionsProps): JSX.Element => { + return ( + <div className="flex items-center gap-2"> + <Switch isChecked={isChecked} onToggle={value => toggleVisibility(value)} /> + <Button onClick={() => editLayer()}>Edit</Button> + <Button onClick={() => removeLayer()} color="error" variantStyles="remove"> + Remove + </Button> + </div> + ); +}; diff --git a/src/components/Map/Map.component.tsx b/src/components/Map/Map.component.tsx index 02ee36d85777fc55ef92157dfe21deb5ea324593..0a57b1fb832379e00a7edaecf18749cf386b60e8 100644 --- a/src/components/Map/Map.component.tsx +++ b/src/components/Map/Map.component.tsx @@ -4,15 +4,20 @@ import { Legend } from '@/components/Map/Legend'; import { MapViewer } from '@/components/Map/MapViewer'; import { MapLoader } from '@/components/Map/MapLoader/MapLoader.component'; import { MapVectorBackgroundSelector } from '@/components/Map/MapVectorBackgroundSelector/MapVectorBackgroundSelector.component'; +import { MapActiveLayerSelector } from '@/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { vectorRenderingSelector } from '@/redux/models/models.selectors'; import { MapAdditionalLogos } from '@/components/Map/MapAdditionalLogos'; +import { MapDrawActions } from '@/components/Map/MapDrawActions/MapDrawActions.component'; +import { layersActiveLayerSelector } from '@/redux/layers/layers.selectors'; import { MapAdditionalActions } from './MapAdditionalActions'; import { MapAdditionalOptions } from './MapAdditionalOptions'; import { PluginsDrawer } from './PluginsDrawer'; export const Map = (): JSX.Element => { const vectorRendering = useAppSelector(vectorRenderingSelector); + const activeLayer = useAppSelector(layersActiveLayerSelector); + return ( <div className="relative z-0 h-screen w-full overflow-hidden bg-black" @@ -20,7 +25,13 @@ export const Map = (): JSX.Element => { > <MapViewer /> {!vectorRendering && <MapAdditionalOptions />} - {vectorRendering && <MapVectorBackgroundSelector />} + {vectorRendering && ( + <> + <MapVectorBackgroundSelector /> + <MapActiveLayerSelector /> + {activeLayer && <MapDrawActions />} + </> + )} <Drawer /> <PluginsDrawer /> <Legend /> diff --git a/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx b/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..10bde5c3f958375d77ae5a763d767d5c3a80dd52 --- /dev/null +++ b/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx @@ -0,0 +1,64 @@ +/* eslint-disable no-magic-numbers */ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { twMerge } from 'tailwind-merge'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { Select } from '@/shared/Select'; +import { + layersActiveLayerSelector, + layersForCurrentModelSelector, + layersVisibilityForCurrentModelSelector, +} from '@/redux/layers/layers.selectors'; +import { useEffect, useMemo } from 'react'; +import { setActiveLayer } from '@/redux/layers/layers.slice'; +import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { mapEditToolsSetActiveAction } from '@/redux/mapEditTools/mapEditTools.slice'; + +export const MapActiveLayerSelector = (): JSX.Element => { + const dispatch = useAppDispatch(); + const layers = useAppSelector(layersForCurrentModelSelector); + const layersVisibility = useAppSelector(layersVisibilityForCurrentModelSelector); + const currentModelId = useAppSelector(currentModelIdSelector); + const activeLayer = useAppSelector(layersActiveLayerSelector); + + const handleChange = (activeLayerId: number): void => { + dispatch(setActiveLayer({ modelId: currentModelId, layerId: activeLayerId })); + }; + + const options: Array<{ id: number; name: string }> = useMemo(() => { + return layers + .filter(layer => layersVisibility[layer.details.id]) + .map(layer => { + return { + id: layer.details.id, + name: layer.details.name, + }; + }); + }, [layers, layersVisibility]); + + useEffect(() => { + const selectedOption = options.find(option => option.id === activeLayer) || null; + if (selectedOption || !currentModelId) { + return; + } + if (options.length === 0) { + dispatch(setActiveLayer({ modelId: currentModelId, layerId: null })); + } else { + dispatch(setActiveLayer({ modelId: currentModelId, layerId: options[0].id })); + } + }, [activeLayer, currentModelId, dispatch, options]); + + useEffect(() => { + if (!options.length) { + dispatch(setActiveLayer({ modelId: currentModelId, layerId: null })); + dispatch(mapEditToolsSetActiveAction(null)); + } + }, [currentModelId, dispatch, options]); + + return ( + <div className={twMerge('absolute right-6 top-[calc(64px+40px+84px)] flex')}> + {Boolean(options.length) && ( + <Select options={options} selectedId={activeLayer} onChange={handleChange} width={140} /> + )} + </div> + ); +}; diff --git a/src/components/Map/MapDrawActions/MapDrawActions.component.test.tsx b/src/components/Map/MapDrawActions/MapDrawActions.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d8f04b15f044ded805623060e372748b70f76ed1 --- /dev/null +++ b/src/components/Map/MapDrawActions/MapDrawActions.component.test.tsx @@ -0,0 +1,77 @@ +import { MapDrawActions } from '@/components/Map/MapDrawActions/MapDrawActions.component'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; +import { mapEditToolsSetActiveAction } from '@/redux/mapEditTools/mapEditTools.slice'; +import { + getReduxWrapperWithStore, + InitialStoreState, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { MAP_EDIT_TOOLS_STATE_INITIAL_MOCK } from '@/redux/mapEditTools/mapEditTools.mock'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { + LAYER_STATE_DEFAULT_DATA, + LAYERS_STATE_INITIAL_LAYER_MOCK, +} from '@/redux/layers/layers.mock'; +import { MAIN_MAP_ID } from '@/constants/mocks'; +import { layerFixture } from '@/models/fixtures/layerFixture'; + +jest.mock('../../../redux/hooks/useAppDispatch', () => ({ + useAppDispatch: jest.fn(), +})); + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <MapDrawActions /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('MapDrawActions', () => { + const mockDispatch = jest.fn(() => {}); + + beforeEach(() => { + (useAppDispatch as jest.Mock).mockReturnValue(mockDispatch); + }); + + it('renders the MapDrawActionsButton and toggles action on click', () => { + const layerId = 0; + renderComponent({ + mapEditTools: MAP_EDIT_TOOLS_STATE_INITIAL_MOCK, + layers: { + [layerId]: { + ...LAYERS_STATE_INITIAL_LAYER_MOCK, + data: { + ...LAYER_STATE_DEFAULT_DATA, + layersVisibility: { [MAIN_MAP_ID]: true }, + layers: [ + { + details: { ...layerFixture, id: MAIN_MAP_ID }, + texts: [], + rects: [], + ovals: [], + lines: [], + images: {}, + }, + ], + }, + }, + }, + }); + const button = screen.getByRole('button', { name: /draw image/i }); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + + expect(mockDispatch).toHaveBeenCalledWith( + mapEditToolsSetActiveAction(MAP_EDIT_ACTIONS.DRAW_IMAGE), + ); + }); +}); diff --git a/src/components/Map/MapDrawActions/MapDrawActions.component.tsx b/src/components/Map/MapDrawActions/MapDrawActions.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..50deda14baae2d6318e793162819e7c106472dc6 --- /dev/null +++ b/src/components/Map/MapDrawActions/MapDrawActions.component.tsx @@ -0,0 +1,42 @@ +/* eslint-disable no-magic-numbers */ +import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; +import { mapEditToolsSetActiveAction } from '@/redux/mapEditTools/mapEditTools.slice'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { mapEditToolsActiveActionSelector } from '@/redux/mapEditTools/mapEditTools.selectors'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { MapDrawActionsButton } from '@/components/Map/MapDrawActions/MapDrawActionsButton.component'; +import { useMemo } from 'react'; +import { + layersForCurrentModelSelector, + layersVisibilityForCurrentModelSelector, +} from '@/redux/layers/layers.selectors'; + +export const MapDrawActions = (): React.JSX.Element | null => { + const activeAction = useAppSelector(mapEditToolsActiveActionSelector); + const dispatch = useAppDispatch(); + const layers = useAppSelector(layersForCurrentModelSelector); + const layersVisibility = useAppSelector(layersVisibilityForCurrentModelSelector); + + const toggleMapEditAction = (action: keyof typeof MAP_EDIT_ACTIONS): void => { + dispatch(mapEditToolsSetActiveAction(action)); + }; + + const visibleLayersLength: number = useMemo(() => { + return layers.filter(layer => layersVisibility[layer.details.id]).length; + }, [layers, layersVisibility]); + + if (visibleLayersLength === 0) { + return null; + } + + return ( + <div className="absolute right-6 top-[calc(64px+40px+144px)] z-10 flex flex-col gap-4"> + <MapDrawActionsButton + isActive={activeAction === MAP_EDIT_ACTIONS.DRAW_IMAGE} + toggleMapEditAction={() => toggleMapEditAction(MAP_EDIT_ACTIONS.DRAW_IMAGE)} + icon="image" + title="Draw image" + /> + </div> + ); +}; diff --git a/src/components/Map/MapDrawActions/MapDrawActionsButton.component.tsx b/src/components/Map/MapDrawActions/MapDrawActionsButton.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eacefa311d4aa5be906188da02218b1341f7b011 --- /dev/null +++ b/src/components/Map/MapDrawActions/MapDrawActionsButton.component.tsx @@ -0,0 +1,33 @@ +/* eslint-disable no-magic-numbers */ +import { Icon } from '@/shared/Icon'; +import type { IconTypes } from '@/types/iconTypes'; + +type MapDrawActionsButtonProps = { + isActive: boolean; + toggleMapEditAction: () => void; + icon: IconTypes; + title?: string; +}; + +export const MapDrawActionsButton = ({ + isActive, + toggleMapEditAction, + icon, + title = '', +}: MapDrawActionsButtonProps): React.JSX.Element => { + return ( + <button + type="button" + className={`flex h-12 w-12 items-center justify-center rounded-full ${ + isActive ? 'bg-primary-100' : 'bg-white drop-shadow-primary' + }`} + onClick={() => toggleMapEditAction()} + title={title} + > + <Icon + className={`h-[28px] w-[28px] ${isActive ? 'text-primary-500' : 'text-black'}`} + name={icon} + /> + </button> + ); +}; diff --git a/src/components/Map/MapVectorBackgroundSelector/MapVectorBackgroundSelector.component.tsx b/src/components/Map/MapVectorBackgroundSelector/MapVectorBackgroundSelector.component.tsx index 32af5af47762600163e1069fc76cb9b2ec892880..c4e2cafa626e4799c0e0dab6fef9721ec18c5d14 100644 --- a/src/components/Map/MapVectorBackgroundSelector/MapVectorBackgroundSelector.component.tsx +++ b/src/components/Map/MapVectorBackgroundSelector/MapVectorBackgroundSelector.component.tsx @@ -20,7 +20,7 @@ export const MapVectorBackgroundSelector = (): JSX.Element => { options={MAP_BACKGROUND_TYPES} selectedId={backgroundType} onChange={handleChange} - width={100} + width={140} /> </div> ); diff --git a/src/components/Map/MapViewer/MapViewer.component.tsx b/src/components/Map/MapViewer/MapViewer.component.tsx index 662384c9bd6b215ea3277fd94c0568eac7dc081d..0307cf080469c999aa576b3b7710fed9c1fffc67 100644 --- a/src/components/Map/MapViewer/MapViewer.component.tsx +++ b/src/components/Map/MapViewer/MapViewer.component.tsx @@ -1,15 +1,22 @@ import 'ol/ol.css'; -import { MAP_VIEWER_ROLE } from './MapViewer.constants'; +import { twMerge } from 'tailwind-merge'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { isMapEditToolsActiveSelector } from '@/redux/mapEditTools/mapEditTools.selectors'; import { useOlMap } from './utils/useOlMap'; +import { MAP_VIEWER_ROLE } from './MapViewer.constants'; export const MapViewer = (): JSX.Element => { const { mapRef } = useOlMap(); + const isMapEditToolsActive = useAppSelector(isMapEditToolsActiveSelector); return ( <div ref={mapRef} role={MAP_VIEWER_ROLE} - className="absolute left-[88px] top-[104px] h-[calc(100%-104px)] w-[calc(100%-88px)] bg-white" + className={twMerge( + 'absolute left-[88px] top-[104px] h-[calc(100%-104px)] w-[calc(100%-88px)] bg-white', + isMapEditToolsActive ? 'bg-[#e4e2de]' : 'bg-white', + )} /> ); }; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts index 6b841f53d586921bb3644aa1bd60caf6755dd8d0..6f60435b25a935e5b0e3a5adf4968a5b01a1a2fe 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts @@ -2,12 +2,13 @@ import { Feature } from 'ol'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; -import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { currentModelIdSelector, vectorRenderingSelector } from '@/redux/models/models.selectors'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { getLayersForModel } from '@/redux/layers/layers.thunks'; import { + layersActiveLayerSelector, layersForCurrentModelSelector, layersLoadingSelector, layersVisibilityForCurrentModelSelector, @@ -19,6 +20,12 @@ import Polygon from 'ol/geom/Polygon'; import Layer from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer'; import { arrowTypesSelector, lineTypesSelector } from '@/redux/shapes/shapes.selectors'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import getDrawImageInteraction from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction'; +import { LayerState } from '@/redux/layers/layers.types'; +import { mapEditToolsActiveActionSelector } from '@/redux/mapEditTools/mapEditTools.selectors'; +import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; +import { Extent } from 'ol/extent'; export const useOlMapAdditionalLayers = ( mapInstance: MapInstance, @@ -27,17 +34,42 @@ export const useOlMapAdditionalLayers = ( VectorSource<Feature<Point> | Feature<Polygon> | Feature<LineString> | Feature<MultiPolygon>> > > => { + const activeAction = useAppSelector(mapEditToolsActiveActionSelector); const dispatch = useAppDispatch(); + const mapSize = useSelector(mapDataSizeSelector); const currentModelId = useSelector(currentModelIdSelector); const layersForCurrentModel = useAppSelector(layersForCurrentModelSelector); const layersLoading = useAppSelector(layersLoadingSelector); const layersVisibilityForCurrentModel = useAppSelector(layersVisibilityForCurrentModelSelector); + const activeLayer = useAppSelector(layersActiveLayerSelector); + const vectorRendering = useAppSelector(vectorRenderingSelector); + + const [layersState, setLayersState] = useState<Array<LayerState>>([]); + const [layersLoadingState, setLayersLoadingState] = useState(false); const lineTypes = useSelector(lineTypesSelector); const arrowTypes = useSelector(arrowTypesSelector); const pointToProjection = usePointToProjection(); + const restrictionExtent: Extent = useMemo(() => { + const restrictionMinPoint = pointToProjection({ x: 0, y: 0 }); + const restrictionMaxPoint = pointToProjection({ x: mapSize.width, y: mapSize.height }); + return [ + restrictionMinPoint[0], + restrictionMaxPoint[1], + restrictionMaxPoint[0], + restrictionMinPoint[1], + ]; + }, [mapSize, pointToProjection]); + + const drawImageInteraction = useMemo(() => { + if (!mapSize || !dispatch) { + return null; + } + return getDrawImageInteraction(mapSize, dispatch, restrictionExtent); + }, [mapSize, dispatch, restrictionExtent]); + useEffect(() => { if (!currentModelId) { return; @@ -48,7 +80,7 @@ export const useOlMapAdditionalLayers = ( }, [currentModelId, dispatch, layersLoading]); const vectorLayers = useMemo(() => { - return layersForCurrentModel.map(layer => { + return layersState.map(layer => { const additionalLayer = new Layer({ texts: layer.texts, rects: layer.rects, @@ -64,7 +96,16 @@ export const useOlMapAdditionalLayers = ( }); return additionalLayer.vectorLayer; }); - }, [arrowTypes, lineTypes, mapInstance, layersForCurrentModel, pointToProjection]); + }, [layersState, lineTypes, arrowTypes, mapInstance, pointToProjection]); + + useEffect(() => { + if (layersLoading === 'pending') { + setLayersLoadingState(true); + } else if (layersLoading === 'succeeded' && layersLoadingState) { + setLayersLoadingState(false); + setLayersState(layersForCurrentModel); + } + }, [layersForCurrentModel, layersLoading, layersLoadingState]); useEffect(() => { vectorLayers.forEach(layer => { @@ -75,5 +116,23 @@ export const useOlMapAdditionalLayers = ( }); }, [layersVisibilityForCurrentModel, vectorLayers]); + useEffect(() => { + if (!drawImageInteraction) { + return; + } + mapInstance?.removeInteraction(drawImageInteraction); + if (!activeLayer || !vectorRendering || activeAction !== MAP_EDIT_ACTIONS.DRAW_IMAGE) { + return; + } + mapInstance?.addInteraction(drawImageInteraction); + }, [ + activeAction, + activeLayer, + currentModelId, + drawImageInteraction, + mapInstance, + vectorRendering, + ]); + return vectorLayers; }; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/mapCardLayer/useOlMapCardLayer.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/mapCardLayer/useOlMapCardLayer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd9a522058ac4677e9bd8d70767cd4d4edd85879 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/mapCardLayer/useOlMapCardLayer.test.ts @@ -0,0 +1,79 @@ +/* eslint-disable no-magic-numbers */ +import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from '@/redux/map/map.constants'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import BaseLayer from 'ol/layer/Base'; +import VectorLayer from 'ol/layer/Vector'; +import React from 'react'; +import MapBackgroundsEnum from '@/redux/map/map.enums'; +import { useOlMapCardLayer } from './useOlMapCardLayer'; + +const useRefValue = { + current: null, +}; + +Object.defineProperty(useRefValue, 'current', { + get: jest.fn(() => ({ + innerHTML: '', + appendChild: jest.fn(), + addEventListener: jest.fn(), + getRootNode: jest.fn(), + })), + set: jest.fn(() => ({ + innerHTML: '', + appendChild: jest.fn(), + addEventListener: jest.fn(), + getRootNode: jest.fn(), + })), +}); + +jest.spyOn(React, 'useRef').mockReturnValue(useRefValue); + +describe('useOlMapCardLayer - util', () => { + const getRenderedHookResults = (): BaseLayer => { + const { Wrapper } = getReduxWrapperWithStore({ + map: { + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + position: { + initial: { + x: 256, + y: 256, + }, + last: { + x: 256, + y: 256, + }, + }, + }, + loading: 'idle', + error: { + name: '', + message: '', + }, + openedMaps: OPENED_MAPS_INITIAL_STATE, + backgroundType: MapBackgroundsEnum.SEMANTIC, + }, + }); + + const { result } = renderHook(() => useOlMapCardLayer(), { + wrapper: Wrapper, + }); + + return result.current; + }; + + it('should return valid VectorLayer instance', () => { + const result = getRenderedHookResults(); + + expect(result).toBeInstanceOf(VectorLayer); + expect(result.getSourceState()).toBe('ready'); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/mapCardLayer/useOlMapCardLayer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/mapCardLayer/useOlMapCardLayer.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1fb741ba7f9c9f103c192d133cb706b00fb0315 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/mapCardLayer/useOlMapCardLayer.ts @@ -0,0 +1,50 @@ +import { Feature } from 'ol'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import { useMemo } from 'react'; +import Polygon from 'ol/geom/Polygon'; +import { useSelector } from 'react-redux'; +import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import Style from 'ol/style/Style'; +import { Fill } from 'ol/style'; + +export const useOlMapCardLayer = (): VectorLayer<VectorSource<Feature<Polygon>>> => { + const mapSize = useSelector(mapDataSizeSelector); + const pointToProjection = usePointToProjection(); + + const rectangle = useMemo(() => { + return new Polygon([ + [ + pointToProjection({ x: 0, y: 0 }), + pointToProjection({ x: mapSize.width, y: 0 }), + pointToProjection({ x: mapSize.width, y: mapSize.height }), + pointToProjection({ x: 0, y: mapSize.height }), + pointToProjection({ x: 0, y: 0 }), + ], + ]); + }, [mapSize.height, mapSize.width, pointToProjection]); + + const rectangleFeature = useMemo(() => { + return new Feature(rectangle); + }, [rectangle]); + + const vectorSource = useMemo(() => { + return new VectorSource({ + features: [rectangleFeature], + }); + }, [rectangleFeature]); + + return useMemo( + () => + new VectorLayer({ + source: vectorSource, + style: new Style({ + fill: new Fill({ + color: '#fff', + }), + }), + }), + [vectorSource], + ); +}; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.ts index 822b54ddd79e39d5d43c58f19464c070f8d6222d..253c3a38954dac9549948a3c3e5f7536fc9399e6 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.ts @@ -2,6 +2,7 @@ import { MapInstance } from '@/types/map'; import { useOlMapAdditionalLayers } from '@/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers'; import { useMemo } from 'react'; +import { useOlMapCardLayer } from '@/components/Map/MapViewer/MapViewerVector/utils/config/mapCardLayer/useOlMapCardLayer'; import { useOlMapReactionsLayer } from './reactionsLayer/useOlMapReactionsLayer'; import { MapConfig } from '../../MapViewerVector.types'; @@ -12,8 +13,9 @@ interface UseOlMapLayersInput { export const useOlMapVectorLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig['layers'] => { const reactionsLayer = useOlMapReactionsLayer({ mapInstance }); const additionalLayers = useOlMapAdditionalLayers(mapInstance); + const mapCardLayer = useOlMapCardLayer(); return useMemo(() => { - return [reactionsLayer, ...additionalLayers]; - }, [reactionsLayer, additionalLayers]); + return [mapCardLayer, reactionsLayer, ...additionalLayers]; + }, [mapCardLayer, reactionsLayer, additionalLayers]); }; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts index f2fc4d4d3422efc7fae549446118bc37c8da47da..6ac8c5d29e0e6aa78f4b7edb05e1a2f61a626b25 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts @@ -59,13 +59,8 @@ describe('Glyph', () => { }); it('should scale image based on map resolution', () => { - const getImageScale = glyph.feature.get('getImageScale'); const getAnchorAndCoords = glyph.feature.get('getAnchorAndCoords'); if (mapInstance) { - const resolution = mapInstance - .getView() - .getResolutionForZoom(mapInstance.getView().getMaxZoom()); - expect(getImageScale(resolution)).toBe(1); expect(getAnchorAndCoords()).toEqual({ anchor: [0, 0], coords: [0, 0] }); } }); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts index 41cc21a6bca428fb2c11b8c58e08b2bc8dc90796..c7844ae36e3acae71caa5accfab855a3e1d06b40 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts @@ -1,7 +1,7 @@ /* eslint-disable no-magic-numbers */ import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; import { Feature } from 'ol'; -import Style from 'ol/style/Style'; +import { Style, Text } from 'ol/style'; import Icon from 'ol/style/Icon'; import { FeatureLike } from 'ol/Feature'; import { MapInstance } from '@/types/map'; @@ -13,10 +13,12 @@ import { Coordinate } from 'ol/coordinate'; import { FEATURE_TYPE } from '@/constants/features'; import getStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStyle'; import { WHITE_COLOR } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill'; +import getScaledElementStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getScaledElementStyle'; export type GlyphProps = { elementId: number; - glyphId: number; + glyphId: number | null; x: number; y: number; width: number; @@ -31,6 +33,8 @@ export default class Glyph { style: Style = new Style({}); + noGlyphStyle: Style; + imageScale: number = 1; polygonStyle: Style; @@ -49,6 +53,8 @@ export default class Glyph { pixelRatio: number = 1; + minResolution: number; + pointToProjection: UsePointToProjectionResult; constructor({ @@ -71,10 +77,10 @@ export default class Glyph { const point2 = this.pointToProjection({ x: this.width, y: this.height }); this.widthOnMap = Math.abs(point2[0] - point1[0]); this.heightOnMap = Math.abs(point2[1] - point1[1]); - const minResolution = mapInstance?.getView().getMinResolution(); - if (minResolution) { - this.pixelRatio = this.widthOnMap / minResolution / this.width; - } + + const maxZoom = mapInstance?.getView().get('originalMaxZoom'); + this.minResolution = mapInstance?.getView().getResolutionForZoom(maxZoom) || 1; + this.pixelRatio = this.widthOnMap / this.minResolution / this.width; const polygon = new Polygon([ [ pointToProjection({ x, y }), @@ -84,23 +90,33 @@ export default class Glyph { pointToProjection({ x, y }), ], ]); + this.polygonStyle = getStyle({ geometry: polygon, zIndex, borderColor: { ...WHITE_COLOR, alpha: 0 }, fillColor: { ...WHITE_COLOR, alpha: 0 }, }); + + this.noGlyphStyle = getStyle({ + geometry: polygon, + zIndex, + fillColor: '#E7E7E7', + }); + this.noGlyphStyle.setText( + new Text({ + text: 'No image', + font: '12pt Arial', + fill: getFill({ color: '#000' }), + overflow: true, + }), + ); + this.feature = new Feature({ geometry: polygon, id: elementId, type: FEATURE_TYPE.GLYPH, zIndex, - getImageScale: (resolution: number): number => { - if (mapInstance) { - return mapInstance.getView().getMinResolution() / resolution; - } - return 1; - }, getAnchorAndCoords: (): { anchor: Array<number>; coords: Coordinate } => { const center = mapInstance?.getView().getCenter(); let anchorX = 0; @@ -115,11 +131,23 @@ export default class Glyph { }, }); + this.feature.setStyle(this.getStyle.bind(this)); + if (!glyphId) { + return; + } const img = new Image(); img.onload = (): void => { const imageWidth = img.naturalWidth; const imageHeight = img.naturalHeight; - this.imageScale = width / imageWidth; + const heightScale = height / imageHeight; + const widthScale = width / imageWidth; + if (heightScale < widthScale) { + this.imageScale = heightScale; + this.widthOnMap = (this.heightOnMap * imageWidth) / imageHeight; + } else { + this.imageScale = widthScale; + this.heightOnMap = (this.widthOnMap * imageHeight) / imageWidth; + } this.style = new Style({ image: new Icon({ anchor: [0, 0], @@ -128,30 +156,27 @@ export default class Glyph { }), zIndex, }); - this.feature.setStyle(this.getStyle.bind(this)); }; img.src = `${BASE_NEW_API_URL}${apiPath.getGlyphImage(glyphId)}`; } protected getStyle(feature: FeatureLike, resolution: number): Style | Array<Style> | void { - const getImageScale = feature.get('getImageScale'); + const scale = this.minResolution / resolution; const getAnchorAndCoords = feature.get('getAnchorAndCoords'); - let imageScale = 1; let anchor = [0, 0]; let coords = this.pointToProjection({ x: this.x, y: this.y }); - if (getImageScale instanceof Function) { - imageScale = getImageScale(resolution); - } + if (getAnchorAndCoords instanceof Function) { const anchorAndCoords = getAnchorAndCoords(); anchor = anchorAndCoords.anchor; coords = anchorAndCoords.coords; } if (this.style.getImage()) { - this.style.getImage()?.setScale(imageScale * this.pixelRatio * this.imageScale); + this.style.getImage()?.setScale(scale * this.pixelRatio * this.imageScale); (this.style.getImage() as Icon).setAnchor(anchor); this.style.setGeometry(new Point(coords)); + return [this.style, this.polygonStyle]; } - return [this.style, this.polygonStyle]; + return getScaledElementStyle(this.noGlyphStyle, undefined, scale); } } diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts index eeabb89c506ce0c4ffba0a0caebab0a7c1bbea95..1cdd1aef0edf1bfae3d0b785eb3080420c2e3fe9 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts @@ -113,8 +113,8 @@ describe('Layer', () => { lineType: 'SOLID', }, ], - images: [ - { + images: { + 1: { id: 1, glyph: 1, x: 1, @@ -123,7 +123,7 @@ describe('Layer', () => { height: 1, z: 1, }, - ], + }, visible: true, layerId: 23, pointToProjection: jest.fn(point => [point.x, point.y]), @@ -144,9 +144,6 @@ describe('Layer', () => { it('should initialize a Layer class', () => { const layer = new Layer(props); - expect(layer.textFeatures.length).toBe(1); - expect(layer.rectFeatures.length).toBe(1); - expect(layer.ovalFeatures.length).toBe(1); expect(layer.vectorSource).toBeInstanceOf(VectorSource); expect(layer.vectorLayer).toBeInstanceOf(VectorLayer); }); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts index ef12c427e4fecdb40d9ff5929abd162f15da1c2c..e48faf93c38db11b282b23a3a87e253979eee661 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts @@ -33,7 +33,7 @@ export interface LayerProps { rects: Array<LayerRect>; ovals: Array<LayerOval>; lines: Array<LayerLine>; - images: Array<LayerImage>; + images: { [key: string]: LayerImage }; visible: boolean; layerId: number; lineTypes: LineTypeDict; @@ -51,24 +51,12 @@ export default class Layer { lines: Array<LayerLine>; - images: Array<LayerImage>; + images: { [key: string]: LayerImage }; lineTypes: LineTypeDict; arrowTypes: ArrowTypeDict; - textFeatures: Array<Feature<Point>>; - - rectFeatures: Array<Feature<Polygon>>; - - ovalFeatures: Array<Feature<Polygon>>; - - imageFeatures: Array<Feature<Polygon>>; - - lineFeatures: Array<Feature<LineString>>; - - arrowFeatures: Array<Feature<MultiPolygon>>; - pointToProjection: UsePointToProjectionResult; mapInstance: MapInstance; @@ -94,6 +82,8 @@ export default class Layer { mapInstance, pointToProjection, }: LayerProps) { + this.vectorSource = new VectorSource({}); + this.texts = texts; this.rects = rects; this.ovals = ovals; @@ -103,28 +93,25 @@ export default class Layer { this.arrowTypes = arrowTypes; this.pointToProjection = pointToProjection; this.mapInstance = mapInstance; - this.textFeatures = this.getTextsFeatures(); - this.rectFeatures = this.getRectsFeatures(); - this.ovalFeatures = this.getOvalsFeatures(); - this.imageFeatures = this.getImagesFeatures(); + + this.vectorSource.addFeatures(this.getTextsFeatures()); + this.vectorSource.addFeatures(this.getRectsFeatures()); + this.vectorSource.addFeatures(this.getOvalsFeatures()); + this.drawImages(); + const { linesFeatures, arrowsFeatures } = this.getLinesFeatures(); - this.lineFeatures = linesFeatures; - this.arrowFeatures = arrowsFeatures; - this.vectorSource = new VectorSource({ - features: [ - ...this.textFeatures, - ...this.rectFeatures, - ...this.ovalFeatures, - ...this.lineFeatures, - ...this.arrowFeatures, - ...this.imageFeatures, - ], - }); + this.vectorSource.addFeatures(linesFeatures); + this.vectorSource.addFeatures(arrowsFeatures); + this.vectorLayer = new VectorLayer({ source: this.vectorSource, visible, + updateWhileAnimating: true, + updateWhileInteracting: true, }); + this.vectorLayer.set('id', layerId); + this.vectorLayer.set('drawImage', this.drawImage.bind(this)); } private getTextsFeatures = (): Array<Feature<Point>> => { @@ -303,22 +290,26 @@ export default class Layer { return { linesFeatures, arrowsFeatures }; }; - private getImagesFeatures = (): Array<Feature<Polygon>> => { - return this.images.map(image => { - const glyph = new Glyph({ - elementId: image.id, - glyphId: image.glyph, - x: image.x, - y: image.y, - width: image.width, - height: image.height, - zIndex: image.z, - pointToProjection: this.pointToProjection, - mapInstance: this.mapInstance, - }); - return glyph.feature; + private drawImages(): void { + Object.values(this.images).forEach(image => { + this.drawImage(image); }); - }; + } + + private drawImage(image: LayerImage): void { + const glyph = new Glyph({ + elementId: image.id, + glyphId: image.glyph, + x: image.x, + y: image.y, + width: image.width, + height: image.height, + zIndex: image.z, + pointToProjection: this.pointToProjection, + mapInstance: this.mapInstance, + }); + this.vectorSource.addFeature(glyph.feature); + } protected getStyle(feature: FeatureLike, resolution: number): Style | Array<Style> | void { const styles: Array<Style> = []; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0c9fb448df9c02b9c29aa36c12206be8c439216 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.test.ts @@ -0,0 +1,66 @@ +/* eslint-disable no-magic-numbers */ +import Draw from 'ol/interaction/Draw'; +import { latLngToPoint } from '@/utils/map/latLngToPoint'; +import modalReducer, { openLayerImageObjectFactoryModal } from '@/redux/modal/modal.slice'; +import { MapSize } from '@/redux/map/map.types'; +import { + createStoreInstanceUsingSliceReducer, + ToolkitStoreWithSingleSlice, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { ModalState } from '@/redux/modal/modal.types'; +import { DEFAULT_TILE_SIZE } from '@/constants/map'; +import { Map } from 'ol'; +import getDrawImageInteraction from './getDrawImageInteraction'; + +jest.mock('../../../../../../../utils/map/latLngToPoint', () => ({ + latLngToPoint: jest.fn(latLng => ({ x: latLng[0], y: latLng[1] })), +})); + +describe('getDrawImageInteraction', () => { + let store = {} as ToolkitStoreWithSingleSlice<ModalState>; + const mockDispatch = jest.fn(() => {}); + + let mapSize: MapSize; + + beforeEach(() => { + mapSize = { + width: 800, + height: 600, + minZoom: 1, + maxZoom: 9, + tileSize: DEFAULT_TILE_SIZE, + }; + store = createStoreInstanceUsingSliceReducer('modal', modalReducer); + store.dispatch = mockDispatch; + }); + + it('returns a Draw interaction', () => { + const drawInteraction = getDrawImageInteraction(mapSize, store.dispatch, [0, 0, 10000, 10000]); + expect(drawInteraction).toBeInstanceOf(Draw); + }); + + it('dispatches openLayerImageObjectFactoryModal on drawend', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + const drawInteraction = getDrawImageInteraction(mapSize, store.dispatch, [0, 0, 10000, 10000]); + mapInstance.addInteraction(drawInteraction); + drawInteraction.appendCoordinates([ + [0, 0], + [10, 10], + ]); + + drawInteraction.finishDrawing(); + + expect(latLngToPoint).toHaveBeenCalledTimes(4); + expect(store.dispatch).toHaveBeenCalledWith( + openLayerImageObjectFactoryModal( + expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }), + ), + ); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts new file mode 100644 index 0000000000000000000000000000000000000000..55a459bcc74378a7a0e2cd289c27050308a7e4ea --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts @@ -0,0 +1,95 @@ +/* eslint-disable no-magic-numbers */ +import Draw from 'ol/interaction/Draw'; +import SimpleGeometry from 'ol/geom/SimpleGeometry'; +import Polygon from 'ol/geom/Polygon'; +import { toLonLat } from 'ol/proj'; +import { latLngToPoint } from '@/utils/map/latLngToPoint'; +import { MapSize } from '@/redux/map/map.types'; +import { AppDispatch } from '@/redux/store'; +import { Coordinate } from 'ol/coordinate'; +import { openLayerImageObjectFactoryModal } from '@/redux/modal/modal.slice'; +import { Extent } from 'ol/extent'; + +export default function getDrawImageInteraction( + mapSize: MapSize, + dispatch: AppDispatch, + restrictionExtent: Extent, +): Draw { + const drawImageInteraction = new Draw({ + type: 'Circle', + freehand: false, + freehandCondition: (mapBrowserEvent): boolean => { + const coords = mapBrowserEvent.coordinate; + return ( + coords[0] >= restrictionExtent[0] && + coords[0] <= restrictionExtent[2] && + coords[1] >= restrictionExtent[1] && + coords[1] <= restrictionExtent[3] + ); + }, + geometryFunction: (coordinates, geometry): SimpleGeometry => { + const newGeometry = geometry || new Polygon([]); + if (!Array.isArray(coordinates) || coordinates.length < 2) { + return geometry; + } + const start = coordinates[0] as Coordinate; + const end = coordinates[1] as Coordinate; + + const minX = Math.min( + restrictionExtent[2], + Math.max(restrictionExtent[0], Math.min(start[0], end[0])), + ); + const minY = Math.min( + restrictionExtent[3], + Math.max(restrictionExtent[1], Math.min(start[1], end[1])), + ); + const maxX = Math.max( + restrictionExtent[0], + Math.min(restrictionExtent[2], Math.max(start[0], end[0])), + ); + const maxY = Math.max( + restrictionExtent[1], + Math.min(restrictionExtent[3], Math.max(start[1], end[1])), + ); + + const coords: Array<Coordinate> = [ + [minX, minY], + [maxX, minY], + [maxX, maxY], + [minX, maxY], + [minX, minY], + ]; + + newGeometry.setCoordinates([coords]); + + return newGeometry; + }, + }); + + drawImageInteraction.on('drawend', event => { + const geometry = event.feature.getGeometry() as Polygon; + const extent = geometry.getExtent(); + + const [startLng, startLat] = toLonLat([extent[0], extent[3]]); + const startPoint = latLngToPoint([startLat, startLng], mapSize); + const [endLng, endLat] = toLonLat([extent[2], extent[1]]); + const endPoint = latLngToPoint([endLat, endLng], mapSize); + + const width = Math.abs(endPoint.x - startPoint.x); + const height = Math.abs(endPoint.y - startPoint.y); + + if (!width || !height) { + return; + } + dispatch( + openLayerImageObjectFactoryModal({ + x: startPoint.x, + y: startPoint.y, + width, + height, + }), + ); + }); + + return drawImageInteraction; +} diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx index 5762a59b846afa2583c0454ef3fcef59d036fddb..f086919b6aefbb458cd037d06c64459aacad5c8b 100644 --- a/src/components/SPA/MinervaSPA.component.tsx +++ b/src/components/SPA/MinervaSPA.component.tsx @@ -6,7 +6,7 @@ import { twMerge } from 'tailwind-merge'; import { useEffect } from 'react'; import { PluginsManager } from '@/services/pluginsManager'; import { useInitializeStore } from '../../utils/initialize/useInitializeStore'; -import { Modal } from '../FunctionalArea/Modal'; +// import { Modal } from '../FunctionalArea/Modal'; import { ContextMenu } from '../FunctionalArea/ContextMenu'; import { CookieBanner } from '../FunctionalArea/CookieBanner'; @@ -24,7 +24,6 @@ export const MinervaSPA = (): JSX.Element => { <div className={twMerge('relative', manrope.variable)}> <FunctionalArea /> <Map /> - <Modal /> <ContextMenu /> <CookieBanner /> </div> diff --git a/src/models/fixtures/glyphsFixture.ts b/src/models/fixtures/glyphsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..489f5015eaac881fe7509252d21ae767cc470328 --- /dev/null +++ b/src/models/fixtures/glyphsFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { pageableSchema } from '@/models/pageableSchema'; +import { glyphSchema } from '@/models/glyphSchema'; + +export const glyphsFixture = createFixture(pageableSchema(glyphSchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/fixtures/layerImageFixture.ts b/src/models/fixtures/layerImageFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..d386d544e29082fcc160df644a1ec24aa5822747 --- /dev/null +++ b/src/models/fixtures/layerImageFixture.ts @@ -0,0 +1,9 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { layerImageSchema } from '@/models/layerImageSchema'; + +export const layerImageFixture = createFixture(layerImageSchema, { + seed: ZOD_SEED, + array: { min: 1, max: 1 }, +}); diff --git a/src/models/fixtures/layerImagesFixture.ts b/src/models/fixtures/layerImagesFixture.ts index 382b5f926d997699015f8f6c7e3aa080428a8abd..77c2d027fd3505ba97240c53a9c4da8575299b7e 100644 --- a/src/models/fixtures/layerImagesFixture.ts +++ b/src/models/fixtures/layerImagesFixture.ts @@ -6,5 +6,5 @@ import { layerImageSchema } from '@/models/layerImageSchema'; export const layerImagesFixture = createFixture(pageableSchema(layerImageSchema), { seed: ZOD_SEED, - array: { min: 3, max: 3 }, + array: { min: 1, max: 1 }, }); diff --git a/src/models/glyphSchema.ts b/src/models/glyphSchema.ts index eedb213a85e069120b3992a3cc62f271e00dbd59..319fd589fe81d23f621ec0321abde43a570abe6f 100644 --- a/src/models/glyphSchema.ts +++ b/src/models/glyphSchema.ts @@ -3,4 +3,5 @@ import { z } from 'zod'; export const glyphSchema = z.object({ id: z.number(), file: z.number(), + filename: z.string().optional().nullable(), }); diff --git a/src/models/layerImageSchema.ts b/src/models/layerImageSchema.ts index 61a6df2dcb417cd9f4b5e389aed50345882ab0b3..8547b313555bdef3d17f894372d4e4ba75ce19d7 100644 --- a/src/models/layerImageSchema.ts +++ b/src/models/layerImageSchema.ts @@ -7,5 +7,5 @@ export const layerImageSchema = z.object({ z: z.number(), width: z.number(), height: z.number(), - glyph: z.number(), + glyph: z.number().nullable(), }); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 1e9f3a81a520446a85d529b5cde0c6fbe373ddd0..af6950d54d6226d48cbba1b00dd400ba27195294 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -65,10 +65,14 @@ export const apiPath = { `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, removeLayer: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, + addLayerImageObject: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/images/`, getLayer: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, getGlyphImage: (glyphId: number): string => `projects/${PROJECT_ID}/glyphs/${glyphId}/fileContent`, + getGlyphs: (): string => `projects/${PROJECT_ID}/glyphs/`, + addGlyph: (): string => `projects/${PROJECT_ID}/glyphs/`, getNewReactionsForModel: (modelId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/bioEntities/reactions/?size=10000`, getNewReaction: (modelId: number, reactionId: number): string => diff --git a/src/redux/glyphs/glyphs.constants.ts b/src/redux/glyphs/glyphs.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..e70a9a58ae42bc143b2b7952cc30aefcc862e9ba --- /dev/null +++ b/src/redux/glyphs/glyphs.constants.ts @@ -0,0 +1 @@ +export const GLYPHS_FETCHING_ERROR_PREFIX = 'Failed to fetch glyphs'; diff --git a/src/redux/glyphs/glyphs.mock.ts b/src/redux/glyphs/glyphs.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e04e5d3b2f05025d6f57daaa01cb00950e081e4 --- /dev/null +++ b/src/redux/glyphs/glyphs.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { GlyphsState } from '@/redux/glyphs/glyphs.types'; + +export const GLYPHS_STATE_INITIAL_MOCK: GlyphsState = { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/glyphs/glyphs.reducers.test.ts b/src/redux/glyphs/glyphs.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a4264b57ac57c9f2a0c875ee0430f6b647c88ea --- /dev/null +++ b/src/redux/glyphs/glyphs.reducers.test.ts @@ -0,0 +1,72 @@ +import { apiPath } from '@/redux/apiPath'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; +import { GLYPHS_STATE_INITIAL_MOCK } from '@/redux/glyphs/glyphs.mock'; +import { GlyphsState } from '@/redux/glyphs/glyphs.types'; +import { getGlyphs } from '@/redux/glyphs/glyphs.thunks'; +import { glyphsFixture } from '@/models/fixtures/glyphsFixture'; +import glyphsReducer from './glyphs.slice'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +const INITIAL_STATE: GlyphsState = GLYPHS_STATE_INITIAL_MOCK; + +describe('glyphs reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<GlyphsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('glyphs', glyphsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(glyphsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + + it('should update store after successful getGlyphs query', async () => { + mockedAxiosClient.onGet(apiPath.getGlyphs()).reply(HttpStatusCode.Ok, glyphsFixture); + + const { type } = await store.dispatch(getGlyphs()); + const { data, loading, error } = store.getState().glyphs; + expect(type).toBe('getGlyphs/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(glyphsFixture.content); + }); + + it('should update store after failed getGlyphs query', async () => { + mockedAxiosClient.onGet(apiPath.getGlyphs()).reply(HttpStatusCode.NotFound, []); + + const action = await store.dispatch(getGlyphs()); + const { data, loading, error } = store.getState().glyphs; + + expect(action.type).toBe('getGlyphs/rejected'); + expect(() => unwrapResult(action)).toThrow( + "Failed to fetch glyphs: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); + expect(loading).toEqual('failed'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual([]); + }); + + it('should update store on loading getGlyphs query', async () => { + mockedAxiosClient.onGet(apiPath.getGlyphs()).reply(HttpStatusCode.Ok, glyphsFixture); + + const glyphsPromise = store.dispatch(getGlyphs()); + + const { data, loading } = store.getState().glyphs; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + glyphsPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().glyphs; + expect(dataPromiseFulfilled).toEqual(glyphsFixture.content); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/glyphs/glyphs.reducers.ts b/src/redux/glyphs/glyphs.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..30db814709668c7196cf927ed404b1acab3b3894 --- /dev/null +++ b/src/redux/glyphs/glyphs.reducers.ts @@ -0,0 +1,16 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getGlyphs } from '@/redux/glyphs/glyphs.thunks'; +import { GlyphsState } from '@/redux/glyphs/glyphs.types'; + +export const getGlyphsReducer = (builder: ActionReducerMapBuilder<GlyphsState>): void => { + builder.addCase(getGlyphs.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getGlyphs.fulfilled, (state, action) => { + state.data = action.payload || {}; + state.loading = 'succeeded'; + }); + builder.addCase(getGlyphs.rejected, state => { + state.loading = 'failed'; + }); +}; diff --git a/src/redux/glyphs/glyphs.selectors.ts b/src/redux/glyphs/glyphs.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f99b412a61aa37a7d10383424794f63e1b9638e --- /dev/null +++ b/src/redux/glyphs/glyphs.selectors.ts @@ -0,0 +1,6 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '@/redux/root/root.selectors'; + +export const glyphsSelector = createSelector(rootSelector, state => state.glyphs); + +export const glyphsDataSelector = createSelector(glyphsSelector, state => state.data); diff --git a/src/redux/glyphs/glyphs.slice.ts b/src/redux/glyphs/glyphs.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff81f3e4588883ffe8e9b3506817d92d29ef1f4a --- /dev/null +++ b/src/redux/glyphs/glyphs.slice.ts @@ -0,0 +1,15 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { GLYPHS_STATE_INITIAL_MOCK } from '@/redux/glyphs/glyphs.mock'; +import { getGlyphsReducer } from '@/redux/glyphs/glyphs.reducers'; + +export const glyphsSlice = createSlice({ + name: 'glyphs', + initialState: GLYPHS_STATE_INITIAL_MOCK, + reducers: {}, + extraReducers: builder => { + getGlyphsReducer(builder); + }, +}); + +export default glyphsSlice.reducer; diff --git a/src/redux/glyphs/glyphs.thunks.test.ts b/src/redux/glyphs/glyphs.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..abd3a86d17b0935e507d1643e3a35b7ac5666e75 --- /dev/null +++ b/src/redux/glyphs/glyphs.thunks.test.ts @@ -0,0 +1,38 @@ +import { apiPath } from '@/redux/apiPath'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { GlyphsState } from '@/redux/glyphs/glyphs.types'; +import { getGlyphs } from '@/redux/glyphs/glyphs.thunks'; +import { glyphsFixture } from '@/models/fixtures/glyphsFixture'; +import glyphsReducer from './glyphs.slice'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +describe('glyphs thunks', () => { + let store = {} as ToolkitStoreWithSingleSlice<GlyphsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('glyphs', glyphsReducer); + }); + + describe('getGlyphs', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient.onGet(apiPath.getGlyphs()).reply(HttpStatusCode.Ok, glyphsFixture); + + const { payload } = await store.dispatch(getGlyphs()); + expect(payload).toEqual(glyphsFixture.content); + }); + + it('should return empty object when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getGlyphs()) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getGlyphs()); + expect(payload).toEqual([]); + }); + }); +}); diff --git a/src/redux/glyphs/glyphs.thunks.ts b/src/redux/glyphs/glyphs.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ecfcc05fa465dd6e1646a1ff11beb88f35a4699 --- /dev/null +++ b/src/redux/glyphs/glyphs.thunks.ts @@ -0,0 +1,41 @@ +import { apiPath } from '@/redux/apiPath'; +import { Glyph, PageOf } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { ThunkConfig } from '@/types/store'; +import { getError } from '@/utils/error-report/getError'; +import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; +import { glyphSchema } from '@/models/glyphSchema'; +import { GLYPHS_FETCHING_ERROR_PREFIX } from '@/redux/glyphs/glyphs.constants'; +import { pageableSchema } from '@/models/pageableSchema'; + +export const getGlyphs = createAsyncThunk<Glyph[], void, ThunkConfig>('getGlyphs', async () => { + try { + const { data } = await axiosInstanceNewAPI.get<PageOf<Glyph>>(apiPath.getGlyphs()); + const isDataValid = validateDataUsingZodSchema(data, pageableSchema(glyphSchema)); + if (!isDataValid) { + return []; + } + return data.content; + } catch (error) { + return Promise.reject(getError({ error, prefix: GLYPHS_FETCHING_ERROR_PREFIX })); + } +}); + +export const addGlyph = createAsyncThunk<Glyph | undefined, File, ThunkConfig>( + 'addGlyph', + async file => { + try { + const formData = new FormData(); + formData.append('file', file); + const { data } = await axiosInstanceNewAPI.post<Glyph>(apiPath.addGlyph(), formData); + const isDataValid = validateDataUsingZodSchema(data, glyphSchema); + if (!isDataValid) { + return undefined; + } + return data; + } catch (error) { + return Promise.reject(getError({ error })); + } + }, +); diff --git a/src/redux/glyphs/glyphs.types.ts b/src/redux/glyphs/glyphs.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..acf2e9056d8796f7041c7c420004c741490f175c --- /dev/null +++ b/src/redux/glyphs/glyphs.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { Glyph } from '@/types/models'; + +export type GlyphsState = FetchDataState<Glyph[], []>; diff --git a/src/redux/layers/layers.mock.ts b/src/redux/layers/layers.mock.ts index 38e72675c3f471a4cc7033d2013eced09d87b0f9..729624ff10b3019218ee7d49e025d394cb3e4295 100644 --- a/src/redux/layers/layers.mock.ts +++ b/src/redux/layers/layers.mock.ts @@ -4,16 +4,16 @@ import { FetchDataState } from '@/types/fetchDataState'; export const LAYERS_STATE_INITIAL_MOCK: LayersState = {}; +export const LAYER_STATE_DEFAULT_DATA = { + layers: [], + layersVisibility: {}, + activeLayer: null, +}; + export const LAYERS_STATE_INITIAL_LAYER_MOCK: FetchDataState<LayersVisibilitiesState> = { data: { - layers: [], - layersVisibility: {}, + ...LAYER_STATE_DEFAULT_DATA, }, loading: 'idle', error: DEFAULT_ERROR, }; - -export const LAYER_STATE_DEFAULT_DATA = { - layers: [], - layersVisibility: {}, -}; diff --git a/src/redux/layers/layers.reducers.test.ts b/src/redux/layers/layers.reducers.test.ts index 938b7f3e049b2fcfa9099dfdab1a473f8daa4c03..20d1cdbd261e35c48ade8c4c0c29287b504ba55f 100644 --- a/src/redux/layers/layers.reducers.test.ts +++ b/src/redux/layers/layers.reducers.test.ts @@ -58,6 +58,7 @@ describe('layers reducer', () => { expect(loading).toEqual('succeeded'); expect(error).toEqual({ message: '', name: '' }); expect(data).toEqual({ + activeLayer: null, layers: [ { details: layersFixture.content[0], @@ -65,7 +66,7 @@ describe('layers reducer', () => { rects: layerRectsFixture.content, ovals: layerOvalsFixture.content, lines: layerLinesFixture.content, - images: layerImagesFixture.content, + images: { [layerImagesFixture.content[0].id]: layerImagesFixture.content[0] }, }, ], layersVisibility: { @@ -86,7 +87,11 @@ describe('layers reducer', () => { ); expect(loading).toEqual('failed'); expect(error).toEqual({ message: '', name: '' }); - expect(data).toEqual({ layers: [], layersVisibility: {} }); + expect(data).toEqual({ + activeLayer: null, + layers: [], + layersVisibility: {}, + }); }); it('should update store on loading getLayers query', async () => { @@ -117,6 +122,7 @@ describe('layers reducer', () => { const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().layers[1]; expect(dataPromiseFulfilled).toEqual({ + activeLayer: null, layers: [ { details: layersFixture.content[0], @@ -124,7 +130,7 @@ describe('layers reducer', () => { rects: layerRectsFixture.content, ovals: layerOvalsFixture.content, lines: layerLinesFixture.content, - images: layerImagesFixture.content, + images: { [layerImagesFixture.content[0].id]: layerImagesFixture.content[0] }, }, ], layersVisibility: { diff --git a/src/redux/layers/layers.reducers.ts b/src/redux/layers/layers.reducers.ts index b0e601d57acd4fbe5bdf5b89d80ddb8d4a3a6e7e..9e41f8ded97ed3a120bc25c17e06d5f55ae886f4 100644 --- a/src/redux/layers/layers.reducers.ts +++ b/src/redux/layers/layers.reducers.ts @@ -7,6 +7,7 @@ import { LAYERS_STATE_INITIAL_LAYER_MOCK, } from '@/redux/layers/layers.mock'; import { DEFAULT_ERROR } from '@/constants/errors'; +import { LayerImage } from '@/types/models'; export const getLayersForModelReducer = (builder: ActionReducerMapBuilder<LayersState>): void => { builder.addCase(getLayersForModel.pending, (state, action) => { @@ -50,3 +51,31 @@ export const setLayerVisibilityReducer = ( data.layersVisibility[layerId] = visible; } }; + +export const setActiveLayerReducer = ( + state: LayersState, + action: PayloadAction<{ modelId: number; layerId: number | null }>, +): void => { + const { modelId, layerId } = action.payload; + const { data } = state[modelId]; + if (!data) { + return; + } + data.activeLayer = layerId; +}; + +export const layerAddImageReducer = ( + state: LayersState, + action: PayloadAction<{ modelId: number; layerId: number; layerImage: LayerImage }>, +): void => { + const { modelId, layerId, layerImage } = action.payload; + const { data } = state[modelId]; + if (!data) { + return; + } + const layer = data.layers.find(layerState => layerState.details.id === layerId); + if (!layer) { + return; + } + layer.images[layerImage.id] = layerImage; +}; diff --git a/src/redux/layers/layers.selectors.ts b/src/redux/layers/layers.selectors.ts index 4d9e3f6d534b6891c8f9bc3eefbb18ecc6aedd03..aa71d5e482cbbe30ef92a9455e50c079f4311f9f 100644 --- a/src/redux/layers/layers.selectors.ts +++ b/src/redux/layers/layers.selectors.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-magic-numbers */ import { createSelector } from '@reduxjs/toolkit'; import { rootSelector } from '@/redux/root/root.selectors'; import { currentModelIdSelector } from '@/redux/models/models.selectors'; @@ -10,6 +11,11 @@ export const layersStateForCurrentModelSelector = createSelector( (state, currentModelId) => state[currentModelId], ); +export const layersActiveLayerSelector = createSelector( + layersStateForCurrentModelSelector, + state => state?.data?.activeLayer || null, +); + export const layersLoadingSelector = createSelector( layersStateForCurrentModelSelector, state => state?.loading, @@ -24,3 +30,22 @@ export const layersForCurrentModelSelector = createSelector( layersStateForCurrentModelSelector, state => state?.data?.layers || [], ); + +export const highestZIndexSelector = createSelector(layersForCurrentModelSelector, layers => { + if (!layers || layers.length === 0) return 0; + + const getMaxZFromItems = <T extends { z?: number }>(items: T[] = []): number => + items.length > 0 ? Math.max(...items.map(item => item.z || 0)) : 0; + + return layers.reduce((maxZ, layer) => { + const textsMaxZ = getMaxZFromItems(layer.texts); + const rectsMaxZ = getMaxZFromItems(layer.rects); + const ovalsMaxZ = getMaxZFromItems(layer.ovals); + const linesMaxZ = getMaxZFromItems(layer.lines); + const imagesMaxZ = getMaxZFromItems(Object.values(layer.images)); + + const layerMaxZ = Math.max(textsMaxZ, rectsMaxZ, ovalsMaxZ, linesMaxZ, imagesMaxZ); + + return Math.max(maxZ, layerMaxZ); + }, 0); +}); diff --git a/src/redux/layers/layers.slice.ts b/src/redux/layers/layers.slice.ts index 7c07cdc0a77d1b7cd9a912a0bccee08970f1e790..9f78f0dd112b9e817107f37b9958b738dfab0ff4 100644 --- a/src/redux/layers/layers.slice.ts +++ b/src/redux/layers/layers.slice.ts @@ -2,6 +2,8 @@ import { createSlice } from '@reduxjs/toolkit'; import { LAYERS_STATE_INITIAL_MOCK } from '@/redux/layers/layers.mock'; import { getLayersForModelReducer, + layerAddImageReducer, + setActiveLayerReducer, setLayerVisibilityReducer, } from '@/redux/layers/layers.reducers'; @@ -10,12 +12,14 @@ export const layersSlice = createSlice({ initialState: LAYERS_STATE_INITIAL_MOCK, reducers: { setLayerVisibility: setLayerVisibilityReducer, + setActiveLayer: setActiveLayerReducer, + layerAddImage: layerAddImageReducer, }, extraReducers: builder => { getLayersForModelReducer(builder); }, }); -export const { setLayerVisibility } = layersSlice.actions; +export const { setLayerVisibility, setActiveLayer, layerAddImage } = layersSlice.actions; export default layersSlice.reducer; diff --git a/src/redux/layers/layers.thunks.test.ts b/src/redux/layers/layers.thunks.test.ts index 975e8ec9e00604497e3bb8c4fe40d016f4e30976..218d9ce56ab4c1a69ad5cda6897f9236fa00991d 100644 --- a/src/redux/layers/layers.thunks.test.ts +++ b/src/redux/layers/layers.thunks.test.ts @@ -52,6 +52,7 @@ describe('layers thunks', () => { const { payload } = await store.dispatch(getLayersForModel(1)); expect(payload).toEqual({ + activeLayer: null, layers: [ { details: layersFixture.content[0], @@ -59,7 +60,7 @@ describe('layers thunks', () => { rects: layerRectsFixture.content, ovals: layerOvalsFixture.content, lines: layerLinesFixture.content, - images: layerImagesFixture.content, + images: { [layerImagesFixture.content[0].id]: layerImagesFixture.content[0] }, }, ], layersVisibility: { diff --git a/src/redux/layers/layers.thunks.ts b/src/redux/layers/layers.thunks.ts index 1fd5d6d443d08f83993450e9886d513163713ef4..354eb086e91b2bb6d3fa59a8c4c7093b9a5f4788 100644 --- a/src/redux/layers/layers.thunks.ts +++ b/src/redux/layers/layers.thunks.ts @@ -1,6 +1,7 @@ -import { z } from 'zod'; +/* eslint-disable no-magic-numbers */ +import { z as zod } from 'zod'; import { apiPath } from '@/redux/apiPath'; -import { Layer, Layers } from '@/types/models'; +import { Layer, LayerImage, Layers } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { ThunkConfig } from '@/types/store'; @@ -19,6 +20,7 @@ import { pageableSchema } from '@/models/pageableSchema'; import { layerOvalSchema } from '@/models/layerOvalSchema'; import { layerLineSchema } from '@/models/layerLineSchema'; import { layerImageSchema } from '@/models/layerImageSchema'; +import arrayToKeyValue from '@/utils/array/arrayToKeyValue'; export const getLayer = createAsyncThunk< Layer | null, @@ -57,33 +59,38 @@ export const getLayersForModel = createAsyncThunk< axiosInstanceNewAPI.get(apiPath.getLayerLines(modelId, layer.id)), axiosInstanceNewAPI.get(apiPath.getLayerImages(modelId, layer.id)), ]); - return { details: layer, texts: textsResponse.data.content, rects: rectsResponse.data.content, ovals: ovalsResponse.data.content, lines: linesResponse.data.content, - images: imagesResponse.data.content, + images: arrayToKeyValue(imagesResponse.data.content as Array<LayerImage>, 'id'), }; }), ); layers = layers.filter(layer => { return ( - z.array(layerTextSchema).safeParse(layer.texts).success && - z.array(layerRectSchema).safeParse(layer.rects).success && - z.array(layerOvalSchema).safeParse(layer.ovals).success && - z.array(layerLineSchema).safeParse(layer.lines).success && - z.array(layerImageSchema).safeParse(layer.images).success + zod.array(layerTextSchema).safeParse(layer.texts).success && + zod.array(layerRectSchema).safeParse(layer.rects).success && + zod.array(layerOvalSchema).safeParse(layer.ovals).success && + zod.array(layerLineSchema).safeParse(layer.lines).success && + zod.array(layerImageSchema).safeParse(Object.values(layer.images)).success ); }); const layersVisibility = layers.reduce((acc: { [key: string]: boolean }, layer) => { acc[layer.details.id] = layer.details.visible; return acc; }, {}); + let activeLayer = null; + const activeLayers = layers.filter(layer => layer.details.visible); + if (activeLayers.length) { + activeLayer = activeLayers[0].details.id; + } return { layers, layersVisibility, + activeLayer, }; } catch (error) { return Promise.reject(getError({ error, prefix: LAYERS_FETCHING_ERROR_PREFIX })); @@ -129,13 +136,47 @@ export const updateLayer = createAsyncThunk<Layer | null, LayerUpdateInterface, ); export const removeLayer = createAsyncThunk< - void, + null, { modelId: number; layerId: number }, ThunkConfig - // eslint-disable-next-line consistent-return >('vectorMap/removeLayer', async ({ modelId, layerId }) => { try { await axiosInstanceNewAPI.delete<void>(apiPath.removeLayer(modelId, layerId)); + return null; + } catch (error) { + return Promise.reject(getError({ error })); + } +}); + +export const addLayerImageObject = createAsyncThunk< + LayerImage | null, + { + modelId: number; + layerId: number; + x: number; + y: number; + z: number; + width: number; + height: number; + glyph: number | null; + }, + ThunkConfig +>('vectorMap/addLayerImageObject', async ({ modelId, layerId, x, y, z, width, height, glyph }) => { + try { + const { data } = await axiosInstanceNewAPI.post<LayerImage>( + apiPath.addLayerImageObject(modelId, layerId), + { + x, + y, + z, + width, + height, + glyph, + }, + ); + const isDataValid = validateDataUsingZodSchema(data, layerImageSchema); + + return isDataValid ? data : null; } catch (error) { return Promise.reject(getError({ error })); } diff --git a/src/redux/layers/layers.types.ts b/src/redux/layers/layers.types.ts index f228310603a20d38ebfd315d2757f2010126adee..27dc36fcd6cc2b8162c492c8ee87f0c95e4f79c5 100644 --- a/src/redux/layers/layers.types.ts +++ b/src/redux/layers/layers.types.ts @@ -22,7 +22,7 @@ export type LayerState = { rects: LayerRect[]; ovals: LayerOval[]; lines: LayerLine[]; - images: LayerImage[]; + images: { [key: string]: LayerImage }; }; export type LayerVisibilityState = { @@ -32,6 +32,7 @@ export type LayerVisibilityState = { export type LayersVisibilitiesState = { layersVisibility: LayerVisibilityState; layers: LayerState[]; + activeLayer: number | null; }; export type LayersState = KeyedFetchDataState<LayersVisibilitiesState>; diff --git a/src/redux/mapEditTools/mapEditTools.constants.ts b/src/redux/mapEditTools/mapEditTools.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f54d2b0e3720fe456fb0759663465d15b54c4b5 --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.constants.ts @@ -0,0 +1,3 @@ +export const MAP_EDIT_ACTIONS = { + DRAW_IMAGE: 'DRAW_IMAGE', +} as const; diff --git a/src/redux/mapEditTools/mapEditTools.mock.ts b/src/redux/mapEditTools/mapEditTools.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..81dd081244074969ac072b418d32b62a2671e2b5 --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.mock.ts @@ -0,0 +1,5 @@ +import { MapEditToolsState } from '@/redux/mapEditTools/mapEditTools.types'; + +export const MAP_EDIT_TOOLS_STATE_INITIAL_MOCK: MapEditToolsState = { + activeAction: null, +}; diff --git a/src/redux/mapEditTools/mapEditTools.reducers.ts b/src/redux/mapEditTools/mapEditTools.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..01334ba6aa845df55bc2436490fda314bb8d211d --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.reducers.ts @@ -0,0 +1,15 @@ +/* eslint-disable no-magic-numbers */ +import { PayloadAction } from '@reduxjs/toolkit'; +import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; +import { MapEditToolsState } from '@/redux/mapEditTools/mapEditTools.types'; + +export const mapEditToolsSetActiveActionReducer = ( + state: MapEditToolsState, + action: PayloadAction<keyof typeof MAP_EDIT_ACTIONS | null>, +): void => { + if (state.activeAction !== action.payload) { + state.activeAction = action.payload; + } else { + state.activeAction = null; + } +}; diff --git a/src/redux/mapEditTools/mapEditTools.selectors.ts b/src/redux/mapEditTools/mapEditTools.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed51e7ab48c7c878d83fd6f2afb4617bb823d705 --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.selectors.ts @@ -0,0 +1,14 @@ +/* eslint-disable no-magic-numbers */ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '@/redux/root/root.selectors'; + +export const mapEditToolsSelector = createSelector(rootSelector, state => state.mapEditTools); + +export const mapEditToolsActiveActionSelector = createSelector( + mapEditToolsSelector, + state => state.activeAction, +); + +export const isMapEditToolsActiveSelector = createSelector(mapEditToolsSelector, state => + Boolean(state.activeAction), +); diff --git a/src/redux/mapEditTools/mapEditTools.slice.ts b/src/redux/mapEditTools/mapEditTools.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..bea57d9cda02e1cc7ca20039cb296f23700bb8dd --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.slice.ts @@ -0,0 +1,15 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { MAP_EDIT_TOOLS_STATE_INITIAL_MOCK } from '@/redux/mapEditTools/mapEditTools.mock'; +import { mapEditToolsSetActiveActionReducer } from '@/redux/mapEditTools/mapEditTools.reducers'; + +export const layersSlice = createSlice({ + name: 'layers', + initialState: MAP_EDIT_TOOLS_STATE_INITIAL_MOCK, + reducers: { + mapEditToolsSetActiveAction: mapEditToolsSetActiveActionReducer, + }, +}); + +export const { mapEditToolsSetActiveAction } = layersSlice.actions; + +export default layersSlice.reducer; diff --git a/src/redux/mapEditTools/mapEditTools.types.ts b/src/redux/mapEditTools/mapEditTools.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a000d1d076a6d7e194eec4bfd1c22d36672d15f --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.types.ts @@ -0,0 +1,5 @@ +import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; + +export type MapEditToolsState = { + activeAction: keyof typeof MAP_EDIT_ACTIONS | null; +}; diff --git a/src/redux/modal/modal.constants.ts b/src/redux/modal/modal.constants.ts index 1184d4ed526038773f9bb90853e52d8f0912dece..1b755f3bcb626e6183d3a5ab9a8e3032db3bc772 100644 --- a/src/redux/modal/modal.constants.ts +++ b/src/redux/modal/modal.constants.ts @@ -14,4 +14,5 @@ export const MODAL_INITIAL_STATE: ModalState = { editOverlayState: null, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }; diff --git a/src/redux/modal/modal.mock.ts b/src/redux/modal/modal.mock.ts index 1a7a519f3509c02ae667c7e310eeb58af77c3af2..40464dd3594081af18545fe2b988a8ff154c82fd 100644 --- a/src/redux/modal/modal.mock.ts +++ b/src/redux/modal/modal.mock.ts @@ -14,4 +14,5 @@ export const MODAL_INITIAL_STATE_MOCK: ModalState = { editOverlayState: null, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }; diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index 3371ed3c102a74f1f32e8b26807291c159ac751c..f678ea91f3f7524626b107a413d7766c3bcd5a20 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -138,3 +138,18 @@ export const openLayerFactoryModalReducer = ( state.modalTitle = 'Add new layer'; } }; + +export const openLayerImageObjectFactoryModalReducer = ( + state: ModalState, + action: PayloadAction<{ + x: number; + y: number; + width: number; + height: number; + }>, +): void => { + state.layerImageObjectFactoryState = action.payload; + state.isOpen = true; + state.modalName = 'layer-image-object-factory'; + state.modalTitle = 'Select glyph or upload file'; +}; diff --git a/src/redux/modal/modal.selector.ts b/src/redux/modal/modal.selector.ts index 7f7c444111a45551d76fa8b41f887e9ceb43fe4b..132472b6c923924d0942f7ce37aa09b691e100d4 100644 --- a/src/redux/modal/modal.selector.ts +++ b/src/redux/modal/modal.selector.ts @@ -30,3 +30,8 @@ export const currentErrorDataSelector = createSelector( modalSelector, modal => modal?.errorReportState.errorData || undefined, ); + +export const layerImageObjectFactoryStateSelector = createSelector( + modalSelector, + modal => modal.layerImageObjectFactoryState, +); diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts index 8ed0421510457f64c783b7c0548d09977f7e8415..a9baf72a027e686a80e91491679078dace605c9f 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -17,6 +17,7 @@ import { openLicenseModalReducer, openToSModalReducer, openLayerFactoryModalReducer, + openLayerImageObjectFactoryModalReducer, } from './modal.reducers'; const modalSlice = createSlice({ @@ -39,6 +40,7 @@ const modalSlice = createSlice({ openLicenseModal: openLicenseModalReducer, openToSModal: openToSModalReducer, openLayerFactoryModal: openLayerFactoryModalReducer, + openLayerImageObjectFactoryModal: openLayerImageObjectFactoryModalReducer, }, }); @@ -59,6 +61,7 @@ export const { openLicenseModal, openToSModal, openLayerFactoryModal, + openLayerImageObjectFactoryModal, } = modalSlice.actions; export default modalSlice.reducer; diff --git a/src/redux/modal/modal.types.ts b/src/redux/modal/modal.types.ts index 1b544f5282525ca4e9b1d6efa3835c0e175313d4..3b22209e0e0b833e8ad404b7d94bc7ab6582b30f 100644 --- a/src/redux/modal/modal.types.ts +++ b/src/redux/modal/modal.types.ts @@ -21,6 +21,15 @@ export type LayerFactoryState = { id: number | undefined; }; +export type LayerImageObjectFactoryState = + | { + x: number; + y: number; + width: number; + height: number; + } + | undefined; + export interface ModalState { isOpen: boolean; modalName: ModalName; @@ -30,6 +39,7 @@ export interface ModalState { errorReportState: ErrorRepostState; editOverlayState: EditOverlayState; layerFactoryState: LayerFactoryState; + layerImageObjectFactoryState: LayerImageObjectFactoryState; } export type OpenEditOverlayModalPayload = MapOverlay; diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index b5a5b4e0899bc7fe13ea745798a4f97abc5a4232..ba7f305972bdad744aa4ce396ae86f9a9f9b2cb6 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -23,6 +23,7 @@ import { USER_ACCEPTED_MATOMO_COOKIES_COOKIE_NAME, } from '@/components/FunctionalArea/CookieBanner/CookieBanner.constants'; import { injectMatomoTracking } from '@/utils/injectMatomoTracking'; +import { getGlyphs } from '@/redux/glyphs/glyphs.thunks'; import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks'; import { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; import { @@ -67,6 +68,7 @@ export const fetchInitialAppData = createAsyncThunk< dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)), dispatch(getModels()), dispatch(getShapes()), + dispatch(getGlyphs()), dispatch(getLineTypes()), dispatch(getArrowTypes()), ]); diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index 007c2b355b0bb4cc0dbf4a5d5e0a9d84b7b8d19c..c90d96c3c158bb301cd7533634e652c0e47fec7a 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -7,6 +7,8 @@ import { SHAPES_STATE_INITIAL_MOCK } from '@/redux/shapes/shapes.mock'; import { MODEL_ELEMENTS_INITIAL_STATE_MOCK } from '@/redux/modelElements/modelElements.mock'; import { LAYERS_STATE_INITIAL_MOCK } from '@/redux/layers/layers.mock'; import { NEW_REACTIONS_INITIAL_STATE_MOCK } from '@/redux/newReactions/newReactions.mock'; +import { GLYPHS_STATE_INITIAL_MOCK } from '@/redux/glyphs/glyphs.mock'; +import { MAP_EDIT_TOOLS_STATE_INITIAL_MOCK } from '@/redux/mapEditTools/mapEditTools.mock'; import { BACKGROUND_INITIAL_STATE_MOCK } from '../backgrounds/background.mock'; import { BIOENTITY_INITIAL_STATE_MOCK } from '../bioEntity/bioEntity.mock'; import { CHEMICALS_INITIAL_STATE_MOCK } from '../chemicals/chemicals.mock'; @@ -41,6 +43,7 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { search: SEARCH_STATE_INITIAL_MOCK, project: PROJECT_STATE_INITIAL_MOCK, shapes: SHAPES_STATE_INITIAL_MOCK, + glyphs: GLYPHS_STATE_INITIAL_MOCK, projects: PROJECTS_STATE_INITIAL_MOCK, drugs: DRUGS_INITIAL_STATE_MOCK, chemicals: CHEMICALS_INITIAL_STATE_MOCK, @@ -71,4 +74,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { markers: MARKERS_INITIAL_STATE_MOCK, entityNumber: ENTITY_NUMBER_INITIAL_STATE_MOCK, comment: COMMENT_INITIAL_STATE_MOCK, + mapEditTools: MAP_EDIT_TOOLS_STATE_INITIAL_MOCK, }; diff --git a/src/redux/store.ts b/src/redux/store.ts index a5d31bb57d0a4db6a0bae569a15a89579317c211..0e3a85b9cf87d116826427e61d973595c2c9fe79 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -11,6 +11,7 @@ import mapReducer from '@/redux/map/map.slice'; import modalReducer from '@/redux/modal/modal.slice'; import modelsReducer from '@/redux/models/models.slice'; import shapesReducer from '@/redux/shapes/shapes.slice'; +import glyphsReducer from '@/redux/glyphs/glyphs.slice'; import modelElementsReducer from '@/redux/modelElements/modelElements.slice'; import layersReducer from '@/redux/layers/layers.slice'; import oauthReducer from '@/redux/oauth/oauth.slice'; @@ -22,6 +23,7 @@ import reactionsReducer from '@/redux/reactions/reactions.slice'; import newReactionsReducer from '@/redux/newReactions/newReactions.slice'; import searchReducer from '@/redux/search/search.slice'; import userReducer from '@/redux/user/user.slice'; +import mapEditToolsReducer from '@/redux/mapEditTools/mapEditTools.slice'; import { autocompleteChemicalReducer, autocompleteDrugReducer, @@ -64,6 +66,7 @@ export const reducers = { overlays: overlaysReducer, models: modelsReducer, shapes: shapesReducer, + glyphs: glyphsReducer, modelElements: modelElementsReducer, layers: layersReducer, reactions: reactionsReducer, @@ -71,6 +74,7 @@ export const reducers = { contextMenu: contextMenuReducer, cookieBanner: cookieBannerReducer, user: userReducer, + mapEditTools: mapEditToolsReducer, configuration: configurationReducer, constant: constantReducer, overlayBioEntity: overlayBioEntityReducer, diff --git a/src/shared/Autocomplete/Autocomplete.component.test.tsx b/src/shared/Autocomplete/Autocomplete.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..65662aed673d1ced4b27119d516f85a3c0655681 --- /dev/null +++ b/src/shared/Autocomplete/Autocomplete.component.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Autocomplete } from './Autocomplete.component'; + +interface Option { + id: number; + name: string; +} + +describe('Autocomplete', () => { + const options: Option[] = [ + { id: 1, name: 'Option 1' }, + { id: 2, name: 'Option 2' }, + { id: 3, name: 'Option 3' }, + ]; + + it('renders the component with placeholder', () => { + render( + <Autocomplete + options={options} + valueKey="id" + labelKey="name" + placeholder="Select an option" + onChange={() => {}} + />, + ); + + const placeholder = screen.getByText('Select an option'); + expect(placeholder).toBeInTheDocument(); + }); + + it('displays options and handles selection', () => { + const handleChange = jest.fn(); + + render( + <Autocomplete + options={options} + valueKey="id" + labelKey="name" + placeholder="Select an option" + onChange={handleChange} + />, + ); + + const dropdown = screen.getByTestId('autocomplete'); + if (!dropdown.firstChild) { + throw new Error('Dropdown does not have a firstChild'); + } + fireEvent.keyDown(dropdown.firstChild, { key: 'ArrowDown' }); + + const option1 = screen.getByText('Option 1'); + const option2 = screen.getByText('Option 2'); + expect(option1).toBeInTheDocument(); + expect(option2).toBeInTheDocument(); + + fireEvent.click(option1); + + expect(handleChange).toHaveBeenCalledWith({ id: 1, name: 'Option 1' }); + }); +}); diff --git a/src/shared/Autocomplete/Autocomplete.component.tsx b/src/shared/Autocomplete/Autocomplete.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..232dca1940bbb8eb595bfeeaf633054af91e38c8 --- /dev/null +++ b/src/shared/Autocomplete/Autocomplete.component.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import Select, { SingleValue } from 'react-select'; +import './Autocomplete.styles.css'; + +type AutocompleteProps<T> = { + options: Array<T>; + valueKey?: keyof T; + labelKey?: keyof T; + placeholder?: string; + onChange: (value: T | null) => void; +}; + +type OptionType<T> = { + value: T[keyof T]; + label: string; + originalOption: T; +}; + +export const Autocomplete = <T,>({ + options, + valueKey = 'value' as keyof T, + labelKey = 'label' as keyof T, + placeholder = 'Select...', + onChange, +}: AutocompleteProps<T>): React.JSX.Element => { + const formattedOptions = options.map(option => ({ + value: option[valueKey], + label: option[labelKey] as string, + originalOption: option, + })); + + const handleChange = (selectedOption: SingleValue<OptionType<T>>): void => { + onChange(selectedOption ? selectedOption.originalOption : null); + }; + + return ( + <div data-testid="autocomplete"> + <Select + options={formattedOptions} + onChange={handleChange} + placeholder={placeholder} + classNamePrefix="react-select" + /> + </div> + ); +}; + +Autocomplete.displayName = 'Autocomplete'; diff --git a/src/shared/Autocomplete/Autocomplete.styles.css b/src/shared/Autocomplete/Autocomplete.styles.css new file mode 100644 index 0000000000000000000000000000000000000000..49f417b137df8d0f28da4a9fa1213898501b3356 --- /dev/null +++ b/src/shared/Autocomplete/Autocomplete.styles.css @@ -0,0 +1,7 @@ +.react-select__control { + height: 40px; +} + +.react-select__menu { + margin: 0 !important; +} diff --git a/src/shared/Autocomplete/index.ts b/src/shared/Autocomplete/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f78074f3c5e84198705ad2a2d22e0b8b3bbd64f8 --- /dev/null +++ b/src/shared/Autocomplete/index.ts @@ -0,0 +1 @@ +export { Autocomplete } from './Autocomplete.component'; diff --git a/src/shared/Icon/Icon.component.tsx b/src/shared/Icon/Icon.component.tsx index f22e882b052efc6e3c7c5d63836d015d210cf88b..784cfb0efe5c148cef4591a9f0753c809dab2a23 100644 --- a/src/shared/Icon/Icon.component.tsx +++ b/src/shared/Icon/Icon.component.tsx @@ -18,6 +18,7 @@ import { PlusIcon } from '@/shared/Icon/Icons/PlusIcon'; import type { IconComponentType, IconTypes } from '@/types/iconTypes'; import { DownloadIcon } from '@/shared/Icon/Icons/DownloadIcon'; +import { ImageIcon } from '@/shared/Icon/Icons/ImageIcon'; import { LocationIcon } from './Icons/LocationIcon'; import { MaginfierZoomInIcon } from './Icons/MagnifierZoomIn'; import { MaginfierZoomOutIcon } from './Icons/MagnifierZoomOut'; @@ -59,6 +60,7 @@ const icons: Record<IconTypes, IconComponentType> = { clear: ClearIcon, user: UserIcon, 'manage-user': ManageUserIcon, + image: ImageIcon, } as const; export const Icon = ({ name, className = '', ...rest }: IconProps): JSX.Element => { diff --git a/src/shared/Icon/Icons/ImageIcon.tsx b/src/shared/Icon/Icons/ImageIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1c616b7fd83cb0bcdd543afb5f3223f041f0a55f --- /dev/null +++ b/src/shared/Icon/Icons/ImageIcon.tsx @@ -0,0 +1,25 @@ +interface ImageIconProps { + className?: string; +} + +export const ImageIcon = ({ className }: ImageIconProps): JSX.Element => ( + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + className={className} + xmlns="http://www.w3.org/2000/svg" + > + <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" /> + <circle cx="8" cy="8" r="1.5" stroke="currentColor" strokeWidth="1.5" fill="none" /> + <path + d="M4 18L9 13L12 16L16 12L20 18H4Z" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + /> + </svg> +); diff --git a/src/shared/Input/Input.component.tsx b/src/shared/Input/Input.component.tsx index 00f3e9240109b21b66e54974b9676daf5ee8c11b..96675fdf171d70071e29fdf5db71a16a06a59a1b 100644 --- a/src/shared/Input/Input.component.tsx +++ b/src/shared/Input/Input.component.tsx @@ -1,4 +1,4 @@ -import React, { InputHTMLAttributes } from 'react'; +import React, { InputHTMLAttributes, forwardRef } from 'react'; import { twMerge } from 'tailwind-merge'; type StyleVariant = 'primary' | 'primaryWithoutFull'; @@ -8,6 +8,7 @@ type InputProps = { className?: string; styleVariant?: StyleVariant; sizeVariant?: SizeVariant; + ref?: React.Ref<HTMLInputElement>; } & InputHTMLAttributes<HTMLInputElement>; const styleVariants = { @@ -22,14 +23,17 @@ const sizeVariants = { medium: 'rounded-lg h-12 text-sm', } as const; -export const Input = ({ - className = '', - sizeVariant = 'small', - styleVariant = 'primary', - ...props -}: InputProps): React.ReactNode => ( - <input - {...props} - className={twMerge(styleVariants[styleVariant], sizeVariants[sizeVariant], className)} - /> +export const Input = forwardRef<HTMLInputElement, InputProps>( + ( + { className = '', sizeVariant = 'small', styleVariant = 'primary', ...props }: InputProps, + ref, + ): React.ReactNode => ( + <input + ref={ref} + {...props} + className={twMerge(styleVariants[styleVariant], sizeVariants[sizeVariant], className)} + /> + ), ); + +Input.displayName = 'Input'; diff --git a/src/shared/Select/Select.component.tsx b/src/shared/Select/Select.component.tsx index aa3ab6d5fc11b0e8e0d7991a9d9e1e2eebd80f63..f0107571e2670d0693d1b4e198855e559420112b 100644 --- a/src/shared/Select/Select.component.tsx +++ b/src/shared/Select/Select.component.tsx @@ -5,7 +5,7 @@ import { Icon } from '@/shared/Icon'; type SelectProps = { options: Array<{ id: number; name: string }>; - selectedId: number; + selectedId: number | null; onChange: (selectedId: number) => void; width?: string | number; }; @@ -16,7 +16,7 @@ export const Select = ({ onChange, width = '100%', }: SelectProps): React.JSX.Element => { - const selectedOption = options.find(option => option.id === selectedId); + const selectedOption = options.find(option => option.id === selectedId) || null; const { isOpen, @@ -63,7 +63,7 @@ export const Select = ({ </div> <ul className={twMerge( - 'absolute z-10 overflow-auto rounded-b bg-white shadow-lg', + 'absolute z-20 overflow-auto rounded-b bg-white shadow-lg', !isOpen && 'hidden', )} style={widthStyle} diff --git a/src/types/iconTypes.ts b/src/types/iconTypes.ts index 7681839461a42db984af5556529f30e0ed4a57b0..0ef11e99da00f9fde333a19968bd5fc7e46dd9b0 100644 --- a/src/types/iconTypes.ts +++ b/src/types/iconTypes.ts @@ -24,6 +24,7 @@ export type IconTypes = | 'user' | 'manage-user' | 'download' - | 'question'; + | 'question' + | 'image'; export type IconComponentType = ({ className }: { className: string }) => JSX.Element; diff --git a/src/types/modal.ts b/src/types/modal.ts index 861bb29569581cb89fcc57b136a3ff7cd8c2dcfc..edf1c858843a5573baf94dd44b19eefe47743026 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -12,4 +12,5 @@ export type ModalName = | 'select-project' | 'terms-of-service' | 'logged-in-menu' - | 'layer-factory'; + | 'layer-factory' + | 'layer-image-object-factory'; diff --git a/src/types/models.ts b/src/types/models.ts index 6e84987532c71383c962942ab3d95ee476b6a092..c70bb0b7c2b737c78090520f96d4faf39845f1f8 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -84,6 +84,7 @@ import { operatorSchema } from '@/models/operatorSchema'; import { modificationResiduesSchema } from '@/models/modificationResiduesSchema'; import { segmentSchema } from '@/models/segmentSchema'; import { layerImageSchema } from '@/models/layerImageSchema'; +import { glyphSchema } from '@/models/glyphSchema'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -106,6 +107,7 @@ export type LayerOval = z.infer<typeof layerOvalSchema>; export type LayerLine = z.infer<typeof layerLineSchema>; export type LayerImage = z.infer<typeof layerImageSchema>; export type Arrow = z.infer<typeof arrowSchema>; +export type Glyph = z.infer<typeof glyphSchema>; const modelElementsSchema = pageableSchema(modelElementSchema); export type ModelElements = z.infer<typeof modelElementsSchema>; export type ModelElement = z.infer<typeof modelElementSchema>; diff --git a/src/utils/array/arrayToKeyValue.test.ts b/src/utils/array/arrayToKeyValue.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..347d3f982e3b3ec2c883450143b945e7b0b3b845 --- /dev/null +++ b/src/utils/array/arrayToKeyValue.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable no-magic-numbers */ +import arrayToKeyValue from './arrayToKeyValue'; + +describe('arrayToKeyValue', () => { + interface Person { + id: number; + name: string; + age: number; + isActive: boolean; + } + + const people: Person[] = [ + { id: 1, name: 'John', age: 30, isActive: true }, + { id: 2, name: 'Anna', age: 25, isActive: false }, + { id: 3, name: 'Peter', age: 28, isActive: true }, + ]; + + it('create dict with key "id" and value "name"', () => { + const result = arrayToKeyValue(people, 'id'); + expect(result).toEqual({ + 1: people[0], + 2: people[1], + 3: people[2], + }); + }); + + it('create dict with key "name" and value "age"', () => { + const result = arrayToKeyValue(people, 'name'); + expect(result).toEqual({ + John: people[0], + Anna: people[1], + Peter: people[2], + }); + }); + + it('handles duplicate keys, overwriting previous values', () => { + const duplicateData = [ + { id: 1, name: 'John', age: 30, isActive: true }, + { id: 1, name: 'Anna', age: 25, isActive: false }, + ]; + const result = arrayToKeyValue(duplicateData, 'id'); + expect(result).toEqual({ + 1: duplicateData[1], + }); + }); +}); diff --git a/src/utils/array/arrayToKeyValue.ts b/src/utils/array/arrayToKeyValue.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b04bab52b244e7903f5521245890b9c632d2558 --- /dev/null +++ b/src/utils/array/arrayToKeyValue.ts @@ -0,0 +1,12 @@ +export default function arrayToKeyValue<T, K extends keyof T>( + array: T[], + key: K, +): Record<T[K] & PropertyKey, T> { + return array.reduce( + (accumulator, currentItem) => { + accumulator[currentItem[key] as T[K] & PropertyKey] = currentItem; + return accumulator; + }, + {} as Record<T[K] & PropertyKey, T>, + ); +}